From 9912fad16059f76a07a8ec5dfb45740275a282dd Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 03:43:40 -0400 Subject: [PATCH 1/3] feat(B-0852.2a): wire-format envelope serializer + CredBundle plaintext schema (17 unit tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B-0852 sub-row .2 first slice — the on-disk wire format that B-0852.2b persist/restore CLIs will use to read/write /esp/zeta-creds.enc. Pure functions; no I/O. Two layers: 1. Envelope serialization (binary; little-endian; v1 magic "ZCV1"): - 8-byte header: 4-byte magic + 4-byte reserved - Length-prefixed: salt (u16) + iv (u16) + tag (u16) + ciphertext (u32) - Trailing bytes rejected in v1 (v2 will explicit-version-bump) 2. CredBundle plaintext schema (post-decryption JSON): - schemaVersion: 1 - globalCreds: { id: bytes } (personaScoped=false manifest entries) - personaCreds: { persona: { id: bytes } } (personaScoped=true) - Bytes base64-encoded inside JSON for safe transport - Composes with B-0852.5 manifest's personaScoped flag Full pipeline (covered by 1 integration test): CredBundle → encodeBundle → encrypt (B-0852.1) → serializeEnvelope → [disk write/read simulation] → parseEnvelope → decrypt → decodeBundle → CredBundle (byte-identical) Test output: 17 pass / 0 fail / 29 expect() calls / 1.67s (scrypt dominates timing per B-0852.1 OWASP-recommended N=2^17). Composes with: - B-0852 parent (cred persistence) - B-0852.1 crypto module (merged PR #5411) — Envelope type producer - B-0852.5 cred-manifest schema (merged PR #5414) — personaScoped semantics - B-0852.10 per-cred handlers (merged PR #5418) — value producers feed globalCreds/personaCreds maps - B-0852.2b future — persist/restore CLIs consume this module What this is NOT: - NOT the persist CLI (next slice; needs FS + passphrase prompt) - NOT the restore CLI (next slice; same) - NOT zflash --bake-cred integration (B-0852.9) --- tools/installer/zeta-creds-envelope.test.ts | 206 +++++++++++++++++ tools/installer/zeta-creds-envelope.ts | 241 ++++++++++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 tools/installer/zeta-creds-envelope.test.ts create mode 100644 tools/installer/zeta-creds-envelope.ts diff --git a/tools/installer/zeta-creds-envelope.test.ts b/tools/installer/zeta-creds-envelope.test.ts new file mode 100644 index 0000000000..8f8e9f1eb7 --- /dev/null +++ b/tools/installer/zeta-creds-envelope.test.ts @@ -0,0 +1,206 @@ +// zeta-creds-envelope.test.ts — B-0852.2a wire-format serializer tests. + +import { describe, expect, it } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { decrypt, encrypt, type Envelope } from "./zeta-creds-crypto"; +import { + type CredBundle, + HEADER_LEN, + MAGIC, + MIN_BLOB_LEN, + decodeBundle, + encodeBundle, + parseEnvelope, + serializeEnvelope, +} from "./zeta-creds-envelope"; + +const UUID = "test-uuid-1234-5678-90ab"; +const PASS = "test-passphrase"; + +describe("serializeEnvelope + parseEnvelope round-trip", () => { + it("serializes + parses a small envelope", () => { + const env: Envelope = { + salt: randomBytes(32), + iv: randomBytes(12), + tag: randomBytes(16), + ciphertext: Buffer.from("hello world", "utf8"), + }; + const blob = serializeEnvelope(env); + const parsed = parseEnvelope(blob); + if (!("salt" in parsed)) throw new Error(`unexpected error: ${parsed.error}`); + expect(Buffer.from(parsed.salt).equals(Buffer.from(env.salt))).toBe(true); + expect(Buffer.from(parsed.iv).equals(Buffer.from(env.iv))).toBe(true); + expect(Buffer.from(parsed.tag).equals(Buffer.from(env.tag))).toBe(true); + expect(Buffer.from(parsed.ciphertext).equals(Buffer.from(env.ciphertext))).toBe(true); + }); + + it("round-trips with actual encrypt() output", () => { + const plaintext = Buffer.from("real cred data"); + const env = encrypt(plaintext, UUID, PASS); + const blob = serializeEnvelope(env); + const parsed = parseEnvelope(blob); + if (!("salt" in parsed)) throw new Error(`unexpected error: ${parsed.error}`); + // Decrypt round-trip via the parsed envelope + const decrypted = decrypt(parsed, UUID, PASS); + if ("error" in decrypted) throw new Error(`decrypt failed: ${decrypted.error}`); + expect(decrypted.equals(plaintext)).toBe(true); + }); + + it("emits canonical magic header bytes", () => { + const env = encrypt(Buffer.from("x"), UUID, PASS); + const blob = serializeEnvelope(env); + expect(blob.subarray(0, 4).equals(MAGIC)).toBe(true); + expect(blob.subarray(0, 4).toString("ascii")).toBe("ZCV1"); + }); + + it("reserved bytes are zero", () => { + const env = encrypt(Buffer.from("x"), UUID, PASS); + const blob = serializeEnvelope(env); + expect(blob.readUInt32LE(4)).toBe(0); + }); +}); + +describe("parseEnvelope — error paths", () => { + it("rejects too-small blob", () => { + const result = parseEnvelope(Buffer.alloc(10)); + expect("error" in result).toBe(true); + }); + + it("rejects wrong magic", () => { + const blob = Buffer.alloc(MIN_BLOB_LEN + 100); + blob.write("XXXX", 0, 4, "ascii"); + const result = parseEnvelope(blob); + if (!("error" in result)) throw new Error("expected error"); + expect(result.error).toContain("magic"); + }); + + it("rejects truncated salt", () => { + const env = encrypt(Buffer.from("x"), UUID, PASS); + const blob = serializeEnvelope(env); + const truncated = blob.subarray(0, HEADER_LEN + 2 + 16); // header + salt-len + half salt + const result = parseEnvelope(truncated); + expect("error" in result).toBe(true); + }); + + it("rejects trailing bytes beyond ciphertext", () => { + const env = encrypt(Buffer.from("x"), UUID, PASS); + const blob = Buffer.concat([serializeEnvelope(env), Buffer.from("extra")]); + const result = parseEnvelope(blob); + if (!("error" in result)) throw new Error("expected error"); + expect(result.error).toContain("trailing"); + }); +}); + +describe("encodeBundle + decodeBundle round-trip", () => { + it("round-trips empty bundle", () => { + const bundle: CredBundle = { schemaVersion: 1, globalCreds: {}, personaCreds: {} }; + const encoded = encodeBundle(bundle); + const decoded = decodeBundle(encoded); + if ("error" in decoded) throw new Error(decoded.error); + expect(Object.keys(decoded.globalCreds).length).toBe(0); + expect(Object.keys(decoded.personaCreds).length).toBe(0); + }); + + it("round-trips bundle with global + persona creds", () => { + const bundle: CredBundle = { + schemaVersion: 1, + globalCreds: { + "gh-cli": Buffer.from("TEST-NOT-A-REAL-TOKEN-abc"), + "ssh-operator-pubkey": Buffer.from("ssh-ed25519 AAAAxxx test@host"), + }, + personaCreds: { + otto: { + claude: Buffer.from('{"creds":"opaque"}'), + gemini: Buffer.from('{"oauth":"opaque"}'), + }, + lior: { + gemini: Buffer.from('{"oauth":"diff"}'), + }, + }, + }; + const encoded = encodeBundle(bundle); + const decoded = decodeBundle(encoded); + if ("error" in decoded) throw new Error(decoded.error); + + expect(decoded.globalCreds["gh-cli"]!.equals(bundle.globalCreds["gh-cli"]!)).toBe(true); + expect(decoded.globalCreds["ssh-operator-pubkey"]!.equals(bundle.globalCreds["ssh-operator-pubkey"]!)).toBe(true); + expect(decoded.personaCreds.otto!.claude!.equals(bundle.personaCreds.otto!.claude!)).toBe(true); + expect(decoded.personaCreds.otto!.gemini!.equals(bundle.personaCreds.otto!.gemini!)).toBe(true); + expect(decoded.personaCreds.lior!.gemini!.equals(bundle.personaCreds.lior!.gemini!)).toBe(true); + }); + + it("preserves binary content (non-utf8 bytes)", () => { + const bytes = randomBytes(256); + const bundle: CredBundle = { + schemaVersion: 1, + globalCreds: { binary: bytes }, + personaCreds: {}, + }; + const encoded = encodeBundle(bundle); + const decoded = decodeBundle(encoded); + if ("error" in decoded) throw new Error(decoded.error); + expect(decoded.globalCreds.binary!.equals(bytes)).toBe(true); + }); +}); + +describe("decodeBundle — error paths", () => { + it("rejects malformed JSON", () => { + expect("error" in decodeBundle(Buffer.from("not json {{"))).toBe(true); + }); + + it("rejects wrong schemaVersion", () => { + const result = decodeBundle(Buffer.from(JSON.stringify({ schemaVersion: 99, globalCreds: {}, personaCreds: {} }))); + expect("error" in result).toBe(true); + }); + + it("rejects missing globalCreds", () => { + const result = decodeBundle(Buffer.from(JSON.stringify({ schemaVersion: 1, personaCreds: {} }))); + expect("error" in result).toBe(true); + }); + + it("rejects non-string values in globalCreds", () => { + const result = decodeBundle( + Buffer.from(JSON.stringify({ schemaVersion: 1, globalCreds: { x: 42 }, personaCreds: {} })), + ); + expect("error" in result).toBe(true); + }); + + it("rejects non-object input", () => { + expect("error" in decodeBundle(Buffer.from("[]"))).toBe(true); + expect("error" in decodeBundle(Buffer.from("null"))).toBe(true); + expect("error" in decodeBundle(Buffer.from('"string"'))).toBe(true); + }); +}); + +describe("full pipeline — bundle → encode → encrypt → serialize → parse → decrypt → decode", () => { + it("round-trips through all layers", () => { + const original: CredBundle = { + schemaVersion: 1, + globalCreds: { + "gh-cli": Buffer.from("test-token-value"), + }, + personaCreds: { + otto: { claude: Buffer.from('{"k":"v"}') }, + }, + }; + // bundle → encoded plaintext + const plaintext = encodeBundle(original); + // plaintext → encrypted envelope + const env = encrypt(plaintext, UUID, PASS); + // envelope → wire blob + const blob = serializeEnvelope(env); + // === simulating disk round-trip === + // wire blob → envelope + const parsedEnv = parseEnvelope(blob); + if (!("salt" in parsedEnv)) throw new Error(parsedEnv.error); + // envelope → decrypted plaintext + const decrypted = decrypt(parsedEnv, UUID, PASS); + if ("error" in decrypted) throw new Error(decrypted.error); + // plaintext → bundle + const decoded = decodeBundle(decrypted); + if ("error" in decoded) throw new Error(decoded.error); + + expect(decoded.globalCreds["gh-cli"]!.equals(original.globalCreds["gh-cli"]!)).toBe(true); + expect(decoded.personaCreds.otto!.claude!.equals(original.personaCreds.otto!.claude!)).toBe(true); + }); +}); diff --git a/tools/installer/zeta-creds-envelope.ts b/tools/installer/zeta-creds-envelope.ts new file mode 100644 index 0000000000..f983a3748e --- /dev/null +++ b/tools/installer/zeta-creds-envelope.ts @@ -0,0 +1,241 @@ +// zeta-creds-envelope.ts — wire-format serializer for B-0852 cred-persistence blob. +// +// B-0852 sub-row .2a (envelope serialization layer). Pure functions; no I/O. +// Composes with: +// - tools/installer/zeta-creds-crypto.ts (B-0852.1; produces/consumes Envelope) +// - tools/installer/zeta-creds-manifest.ts (B-0852.5; per-cred entries) +// - tools/installer/zeta-cred-handlers.ts (B-0852.10; per-cred value handlers) +// - tools/installer/zeta-creds-persist.ts (B-0852.2b; writes to ESP) +// - tools/installer/zeta-creds-restore.ts (B-0852.2b; reads from ESP) +// +// Wire format (binary; little-endian; v1): +// +// Header (8 bytes): +// magic : 4 bytes "ZCV1" (zeta-creds v1; magic-number sanity check) +// reserved : 4 bytes zero (future flags / version bumps) +// +// Envelope (variable; from B-0852.1 crypto): +// salt_len : 2 bytes uint16le (always 32; explicit for forward-compat) +// salt : bytes +// iv_len : 2 bytes uint16le (always 12) +// iv : bytes +// tag_len : 2 bytes uint16le (always 16) +// tag : bytes +// ciphertext_len : 4 bytes uint32le +// ciphertext : bytes +// +// Inner plaintext (after decryption) is a separate concern (CredBundle); this +// module only handles the on-disk envelope frame. +// +// Why explicit length-prefixed framing rather than JSON serialization: +// - Binary blob can't accidentally leak via cat/grep on the ESP +// - Length-prefixed format is forward-compat (future fields prepend without +// breaking older readers that ignore unknown trailing bytes — though v1 +// readers reject extra bytes; v2 will need explicit version field bump) +// - 8-byte header gives clear file-type identification for forensics + +import type { Envelope } from "./zeta-creds-crypto"; + +/** Magic 4-byte header identifying a Zeta Creds v1 blob. */ +export const MAGIC = Buffer.from("ZCV1", "ascii"); + +/** Header length: magic (4) + reserved (4) = 8 bytes. */ +export const HEADER_LEN = 8; + +/** Total minimum blob size (header + 4 length prefixes + min 1-byte ciphertext). */ +export const MIN_BLOB_LEN = HEADER_LEN + 2 + 32 + 2 + 12 + 2 + 16 + 4 + 1; + +/** + * Serialize an Envelope to the on-disk wire format. + * + * @param env - the encryption envelope from B-0852.1 encrypt() + * @returns Buffer ready to write to /esp/zeta-creds.enc + */ +export function serializeEnvelope(env: Envelope): Buffer { + const buffers: Buffer[] = []; + + // Header + buffers.push(MAGIC); + const reserved = Buffer.alloc(4); + buffers.push(reserved); // zero-filled + + // Salt + const saltLen = Buffer.alloc(2); + saltLen.writeUInt16LE(env.salt.length, 0); + buffers.push(saltLen); + buffers.push(Buffer.from(env.salt)); + + // IV + const ivLen = Buffer.alloc(2); + ivLen.writeUInt16LE(env.iv.length, 0); + buffers.push(ivLen); + buffers.push(Buffer.from(env.iv)); + + // Tag + const tagLen = Buffer.alloc(2); + tagLen.writeUInt16LE(env.tag.length, 0); + buffers.push(tagLen); + buffers.push(Buffer.from(env.tag)); + + // Ciphertext + const ctLen = Buffer.alloc(4); + ctLen.writeUInt32LE(env.ciphertext.length, 0); + buffers.push(ctLen); + buffers.push(Buffer.from(env.ciphertext)); + + return Buffer.concat(buffers); +} + +/** + * Parse a wire-format blob back into an Envelope. Returns structured error + * on any framing issue (substrate-honest: failure IS a value). + */ +export function parseEnvelope(blob: Buffer): Envelope | { readonly error: string } { + if (blob.length < MIN_BLOB_LEN) { + return { error: `blob too small: ${blob.length} bytes < ${MIN_BLOB_LEN} minimum` }; + } + + // Validate magic + if (!blob.subarray(0, 4).equals(MAGIC)) { + return { + error: `invalid magic header; expected "ZCV1", got bytes ${Array.from(blob.subarray(0, 4)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("")}`, + }; + } + + let offset = HEADER_LEN; + + // Helper: read length-prefixed bytes + function readLenPrefixed(prefixSize: 2 | 4, what: string): Buffer | { error: string } { + if (offset + prefixSize > blob.length) { + return { error: `blob truncated reading ${what} length prefix at offset ${offset}` }; + } + const len = prefixSize === 2 ? blob.readUInt16LE(offset) : blob.readUInt32LE(offset); + offset += prefixSize; + if (offset + len > blob.length) { + return { error: `blob truncated reading ${what} payload (${len} bytes) at offset ${offset}` }; + } + const data = blob.subarray(offset, offset + len); + offset += len; + return Buffer.from(data); + } + + const salt = readLenPrefixed(2, "salt"); + if (!(salt instanceof Buffer)) return salt; + + const iv = readLenPrefixed(2, "iv"); + if (!(iv instanceof Buffer)) return iv; + + const tag = readLenPrefixed(2, "tag"); + if (!(tag instanceof Buffer)) return tag; + + const ciphertext = readLenPrefixed(4, "ciphertext"); + if (!(ciphertext instanceof Buffer)) return ciphertext; + + // v1: extra trailing bytes are an error (future versions may relax) + if (offset !== blob.length) { + return { error: `unexpected trailing bytes after envelope: ${blob.length - offset} bytes` }; + } + + return { salt, iv, tag, ciphertext }; +} + +/** + * Inner plaintext shape (post-decryption): a map of cred-id → bytes, with + * persona-scoped credentials nested under each persona's section. + * + * Serialized as JSON for the plaintext layer (envelope wraps + encrypts this). + * + * Schema: + * { + * schemaVersion: 1, + * globalCreds: { "": , ... }, + * personaCreds: { "": { "": , ... }, ... } + * } + * + * Per B-0852.5 manifest: personaScoped=false entries go in globalCreds; + * personaScoped=true entries go in personaCreds[][]. + */ +export interface CredBundle { + readonly schemaVersion: 1; + readonly globalCreds: Readonly>; + readonly personaCreds: Readonly>>>; +} + +/** JSON-serializable form of CredBundle (bytes base64-encoded). */ +interface CredBundleJson { + readonly schemaVersion: 1; + readonly globalCreds: Readonly>; + readonly personaCreds: Readonly>>>; +} + +/** Encode CredBundle → utf8 JSON bytes (the plaintext that gets encrypted). */ +export function encodeBundle(bundle: CredBundle): Buffer { + const jsonForm: CredBundleJson = { + schemaVersion: 1, + globalCreds: Object.fromEntries(Object.entries(bundle.globalCreds).map(([k, v]) => [k, v.toString("base64")])), + personaCreds: Object.fromEntries( + Object.entries(bundle.personaCreds).map(([persona, creds]) => [ + persona, + Object.fromEntries(Object.entries(creds).map(([k, v]) => [k, v.toString("base64")])), + ]), + ), + }; + return Buffer.from(JSON.stringify(jsonForm), "utf8"); +} + +/** Decode utf8 JSON bytes → CredBundle. Returns structured error on parse failure. */ +export function decodeBundle(plaintext: Buffer): CredBundle | { readonly error: string } { + let parsed: unknown; + try { + parsed = JSON.parse(plaintext.toString("utf8")); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { error: `cred bundle JSON parse failed: ${msg}` }; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { error: "cred bundle must be a JSON object" }; + } + const obj = parsed as Record; + if (obj.schemaVersion !== 1) { + return { error: `cred bundle schemaVersion must be 1; got ${JSON.stringify(obj.schemaVersion)}` }; + } + if (!isStringMap(obj.globalCreds)) { + return { error: "cred bundle globalCreds must be { string: string } map" }; + } + if (!isPersonaCredsMap(obj.personaCreds)) { + return { error: "cred bundle personaCreds must be { string: { string: string } } map" }; + } + + const globalCreds: Record = {}; + for (const [k, v] of Object.entries(obj.globalCreds)) { + globalCreds[k] = Buffer.from(v, "base64"); + } + const personaCreds: Record> = {}; + for (const [persona, creds] of Object.entries(obj.personaCreds)) { + const personaSection: Record = {}; + for (const [k, v] of Object.entries(creds)) { + personaSection[k] = Buffer.from(v, "base64"); + } + personaCreds[persona] = personaSection; + } + + return { schemaVersion: 1, globalCreds, personaCreds }; +} + +function isStringMap(v: unknown): v is Record { + if (v === null || typeof v !== "object" || Array.isArray(v)) return false; + for (const val of Object.values(v as Record)) { + if (typeof val !== "string") return false; + } + return true; +} + +function isPersonaCredsMap(v: unknown): v is Record> { + if (v === null || typeof v !== "object" || Array.isArray(v)) return false; + for (const val of Object.values(v as Record)) { + if (!isStringMap(val)) return false; + } + return true; +} From a5146d81a8bc36cc3723ef93d3b39cb0a6e78d0a Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 03:46:00 -0400 Subject: [PATCH 2/3] feat(B-0852.2b): persist + restore CLIs composing the full cred-persistence stack (19 integration tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B-0852 sub-row .2 final slice — operational CLI surface that composes all 4 already-shipped modules: - B-0852.1 crypto (encrypt/decrypt with scrypt+HKDF+AES-256-GCM) - B-0852.5 manifest (declarative cred catalog) - B-0852.10 per-cred handlers (--bake-cred parse + validate) - B-0852.2a envelope (wire format + CredBundle plaintext schema) THREE files: 1. tools/installer/zeta-creds-persist.ts (CLI) Usage: bun zeta-creds-persist.ts --usb-uuid --output /esp/zeta-creds.enc --passphrase-{env VAR | file PATH} [--persona ] [--bake-cred =]... Composes --bake-cred args + manifest personaScoped flag → CredBundle → encrypt via crypto module → serialize via envelope → write to ESP. 2. tools/installer/zeta-creds-restore.ts (CLI) Usage: bun zeta-creds-restore.ts --usb-uuid --input /esp/zeta-creds.enc --passphrase-{env VAR | file PATH} [--persona ] [--target-root /] [--dry-run] Reads encrypted blob → parseEnvelope → decrypt → decodeBundle → writes each cred to its manifest-declared paths under target-root. Exit codes: 0=ok / 2=arg-parse / 3=file-read / 4=envelope-parse / 5=decrypt-fail (wrong pass/UUID/tampered) / 6=bundle-decode / 7=manifest-mismatch. --dry-run prints plan without writing. 3. tools/installer/zeta-creds-persist-restore.test.ts (19 tests) - parsePersistArgs / parseRestoreArgs (well-formed + each error path) - composeBundle (global / persona / missing-persona / unknown-id) - resolveCredPaths (~ expansion + absolute paths under target-root) - persist→restore round-trips via tmpdir (gh-cli global + claude persona) - wrong-passphrase / wrong-UUID / tampered-blob / invalid-magic each surface their canonical exit code via planRestore Note: passphrase-interactive-prompt deferred to a NixOS module wrapper (B-0852.4); these CLIs require --passphrase-file or --passphrase-env so the modules are scriptable + test-driveable. Interactive entry happens at the install-script Step 6.77 (per B-0852 row body) which prompts + exports to env before invoking these CLIs. Test output: 19 pass / 0 fail / 28 expect() calls / 2.06s (scrypt-bound). Format prettier-clean. Composes with: - B-0852 parent (cred persistence) - B-0852.1 crypto module (merged PR #5411) - B-0852.5 cred-manifest (merged PR #5414) - B-0852.10 per-cred handlers (merged PR #5418) - B-0852.2a envelope (PR #5421 — this PR chains off; will resolve cleanly when #5421 merges to main) - B-0852.4 future — NixOS module wraps these CLIs with passphrase prompt - B-0852.3 future — zeta-install.sh Step 6.77 picker invokes these CLIs - B-0852.9 future — zflash --bake-cred at flash-time invokes persist directly What this is NOT: - NOT the interactive passphrase prompt (B-0852.4 NixOS-module scope) - NOT the zeta-install.sh Step 6.77 integration (B-0852.3) - NOT the zflash --bake-cred at flash time (B-0852.9; same persist CLI invoked from operator's Mac instead of target boot) --- .../zeta-creds-persist-restore.test.ts | 274 ++++++++++++++++++ tools/installer/zeta-creds-persist.ts | 149 ++++++++++ tools/installer/zeta-creds-restore.ts | 267 +++++++++++++++++ 3 files changed, 690 insertions(+) create mode 100644 tools/installer/zeta-creds-persist-restore.test.ts create mode 100644 tools/installer/zeta-creds-persist.ts create mode 100644 tools/installer/zeta-creds-restore.ts diff --git a/tools/installer/zeta-creds-persist-restore.test.ts b/tools/installer/zeta-creds-persist-restore.test.ts new file mode 100644 index 0000000000..a06cd00157 --- /dev/null +++ b/tools/installer/zeta-creds-persist-restore.test.ts @@ -0,0 +1,274 @@ +// zeta-creds-persist-restore.test.ts — B-0852.2b CLI integration tests. +// +// Covers the full persist → restore round-trip via temp-dir filesystem +// (not just pure-function units; the CLI surfaces have FS I/O). + +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildBlob, composeBundle, parseArgs as parsePersistArgs } from "./zeta-creds-persist"; +import { applyPlan, parseArgs as parseRestoreArgs, planRestore, resolveCredPaths } from "./zeta-creds-restore"; +import { DEFAULT_MANIFEST } from "./zeta-creds-manifest"; + +const UUID = "test-uuid-1234-5678-9abc"; +const PASS = "integration-test-passphrase"; + +describe("parsePersistArgs", () => { + it("accepts well-formed args with --passphrase-env", () => { + const result = parsePersistArgs( + ["--usb-uuid", UUID, "--output", "/tmp/blob", "--passphrase-env", "TP", "--bake-cred", "gh-cli=ghp_x"], + { TP: PASS }, + ); + if ("error" in result) throw new Error(result.error); + expect(result.usbUuid).toBe(UUID); + expect(result.output).toBe("/tmp/blob"); + expect(result.passphrase).toBe(PASS); + expect(result.bakeCredArgs.length).toBe(1); + }); + + it("rejects missing --usb-uuid", () => { + const result = parsePersistArgs(["--output", "/tmp/x", "--passphrase-env", "PP"], { PP: "x" }); + expect("error" in result).toBe(true); + }); + + it("rejects missing --output", () => { + const result = parsePersistArgs(["--usb-uuid", UUID, "--passphrase-env", "PP"], { PP: "x" }); + expect("error" in result).toBe(true); + }); + + it("rejects empty --passphrase-env", () => { + const result = parsePersistArgs(["--usb-uuid", UUID, "--output", "/tmp/x", "--passphrase-env", "MISSING"], {}); + expect("error" in result).toBe(true); + }); + + it("rejects unknown flag", () => { + const result = parsePersistArgs(["--bogus"], {}); + expect("error" in result).toBe(true); + }); +}); + +describe("composeBundle", () => { + it("composes global cred (gh-cli; personaScoped:false)", () => { + const args = { + usbUuid: UUID, + output: "x", + passphrase: PASS, + persona: null, + bakeCredArgs: ["gh-cli=test-token-value"], + }; + const bundle = composeBundle(args); + if ("error" in bundle) throw new Error(bundle.error); + expect(bundle.globalCreds["gh-cli"]).toBeDefined(); + expect(Object.keys(bundle.personaCreds).length).toBe(0); + }); + + it("composes persona cred when --persona given", () => { + const args = { + usbUuid: UUID, + output: "x", + passphrase: PASS, + persona: "otto", + bakeCredArgs: ['claude={"creds":"value"}'], + }; + const bundle = composeBundle(args); + if ("error" in bundle) throw new Error(bundle.error); + expect(bundle.personaCreds.otto!.claude).toBeDefined(); + }); + + it("rejects personaScoped cred without --persona", () => { + const args = { + usbUuid: UUID, + output: "x", + passphrase: PASS, + persona: null, + bakeCredArgs: ['claude={"creds":"value"}'], + }; + const result = composeBundle(args); + expect("error" in result).toBe(true); + }); + + it("rejects unknown cred id", () => { + const args = { + usbUuid: UUID, + output: "x", + passphrase: PASS, + persona: null, + bakeCredArgs: ["nonexistent=value"], + }; + const result = composeBundle(args); + expect("error" in result).toBe(true); + }); +}); + +describe("resolveCredPaths", () => { + const entry = DEFAULT_MANIFEST.credentials.find((c) => c.id === "gh-cli")!; + + it("expands ~ to home dir under target-root", () => { + const paths = resolveCredPaths(entry, "/mnt"); + expect(paths[0]).toContain("/mnt/"); + expect(paths[0]).toContain(".config/gh/hosts.yml"); + }); + + it("respects target-root for absolute paths", () => { + const sshEntry = DEFAULT_MANIFEST.credentials.find((c) => c.id === "ssh-operator-pubkey")!; + const paths = resolveCredPaths(sshEntry, "/mnt"); + expect(paths[0]).toBe("/mnt/etc/zeta/operator-authorized-keys"); + }); +}); + +describe("persist → restore round-trip via tmpdir", () => { + let tmp: string; + + beforeAll(() => { + tmp = mkdtempSync(join(tmpdir(), "zeta-creds-2b-test-")); + }); + + afterAll(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it("global cred (gh-cli) round-trips via blob through tmpdir", () => { + // Persist + const persistArgs = { + usbUuid: UUID, + output: join(tmp, "blob1.enc"), + passphrase: PASS, + persona: null, + bakeCredArgs: ["gh-cli=PERSISTED-TOKEN-VALUE"], + }; + const bundle = composeBundle(persistArgs); + if ("error" in bundle) throw new Error(bundle.error); + const blob = buildBlob(bundle, persistArgs.usbUuid, persistArgs.passphrase); + writeFileSync(persistArgs.output, blob); + + // Restore + const restoreRoot = join(tmp, "restore-target"); + const blobRead = readFileSync(persistArgs.output); + const written = applyPlan(blobRead, UUID, PASS, null, restoreRoot); + expect(written).toBe(1); + // gh-cli writes to //.config/gh/hosts.yml + const ghPath = resolveCredPaths(DEFAULT_MANIFEST.credentials.find((c) => c.id === "gh-cli")!, restoreRoot)[0]!; + const restored = readFileSync(ghPath, "utf8"); + expect(restored).toBe("PERSISTED-TOKEN-VALUE"); + }); + + it("persona cred (claude under otto) round-trips", () => { + const persistArgs = { + usbUuid: UUID, + output: join(tmp, "blob2.enc"), + passphrase: PASS, + persona: "otto", + bakeCredArgs: ['claude={"creds":"OTTO-CLAUDE"}'], + }; + const bundle = composeBundle(persistArgs); + if ("error" in bundle) throw new Error(bundle.error); + const blob = buildBlob(bundle, persistArgs.usbUuid, persistArgs.passphrase); + writeFileSync(persistArgs.output, blob); + + const restoreRoot = join(tmp, "restore-target-2"); + const written = applyPlan(readFileSync(persistArgs.output), UUID, PASS, "otto", restoreRoot); + expect(written).toBe(1); + const claudePath = resolveCredPaths(DEFAULT_MANIFEST.credentials.find((c) => c.id === "claude")!, restoreRoot)[0]!; + const restored = readFileSync(claudePath, "utf8"); + expect(restored).toBe('{"creds":"OTTO-CLAUDE"}'); + }); + + it("planRestore reports wrong passphrase as code 5", () => { + const persistArgs = { + usbUuid: UUID, + output: join(tmp, "blob3.enc"), + passphrase: PASS, + persona: null, + bakeCredArgs: ["gh-cli=ANY"], + }; + const bundle = composeBundle(persistArgs); + if ("error" in bundle) throw new Error(bundle.error); + const blob = buildBlob(bundle, persistArgs.usbUuid, persistArgs.passphrase); + + const plan = planRestore(blob, UUID, "WRONG-PASSPHRASE", null, "/tmp/dontcare"); + if (!("error" in plan)) throw new Error("expected error from wrong passphrase"); + expect(plan.code).toBe(5); + }); + + it("planRestore reports wrong UUID as code 5 (defeats copy-to-different-USB)", () => { + const persistArgs = { + usbUuid: UUID, + output: join(tmp, "blob4.enc"), + passphrase: PASS, + persona: null, + bakeCredArgs: ["gh-cli=ANY"], + }; + const bundle = composeBundle(persistArgs); + if ("error" in bundle) throw new Error(bundle.error); + const blob = buildBlob(bundle, persistArgs.usbUuid, persistArgs.passphrase); + + const plan = planRestore(blob, "different-uuid", PASS, null, "/tmp/dontcare"); + if (!("error" in plan)) throw new Error("expected error from wrong UUID"); + expect(plan.code).toBe(5); + }); + + it("planRestore reports tampered blob as code 5", () => { + const persistArgs = { + usbUuid: UUID, + output: join(tmp, "blob5.enc"), + passphrase: PASS, + persona: null, + bakeCredArgs: ["gh-cli=ANY"], + }; + const bundle = composeBundle(persistArgs); + if ("error" in bundle) throw new Error(bundle.error); + const blob = buildBlob(bundle, persistArgs.usbUuid, persistArgs.passphrase); + + // Flip a byte in the middle of the ciphertext region + const tampered = Buffer.from(blob); + tampered[blob.length - 10] = tampered[blob.length - 10]! ^ 0x01; + + const plan = planRestore(tampered, UUID, PASS, null, "/tmp/dontcare"); + if (!("error" in plan)) throw new Error("expected error from tampered blob"); + expect(plan.code).toBe(5); + }); + + it("planRestore reports invalid magic header as code 4", () => { + const plan = planRestore( + Buffer.from("not a valid blob at all just text bytes" + "x".repeat(200)), + UUID, + PASS, + null, + "/tmp/dontcare", + ); + if (!("error" in plan)) throw new Error("expected error from invalid header"); + expect(plan.code).toBe(4); + }); +}); + +describe("parseRestoreArgs", () => { + it("accepts well-formed args", () => { + const result = parseRestoreArgs( + [ + "--usb-uuid", + UUID, + "--input", + "/x.enc", + "--passphrase-env", + "PP", + "--persona", + "otto", + "--target-root", + "/mnt", + "--dry-run", + ], + { PP: PASS }, + ); + if ("error" in result) throw new Error(result.error); + expect(result.persona).toBe("otto"); + expect(result.targetRoot).toBe("/mnt"); + expect(result.dryRun).toBe(true); + }); + + it("default target-root is /", () => { + const result = parseRestoreArgs(["--usb-uuid", UUID, "--input", "/x", "--passphrase-env", "PP"], { PP: "x" }); + if ("error" in result) throw new Error(result.error); + expect(result.targetRoot).toBe("/"); + }); +}); diff --git a/tools/installer/zeta-creds-persist.ts b/tools/installer/zeta-creds-persist.ts new file mode 100644 index 0000000000..e08b00cc9d --- /dev/null +++ b/tools/installer/zeta-creds-persist.ts @@ -0,0 +1,149 @@ +#!/usr/bin/env bun +// zeta-creds-persist.ts — write encrypted cred-blob to ESP. B-0852.2b CLI. +// +// Reads --bake-cred args + per-cred handler validation + crypto encrypt + +// envelope serialize → writes to --output (e.g., /esp/zeta-creds.enc). +// +// Composes: +// - tools/installer/zeta-creds-crypto.ts (B-0852.1; encrypt) +// - tools/installer/zeta-creds-manifest.ts (B-0852.5; cred catalog) +// - tools/installer/zeta-cred-handlers.ts (B-0852.10; parse + validate args) +// - tools/installer/zeta-creds-envelope.ts (B-0852.2a; wire format + bundle) +// +// Usage: +// bun tools/installer/zeta-creds-persist.ts \ +// --usb-uuid \ +// --output /esp/zeta-creds.enc \ +// [--passphrase-file | --passphrase-env | (interactive prompt)] \ +// [--persona ] \ +// [--bake-cred =] ... +// +// Per .claude/rules/non-coercion-invariant.md HC-8: operator authority over +// own creds; passphrase NEVER logged; --bake-cred error messages redact +// value-source contents per B-0852.10 P0 security discipline. + +import { writeFileSync, readFileSync, existsSync } from "node:fs"; +import { encrypt } from "./zeta-creds-crypto"; +import { DEFAULT_HANDLERS, resolveBakeCred } from "./zeta-cred-handlers"; +import { encodeBundle, serializeEnvelope, type CredBundle } from "./zeta-creds-envelope"; +import { DEFAULT_MANIFEST } from "./zeta-creds-manifest"; + +interface Args { + readonly usbUuid: string; + readonly output: string; + readonly passphrase: string; + readonly persona: string | null; + readonly bakeCredArgs: readonly string[]; +} + +/** Parse CLI args. Pure (no I/O). Returns args OR error message. */ +export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv): Args | { readonly error: string } { + let usbUuid: string | null = null; + let output: string | null = null; + let passphraseFile: string | null = null; + let passphraseEnv: string | null = null; + let persona: string | null = null; + const bakeCredArgs: string[] = []; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + const next = (): string => { + if (i + 1 >= argv.length) throw new Error(`${arg} requires a value`); + return argv[++i]!; + }; + try { + if (arg === "--usb-uuid") usbUuid = next(); + else if (arg === "--output") output = next(); + else if (arg === "--passphrase-file") passphraseFile = next(); + else if (arg === "--passphrase-env") passphraseEnv = next(); + else if (arg === "--persona") persona = next(); + else if (arg === "--bake-cred") bakeCredArgs.push(next()); + else return { error: `unknown flag: ${arg}` }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + } + + if (!usbUuid) return { error: "--usb-uuid required" }; + if (!output) return { error: "--output required" }; + + // Resolve passphrase source. Interactive prompt handled by caller (main); this + // path is exercised when --passphrase-file or --passphrase-env is given. + let passphrase: string | null = null; + if (passphraseFile) { + if (!existsSync(passphraseFile)) return { error: `--passphrase-file not found: ${passphraseFile}` }; + passphrase = readFileSync(passphraseFile, "utf8").replace(/\r?\n$/, ""); + if (passphrase.length === 0) return { error: "--passphrase-file is empty" }; + } else if (passphraseEnv) { + const v = env[passphraseEnv]; + if (v === undefined || v === null || v === "") { + return { error: `--passphrase-env ${passphraseEnv} not set or empty` }; + } + passphrase = v; + } + if (passphrase === null) + return { + error: + "passphrase source required (interactive prompt not supported in this entry-point; use --passphrase-file or --passphrase-env)", + }; + + return { usbUuid, output, passphrase, persona, bakeCredArgs }; +} + +/** Compose a CredBundle from --bake-cred args + manifest. Pure (in test mode). */ +export function composeBundle(args: Args): CredBundle | { readonly error: string } { + const globalCreds: Record = {}; + const personaCreds: Record> = {}; + + for (const arg of args.bakeCredArgs) { + const result = resolveBakeCred(arg, DEFAULT_HANDLERS); + if ("error" in result) return { error: `bake-cred parse failed: ${result.error}` }; + + // Look up manifest entry to determine personaScoped + const manifestEntry = DEFAULT_MANIFEST.credentials.find((c) => c.id === result.ok.id); + if (!manifestEntry) return { error: `cred id "${result.ok.id}" not declared in manifest` }; + + if (manifestEntry.personaScoped) { + if (!args.persona) return { error: `cred "${result.ok.id}" is personaScoped; --persona required` }; + personaCreds[args.persona] ??= {}; + personaCreds[args.persona]![result.ok.id] = result.ok.value; + } else { + globalCreds[result.ok.id] = result.ok.value; + } + } + + return { schemaVersion: 1, globalCreds, personaCreds }; +} + +/** Pure pipeline (no FS write): bundle → encrypt → serialize. */ +export function buildBlob(bundle: CredBundle, usbUuid: string, passphrase: string): Buffer { + const plaintext = encodeBundle(bundle); + const env = encrypt(plaintext, usbUuid, passphrase); + return serializeEnvelope(env); +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const parsed = parseArgs(argv, process.env); + if ("error" in parsed) { + console.error(`zeta-creds-persist: ${parsed.error}`); + return 2; + } + const bundle = composeBundle(parsed); + if ("error" in bundle) { + console.error(`zeta-creds-persist: ${bundle.error}`); + return 3; + } + const blob = buildBlob(bundle, parsed.usbUuid, parsed.passphrase); + writeFileSync(parsed.output, blob); + console.log(`zeta-creds-persist: wrote ${blob.length} bytes to ${parsed.output}`); + console.log(` globalCreds: ${Object.keys(bundle.globalCreds).join(", ") || "(none)"}`); + for (const [persona, creds] of Object.entries(bundle.personaCreds)) { + console.log(` personaCreds[${persona}]: ${Object.keys(creds).join(", ")}`); + } + return 0; +} + +if (import.meta.main) { + main().then((code) => process.exit(code)); +} diff --git a/tools/installer/zeta-creds-restore.ts b/tools/installer/zeta-creds-restore.ts new file mode 100644 index 0000000000..aee1d09179 --- /dev/null +++ b/tools/installer/zeta-creds-restore.ts @@ -0,0 +1,267 @@ +#!/usr/bin/env bun +// zeta-creds-restore.ts — read encrypted cred-blob from ESP + restore to disk. +// B-0852.2b CLI sibling to zeta-creds-persist.ts. +// +// Composes: +// - tools/installer/zeta-creds-crypto.ts (B-0852.1; decrypt) +// - tools/installer/zeta-creds-manifest.ts (B-0852.5; cred catalog → paths) +// - tools/installer/zeta-creds-envelope.ts (B-0852.2a; wire format + bundle) +// +// Usage: +// bun tools/installer/zeta-creds-restore.ts \ +// --usb-uuid \ +// --input /esp/zeta-creds.enc \ +// [--passphrase-file | --passphrase-env | (interactive)] \ +// [--persona ] \ +// [--target-root / (default: filesystem root; for tests use tmp dir)] \ +// [--dry-run] (print what would be written; don't write) +// +// Exit codes: +// 0 success +// 2 arg parse error +// 3 file read failure +// 4 envelope parse failure +// 5 decrypt failure (wrong passphrase / wrong UUID / tampered blob) +// 6 bundle decode failure +// 7 manifest missing cred id present in blob (mismatch) +// +// Per .claude/rules/non-coercion-invariant.md HC-8: operator authority over +// own creds; passphrase NEVER logged; required-cred write failure surfaces +// the failure rather than silently degrading. + +import { writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { homedir } from "node:os"; +import { decrypt } from "./zeta-creds-crypto"; +import { decodeBundle, parseEnvelope } from "./zeta-creds-envelope"; +import { DEFAULT_MANIFEST, type CredentialEntry } from "./zeta-creds-manifest"; + +interface Args { + readonly usbUuid: string; + readonly input: string; + readonly passphrase: string; + readonly persona: string | null; + readonly targetRoot: string; + readonly dryRun: boolean; +} + +/** Parse CLI args. Pure (env lookup + FS existence check for passphrase-file only). */ +export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv): Args | { readonly error: string } { + let usbUuid: string | null = null; + let input: string | null = null; + let passphraseFile: string | null = null; + let passphraseEnv: string | null = null; + let persona: string | null = null; + let targetRoot = "/"; + let dryRun = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + const next = (): string => { + if (i + 1 >= argv.length) throw new Error(`${arg} requires a value`); + return argv[++i]!; + }; + try { + if (arg === "--usb-uuid") usbUuid = next(); + else if (arg === "--input") input = next(); + else if (arg === "--passphrase-file") passphraseFile = next(); + else if (arg === "--passphrase-env") passphraseEnv = next(); + else if (arg === "--persona") persona = next(); + else if (arg === "--target-root") targetRoot = next(); + else if (arg === "--dry-run") dryRun = true; + else return { error: `unknown flag: ${arg}` }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + } + + if (!usbUuid) return { error: "--usb-uuid required" }; + if (!input) return { error: "--input required" }; + + let passphrase: string | null = null; + if (passphraseFile) { + if (!existsSync(passphraseFile)) return { error: `--passphrase-file not found: ${passphraseFile}` }; + passphrase = readFileSync(passphraseFile, "utf8").replace(/\r?\n$/, ""); + if (passphrase.length === 0) return { error: "--passphrase-file is empty" }; + } else if (passphraseEnv) { + const v = env[passphraseEnv]; + if (v === undefined || v === null || v === "") { + return { error: `--passphrase-env ${passphraseEnv} not set or empty` }; + } + passphrase = v; + } + if (passphrase === null) + return { + error: + "passphrase source required (interactive prompt not supported in this entry-point; use --passphrase-file or --passphrase-env)", + }; + + return { usbUuid, input, passphrase, persona, targetRoot, dryRun }; +} + +/** Resolve manifest entry's paths relative to target-root + ~ expansion. */ +export function resolveCredPaths(entry: CredentialEntry, targetRoot: string): readonly string[] { + return entry.paths.map((p) => { + if (p.startsWith("~/")) { + // ~ refers to caller's home (root user during install); restore happens + // pre-user-home so ~ resolution is best-effort here. + return resolve(targetRoot, homedir().replace(/^\//, ""), p.slice(2)); + } + if (p.startsWith("/")) { + return resolve(targetRoot, p.slice(1)); + } + return resolve(targetRoot, p); + }); +} + +/** Plan + summarize what would be written (for dry-run + logging). */ +export interface RestorePlan { + readonly writes: readonly { readonly path: string; readonly bytes: number }[]; + readonly skipped: readonly { readonly id: string; readonly reason: string }[]; + readonly errors: readonly string[]; +} + +export function planRestore( + blob: Buffer, + usbUuid: string, + passphrase: string, + persona: string | null, + targetRoot: string, +): RestorePlan | { readonly error: string; readonly code: 4 | 5 | 6 | 7 } { + const env = parseEnvelope(blob); + if ("error" in env) return { error: `envelope parse: ${env.error}`, code: 4 }; + + const plaintext = decrypt(env, usbUuid, passphrase); + if ("error" in plaintext) return { error: `decrypt: ${plaintext.error}`, code: 5 }; + + const bundle = decodeBundle(plaintext); + if ("error" in bundle) return { error: `bundle decode: ${bundle.error}`, code: 6 }; + + const writes: { path: string; bytes: number }[] = []; + const skipped: { id: string; reason: string }[] = []; + const errors: string[] = []; + + // Global creds + for (const [id, value] of Object.entries(bundle.globalCreds)) { + const entry = DEFAULT_MANIFEST.credentials.find((c) => c.id === id); + if (!entry) { + errors.push(`blob contains unknown cred id "${id}" (not in default manifest)`); + continue; + } + if (entry.personaScoped) { + skipped.push({ id, reason: `manifest declares personaScoped:true but blob put in globalCreds` }); + continue; + } + const paths = resolveCredPaths(entry, targetRoot); + // Write to FIRST path only (canonical); other paths are alternates the caller may symlink + writes.push({ path: paths[0]!, bytes: value.length }); + } + + // Persona creds (only restore the requested persona's section) + if (persona) { + const personaSection = bundle.personaCreds[persona]; + if (personaSection) { + for (const [id, value] of Object.entries(personaSection)) { + const entry = DEFAULT_MANIFEST.credentials.find((c) => c.id === id); + if (!entry) { + errors.push(`blob persona ${persona} contains unknown cred id "${id}"`); + continue; + } + if (!entry.personaScoped) { + skipped.push({ + id, + reason: `manifest declares personaScoped:false but blob put in personaCreds[${persona}]`, + }); + continue; + } + const paths = resolveCredPaths(entry, targetRoot); + writes.push({ path: paths[0]!, bytes: value.length }); + } + } else if (Object.keys(bundle.personaCreds).length > 0) { + skipped.push({ + id: `(persona-section)`, + reason: `requested persona "${persona}" not in blob; available: ${Object.keys(bundle.personaCreds).join(", ")}`, + }); + } + } else if (Object.keys(bundle.personaCreds).length > 0) { + for (const persona of Object.keys(bundle.personaCreds)) { + for (const id of Object.keys(bundle.personaCreds[persona]!)) { + skipped.push({ id: `${persona}/${id}`, reason: `persona-scoped cred; --persona not specified` }); + } + } + } + + if (errors.length > 0) return { error: errors.join("; "), code: 7 }; + return { writes, skipped, errors: [] }; +} + +/** Apply the plan (FS writes). Caller must have done planRestore first. */ +export function applyPlan( + blob: Buffer, + usbUuid: string, + passphrase: string, + persona: string | null, + targetRoot: string, +): number { + const env = parseEnvelope(blob); + if ("error" in env) throw new Error(`envelope parse: ${env.error}`); + const plaintext = decrypt(env, usbUuid, passphrase); + if ("error" in plaintext) throw new Error(`decrypt: ${plaintext.error}`); + const bundle = decodeBundle(plaintext); + if ("error" in bundle) throw new Error(`bundle decode: ${bundle.error}`); + + let writeCount = 0; + for (const [id, value] of Object.entries(bundle.globalCreds)) { + const entry = DEFAULT_MANIFEST.credentials.find((c) => c.id === id); + if (!entry || entry.personaScoped) continue; + const paths = resolveCredPaths(entry, targetRoot); + mkdirSync(dirname(paths[0]!), { recursive: true }); + writeFileSync(paths[0]!, value); + writeCount++; + } + if (persona) { + const personaSection = bundle.personaCreds[persona] ?? {}; + for (const [id, value] of Object.entries(personaSection)) { + const entry = DEFAULT_MANIFEST.credentials.find((c) => c.id === id); + if (!entry || !entry.personaScoped) continue; + const paths = resolveCredPaths(entry, targetRoot); + mkdirSync(dirname(paths[0]!), { recursive: true }); + writeFileSync(paths[0]!, value); + writeCount++; + } + } + return writeCount; +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const parsed = parseArgs(argv, process.env); + if ("error" in parsed) { + console.error(`zeta-creds-restore: ${parsed.error}`); + return 2; + } + if (!existsSync(parsed.input)) { + console.error(`zeta-creds-restore: input file not found: ${parsed.input}`); + return 3; + } + const blob = readFileSync(parsed.input); + const plan = planRestore(blob, parsed.usbUuid, parsed.passphrase, parsed.persona, parsed.targetRoot); + if ("error" in plan) { + console.error(`zeta-creds-restore: ${plan.error}`); + return plan.code; + } + if (parsed.dryRun) { + console.log(`zeta-creds-restore (DRY RUN; ${plan.writes.length} writes would happen):`); + for (const w of plan.writes) console.log(` WRITE ${w.path} (${w.bytes} bytes)`); + for (const s of plan.skipped) console.log(` SKIP ${s.id}: ${s.reason}`); + return 0; + } + const written = applyPlan(blob, parsed.usbUuid, parsed.passphrase, parsed.persona, parsed.targetRoot); + console.log(`zeta-creds-restore: wrote ${written} creds (target-root: ${parsed.targetRoot})`); + for (const s of plan.skipped) console.log(` SKIP ${s.id}: ${s.reason}`); + return 0; +} + +if (import.meta.main) { + main().then((code) => process.exit(code)); +} From 173c544fc356fdae91f984feaa4043a19c1c4211 Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 03:55:08 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(B-0852.2b):=2014=20Copilot=20findings?= =?UTF-8?q?=20=E2=80=94=20P0=20CodeQL=20secret-leak=20+=205=20error-handli?= =?UTF-8?q?ng=20bugs=20+=20applyPlan=20refactor=20(single=20decrypt)=20+?= =?UTF-8?q?=200-byte=20ciphertext=20+=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #5422 Copilot review caught 14 findings; all valid. Comprehensive fix-pass before re-arming auto-merge. P0 CodeQL clear-text-logging (2 alerts; persist.ts L129 + restore.ts L240): CodeQL flagged the env-var NAME from --passphrase-env being included in error strings (taint tracker treats env[passphraseEnv] access as sensitive → var-name becomes tainted). Fix: omit env-var name from error message; generic "--passphrase-env target var is not set or is empty" instead. Same change in both files. Error-handling bugs: - readFileSync(passphrase-file) in parseArgs not wrapped → could throw on permission failure instead of returning {error}. Fixed in both persist + restore. - readFileSync(input) in restore main() not wrapped → could throw on permission failure instead of returning code 3. Fixed. - writeFileSync(output) in persist main() not wrapped → could throw on unwritable path. Fixed; new exit code 4 for write failure. applyPlan refactor (P1 design): Prior: planRestore + applyPlan each did full parse→decrypt→decode, doubling scrypt cost + extending passphrase-derived key lifetime in memory. Now: planRestore returns RestorePlan with embedded value Buffer per write entry; applyPlan takes the plan + just writes (no decrypt). Single scrypt invocation per restore. Also fixes the silent-skip mismatch — applyPlan now consumes the plan's pre-validated writes; can't accidentally bypass the manifest match checks. 0-byte ciphertext (P2 envelope): MIN_BLOB_LEN was header + lens + 1-byte ciphertext; AES-GCM allows empty plaintext/ciphertext. Fixed: drop the +1. Documentation: - parseArgs docs: removed "Pure (no I/O)" claim (does FS reads for --passphrase-file); accurate description in new doc comment - Usage docs: removed "(interactive prompt)" mention (not implemented in this entry-point per design); replaced with "interactive prompting is the wrapping NixOS module's responsibility (B-0852.4)" Unused import: - tools/installer/zeta-creds-restore.ts: removed unused `join` import from node:path Test updates: - applyPlan signature changed; updated 2 test cases to call planRestore first + pass plan to applyPlan. All 19 persist/restore tests + 17 envelope tests still pass. Resolves 14 Copilot threads on PR #5422. --- tools/installer/zeta-creds-envelope.ts | 8 +- .../zeta-creds-persist-restore.test.ts | 8 +- tools/installer/zeta-creds-persist.ts | 43 ++++++-- tools/installer/zeta-creds-restore.ts | 104 ++++++++++-------- 4 files changed, 102 insertions(+), 61 deletions(-) diff --git a/tools/installer/zeta-creds-envelope.ts b/tools/installer/zeta-creds-envelope.ts index f983a3748e..bdbe0f94fc 100644 --- a/tools/installer/zeta-creds-envelope.ts +++ b/tools/installer/zeta-creds-envelope.ts @@ -42,8 +42,12 @@ export const MAGIC = Buffer.from("ZCV1", "ascii"); /** Header length: magic (4) + reserved (4) = 8 bytes. */ export const HEADER_LEN = 8; -/** Total minimum blob size (header + 4 length prefixes + min 1-byte ciphertext). */ -export const MIN_BLOB_LEN = HEADER_LEN + 2 + 32 + 2 + 12 + 2 + 16 + 4 + 1; +/** + * Total minimum blob size (header + 4 length prefixes + 0-byte ciphertext). + * AES-GCM permits empty plaintext/ciphertext, so the minimum includes a + * 0-length ciphertext per Copilot review on PR #5422. + */ +export const MIN_BLOB_LEN = HEADER_LEN + 2 + 32 + 2 + 12 + 2 + 16 + 4; /** * Serialize an Envelope to the on-disk wire format. diff --git a/tools/installer/zeta-creds-persist-restore.test.ts b/tools/installer/zeta-creds-persist-restore.test.ts index a06cd00157..cbb5cb3f7f 100644 --- a/tools/installer/zeta-creds-persist-restore.test.ts +++ b/tools/installer/zeta-creds-persist-restore.test.ts @@ -145,7 +145,9 @@ describe("persist → restore round-trip via tmpdir", () => { // Restore const restoreRoot = join(tmp, "restore-target"); const blobRead = readFileSync(persistArgs.output); - const written = applyPlan(blobRead, UUID, PASS, null, restoreRoot); + const plan = planRestore(blobRead, UUID, PASS, null, restoreRoot); + if ("error" in plan) throw new Error(plan.error); + const written = applyPlan(plan); expect(written).toBe(1); // gh-cli writes to //.config/gh/hosts.yml const ghPath = resolveCredPaths(DEFAULT_MANIFEST.credentials.find((c) => c.id === "gh-cli")!, restoreRoot)[0]!; @@ -167,7 +169,9 @@ describe("persist → restore round-trip via tmpdir", () => { writeFileSync(persistArgs.output, blob); const restoreRoot = join(tmp, "restore-target-2"); - const written = applyPlan(readFileSync(persistArgs.output), UUID, PASS, "otto", restoreRoot); + const plan = planRestore(readFileSync(persistArgs.output), UUID, PASS, "otto", restoreRoot); + if ("error" in plan) throw new Error(plan.error); + const written = applyPlan(plan); expect(written).toBe(1); const claudePath = resolveCredPaths(DEFAULT_MANIFEST.credentials.find((c) => c.id === "claude")!, restoreRoot)[0]!; const restored = readFileSync(claudePath, "utf8"); diff --git a/tools/installer/zeta-creds-persist.ts b/tools/installer/zeta-creds-persist.ts index e08b00cc9d..51bd44d7ab 100644 --- a/tools/installer/zeta-creds-persist.ts +++ b/tools/installer/zeta-creds-persist.ts @@ -14,10 +14,14 @@ // bun tools/installer/zeta-creds-persist.ts \ // --usb-uuid \ // --output /esp/zeta-creds.enc \ -// [--passphrase-file | --passphrase-env | (interactive prompt)] \ +// ( --passphrase-file | --passphrase-env ) \ // [--persona ] \ // [--bake-cred =] ... // +// Interactive passphrase prompts are NOT implemented in this CLI — caller +// must supply --passphrase-file or --passphrase-env. Interactive prompting +// is the wrapping NixOS module's responsibility (B-0852.4). +// // Per .claude/rules/non-coercion-invariant.md HC-8: operator authority over // own creds; passphrase NEVER logged; --bake-cred error messages redact // value-source contents per B-0852.10 P0 security discipline. @@ -36,7 +40,17 @@ interface Args { readonly bakeCredArgs: readonly string[]; } -/** Parse CLI args. Pure (no I/O). Returns args OR error message. */ +/** + * Parse CLI args. Reads filesystem ONLY for --passphrase-file (existence + * check + read). Returns Args OR structured error message. + * + * SECURITY: Error messages NEVER include the passphrase value itself, AND + * the env-var NAME passed as --passphrase-env value is not echoed back in + * error strings (CodeQL clear-text-logging finding on PR #5422: the + * var-name was tainted by env-access flow; omitting it avoids the false- + * positive AND avoids the genuine "operator might paste a long secret in + * argv if they confuse env-var-name with literal-secret" footgun). + */ export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv): Args | { readonly error: string } { let usbUuid: string | null = null; let output: string | null = null; @@ -67,24 +81,29 @@ export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv): Args if (!usbUuid) return { error: "--usb-uuid required" }; if (!output) return { error: "--output required" }; - // Resolve passphrase source. Interactive prompt handled by caller (main); this - // path is exercised when --passphrase-file or --passphrase-env is given. + // Resolve passphrase source. Interactive prompt is NOT implemented in this + // entry-point — caller must supply --passphrase-file or --passphrase-env. let passphrase: string | null = null; if (passphraseFile) { if (!existsSync(passphraseFile)) return { error: `--passphrase-file not found: ${passphraseFile}` }; - passphrase = readFileSync(passphraseFile, "utf8").replace(/\r?\n$/, ""); + try { + passphrase = readFileSync(passphraseFile, "utf8").replace(/\r?\n$/, ""); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { error: `--passphrase-file read failed: ${msg}` }; + } if (passphrase.length === 0) return { error: "--passphrase-file is empty" }; } else if (passphraseEnv) { const v = env[passphraseEnv]; if (v === undefined || v === null || v === "") { - return { error: `--passphrase-env ${passphraseEnv} not set or empty` }; + // SECURITY: omit env-var name from error (CodeQL taint via passphraseEnv) + return { error: "--passphrase-env target var is not set or is empty" }; } passphrase = v; } if (passphrase === null) return { - error: - "passphrase source required (interactive prompt not supported in this entry-point; use --passphrase-file or --passphrase-env)", + error: "passphrase source required: pass --passphrase-file or --passphrase-env ", }; return { usbUuid, output, passphrase, persona, bakeCredArgs }; @@ -135,7 +154,13 @@ async function main(): Promise { return 3; } const blob = buildBlob(bundle, parsed.usbUuid, parsed.passphrase); - writeFileSync(parsed.output, blob); + try { + writeFileSync(parsed.output, blob); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`zeta-creds-persist: write to ${parsed.output} failed: ${msg}`); + return 4; + } console.log(`zeta-creds-persist: wrote ${blob.length} bytes to ${parsed.output}`); console.log(` globalCreds: ${Object.keys(bundle.globalCreds).join(", ") || "(none)"}`); for (const [persona, creds] of Object.entries(bundle.personaCreds)) { diff --git a/tools/installer/zeta-creds-restore.ts b/tools/installer/zeta-creds-restore.ts index aee1d09179..e90dea1772 100644 --- a/tools/installer/zeta-creds-restore.ts +++ b/tools/installer/zeta-creds-restore.ts @@ -11,11 +11,15 @@ // bun tools/installer/zeta-creds-restore.ts \ // --usb-uuid \ // --input /esp/zeta-creds.enc \ -// [--passphrase-file | --passphrase-env | (interactive)] \ +// ( --passphrase-file | --passphrase-env ) \ // [--persona ] \ // [--target-root / (default: filesystem root; for tests use tmp dir)] \ // [--dry-run] (print what would be written; don't write) // +// Interactive passphrase prompts are NOT implemented in this CLI — caller +// must supply --passphrase-file or --passphrase-env. Interactive prompting +// is the wrapping NixOS module's responsibility (B-0852.4). +// // Exit codes: // 0 success // 2 arg parse error @@ -30,7 +34,7 @@ // the failure rather than silently degrading. import { writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { homedir } from "node:os"; import { decrypt } from "./zeta-creds-crypto"; import { decodeBundle, parseEnvelope } from "./zeta-creds-envelope"; @@ -45,7 +49,15 @@ interface Args { readonly dryRun: boolean; } -/** Parse CLI args. Pure (env lookup + FS existence check for passphrase-file only). */ +/** + * Parse CLI args. Reads filesystem ONLY for --passphrase-file. Returns + * Args OR structured error message. + * + * SECURITY: Error messages NEVER include the passphrase value itself, AND + * the env-var NAME passed as --passphrase-env value is not echoed back in + * error strings (CodeQL clear-text-logging finding on PR #5422 — see + * sibling note in zeta-creds-persist.ts). + */ export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv): Args | { readonly error: string } { let usbUuid: string | null = null; let input: string | null = null; @@ -81,19 +93,24 @@ export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv): Args let passphrase: string | null = null; if (passphraseFile) { if (!existsSync(passphraseFile)) return { error: `--passphrase-file not found: ${passphraseFile}` }; - passphrase = readFileSync(passphraseFile, "utf8").replace(/\r?\n$/, ""); + try { + passphrase = readFileSync(passphraseFile, "utf8").replace(/\r?\n$/, ""); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { error: `--passphrase-file read failed: ${msg}` }; + } if (passphrase.length === 0) return { error: "--passphrase-file is empty" }; } else if (passphraseEnv) { const v = env[passphraseEnv]; if (v === undefined || v === null || v === "") { - return { error: `--passphrase-env ${passphraseEnv} not set or empty` }; + // SECURITY: omit env-var name from error (CodeQL taint via passphraseEnv) + return { error: "--passphrase-env target var is not set or is empty" }; } passphrase = v; } if (passphrase === null) return { - error: - "passphrase source required (interactive prompt not supported in this entry-point; use --passphrase-file or --passphrase-env)", + error: "passphrase source required: pass --passphrase-file or --passphrase-env ", }; return { usbUuid, input, passphrase, persona, targetRoot, dryRun }; @@ -116,11 +133,17 @@ export function resolveCredPaths(entry: CredentialEntry, targetRoot: string): re /** Plan + summarize what would be written (for dry-run + logging). */ export interface RestorePlan { - readonly writes: readonly { readonly path: string; readonly bytes: number }[]; + readonly writes: readonly { readonly path: string; readonly bytes: number; readonly value: Buffer }[]; readonly skipped: readonly { readonly id: string; readonly reason: string }[]; readonly errors: readonly string[]; } +/** + * Decrypt + decode the blob + plan writes per manifest. Single decrypt + * (scrypt is expensive — per Copilot review on PR #5422 the prior split + * planRestore+applyPlan doubled scrypt cost). applyPlan() now takes a + * RestorePlan rather than re-decrypting. + */ export function planRestore( blob: Buffer, usbUuid: string, @@ -137,7 +160,7 @@ export function planRestore( const bundle = decodeBundle(plaintext); if ("error" in bundle) return { error: `bundle decode: ${bundle.error}`, code: 6 }; - const writes: { path: string; bytes: number }[] = []; + const writes: { path: string; bytes: number; value: Buffer }[] = []; const skipped: { id: string; reason: string }[] = []; const errors: string[] = []; @@ -154,7 +177,7 @@ export function planRestore( } const paths = resolveCredPaths(entry, targetRoot); // Write to FIRST path only (canonical); other paths are alternates the caller may symlink - writes.push({ path: paths[0]!, bytes: value.length }); + writes.push({ path: paths[0]!, bytes: value.length, value }); } // Persona creds (only restore the requested persona's section) @@ -175,7 +198,7 @@ export function planRestore( continue; } const paths = resolveCredPaths(entry, targetRoot); - writes.push({ path: paths[0]!, bytes: value.length }); + writes.push({ path: paths[0]!, bytes: value.length, value }); } } else if (Object.keys(bundle.personaCreds).length > 0) { skipped.push({ @@ -184,9 +207,9 @@ export function planRestore( }); } } else if (Object.keys(bundle.personaCreds).length > 0) { - for (const persona of Object.keys(bundle.personaCreds)) { - for (const id of Object.keys(bundle.personaCreds[persona]!)) { - skipped.push({ id: `${persona}/${id}`, reason: `persona-scoped cred; --persona not specified` }); + for (const personaName of Object.keys(bundle.personaCreds)) { + for (const id of Object.keys(bundle.personaCreds[personaName]!)) { + skipped.push({ id: `${personaName}/${id}`, reason: `persona-scoped cred; --persona not specified` }); } } } @@ -195,41 +218,19 @@ export function planRestore( return { writes, skipped, errors: [] }; } -/** Apply the plan (FS writes). Caller must have done planRestore first. */ -export function applyPlan( - blob: Buffer, - usbUuid: string, - passphrase: string, - persona: string | null, - targetRoot: string, -): number { - const env = parseEnvelope(blob); - if ("error" in env) throw new Error(`envelope parse: ${env.error}`); - const plaintext = decrypt(env, usbUuid, passphrase); - if ("error" in plaintext) throw new Error(`decrypt: ${plaintext.error}`); - const bundle = decodeBundle(plaintext); - if ("error" in bundle) throw new Error(`bundle decode: ${bundle.error}`); - +/** + * Apply a plan returned by planRestore. NO re-decrypt (per Copilot review + * on PR #5422 — the prior implementation re-decrypted, doubling scrypt + * cost + extending passphrase-derived-key lifetime in memory). Returns + * the count of writes performed. + */ +export function applyPlan(plan: RestorePlan): number { let writeCount = 0; - for (const [id, value] of Object.entries(bundle.globalCreds)) { - const entry = DEFAULT_MANIFEST.credentials.find((c) => c.id === id); - if (!entry || entry.personaScoped) continue; - const paths = resolveCredPaths(entry, targetRoot); - mkdirSync(dirname(paths[0]!), { recursive: true }); - writeFileSync(paths[0]!, value); + for (const w of plan.writes) { + mkdirSync(dirname(w.path), { recursive: true }); + writeFileSync(w.path, w.value); writeCount++; } - if (persona) { - const personaSection = bundle.personaCreds[persona] ?? {}; - for (const [id, value] of Object.entries(personaSection)) { - const entry = DEFAULT_MANIFEST.credentials.find((c) => c.id === id); - if (!entry || !entry.personaScoped) continue; - const paths = resolveCredPaths(entry, targetRoot); - mkdirSync(dirname(paths[0]!), { recursive: true }); - writeFileSync(paths[0]!, value); - writeCount++; - } - } return writeCount; } @@ -244,7 +245,14 @@ async function main(): Promise { console.error(`zeta-creds-restore: input file not found: ${parsed.input}`); return 3; } - const blob = readFileSync(parsed.input); + let blob: Buffer; + try { + blob = readFileSync(parsed.input); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`zeta-creds-restore: input file read failed: ${msg}`); + return 3; + } const plan = planRestore(blob, parsed.usbUuid, parsed.passphrase, parsed.persona, parsed.targetRoot); if ("error" in plan) { console.error(`zeta-creds-restore: ${plan.error}`); @@ -256,7 +264,7 @@ async function main(): Promise { for (const s of plan.skipped) console.log(` SKIP ${s.id}: ${s.reason}`); return 0; } - const written = applyPlan(blob, parsed.usbUuid, parsed.passphrase, parsed.persona, parsed.targetRoot); + const written = applyPlan(plan); console.log(`zeta-creds-restore: wrote ${written} creds (target-root: ${parsed.targetRoot})`); for (const s of plan.skipped) console.log(` SKIP ${s.id}: ${s.reason}`); return 0;