1234567891011import { describe, expect, it } from "vitest";12import {13 deriveRecoveryKey,14 rootKeyToWords,15 wordsToRootKeyMaterial,16} from "../../shared/recovery-words";171819function 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 28 29 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 38 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 47 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 57 58 59 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 72 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 85 86 87 88 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 100 expect(Array.from(wordMaterial.slice(0, 16))).toEqual(101 Array.from(rootKey.slice(0, 16)),102 );103 104 expect(Array.from(wordMaterial.slice(16))).toEqual(new Array(16).fill(0));105 });106107 108 109 110 111 112 113 114 115 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); 121122 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 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});