123456789101112import { WORD_LIST } from "./recovery-word-list";1314const WORD_BITS = 11; 15const TOTAL_WORDS = 12; 161718192021export async function rootKeyToWords(rootKeyBytes: Uint8Array): Promise<string[]> {22 if (rootKeyBytes.length !== 32) {23 throw new Error("root key must be 32 bytes");24 }2526 27 28 29 const entropy16 = rootKeyBytes.slice(0, 16);30 const entropyHash = new Uint8Array(31 await crypto.subtle.digest(32 "SHA-256",33 entropy16 34 )35 );36 const checksum = entropyHash[0] >> 4; 3738 39 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 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); 51 words.push(WORD_LIST[index]);52 }5354 return words;55}565758596061export 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 69 const indices: number[] = [];70 for (let i = 0; i < words.length; i++) {71 72 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 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}