mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
110 lines
3.6 KiB
TypeScript
110 lines
3.6 KiB
TypeScript
// Full BIP39 validation, including checksum and wordlist membership.
|
|
import wordlistTxt from '../bip39_wordlist.txt?raw';
|
|
|
|
// --- BIP39 Wordlist Loading ---
|
|
export const BIP39_WORDLIST: readonly string[] = wordlistTxt.trim().split('\n');
|
|
export const WORD_INDEX = new Map<string, number>(
|
|
BIP39_WORDLIST.map((word, index) => [word, index])
|
|
);
|
|
|
|
if (BIP39_WORDLIST.length !== 2048) {
|
|
throw new Error(`Invalid wordlist loaded: expected 2048 words, got ${BIP39_WORDLIST.length}`);
|
|
}
|
|
|
|
// --- Web Crypto API Helpers ---
|
|
async function getCrypto(): Promise<SubtleCrypto> {
|
|
if (globalThis.crypto?.subtle) {
|
|
return globalThis.crypto.subtle;
|
|
}
|
|
try {
|
|
const { webcrypto } = await import('crypto');
|
|
if (webcrypto?.subtle) {
|
|
return webcrypto.subtle as SubtleCrypto;
|
|
}
|
|
} catch (e) {
|
|
// Ignore import errors
|
|
}
|
|
throw new Error("SubtleCrypto not found in this environment");
|
|
}
|
|
|
|
async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
|
const subtle = await getCrypto();
|
|
// Create a new Uint8Array to ensure the underlying buffer is not shared.
|
|
const dataCopy = new Uint8Array(data);
|
|
const hashBuffer = await subtle.digest('SHA-256', dataCopy);
|
|
return new Uint8Array(hashBuffer);
|
|
}
|
|
|
|
// --- Public API ---
|
|
|
|
export function normalizeBip39Mnemonic(words: string): string {
|
|
return words.trim().toLowerCase().replace(/\s+/g, " ");
|
|
}
|
|
|
|
/**
|
|
* Asynchronously validates a BIP39 mnemonic, including wordlist membership and checksum.
|
|
* @param mnemonicStr The mnemonic string to validate.
|
|
* @returns A promise that resolves to an object with a `valid` boolean and an optional `error` message.
|
|
*/
|
|
export async function validateBip39Mnemonic(mnemonicStr: string): Promise<{ valid: boolean; error?: string }> {
|
|
const normalized = normalizeBip39Mnemonic(mnemonicStr);
|
|
const words = normalized.length ? normalized.split(" ") : [];
|
|
|
|
const validCounts = new Set([12, 15, 18, 21, 24]);
|
|
if (!validCounts.has(words.length)) {
|
|
return {
|
|
valid: false,
|
|
error: `Invalid word count: ${words.length}. Must be 12, 15, 18, 21, or 24.`,
|
|
};
|
|
}
|
|
|
|
// 1. Check if all words are in the wordlist
|
|
for (const word of words) {
|
|
if (!WORD_INDEX.has(word)) {
|
|
return {
|
|
valid: false,
|
|
error: `Invalid word: "${word}" is not in the BIP39 wordlist.`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// 2. Reconstruct entropy and validate checksum
|
|
try {
|
|
let fullInt = 0n;
|
|
for (const word of words) {
|
|
fullInt = (fullInt << 11n) | BigInt(WORD_INDEX.get(word)!);
|
|
}
|
|
|
|
const totalBits = words.length * 11;
|
|
const checksumBits = totalBits / 33;
|
|
const entropyBits = totalBits - checksumBits;
|
|
|
|
let entropyInt = fullInt >> BigInt(checksumBits);
|
|
const entropyBytes = new Uint8Array(entropyBits / 8);
|
|
|
|
for (let i = entropyBytes.length - 1; i >= 0; i--) {
|
|
entropyBytes[i] = Number(entropyInt & 0xFFn);
|
|
entropyInt >>= 8n;
|
|
}
|
|
|
|
const hashBytes = await sha256(entropyBytes);
|
|
const computedChecksum = hashBytes[0] >> (8 - checksumBits);
|
|
const originalChecksum = Number(fullInt & ((1n << BigInt(checksumBits)) - 1n));
|
|
|
|
if (originalChecksum !== computedChecksum) {
|
|
return {
|
|
valid: false,
|
|
error: "Invalid mnemonic: Checksum mismatch.",
|
|
};
|
|
}
|
|
|
|
} catch (e) {
|
|
return {
|
|
valid: false,
|
|
error: `An unexpected error occurred during validation: ${e instanceof Error ? e.message : 'Unknown error'}`,
|
|
};
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|