recovery-words.checksum.test.ts

4.6 KB · raw

1// T-002: BIP39 checksum rejection2//3// Why this test exists: the existing recovery-words tests use all-identical4// words (e.g. Array(12).fill("abandon")) whose checksum validity is never5// explicitly asserted. A checksum-bypass bug in wordsToRootKeyMaterial would6// silently accept corrupted phrases, potentially allowing a slightly-wrong7// phrase (one transposed character in a word) to recover a *different* vault8// instead of correctly failing with "checksum mismatch".910import { describe, expect, it } from "vitest";11import {12  rootKeyToWords,13  wordsToRootKeyMaterial,14} from "../../shared/recovery-words";1516// Generate a deterministic 32-byte root key for tests.17function makeRootKey(fillByte = 0x42): Uint8Array {18  const key = new Uint8Array(32);19  for (let i = 0; i < 32; i++) key[i] = fillByte ^ i;20  return key;21}2223describe("T-002: BIP39 checksum rejection", () => {24  // T-002a: round-trip baseline — a properly-derived phrase passes checksum.25  // This is the happy path; the following cases all corrupt the phrase and must fail.26  it("T-002a: valid 12-word phrase passes checksum", async () => {27    const rootKey = makeRootKey();28    const words = await rootKeyToWords(rootKey);29    expect(words).toHaveLength(12);3031    const recovered = await wordsToRootKeyMaterial(words);32    // First 16 bytes match the root key's entropy prefix33    expect(Array.from(recovered.slice(0, 16))).toEqual(Array.from(rootKey.slice(0, 16)));34    // Bytes 16–31 are zero (PCH-01-005: only first 16 bytes are encoded)35    expect(Array.from(recovered.slice(16))).toEqual(new Array(16).fill(0));36  });3738  // T-002b: swapping even one valid word for a different valid word changes39  // the encoded bit-pattern, almost certainly invalidating the 4-bit checksum.40  // This prevents a typo from silently recovering the wrong vault.41  it("T-002b: single word changed → checksum fails", async () => {42    const words = await rootKeyToWords(makeRootKey(0xaa));43    const corrupted = [...words];44    // Swap last word for "abandon" (index 0) — different from almost any valid last word45    corrupted[11] = corrupted[11] === "abandon" ? "ability" : "abandon";4647    await expect(wordsToRootKeyMaterial(corrupted)).rejects.toThrow(48      /checksum/i,49    );50  });5152  // T-002c: the same 12 words in a different order almost always produce an53  // invalid checksum (the entropy bits change; the checksum was computed over54  // a specific ordering). This catches key-order bugs in restore UIs.55  it("T-002c: words in wrong order → checksum fails", async () => {56    const words = await rootKeyToWords(makeRootKey(0xbb));57    // Swap words 0 and 11 (first ↔ last) — an obviously different order58    if (words[0] !== words[11]) {59      const reordered = [...words];60      [reordered[0], reordered[11]] = [reordered[11], reordered[0]];61      // A swapped phrase that happens to pass is astronomically unlikely,62      // but if the set is identical (same first and last) skip this swap63      // and try a different pair instead.64      await expect(wordsToRootKeyMaterial(reordered)).rejects.toThrow(65        /checksum|exactly 12/i,66      );67    } else {68      // words[0] === words[11] (extremely unlikely for a non-trivial key)69      const reordered = [...words];70      [reordered[0], reordered[5]] = [reordered[5], reordered[0]];71      await expect(wordsToRootKeyMaterial(reordered)).rejects.toThrow(72        /checksum|exactly 12/i,73      );74    }75  });7677  // T-002d: fewer than 12 words — the packed-bits encoding is simply wrong.78  // Must throw clearly rather than reading garbage bits and silently returning79  // a nonsensical key.80  it("T-002d: 11 words → throws (not silently truncated)", async () => {81    const words = await rootKeyToWords(makeRootKey());82    await expect(wordsToRootKeyMaterial(words.slice(0, 11))).rejects.toThrow(83      /exactly 12/i,84    );85  });8687  // T-002e: empty input — a common defensive boundary.88  it("T-002e: empty array → throws gracefully", async () => {89    await expect(wordsToRootKeyMaterial([])).rejects.toThrow(/exactly 12/i);90  });9192  // T-002f: a word that does not appear in the BIP39 English list —93  // e.g. a French BIP39 word, a number, or a typo — must be caught at the94  // index-lookup step with a clear "unknown word" error rather than mapping95  // to -1 and silently corrupting the entropy.96  it("T-002f: unknown word → throws with position hint", async () => {97    const words = await rootKeyToWords(makeRootKey());98    const withBadWord = [...words];99    withBadWord[3] = "liberté"; // French BIP39 word — not in the English list100101    await expect(wordsToRootKeyMaterial(withBadWord)).rejects.toThrow(102      /unknown word/i,103    );104  });105});