/** * @file Seed Blending Library for seedpgp-web * @author Gemini * @version 1.0.0 * * @summary * A direct and 100% logic-compliant port of the 'dice_mix_interactive.py' * Python script to TypeScript for use in browser environments. This module * implements XOR-based seed blending and HKDF-SHA256 enhancement with dice * rolls using the Web Crypto API. * * @description * The process involves two stages: * 1. **Mnemonic Blending**: Multiple BIP39 mnemonics are converted to their * raw entropy and commutatively blended using a bitwise XOR operation. * 2. **Dice Mixing**: The blended entropy is combined with entropy from a * long string of physical dice rolls. The result is processed through * HKDF-SHA256 to produce a final, cryptographically-strong mnemonic. * * This implementation strictly follows the Python script's logic, including * checksum validation, bitwise operations, and cryptographic constructions, * to ensure verifiable, deterministic outputs that match the reference script. */ import wordlistTxt from '../bip39_wordlist.txt?raw'; import { webcrypto } from 'crypto'; // --- Isomorphic Crypto Setup --- // Use browser crypto if available, otherwise fallback to Node.js webcrypto. // This allows the library to run in both the browser and the test environment (Node.js). const subtle = (typeof window !== 'undefined' && window.crypto?.subtle) ? window.crypto.subtle : webcrypto.subtle; // --- BIP39 Wordlist Loading --- /** * The BIP39 English wordlist, loaded directly from the project file. */ export const BIP39_WORDLIST: readonly string[] = wordlistTxt.trim().split('\n'); /** * A Map for fast, case-insensitive lookup of a word's index. */ export const WORD_INDEX = new Map( 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 --- /** * Computes the SHA-256 hash of the given data. * @param data The data to hash. * @returns A promise that resolves to the hash as a Uint8Array. */ async function sha256(data: Uint8Array): Promise { const hashBuffer = await subtle.digest('SHA-256', data); return new Uint8Array(hashBuffer); } /** * Performs an HMAC-SHA256 operation. * @param key The HMAC key. * @param data The data to authenticate. * @returns A promise that resolves to the HMAC tag. */ async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise { const cryptoKey = await subtle.importKey( 'raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, // not exportable ['sign'] ); const signature = await subtle.sign('HMAC', cryptoKey, data); return new Uint8Array(signature); } // --- Core Cryptographic Functions (Ported from Python) --- /** * XOR two byte arrays, cycling the shorter one if lengths differ. * This is a direct port of `xor_bytes` from the Python script. */ export function xorBytes(a: Uint8Array, b: Uint8Array): Uint8Array { const maxLen = Math.max(a.length, b.length); const result = new Uint8Array(maxLen); for (let i = 0; i < maxLen; i++) { result[i] = a[i % a.length] ^ b[i % b.length]; } return result; } /** * An asynchronous, browser-compatible port of `hkdf_extract_expand` from the Python script. * Implements HKDF using HMAC-SHA256 according to RFC 5869. * * @param keyMaterial The input keying material (IKM). * @param length The desired output length in bytes. * @param info Optional context and application specific information. * @returns A promise resolving to the output keying material (OKM). */ export async function hkdfExtractExpand( keyMaterial: Uint8Array, length: number = 32, info: Uint8Array = new Uint8Array(0) ): Promise { // 1. Extract const salt = new Uint8Array(32).fill(0); // Fixed zero salt, as in Python script const prk = await hmacSha256(salt, keyMaterial); // 2. Expand let t = new Uint8Array(0); let okm = new Uint8Array(length); let written = 0; let counter = 1; while (written < length) { const dataToHmac = new Uint8Array(t.length + info.length + 1); dataToHmac.set(t, 0); dataToHmac.set(info, t.length); dataToHmac.set([counter], t.length + info.length); t = await hmacSha256(prk, dataToHmac); const toWrite = Math.min(t.length, length - written); okm.set(t.slice(0, toWrite), written); written += toWrite; counter++; } return okm; } /** * Converts a BIP39 mnemonic string to its raw entropy bytes. * Asynchronously performs checksum validation. * This is a direct port of `mnemonic_to_bytes` from the Python script. */ export async function mnemonicToEntropy(mnemonicStr: string): Promise { const words = mnemonicStr.trim().toLowerCase().split(/\s+/); if (words.length !== 12 && words.length !== 24) { throw new Error("Mnemonic must be 12 or 24 words"); } let fullInt = 0n; for (const word of words) { const index = WORD_INDEX.get(word); if (index === undefined) { throw new Error(`Invalid word: ${word}`); } fullInt = (fullInt << 11n) | BigInt(index); } const totalBits = words.length * 11; const CS = totalBits / 33; // 4 for 12 words, 8 for 24 words const entropyBits = totalBits - CS; let entropyInt = fullInt >> BigInt(CS); const entropyBytes = new Uint8Array(entropyBits / 8); for (let i = entropyBytes.length - 1; i >= 0; i--) { entropyBytes[i] = Number(entropyInt & 0xFFn); entropyInt >>= 8n; } // Verify checksum const hashBytes = await sha256(entropyBytes); const computedChecksum = hashBytes[0] >> (8 - CS); const originalChecksum = Number(fullInt & ((1n << BigInt(CS)) - 1n)); if (originalChecksum !== computedChecksum) { throw new Error("Invalid mnemonic checksum"); } return entropyBytes; } /** * Converts raw entropy bytes to a BIP39 mnemonic string. * Asynchronously calculates and appends the checksum. * This is a direct port of `bytes_to_mnemonic` from the Python script. */ export async function entropyToMnemonic(entropyBytes: Uint8Array): Promise { const ENT = entropyBytes.length * 8; if (ENT !== 128 && ENT !== 256) { throw new Error("Entropy must be 128 or 256 bits"); } const CS = ENT / 32; const hashBytes = await sha256(entropyBytes); const checksum = hashBytes[0] >> (8 - CS); let entropyInt = 0n; for (const byte of entropyBytes) { entropyInt = (entropyInt << 8n) | BigInt(byte); } const fullInt = (entropyInt << BigInt(CS)) | BigInt(checksum); const totalBits = ENT + CS; const mnemonicWords: string[] = []; for (let i = 0; i < totalBits / 11; i++) { const shift = BigInt(totalBits - (i + 1) * 11); const index = Number((fullInt >> shift) & 0x7FFn); mnemonicWords.push(BIP39_WORDLIST[index]); } return mnemonicWords.join(' '); } // --- Dice and Statistical Functions --- /** * Converts a string of dice rolls to a byte array using integer-based math * to avoid floating point precision issues. * This is a direct port of the dice conversion logic from the Python script. */ export function diceToBytes(diceRolls: string): Uint8Array { const n = diceRolls.length; // Integer-based calculation of bits: n * log2(6) // log2(6) ≈ 2.5849625, so we use a scaled integer 2584965 for precision. const totalBits = Math.floor(n * 2584965 / 1000000); const diceBytesLen = Math.ceil(totalBits / 8); let diceInt = 0n; for (const roll of diceRolls) { const value = parseInt(roll, 10); if (isNaN(value) || value < 1 || value > 6) { throw new Error(`Invalid dice roll: '${roll}'. Must be 1-6.`); } diceInt = diceInt * 6n + BigInt(value - 1); } if (diceBytesLen === 0 && diceInt > 0n) { // This case should not be hit with reasonable inputs but is a safeguard. throw new Error("Cannot represent non-zero dice value in zero bytes."); } const diceBytes = new Uint8Array(diceBytesLen); for (let i = diceBytes.length - 1; i >= 0; i--) { diceBytes[i] = Number(diceInt & 0xFFn); diceInt >>= 8n; } return diceBytes; } /** * Detects statistically unlikely patterns in a string of dice rolls. * This is a direct port of `detect_bad_patterns`. */ export function detectBadPatterns(diceRolls: string): { bad: boolean; message?: string } { const patterns = [ /1{5,}/, /2{5,}/, /3{5,}/, /4{5,}/, /5{5,}/, /6{5,}/, // Long repeats /(123456){2,}/, /(654321){2,}/, /(123){3,}/, /(321){3,}/, // Sequences /(?:222333444|333444555|444555666)/, // Grouped increments /(\d)\1{4,}/, // Any digit repeated 5+ /(?:121212|131313|141414|151515|161616){2,}/, // Alternating ]; for (const pattern of patterns) { if (pattern.test(diceRolls)) { return { bad: true, message: `Bad pattern detected: matches ${pattern.source}` }; } } return { bad: false }; } /** * Interface for dice roll statistics. */ export interface DiceStats { length: number; distribution: Record; mean: number; stdDev: number; estimatedEntropyBits: number; chiSquare: number; } /** * Calculates and returns various statistics for the given dice rolls. * Ported from `calculate_dice_stats` and the main script's stats logic. */ export function calculateDiceStats(diceRolls: string): DiceStats { if (!diceRolls) { return { length: 0, distribution: {}, mean: 0, stdDev: 0, estimatedEntropyBits: 0, chiSquare: 0 }; } const rolls = diceRolls.split('').map(c => parseInt(c, 10)); const n = rolls.length; const counts: Record = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 }; for (const roll of rolls) { counts[roll]++; } const sum = rolls.reduce((a, b) => a + b, 0); const mean = sum / n; const variance = rolls.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / n; const stdDev = n > 1 ? Math.sqrt(rolls.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / (n - 1)) : 0; const estimatedEntropyBits = n * Math.log2(6); const expected = n / 6; let chiSquare = 0; for (let i = 1; i <= 6; i++) { chiSquare += Math.pow(counts[i] - expected, 2) / expected; } return { length: n, distribution: counts, mean: mean, stdDev: stdDev, estimatedEntropyBits, chiSquare, }; } // --- Main Blending Logic --- /** * Checks for weak XOR results (low diversity or all zeros). * Ported from the main logic in the Python script. */ export function checkXorStrength(blendedEntropy: Uint8Array): { isWeak: boolean; uniqueBytes: number; allZeros: boolean; } { const uniqueBytes = new Set(blendedEntropy).size; const allZeros = blendedEntropy.every(byte => byte === 0); // Heuristic from Python script: < 32 unique bytes is a warning. return { isWeak: uniqueBytes < 32 || allZeros, uniqueBytes, allZeros, }; } // --- Main Blending & Mixing Orchestration --- /** * Stage 1: Asynchronously blends multiple mnemonics using XOR. * * @param mnemonics An array of mnemonic strings to blend. * @returns A promise that resolves to the blended entropy and preview mnemonics. */ export async function blendMnemonicsAsync(mnemonics: string[]): Promise<{ blendedEntropy: Uint8Array; blendedMnemonic12: string; blendedMnemonic24?: string; maxEntropyBits: number; }> { if (mnemonics.length === 0) { throw new Error("At least one mnemonic is required for blending."); } const entropies = await Promise.all(mnemonics.map(mnemonicToEntropy)); let maxEntropyBits = 128; for (const entropy of entropies) { if (entropy.length * 8 > maxEntropyBits) { maxEntropyBits = entropy.length * 8; } } // Commutative XOR blending let blendedEntropy = entropies[0]; for (let i = 1; i < entropies.length; i++) { blendedEntropy = xorBytes(blendedEntropy, entropies[i]); } // Generate previews const blendedMnemonic12 = await entropyToMnemonic(blendedEntropy.slice(0, 16)); let blendedMnemonic24: string | undefined; if (blendedEntropy.length >= 32) { blendedMnemonic24 = await entropyToMnemonic(blendedEntropy.slice(0, 32)); } return { blendedEntropy, blendedMnemonic12, blendedMnemonic24, maxEntropyBits }; } /** * Stage 2: Asynchronously mixes blended entropy with dice rolls using HKDF. * * @param blendedEntropy The result from the XOR blending stage. * @param diceRolls A string of dice rolls (e.g., "16345..."). * @param outputBits The desired final entropy size (128 or 256). * @param info A domain separation tag for HKDF. * @returns A promise that resolves to the final mnemonic and related data. */ export async function mixWithDiceAsync( blendedEntropy: Uint8Array, diceRolls: string, outputBits: 128 | 256 = 256, info: string = 'seedsigner-dice-mix' ): Promise<{ finalEntropy: Uint8Array; finalMnemonic: string; diceOnlyMnemonic: string; }> { if (diceRolls.length < 50) { throw new Error("A minimum of 50 dice rolls is required (99+ recommended)."); } const diceBytes = diceToBytes(diceRolls); const outputByteLength = outputBits === 128 ? 16 : 32; const infoBytes = new TextEncoder().encode(info); const diceOnlyInfoBytes = new TextEncoder().encode('dice-only'); // Generate dice-only preview const diceOnlyEntropy = await hkdfExtractExpand(diceBytes, outputByteLength, diceOnlyInfoBytes); const diceOnlyMnemonic = await entropyToMnemonic(diceOnlyEntropy); // Combine blended entropy with dice bytes const combinedMaterial = new Uint8Array(blendedEntropy.length + diceBytes.length); combinedMaterial.set(blendedEntropy, 0); combinedMaterial.set(diceBytes, blendedEntropy.length); // Apply HKDF to the combined material const finalEntropy = await hkdfExtractExpand(combinedMaterial, outputByteLength, infoBytes); const finalMnemonic = await entropyToMnemonic(finalEntropy); return { finalEntropy, finalMnemonic, diceOnlyMnemonic, }; }