recovery-words.kek.test.ts

6.7 KB · raw

1// T-003: 128-bit prefix-KEK invariant (PCH-01-005 sibling)2//3// Why this test exists: the recovery phrase encodes ONLY the first 16 bytes4// (128 bits) of the 32-byte root key — bytes 16–31 are zero-padded in the5// word material (PCH-01-005 SECURITY MODEL comment). This is an intentional6// 128-bit security claim. If the encoding ever drifted to use all 32 bytes,7// (a) the security claim becomes wrong, or (b) recovery silently breaks for8// vaults with non-zero upper halves. Neither failure would be immediately9// visible; only a test of the invariant catches it.1011import { describe, expect, it } from "vitest";12import {13  deriveRecoveryKey,14  rootKeyToWords,15  wordsToRootKeyMaterial,16} from "../../shared/recovery-words";1718// Build a 32-byte root key where the first 16 bytes are controlled.19function makeRootKey(prefix: number[], suffix: number[] = new Array(16).fill(0)): Uint8Array {20  const key = new Uint8Array(32);21  key.set(prefix.slice(0, 16), 0);22  key.set(suffix.slice(0, 16), 16);23  return key;24}2526describe("T-003: 128-bit prefix-KEK invariant", () => {27  // T-003a: KEK is an AES-256-GCM key — 256 bits = 32 bytes.28  // The derived key is non-extractable, but we can verify the round-trip29  // works (encrypt then decrypt succeeds) and failure cases reject.30  it("T-003a: round-trip encrypt/decrypt succeeds (key is 256 bits)", async () => {31    const rootKey = makeRootKey([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe,32                                  0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]);33    const wordMaterial = await wordsToRootKeyMaterial(await rootKeyToWords(rootKey));34    const salt = crypto.getRandomValues(new Uint8Array(16));35    const kek = await deriveRecoveryKey(wordMaterial, salt);3637    // Encrypt a test payload with the KEK38    const nonce = crypto.getRandomValues(new Uint8Array(12));39    const plaintext = new TextEncoder().encode("test-root-key-material");40    const ciphertext = await crypto.subtle.encrypt(41      { name: "AES-GCM", iv: nonce },42      kek,43      plaintext,44    );4546    // Derive the KEK again from the same inputs — must be deterministic47    const kek2 = await deriveRecoveryKey(wordMaterial, salt);48    const decrypted = await crypto.subtle.decrypt(49      { name: "AES-GCM", iv: nonce },50      kek2,51      ciphertext,52    );53    expect(new Uint8Array(decrypted)).toEqual(plaintext);54  });5556  // T-003b: two root keys identical in bytes 0–15 but differing in bytes 16–3157  // must produce identical recovery words. This pins the 128-bit prefix-only58  // encoding. If both halves were encoded, these two keys would produce59  // different phrases and the "128-bit recovery" claim would be misleading.60  it("T-003b: root keys equal in bytes 0–15 produce identical recovery words", async () => {61    const sharedPrefix = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,62                           0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00];63    const keyA = makeRootKey(sharedPrefix, new Array(16).fill(0x01));64    const keyB = makeRootKey(sharedPrefix, new Array(16).fill(0xff));6566    const wordsA = await rootKeyToWords(keyA);67    const wordsB = await rootKeyToWords(keyB);68    expect(wordsA).toEqual(wordsB);69  });7071  // T-003c: root keys with different byte 0–15 prefixes must produce72  // different recovery words (no collision for distinct prefixes).73  it("T-003c: different prefixes produce different recovery words", async () => {74    const keyA = makeRootKey([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11,75                               0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99]);76    const keyB = makeRootKey([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,77                               0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]);7879    const wordsA = await rootKeyToWords(keyA);80    const wordsB = await rootKeyToWords(keyB);81    expect(wordsA).not.toEqual(wordsB);82  });8384  // T-003d: wordsToRootKeyMaterial returns exactly 32 bytes where bytes 16–3185  // are ALL zero. This is the security model from PCH-01-005: the phrase86  // carries a 128-bit prefix, not the full 256-bit root key. The 16-byte87  // zero suffix is fed to deriveRecoveryKey as HKDF IKM — the HKDF then88  // produces the actual KEK.89  it("T-003d: word material has 32 bytes with bytes 16–31 zero-padded", async () => {90    const rootKey = makeRootKey(91      [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe,92       0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef],93      [0xff, 0xfe, 0xfd, 0xfc, 0xfb, 0xfa, 0xf9, 0xf8,94       0xf7, 0xf6, 0xf5, 0xf4, 0xf3, 0xf2, 0xf1, 0xf0],95    );96    const wordMaterial = await wordsToRootKeyMaterial(await rootKeyToWords(rootKey));9798    expect(wordMaterial).toHaveLength(32);99    // First 16 bytes match the root key's entropy prefix100    expect(Array.from(wordMaterial.slice(0, 16))).toEqual(101      Array.from(rootKey.slice(0, 16)),102    );103    // Bytes 16–31 are zero — the upper half of rootKey is NOT represented104    expect(Array.from(wordMaterial.slice(16))).toEqual(new Array(16).fill(0));105  });106107  // T-003e: the HKDF info string "peach-recovery-v1" is load-bearing.108  // Changing it would make all existing recovery escrows unreadable —109  // a vault locked with the old info string could never be recovered.110  // This KAT pins the current derivation so any accidental change is caught.111  //112  // We verify the pin indirectly: derive the same KEK twice from identical113  // inputs (must succeed), then verify a payload encrypted under the first114  // KEK can be decrypted under the second (proves determinism and pins the115  // info string implicitly — a changed info string would fail this decrypt).116  it("T-003e: HKDF key derivation is deterministic — info string is pinned", async () => {117    const rootKey = makeRootKey([0x55, 0x44, 0x33, 0x22, 0x11, 0x00, 0xff, 0xee,118                                  0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, 0x77, 0x66]);119    const wordMaterial = await wordsToRootKeyMaterial(await rootKeyToWords(rootKey));120    const salt = new Uint8Array(16).fill(0x5a); // deterministic salt121122    const kek1 = await deriveRecoveryKey(wordMaterial, salt);123    const kek2 = await deriveRecoveryKey(wordMaterial, salt);124125    const nonce = new Uint8Array(12).fill(0x01);126    const payload = new TextEncoder().encode("pinned-recovery-kek-v1");127128    const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, kek1, payload);129    const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, kek2, ct);130131    expect(new Uint8Array(pt)).toEqual(payload);132133    // A key derived from a different salt must NOT decrypt the same ciphertext.134    const saltDifferent = new Uint8Array(16).fill(0xa5);135    const kekWrong = await deriveRecoveryKey(wordMaterial, saltDifferent);136    await expect(137      crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, kekWrong, ct),138    ).rejects.toThrow();139  });140});