// T-002: BIP39 checksum rejection // // Why this test exists: the existing recovery-words tests use all-identical // words (e.g. Array(12).fill("abandon")) whose checksum validity is never // explicitly asserted. A checksum-bypass bug in wordsToRootKeyMaterial would // silently accept corrupted phrases, potentially allowing a slightly-wrong // phrase (one transposed character in a word) to recover a *different* vault // instead of correctly failing with "checksum mismatch". import { describe, expect, it } from "vitest"; import { rootKeyToWords, wordsToRootKeyMaterial, } from "./recovery-words"; // Generate a deterministic 32-byte root key for tests. function makeRootKey(fillByte = 0x42): Uint8Array { const key = new Uint8Array(32); for (let i = 0; i < 32; i++) key[i] = fillByte ^ i; return key; } describe("T-002: BIP39 checksum rejection", () => { // T-002a: round-trip baseline — a properly-derived phrase passes checksum. // This is the happy path; the following cases all corrupt the phrase and must fail. it("T-002a: valid 12-word phrase passes checksum", async () => { const rootKey = makeRootKey(); const words = await rootKeyToWords(rootKey); expect(words).toHaveLength(12); const recovered = await wordsToRootKeyMaterial(words); // First 16 bytes match the root key's entropy prefix expect(Array.from(recovered.slice(0, 16))).toEqual(Array.from(rootKey.slice(0, 16))); // Bytes 16–31 are zero (PCH-01-005: only first 16 bytes are encoded) expect(Array.from(recovered.slice(16))).toEqual(new Array(16).fill(0)); }); // T-002b: swapping even one valid word for a different valid word changes // the encoded bit-pattern, almost certainly invalidating the 4-bit checksum. // This prevents a typo from silently recovering the wrong vault. it("T-002b: single word changed → checksum fails", async () => { const words = await rootKeyToWords(makeRootKey(0xaa)); const corrupted = [...words]; // Swap last word for "abandon" (index 0) — different from almost any valid last word corrupted[11] = corrupted[11] === "abandon" ? "ability" : "abandon"; await expect(wordsToRootKeyMaterial(corrupted)).rejects.toThrow( /checksum/i, ); }); // T-002c: the same 12 words in a different order almost always produce an // invalid checksum (the entropy bits change; the checksum was computed over // a specific ordering). This catches key-order bugs in restore UIs. it("T-002c: words in wrong order → checksum fails", async () => { const words = await rootKeyToWords(makeRootKey(0xbb)); // Swap words 0 and 11 (first ↔ last) — an obviously different order if (words[0] !== words[11]) { const reordered = [...words]; [reordered[0], reordered[11]] = [reordered[11], reordered[0]]; // A swapped phrase that happens to pass is astronomically unlikely, // but if the set is identical (same first and last) skip this swap // and try a different pair instead. await expect(wordsToRootKeyMaterial(reordered)).rejects.toThrow( /checksum|exactly 12/i, ); } else { // words[0] === words[11] (extremely unlikely for a non-trivial key) const reordered = [...words]; [reordered[0], reordered[5]] = [reordered[5], reordered[0]]; await expect(wordsToRootKeyMaterial(reordered)).rejects.toThrow( /checksum|exactly 12/i, ); } }); // T-002d: fewer than 12 words — the packed-bits encoding is simply wrong. // Must throw clearly rather than reading garbage bits and silently returning // a nonsensical key. it("T-002d: 11 words → throws (not silently truncated)", async () => { const words = await rootKeyToWords(makeRootKey()); await expect(wordsToRootKeyMaterial(words.slice(0, 11))).rejects.toThrow( /exactly 12/i, ); }); // T-002e: empty input — a common defensive boundary. it("T-002e: empty array → throws gracefully", async () => { await expect(wordsToRootKeyMaterial([])).rejects.toThrow(/exactly 12/i); }); // T-002f: a word that does not appear in the BIP39 English list — // e.g. a French BIP39 word, a number, or a typo — must be caught at the // index-lookup step with a clear "unknown word" error rather than mapping // to -1 and silently corrupting the entropy. it("T-002f: unknown word → throws with position hint", async () => { const words = await rootKeyToWords(makeRootKey()); const withBadWord = [...words]; withBadWord[3] = "liberté"; // French BIP39 word — not in the English list await expect(wordsToRootKeyMaterial(withBadWord)).rejects.toThrow( /unknown word/i, ); }); });