recovery-words.ts

5.4 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    const word = words[i].toLowerCase().trim();72    const index = WORD_LIST.indexOf(word);73    if (index === -1) {74      throw new Error(`unknown word at position ${i + 1}: "${words[i]}"`);75    }76    indices.push(index);77  }7879  // Pack all 11-bit indices into a 132-bit BigInt, MSB first.80  let bitsBig = 0n;81  for (let i = 0; i < indices.length; i++) {82    bitsBig = (bitsBig << BigInt(WORD_BITS)) | BigInt(indices[i]);83  }8485  // Extract the first 128 bits as entropy bytes.86  // Encoding: bitsBig = (entropy_128bits << 4) | checksum_4bits87  // entropy[i] occupies bits [131-8i .. 124-8i], so shift = 124 - 8i.88  const entropy = new Uint8Array(16);89  for (let i = 0; i < 16; i++) {90    const byteShift = 124 - i * 8;91    entropy[i] = Number((bitsBig >> BigInt(byteShift)) & 0xffn);92  }9394  // Extract the 4-bit checksum from bits 3..0 (LSB side).95  const storedChecksum = Number(bitsBig & 0xfn);9697  // Verify checksum98  const entropyHash = new Uint8Array(99    await crypto.subtle.digest("SHA-256", entropy)100  );101  const computedChecksum = entropyHash[0] >> 4;102103  if (storedChecksum !== computedChecksum) {104    throw new Error("invalid recovery words — checksum mismatch");105  }106107  // Pad to 32 bytes (the full root key is 32 bytes, only first 16 are entropy).108  const rootKey = new Uint8Array(32);109  rootKey.set(entropy);110  return rootKey;111}112113/**114 * Derive a recovery key from word material using HKDF-SHA256.115 * The recovery key is used to unwrap the vault's root key.116 */117export async function deriveRecoveryKey(118  wordMaterial: Uint8Array,119  salt: Uint8Array120): Promise<CryptoKey> {121  // HKDF base key: import word material as raw key122  const baseKey = await crypto.subtle.importKey(123    "raw",124    wordMaterial.buffer as ArrayBuffer,125    { name: "HKDF" },126    false,127    ["deriveKey"]128  );129130  return crypto.subtle.deriveKey(131    {132      name: "HKDF",133      hash: "SHA-256",134      salt: salt.buffer as ArrayBuffer,135      info: new TextEncoder().encode("peach-recovery-v1"),136    },137    baseKey,138    { name: "AES-GCM", length: 256 },139    false,140    ["encrypt", "decrypt"]141  );142}143144/**145 * Validate a list of words, returning invalid words.146 * Used for real-time validation in the recovery entry UI.147 */148export function findInvalidWords(149  words: string[]150): { index: number; word: string }[] {151  const invalid: { index: number; word: string }[] = [];152  for (let i = 0; i < words.length; i++) {153    const w = words[i]?.toLowerCase().trim();154    if (!w || !WORD_LIST.includes(w)) {155      invalid.push({ index: i, word: words[i] ?? "" });156    }157  }158  return invalid;159}160161/**162 * Find word suggestions given a prefix (for autocomplete).163 * Returns up to 5 matching words starting with the given prefix.164 */165export function getWordSuggestions(prefix: string, maxResults = 5): string[] {166  const p = prefix.toLowerCase().trim();167  if (!p || p.length < 2) return [];168  return WORD_LIST.filter((w) => w.startsWith(p)).slice(0, maxResults);169}