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 const entropyHash = new Uint8Array(30 await crypto.subtle.digest("SHA-256", rootKeyBytes.buffer as ArrayBuffer)31 );32 const checksum = entropyHash[0] >> 4; 3334 35 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 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); 47 words.push(WORD_LIST[index]);48 }4950 return words;51}525354555657export 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 65 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 76 let bitsBig = 0n;77 for (let i = 0; i < indices.length; i++) {78 bitsBig = (bitsBig << BigInt(WORD_BITS)) | BigInt(indices[i]);79 }8081 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 89 const storedChecksum = Number(90 (bitsBig >> BigInt(ENTropy_BITS)) & 0xfn91 );9293 94 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 104 const rootKey = new Uint8Array(32);105 rootKey.set(entropy);106 return rootKey;107}108109110111112113export async function deriveRecoveryKey(114 wordMaterial: Uint8Array,115 salt: Uint8Array116): Promise<CryptoKey> {117 118 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}139140141142143144export 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}156157158159160161export 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}