mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 01:47:52 +08:00
Refactors the crypto module loading in `seedblend.ts` to be truly isomorphic and prevent browser runtime errors. - Replaces the static Node.js `crypto` import with a dynamic `import()` inside a singleton promise (`getCrypto`). - This ensures Vite does not externalize the module for browser builds, resolving the 'Cannot access \'crypto.webcrypto\' in client code' error. - The browser will use its native `window.crypto`, while the Node.js test environment dynamically loads the `crypto` module. - All tests continue to pass, verifying the fix.
465 lines
14 KiB
TypeScript
465 lines
14 KiB
TypeScript
/**
|
|
* @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';
|
|
|
|
// --- Isomorphic Crypto Setup ---
|
|
|
|
let cryptoPromise: Promise<SubtleCrypto>;
|
|
/**
|
|
* Asynchronously gets the appropriate SubtleCrypto interface, using a singleton
|
|
* pattern to ensure the module is loaded only once.
|
|
* This approach uses a dynamic import() to prevent Vite from bundling the
|
|
* Node.js 'crypto' module in browser builds.
|
|
*/
|
|
function getCrypto(): Promise<SubtleCrypto> {
|
|
if (!cryptoPromise) {
|
|
cryptoPromise = (async () => {
|
|
if (typeof window !== 'undefined' && window.crypto?.subtle) {
|
|
return window.crypto.subtle;
|
|
}
|
|
const { webcrypto } = await import('crypto');
|
|
return webcrypto.subtle;
|
|
})();
|
|
}
|
|
return cryptoPromise;
|
|
}
|
|
|
|
// --- 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<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 ---
|
|
|
|
/**
|
|
* 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<Uint8Array> {
|
|
const subtle = await getCrypto();
|
|
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<Uint8Array> {
|
|
const subtle = await getCrypto();
|
|
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<Uint8Array> {
|
|
// 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<Uint8Array> {
|
|
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<string> {
|
|
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<number, number>;
|
|
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<number, number> = { 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,
|
|
};
|
|
}
|