123456789101112import { WORD_LIST } from "./recovery-word-list";1314const ENTropy_BITS = 128; 15const WORD_BITS = 11; 16const CHECKSUM_BITS = 4; 17const TOTAL_WORDS = 12; 181920212223export async function rootKeyToWords(rootKeyBytes: Uint8Array): Promise<string[]> {24 if (rootKeyBytes.length !== 32) {25 throw new Error("root key must be 32 bytes");26 }2728 29 30 31 const entropy16 = rootKeyBytes.slice(0, 16);32 const entropyHash = new Uint8Array(33 await crypto.subtle.digest(34 "SHA-256",35 entropy16 36 )37 );38 const checksum = entropyHash[0] >> 4; 3940 41 42 let bitsBig = 0n;43 for (let i = 0; i < 16; i++) {44 bitsBig = (bitsBig << 8n) | BigInt(rootKeyBytes[i]);45 }46 bitsBig = (bitsBig << 4n) | BigInt(checksum);4748 49 const words: string[] = [];50 for (let i = 0; i < TOTAL_WORDS; i++) {51 const shift = (TOTAL_WORDS - 1 - i) * WORD_BITS;52 const index = Number((bitsBig >> BigInt(shift)) & 0x7ffn); 53 words.push(WORD_LIST[index]);54 }5556 return words;57}585960616263export async function wordsToRootKeyMaterial(64 words: string[]65): Promise<Uint8Array> {66 if (words.length !== TOTAL_WORDS) {67 throw new Error(`exactly ${TOTAL_WORDS} words required`);68 }6970 71 const indices: number[] = [];72 for (let i = 0; i < words.length; i++) {73 const word = words[i].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 82 let bitsBig = 0n;83 for (let i = 0; i < indices.length; i++) {84 bitsBig = (bitsBig << BigInt(WORD_BITS)) | BigInt(indices[i]);85 }8687 88 89 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 97 const storedChecksum = Number(bitsBig & 0xfn);9899 100 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 110 const rootKey = new Uint8Array(32);111 rootKey.set(entropy);112 return rootKey;113}114115116117118119export async function deriveRecoveryKey(120 wordMaterial: Uint8Array,121 salt: Uint8Array122): Promise<CryptoKey> {123 124 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}145146147148149150export 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}162163164165166167export 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}