recovery-words.ts

6.3 KB · raw

1/**2 * recovery-words.ts — BIP39-compatible recovery word encoding for Peach vault recovery.3 *4 * The vault root key (256-bit random) is encoded as 12 words from the 2048-word5 * recovery-word-list. This is compatible with BIP39 mnemonic generation, using6 * the same word-to-index mapping and checksum scheme.7 *8 * Word count: 12 words = 132 bits total (128 bits entropy + 4-bit checksum).9 * Each word encodes 11 bits. 12 × 11 = 132 bits.10 */1112import { WORD_LIST } from "./recovery-word-list";1314const WORD_BITS = 11; // 2^11 = 2048 words per word15const TOTAL_WORDS = 12; // (128 + 4) / 11 = 12 words; 128 entropy + 4-bit BIP39 checksum1617/**18 * Convert a 32-byte root key to 12 recovery words.19 * Encodes 128 bits of entropy + 4-bit BIP39 checksum as 12 eleven-bit word indices.20 *21 * SECURITY MODEL (PCH-01-005): the words encode ONLY rootKey[0..16]. The second22 * 16 bytes of the root key are NOT represented in the phrase. Recovery security is23 * therefore 128-bit — the standard for a 12-word BIP39 phrase — NOT 256-bit. The24 * 12 words are a 128-bit prefix, not independent secret material; do not assume the25 * full root key can be reconstructed from them. (See generateAndWrapRootKey in26 * vault.ts and wordsToRootKeyMaterial below.)27 */28export async function rootKeyToWords(rootKeyBytes: Uint8Array): Promise<string[]> {29  if (rootKeyBytes.length !== 32) {30    throw new Error("root key must be 32 bytes");31  }3233  // SHA256 of the 16-byte entropy portion gives the checksum (first 4 bits).34  // Copy to a fresh 16-byte buffer — slice() on a Uint8Array returns a view with35  // the same underlying ArrayBuffer, so .buffer would still be 32 bytes.36  const entropy16 = rootKeyBytes.slice(0, 16);37  const entropyHash = new Uint8Array(38    await crypto.subtle.digest(39      "SHA-256",40      entropy16 /* Uint8Array — WebCrypto reads its byte content directly */41    )42  );43  const checksum = entropyHash[0] >> 4; // 4-bit checksum4445  // Pack 128 entropy bits + 4 checksum bits = 132 bits into a BigInt.46  // We only use the first 16 bytes of rootKeyBytes as entropy.47  let bitsBig = 0n;48  for (let i = 0; i < 16; i++) {49    bitsBig = (bitsBig << 8n) | BigInt(rootKeyBytes[i]);50  }51  bitsBig = (bitsBig << 4n) | BigInt(checksum);5253  // Slice into 12 × 11-bit indices, from most-significant bit first.54  const words: string[] = [];55  for (let i = 0; i < TOTAL_WORDS; i++) {56    const shift = (TOTAL_WORDS - 1 - i) * WORD_BITS;57    const index = Number((bitsBig >> BigInt(shift)) & 0x7ffn); // 2047 max58    words.push(WORD_LIST[index]);59  }6061  return words;62}6364/**65 * Convert recovery words back to the 32-byte root key material.66 * Validates the checksum and throws on invalid word combinations.67 */68export async function wordsToRootKeyMaterial(69  words: string[]70): Promise<Uint8Array> {71  if (words.length !== TOTAL_WORDS) {72    throw new Error(`exactly ${TOTAL_WORDS} words required`);73  }7475  // Map words to 11-bit indices76  const indices: number[] = [];77  for (let i = 0; i < words.length; i++) {78    // LV-1: BIP39 words are ASCII so normalize is a no-op today; the call79    // makes the invariant explicit and future-proofs localized word lists.80    const word = words[i].normalize("NFC").toLowerCase().trim();81    const index = WORD_LIST.indexOf(word);82    if (index === -1) {83      throw new Error(`unknown word at position ${i + 1}: "${words[i]}"`);84    }85    indices.push(index);86  }8788  // Pack all 11-bit indices into a 132-bit BigInt, MSB first.89  let bitsBig = 0n;90  for (let i = 0; i < indices.length; i++) {91    bitsBig = (bitsBig << BigInt(WORD_BITS)) | BigInt(indices[i]);92  }9394  // Extract the first 128 bits as entropy bytes.95  // Encoding: bitsBig = (entropy_128bits << 4) | checksum_4bits96  // entropy[i] occupies bits [131-8i .. 124-8i], so shift = 124 - 8i.97  const entropy = new Uint8Array(16);98  for (let i = 0; i < 16; i++) {99    const byteShift = 124 - i * 8;100    entropy[i] = Number((bitsBig >> BigInt(byteShift)) & 0xffn);101  }102103  // Extract the 4-bit checksum from bits 3..0 (LSB side).104  const storedChecksum = Number(bitsBig & 0xfn);105106  // Verify checksum107  const entropyHash = new Uint8Array(108    await crypto.subtle.digest("SHA-256", entropy)109  );110  const computedChecksum = entropyHash[0] >> 4;111112  if (storedChecksum !== computedChecksum) {113    throw new Error("invalid recovery words — checksum mismatch");114  }115116  // Pad to 32 bytes. The phrase carries only the 128-bit prefix (PCH-01-005), so117  // bytes 16..31 are ZERO here — this is word *material*, not the recovered root118  // key. It is fed straight into deriveRecoveryKey as HKDF IKM; the resulting KEK119  // unwraps the full 32-byte root key from the escrow blob. The padding is part of120  // the security model (128-bit recovery), not a placeholder to be "filled in".121  const rootKey = new Uint8Array(32);122  rootKey.set(entropy);123  return rootKey;124}125126/**127 * Derive a recovery key from word material using HKDF-SHA256.128 * The recovery key is used to unwrap the vault's root key.129 */130export async function deriveRecoveryKey(131  wordMaterial: Uint8Array,132  salt: Uint8Array133): Promise<CryptoKey> {134  // HKDF base key: import word material as raw key135  const baseKey = await crypto.subtle.importKey(136    "raw",137    wordMaterial.buffer as ArrayBuffer,138    { name: "HKDF" },139    false,140    ["deriveKey"]141  );142143  return crypto.subtle.deriveKey(144    {145      name: "HKDF",146      hash: "SHA-256",147      salt: salt.buffer as ArrayBuffer,148      info: new TextEncoder().encode("peach-recovery-v1"),149    },150    baseKey,151    { name: "AES-GCM", length: 256 },152    false,153    ["encrypt", "decrypt"]154  );155}156157/**158 * Validate a list of words, returning invalid words.159 * Used for real-time validation in the recovery entry UI.160 */161export function findInvalidWords(162  words: string[]163): { index: number; word: string }[] {164  const invalid: { index: number; word: string }[] = [];165  for (let i = 0; i < words.length; i++) {166    const w = words[i]?.toLowerCase().trim();167    if (!w || !WORD_LIST.includes(w)) {168      invalid.push({ index: i, word: words[i] ?? "" });169    }170  }171  return invalid;172}173174/**175 * Find word suggestions given a prefix (for autocomplete).176 * Returns up to 5 matching words starting with the given prefix.177 */178export function getWordSuggestions(prefix: string, maxResults = 5): string[] {179  const p = prefix.toLowerCase().trim();180  if (!p || p.length < 2) return [];181  return WORD_LIST.filter((w) => w.startsWith(p)).slice(0, maxResults);182}