recovery-words.ts

5.5 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 */21export async function rootKeyToWords(rootKeyBytes: Uint8Array): Promise<string[]> {22  if (rootKeyBytes.length !== 32) {23    throw new Error("root key must be 32 bytes");24  }2526  // SHA256 of the 16-byte entropy portion gives the checksum (first 4 bits).27  // Copy to a fresh 16-byte buffer — slice() on a Uint8Array returns a view with28  // the same underlying ArrayBuffer, so .buffer would still be 32 bytes.29  const entropy16 = rootKeyBytes.slice(0, 16);30  const entropyHash = new Uint8Array(31    await crypto.subtle.digest(32      "SHA-256",33      entropy16 /* Uint8Array — WebCrypto reads its byte content directly */34    )35  );36  const checksum = entropyHash[0] >> 4; // 4-bit checksum3738  // Pack 128 entropy bits + 4 checksum bits = 132 bits into a BigInt.39  // We only use the first 16 bytes of rootKeyBytes as entropy.40  let bitsBig = 0n;41  for (let i = 0; i < 16; i++) {42    bitsBig = (bitsBig << 8n) | BigInt(rootKeyBytes[i]);43  }44  bitsBig = (bitsBig << 4n) | BigInt(checksum);4546  // Slice into 12 × 11-bit indices, from most-significant bit first.47  const words: string[] = [];48  for (let i = 0; i < TOTAL_WORDS; i++) {49    const shift = (TOTAL_WORDS - 1 - i) * WORD_BITS;50    const index = Number((bitsBig >> BigInt(shift)) & 0x7ffn); // 2047 max51    words.push(WORD_LIST[index]);52  }5354  return words;55}5657/**58 * Convert recovery words back to the 32-byte root key material.59 * Validates the checksum and throws on invalid word combinations.60 */61export async function wordsToRootKeyMaterial(62  words: string[]63): Promise<Uint8Array> {64  if (words.length !== TOTAL_WORDS) {65    throw new Error(`exactly ${TOTAL_WORDS} words required`);66  }6768  // Map words to 11-bit indices69  const indices: number[] = [];70  for (let i = 0; i < words.length; i++) {71    // LV-1: BIP39 words are ASCII so normalize is a no-op today; the call72    // makes the invariant explicit and future-proofs localized word lists.73    const word = words[i].normalize("NFC").toLowerCase().trim();74    const index = WORD_LIST.indexOf(word);75    if (index === -1) {76      throw new Error(`unknown word at position ${i + 1}: "${words[i]}"`);77    }78    indices.push(index);79  }8081  // Pack all 11-bit indices into a 132-bit BigInt, MSB first.82  let bitsBig = 0n;83  for (let i = 0; i < indices.length; i++) {84    bitsBig = (bitsBig << BigInt(WORD_BITS)) | BigInt(indices[i]);85  }8687  // Extract the first 128 bits as entropy bytes.88  // Encoding: bitsBig = (entropy_128bits << 4) | checksum_4bits89  // entropy[i] occupies bits [131-8i .. 124-8i], so shift = 124 - 8i.90  const entropy = new Uint8Array(16);91  for (let i = 0; i < 16; i++) {92    const byteShift = 124 - i * 8;93    entropy[i] = Number((bitsBig >> BigInt(byteShift)) & 0xffn);94  }9596  // Extract the 4-bit checksum from bits 3..0 (LSB side).97  const storedChecksum = Number(bitsBig & 0xfn);9899  // Verify checksum100  const entropyHash = new Uint8Array(101    await crypto.subtle.digest("SHA-256", entropy)102  );103  const computedChecksum = entropyHash[0] >> 4;104105  if (storedChecksum !== computedChecksum) {106    throw new Error("invalid recovery words — checksum mismatch");107  }108109  // Pad to 32 bytes (the full root key is 32 bytes, only first 16 are entropy).110  const rootKey = new Uint8Array(32);111  rootKey.set(entropy);112  return rootKey;113}114115/**116 * Derive a recovery key from word material using HKDF-SHA256.117 * The recovery key is used to unwrap the vault's root key.118 */119export async function deriveRecoveryKey(120  wordMaterial: Uint8Array,121  salt: Uint8Array122): Promise<CryptoKey> {123  // HKDF base key: import word material as raw key124  const baseKey = await crypto.subtle.importKey(125    "raw",126    wordMaterial.buffer as ArrayBuffer,127    { name: "HKDF" },128    false,129    ["deriveKey"]130  );131132  return crypto.subtle.deriveKey(133    {134      name: "HKDF",135      hash: "SHA-256",136      salt: salt.buffer as ArrayBuffer,137      info: new TextEncoder().encode("peach-recovery-v1"),138    },139    baseKey,140    { name: "AES-GCM", length: 256 },141    false,142    ["encrypt", "decrypt"]143  );144}145146/**147 * Validate a list of words, returning invalid words.148 * Used for real-time validation in the recovery entry UI.149 */150export function findInvalidWords(151  words: string[]152): { index: number; word: string }[] {153  const invalid: { index: number; word: string }[] = [];154  for (let i = 0; i < words.length; i++) {155    const w = words[i]?.toLowerCase().trim();156    if (!w || !WORD_LIST.includes(w)) {157      invalid.push({ index: i, word: words[i] ?? "" });158    }159  }160  return invalid;161}162163/**164 * Find word suggestions given a prefix (for autocomplete).165 * Returns up to 5 matching words starting with the given prefix.166 */167export function getWordSuggestions(prefix: string, maxResults = 5): string[] {168  const p = prefix.toLowerCase().trim();169  if (!p || p.length < 2) return [];170  return WORD_LIST.filter((w) => w.startsWith(p)).slice(0, maxResults);171}