UNPKG

j-bitcoin

Version:

Comprehensive JavaScript/TypeScript Bitcoin (BTC) wallet library with custodial and non-custodial support, hierarchical deterministic keys, threshold signatures, and advanced cryptographic features

335 lines (280 loc) 10.7 kB
/** * @fileoverview BIP39 mnemonic phrase generation and seed derivation * @version 1.0.0 * @author yfbsei * @license ISC */ import { createHash, randomBytes, pbkdf2Sync, timingSafeEqual } from 'node:crypto'; import ENGLISH_WORDLIST from './wordList_en.js'; /** * BIP39 implementation constants * @constant {Object} */ const BIP39_CONSTANTS = { ENTROPY_BITS: 128, CHECKSUM_BITS: 4, WORD_COUNT: 12, BITS_PER_WORD: 11, PBKDF2_ITERATIONS: 2048, SEED_LENGTH_BYTES: 64, MNEMONIC_SALT_PREFIX: 'mnemonic', MIN_ENTROPY_BYTES: 16, MAX_ENTROPY_BYTES: 64, VALID_WORD_COUNTS: [12, 15, 18, 21, 24], ENTROPY_QUALITY_THRESHOLD: 0.4 }; /** * Normalize Unicode text using specified form * @param {string} text - Text to normalize * @param {string} [form='NFKD'] - Normalization form * @returns {string} Normalized text * @throws {Error} If input is not a string */ function normalizeUnicode(text, form = 'NFKD') { if (typeof text !== 'string') { throw new Error('Input must be a string for Unicode normalization'); } return text.normalize(form); } /** * Validate entropy quality using Shannon entropy * @param {Buffer} entropy - Entropy bytes to validate * @returns {Object} Validation result with isValid, score, issues */ function validateEntropyQuality(entropy) { const bytes = Array.from(entropy); const byteFrequency = new Map(); for (const byte of bytes) { byteFrequency.set(byte, (byteFrequency.get(byte) || 0) + 1); } let entropySum = 0; for (const count of byteFrequency.values()) { const p = count / bytes.length; if (p > 0) { entropySum -= p * Math.log2(p); } } const maxEntropy = Math.log2(256); const score = entropySum / maxEntropy; const issues = []; if (score < BIP39_CONSTANTS.ENTROPY_QUALITY_THRESHOLD) { issues.push(`Low entropy score: ${score.toFixed(2)}`); } const allZeros = bytes.every(b => b === 0); const allSame = bytes.every(b => b === bytes[0]); if (allZeros) issues.push('All zeros detected'); if (allSame) issues.push('All bytes identical'); return { isValid: issues.length === 0, score, issues, recommendations: issues.length > 0 ? ['Use cryptographically secure random source'] : [] }; } /** * BIP39 mnemonic phrase generation and seed derivation * @namespace BIP39 */ const BIP39 = { /** * Generate a new BIP39 mnemonic phrase * @param {number|Object} [strengthOrOptions=128] - Entropy bits (128,160,192,224,256) or options object * @param {Buffer} [strengthOrOptions.entropy] - Custom entropy * @param {number} [strengthOrOptions.strength=128] - Entropy bits * @param {boolean} [strengthOrOptions.skipEntropyValidation] - Skip quality check * @returns {Object} Result with mnemonic, entropyQuality, generationTime * @throws {Error} If entropy is invalid or generation fails */ generateMnemonic(strengthOrOptions = 128) { let options = {}; let strength = 128; // Handle both number and object parameters if (typeof strengthOrOptions === 'number') { strength = strengthOrOptions; } else if (typeof strengthOrOptions === 'object') { options = strengthOrOptions; strength = options.strength || 128; } // Validate strength const validStrengths = [128, 160, 192, 224, 256]; if (!validStrengths.includes(strength)) { throw new Error(`Invalid strength: ${strength}. Must be one of: ${validStrengths.join(', ')}`); } // Calculate parameters based on strength const entropyBytes = strength / 8; const checksumBits = strength / 32; const totalBits = strength + checksumBits; const wordCount = totalBits / 11; let entropyBuffer; if (options.entropy) { if (!Buffer.isBuffer(options.entropy)) { throw new Error('Custom entropy must be a Buffer'); } if (options.entropy.length !== entropyBytes) { throw new Error(`Entropy must be ${entropyBytes} bytes for ${strength}-bit strength`); } entropyBuffer = options.entropy; } else { entropyBuffer = randomBytes(entropyBytes); } const qualityResult = validateEntropyQuality(entropyBuffer); if (!qualityResult.isValid && !options.skipEntropyValidation) { throw new Error(`Entropy quality validation failed: ${qualityResult.issues.join(', ')}`); } const entropyHash = createHash('sha256').update(entropyBuffer).digest(); const entropyBinary = Array.from(entropyBuffer) .map(byte => byte.toString(2).padStart(8, '0')) .join(''); // Get checksum bits from hash let checksumBinary = ''; const fullBytesNeeded = Math.ceil(checksumBits / 8); for (let i = 0; i < fullBytesNeeded; i++) { checksumBinary += entropyHash[i].toString(2).padStart(8, '0'); } checksumBinary = checksumBinary.slice(0, checksumBits); const completeBinary = entropyBinary + checksumBinary; const mnemonicWords = []; for (let i = 0; i < wordCount; i++) { const startBit = i * 11; const endBit = startBit + 11; const wordIndex = parseInt(completeBinary.slice(startBit, endBit), 2); if (wordIndex >= ENGLISH_WORDLIST.length) { throw new Error(`Invalid word index: ${wordIndex}`); } mnemonicWords.push(ENGLISH_WORDLIST[wordIndex]); } const mnemonic = mnemonicWords.join(' '); if (!this.validateChecksum(mnemonic)) { throw new Error('Generated mnemonic failed self-validation'); } return { mnemonic, entropyQuality: qualityResult, generationTime: Date.now() }; }, /** * Derive a seed from a mnemonic phrase * @param {string} mnemonicPhrase - BIP39 mnemonic * @param {string} [passphrase=''] - Optional passphrase * @param {Object} [options={}] - Derivation options * @returns {string} 64-byte seed as hex string * @throws {Error} If mnemonic or passphrase is invalid */ deriveSeed(mnemonicPhrase, passphrase = '', options = {}) { if (!mnemonicPhrase || typeof mnemonicPhrase !== 'string') { throw new Error('Mnemonic phrase is required'); } if (typeof passphrase !== 'string') { throw new Error('Passphrase must be a string'); } const words = mnemonicPhrase.trim().split(/\s+/); if (!BIP39_CONSTANTS.VALID_WORD_COUNTS.includes(words.length)) { throw new Error(`Invalid mnemonic length: ${words.length} words`); } const normalizedMnemonic = normalizeUnicode(mnemonicPhrase.trim()); const normalizedPassphrase = normalizeUnicode(passphrase); const iterations = options.iterations || BIP39_CONSTANTS.PBKDF2_ITERATIONS; const salt = BIP39_CONSTANTS.MNEMONIC_SALT_PREFIX + normalizedPassphrase; const seed = pbkdf2Sync( Buffer.from(normalizedMnemonic, 'utf8'), Buffer.from(salt, 'utf8'), iterations, BIP39_CONSTANTS.SEED_LENGTH_BYTES, 'sha512' ); return seed.toString('hex'); }, /** * Validate a mnemonic phrase checksum * @param {string} mnemonicPhrase - Mnemonic to validate * @returns {boolean} True if checksum is valid */ validateChecksum(mnemonicPhrase) { if (!mnemonicPhrase || typeof mnemonicPhrase !== 'string') { return false; } const words = mnemonicPhrase.trim().toLowerCase().split(/\s+/); if (!BIP39_CONSTANTS.VALID_WORD_COUNTS.includes(words.length)) { return false; } const wordIndices = []; for (const word of words) { const wordIndex = ENGLISH_WORDLIST.indexOf(word); if (wordIndex === -1) { return false; } wordIndices.push(wordIndex); } let completeBinary = ''; for (const wordIndex of wordIndices) { completeBinary += wordIndex.toString(2).padStart(BIP39_CONSTANTS.BITS_PER_WORD, '0'); } const totalBits = words.length * BIP39_CONSTANTS.BITS_PER_WORD; const entropyBits = (totalBits * 32) / 33; const checksumBits = totalBits - entropyBits; const entropyBinary = completeBinary.slice(0, entropyBits); const providedChecksum = completeBinary.slice(entropyBits); const entropyBytes = []; for (let i = 0; i < entropyBinary.length; i += 8) { const byteBinary = entropyBinary.slice(i, i + 8); entropyBytes.push(parseInt(byteBinary, 2)); } const entropyBuffer = Buffer.from(entropyBytes); const entropyHash = createHash('sha256').update(entropyBuffer).digest(); const expectedChecksum = entropyHash[0].toString(2).padStart(8, '0').slice(0, checksumBits); try { return timingSafeEqual( Buffer.from(providedChecksum, 'binary'), Buffer.from(expectedChecksum, 'binary') ); } catch { return providedChecksum === expectedChecksum; } }, /** * Validate a mnemonic phrase (alias for validateChecksum) * @param {string} mnemonicPhrase - Mnemonic to validate * @returns {boolean} True if valid */ validateMnemonic(mnemonicPhrase) { return this.validateChecksum(mnemonicPhrase); }, /** * Convert mnemonic back to entropy bytes * @param {string} mnemonicPhrase - Valid mnemonic phrase * @returns {Buffer} Original entropy bytes * @throws {Error} If mnemonic checksum is invalid */ mnemonicToEntropy(mnemonicPhrase) { if (!this.validateChecksum(mnemonicPhrase)) { throw new Error('Invalid mnemonic checksum'); } const words = mnemonicPhrase.trim().toLowerCase().split(/\s+/); let completeBinary = ''; for (const word of words) { const wordIndex = ENGLISH_WORDLIST.indexOf(word); completeBinary += wordIndex.toString(2).padStart(BIP39_CONSTANTS.BITS_PER_WORD, '0'); } const totalBits = words.length * BIP39_CONSTANTS.BITS_PER_WORD; const entropyBits = (totalBits * 32) / 33; const entropyBinary = completeBinary.slice(0, entropyBits); const entropyBytes = []; for (let i = 0; i < entropyBinary.length; i += 8) { entropyBytes.push(parseInt(entropyBinary.slice(i, i + 8), 2)); } return Buffer.from(entropyBytes); }, /** * Get the BIP39 English word list * @returns {string[]} Copy of the 2048-word list */ getWordList() { return [...ENGLISH_WORDLIST]; }, getWordIndex(word) { return ENGLISH_WORDLIST.indexOf(word.toLowerCase()); } }; export { BIP39, BIP39_CONSTANTS, validateEntropyQuality }; export default BIP39;