// T-003: 128-bit prefix-KEK invariant (PCH-01-005 sibling) // // Why this test exists: the recovery phrase encodes ONLY the first 16 bytes // (128 bits) of the 32-byte root key — bytes 16–31 are zero-padded in the // word material (PCH-01-005 SECURITY MODEL comment). This is an intentional // 128-bit security claim. If the encoding ever drifted to use all 32 bytes, // (a) the security claim becomes wrong, or (b) recovery silently breaks for // vaults with non-zero upper halves. Neither failure would be immediately // visible; only a test of the invariant catches it. import { describe, expect, it } from "vitest"; import { deriveRecoveryKey, rootKeyToWords, wordsToRootKeyMaterial, } from "./recovery-words"; // Build a 32-byte root key where the first 16 bytes are controlled. function makeRootKey(prefix: number[], suffix: number[] = new Array(16).fill(0)): Uint8Array { const key = new Uint8Array(32); key.set(prefix.slice(0, 16), 0); key.set(suffix.slice(0, 16), 16); return key; } describe("T-003: 128-bit prefix-KEK invariant", () => { // T-003a: KEK is an AES-256-GCM key — 256 bits = 32 bytes. // The derived key is non-extractable, but we can verify the round-trip // works (encrypt then decrypt succeeds) and failure cases reject. it("T-003a: round-trip encrypt/decrypt succeeds (key is 256 bits)", async () => { const rootKey = makeRootKey([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]); const wordMaterial = await wordsToRootKeyMaterial(await rootKeyToWords(rootKey)); const salt = crypto.getRandomValues(new Uint8Array(16)); const kek = await deriveRecoveryKey(wordMaterial, salt); // Encrypt a test payload with the KEK const nonce = crypto.getRandomValues(new Uint8Array(12)); const plaintext = new TextEncoder().encode("test-root-key-material"); const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce }, kek, plaintext, ); // Derive the KEK again from the same inputs — must be deterministic const kek2 = await deriveRecoveryKey(wordMaterial, salt); const decrypted = await crypto.subtle.decrypt( { name: "AES-GCM", iv: nonce }, kek2, ciphertext, ); expect(new Uint8Array(decrypted)).toEqual(plaintext); }); // T-003b: two root keys identical in bytes 0–15 but differing in bytes 16–31 // must produce identical recovery words. This pins the 128-bit prefix-only // encoding. If both halves were encoded, these two keys would produce // different phrases and the "128-bit recovery" claim would be misleading. it("T-003b: root keys equal in bytes 0–15 produce identical recovery words", async () => { const sharedPrefix = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00]; const keyA = makeRootKey(sharedPrefix, new Array(16).fill(0x01)); const keyB = makeRootKey(sharedPrefix, new Array(16).fill(0xff)); const wordsA = await rootKeyToWords(keyA); const wordsB = await rootKeyToWords(keyB); expect(wordsA).toEqual(wordsB); }); // T-003c: root keys with different byte 0–15 prefixes must produce // different recovery words (no collision for distinct prefixes). it("T-003c: different prefixes produce different recovery words", async () => { const keyA = makeRootKey([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99]); const keyB = makeRootKey([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]); const wordsA = await rootKeyToWords(keyA); const wordsB = await rootKeyToWords(keyB); expect(wordsA).not.toEqual(wordsB); }); // T-003d: wordsToRootKeyMaterial returns exactly 32 bytes where bytes 16–31 // are ALL zero. This is the security model from PCH-01-005: the phrase // carries a 128-bit prefix, not the full 256-bit root key. The 16-byte // zero suffix is fed to deriveRecoveryKey as HKDF IKM — the HKDF then // produces the actual KEK. it("T-003d: word material has 32 bytes with bytes 16–31 zero-padded", async () => { const rootKey = makeRootKey( [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef], [0xff, 0xfe, 0xfd, 0xfc, 0xfb, 0xfa, 0xf9, 0xf8, 0xf7, 0xf6, 0xf5, 0xf4, 0xf3, 0xf2, 0xf1, 0xf0], ); const wordMaterial = await wordsToRootKeyMaterial(await rootKeyToWords(rootKey)); expect(wordMaterial).toHaveLength(32); // First 16 bytes match the root key's entropy prefix expect(Array.from(wordMaterial.slice(0, 16))).toEqual( Array.from(rootKey.slice(0, 16)), ); // Bytes 16–31 are zero — the upper half of rootKey is NOT represented expect(Array.from(wordMaterial.slice(16))).toEqual(new Array(16).fill(0)); }); // T-003e: the HKDF info string "peach-recovery-v1" is load-bearing. // Changing it would make all existing recovery escrows unreadable — // a vault locked with the old info string could never be recovered. // This KAT pins the current derivation so any accidental change is caught. // // We verify the pin indirectly: derive the same KEK twice from identical // inputs (must succeed), then verify a payload encrypted under the first // KEK can be decrypted under the second (proves determinism and pins the // info string implicitly — a changed info string would fail this decrypt). it("T-003e: HKDF key derivation is deterministic — info string is pinned", async () => { const rootKey = makeRootKey([0x55, 0x44, 0x33, 0x22, 0x11, 0x00, 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, 0x77, 0x66]); const wordMaterial = await wordsToRootKeyMaterial(await rootKeyToWords(rootKey)); const salt = new Uint8Array(16).fill(0x5a); // deterministic salt const kek1 = await deriveRecoveryKey(wordMaterial, salt); const kek2 = await deriveRecoveryKey(wordMaterial, salt); const nonce = new Uint8Array(12).fill(0x01); const payload = new TextEncoder().encode("pinned-recovery-kek-v1"); const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, kek1, payload); const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, kek2, ct); expect(new Uint8Array(pt)).toEqual(payload); // A key derived from a different salt must NOT decrypt the same ciphertext. const saltDifferent = new Uint8Array(16).fill(0xa5); const kekWrong = await deriveRecoveryKey(wordMaterial, saltDifferent); await expect( crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, kekWrong, ct), ).rejects.toThrow(); }); });