src/bip/bip39/mnemonic.js

/**
 * @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;