recovery-words.ts

5.1 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 ENTropy_BITS = 128; // 128 bits of entropy15const WORD_BITS = 11; // 2^11 = 2048 words per word16const CHECKSUM_BITS = 4; // 4-bit checksum (first 4 bits of SHA256 of entropy)17const TOTAL_WORDS = 12; // (128 + 4) / 11 = 12 words1819/**20 * Convert a 32-byte root key to 12 recovery words.21 * Encodes 128 bits of entropy + 4-bit BIP39 checksum as 12 eleven-bit word indices.22 */23export async function rootKeyToWords(rootKeyBytes: Uint8Array): Promise<string[]> {24  if (rootKeyBytes.length !== 32) {25    throw new Error("root key must be 32 bytes");26  }2728  // SHA256 of the 16-byte entropy portion gives the checksum (first 4 bits).29  const entropyHash = new Uint8Array(30    await crypto.subtle.digest("SHA-256", rootKeyBytes.buffer as ArrayBuffer)31  );32  const checksum = entropyHash[0] >> 4; // 4-bit checksum3334  // Pack 128 entropy bits + 4 checksum bits = 132 bits into a BigInt.35  // We only use the first 16 bytes of rootKeyBytes as entropy.36  let bitsBig = 0n;37  for (let i = 0; i < 16; i++) {38    bitsBig = (bitsBig << 8n) | BigInt(rootKeyBytes[i]);39  }40  bitsBig = (bitsBig << 4n) | BigInt(checksum);4142  // Slice into 12 × 11-bit indices, from most-significant bit first.43  const words: string[] = [];44  for (let i = 0; i < TOTAL_WORDS; i++) {45    const shift = (TOTAL_WORDS - 1 - i) * WORD_BITS;46    const index = Number((bitsBig >> BigInt(shift)) & 0x7ffn); // 2047 max47    words.push(WORD_LIST[index]);48  }4950  return words;51}5253/**54 * Convert recovery words back to the 32-byte root key material.55 * Validates the checksum and throws on invalid word combinations.56 */57export async function wordsToRootKeyMaterial(58  words: string[]59): Promise<Uint8Array> {60  if (words.length !== TOTAL_WORDS) {61    throw new Error(`exactly ${TOTAL_WORDS} words required`);62  }6364  // Map words to 11-bit indices65  const indices: number[] = [];66  for (let i = 0; i < words.length; i++) {67    const word = words[i].toLowerCase().trim();68    const index = WORD_LIST.indexOf(word);69    if (index === -1) {70      throw new Error(`unknown word at position ${i + 1}: "${words[i]}"`);71    }72    indices.push(index);73  }7475  // Pack all 11-bit indices into a 132-bit BigInt, MSB first.76  let bitsBig = 0n;77  for (let i = 0; i < indices.length; i++) {78    bitsBig = (bitsBig << BigInt(WORD_BITS)) | BigInt(indices[i]);79  }8081  // Extract the first 128 bits as entropy bytes.82  const entropy = new Uint8Array(16);83  for (let i = 0; i < 16; i++) {84    const byteShift = (15 - i) * 8;85    entropy[i] = Number((bitsBig >> BigInt(byteShift)) & 0xffn);86  }8788  // Extract the 4-bit checksum from bits 128–131.89  const storedChecksum = Number(90    (bitsBig >> BigInt(ENTropy_BITS)) & 0xfn91  );9293  // Verify checksum94  const entropyHash = new Uint8Array(95    await crypto.subtle.digest("SHA-256", entropy.buffer as ArrayBuffer)96  );97  const computedChecksum = entropyHash[0] >> 4;9899  if (storedChecksum !== computedChecksum) {100    throw new Error("invalid recovery words — checksum mismatch");101  }102103  // Pad to 32 bytes (the full root key is 32 bytes, only first 16 are entropy).104  const rootKey = new Uint8Array(32);105  rootKey.set(entropy);106  return rootKey;107}108109/**110 * Derive a recovery key from word material using HKDF-SHA256.111 * The recovery key is used to unwrap the vault's root key.112 */113export async function deriveRecoveryKey(114  wordMaterial: Uint8Array,115  salt: Uint8Array116): Promise<CryptoKey> {117  // HKDF base key: import word material as raw key118  const baseKey = await crypto.subtle.importKey(119    "raw",120    wordMaterial.buffer as ArrayBuffer,121    { name: "HKDF" },122    false,123    ["deriveKey"]124  );125126  return crypto.subtle.deriveKey(127    {128      name: "HKDF",129      hash: "SHA-256",130      salt: salt.buffer as ArrayBuffer,131      info: new TextEncoder().encode("peach-recovery-v1"),132    },133    baseKey,134    { name: "AES-GCM", length: 256 },135    false,136    ["encrypt", "decrypt"]137  );138}139140/**141 * Validate a list of words, returning invalid words.142 * Used for real-time validation in the recovery entry UI.143 */144export function findInvalidWords(145  words: string[]146): { index: number; word: string }[] {147  const invalid: { index: number; word: string }[] = [];148  for (let i = 0; i < words.length; i++) {149    const w = words[i]?.toLowerCase().trim();150    if (!w || !WORD_LIST.includes(w)) {151      invalid.push({ index: i, word: words[i] ?? "" });152    }153  }154  return invalid;155}156157/**158 * Find word suggestions given a prefix (for autocomplete).159 * Returns up to 5 matching words starting with the given prefix.160 */161export function getWordSuggestions(prefix: string, maxResults = 5): string[] {162  const p = prefix.toLowerCase().trim();163  if (!p || p.length < 2) return [];164  return WORD_LIST.filter((w) => w.startsWith(p)).slice(0, maxResults);165}