From 9912fad16059f76a07a8ec5dfb45740275a282dd Mon Sep 17 00:00:00 2001 From: Lior Date: Wed, 27 May 2026 03:43:40 -0400 Subject: [PATCH] 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; +}