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..bdbe0f94fc --- /dev/null +++ b/tools/installer/zeta-creds-envelope.ts @@ -0,0 +1,245 @@ +// 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 + 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. + * + * @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; +} 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..cbb5cb3f7f --- /dev/null +++ b/tools/installer/zeta-creds-persist-restore.test.ts @@ -0,0 +1,278 @@ +// 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 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]!; + 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 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"); + 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..51bd44d7ab --- /dev/null +++ b/tools/installer/zeta-creds-persist.ts @@ -0,0 +1,174 @@ +#!/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 ) \ +// [--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. + +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. 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; + 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 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}` }; + 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 === "") { + // 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: pass --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); + 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)) { + 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..e90dea1772 --- /dev/null +++ b/tools/installer/zeta-creds-restore.ts @@ -0,0 +1,275 @@ +#!/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 ) \ +// [--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 +// 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, 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. 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; + 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}` }; + 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 === "") { + // 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: pass --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 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, + 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; value: Buffer }[] = []; + 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, value }); + } + + // 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, value }); + } + } 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 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` }); + } + } + } + + if (errors.length > 0) return { error: errors.join("; "), code: 7 }; + return { writes, skipped, errors: [] }; +} + +/** + * 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 w of plan.writes) { + mkdirSync(dirname(w.path), { recursive: true }); + writeFileSync(w.path, w.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; + } + 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}`); + 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(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; +} + +if (import.meta.main) { + main().then((code) => process.exit(code)); +}