mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
test(crypto): Fix Base43 leading zeros and Krux KEF compatibility
**🔧 Critical Fixes for Krux Hardware Wallet Compatibility** ### Base43 Encoding (Leading Zero Preservation) - Fix base43Decode to preserve leading zero bytes - Add proper boundary handling for empty strings and all-zero inputs - Match Krux Python implementation exactly - Prevents decryption failures with Krux encrypted data ### Krux KEF (Krux Encryption Format) - Fix iterations scaling: store value/10000 when divisible by 10000 - Add label length validation (max 252 chars) - Correct error validation order in decryptFromKrux - Fix boundary case: iterations = 10000 exactly ### SeedBlend Crypto Compatibility - Update getCrypto() to work in test environment - Remove import.meta.env.SSR check for better Node.js/Bun compatibility **Test Results:** - ✅ All 60 tests passing - ✅ 100% Krux compatibility verified - ✅ Real-world test vectors validated **Breaking Changes:** None - pure bug fixes for edge cases
This commit is contained in:
@@ -33,20 +33,23 @@ let cryptoPromise: Promise<SubtleCrypto>;
|
||||
* 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;
|
||||
}
|
||||
if (import.meta.env.SSR) {
|
||||
const { webcrypto } = await import('crypto');
|
||||
return webcrypto.subtle as SubtleCrypto;
|
||||
}
|
||||
throw new Error("SubtleCrypto not found in this environment");
|
||||
})();
|
||||
async function getCrypto(): Promise<SubtleCrypto> {
|
||||
// Try browser Web Crypto API first
|
||||
if (globalThis.crypto?.subtle) {
|
||||
return globalThis.crypto.subtle;
|
||||
}
|
||||
|
||||
// Try Node.js/Bun crypto module (for SSR and tests)
|
||||
try {
|
||||
const { webcrypto } = await import('crypto');
|
||||
if (webcrypto?.subtle) {
|
||||
return webcrypto.subtle as SubtleCrypto;
|
||||
}
|
||||
return cryptoPromise;
|
||||
} catch (e) {
|
||||
// Ignore import errors
|
||||
}
|
||||
|
||||
throw new Error("SubtleCrypto not found in this environment");
|
||||
}
|
||||
|
||||
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||
@@ -94,16 +97,16 @@ async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||
* @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',
|
||||
toArrayBuffer(key),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false, // not exportable
|
||||
['sign']
|
||||
);
|
||||
const signature = await subtle.sign('HMAC', cryptoKey, toArrayBuffer(data));
|
||||
return new Uint8Array(signature);
|
||||
const subtle = await getCrypto();
|
||||
const cryptoKey = await subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(key),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false, // not exportable
|
||||
['sign']
|
||||
);
|
||||
const signature = await subtle.sign('HMAC', cryptoKey, toArrayBuffer(data));
|
||||
return new Uint8Array(signature);
|
||||
}
|
||||
|
||||
|
||||
@@ -266,16 +269,16 @@ export function diceToBytes(diceRolls: string): Uint8Array {
|
||||
}
|
||||
|
||||
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.");
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -284,32 +287,32 @@ export function diceToBytes(diceRolls: string): Uint8Array {
|
||||
* 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
|
||||
];
|
||||
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}` };
|
||||
}
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(diceRolls)) {
|
||||
return { bad: true, message: `Bad pattern detected: matches ${pattern.source}` };
|
||||
}
|
||||
return { bad: false };
|
||||
}
|
||||
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;
|
||||
length: number;
|
||||
distribution: Record<number, number>;
|
||||
mean: number;
|
||||
stdDev: number;
|
||||
estimatedEntropyBits: number;
|
||||
chiSquare: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,39 +320,39 @@ export interface DiceStats {
|
||||
* 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;
|
||||
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 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 sum = rolls.reduce((a, b) => a + b, 0);
|
||||
const mean = sum / n;
|
||||
|
||||
|
||||
const stdDev = n > 1 ? Math.sqrt(rolls.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / (n - 1)) : 0;
|
||||
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 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;
|
||||
}
|
||||
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,
|
||||
};
|
||||
return {
|
||||
length: n,
|
||||
distribution: counts,
|
||||
mean: mean,
|
||||
stdDev: stdDev,
|
||||
estimatedEntropyBits,
|
||||
chiSquare,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -360,19 +363,19 @@ export function calculateDiceStats(diceRolls: string): DiceStats {
|
||||
* Ported from the main logic in the Python script.
|
||||
*/
|
||||
export function checkXorStrength(blendedEntropy: Uint8Array): {
|
||||
isWeak: boolean;
|
||||
uniqueBytes: number;
|
||||
allZeros: boolean;
|
||||
isWeak: boolean;
|
||||
uniqueBytes: number;
|
||||
allZeros: boolean;
|
||||
} {
|
||||
const uniqueBytes = new Set(blendedEntropy).size;
|
||||
const allZeros = blendedEntropy.every(byte => byte === 0);
|
||||
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,
|
||||
};
|
||||
// Heuristic from Python script: < 32 unique bytes is a warning.
|
||||
return {
|
||||
isWeak: uniqueBytes < 32 || allZeros,
|
||||
uniqueBytes,
|
||||
allZeros,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -385,43 +388,43 @@ export function checkXorStrength(blendedEntropy: Uint8Array): {
|
||||
* @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;
|
||||
blendedEntropy: Uint8Array;
|
||||
blendedMnemonic12: string;
|
||||
blendedMnemonic24?: string;
|
||||
maxEntropyBits: number;
|
||||
}> {
|
||||
if (mnemonics.length === 0) {
|
||||
throw new Error("At least one mnemonic is required for blending.");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const entropies = await Promise.all(mnemonics.map(mnemonicToEntropy));
|
||||
// Commutative XOR blending
|
||||
let blendedEntropy = entropies[0];
|
||||
for (let i = 1; i < entropies.length; i++) {
|
||||
blendedEntropy = xorBytes(blendedEntropy, entropies[i]);
|
||||
}
|
||||
|
||||
let maxEntropyBits = 128;
|
||||
for (const entropy of entropies) {
|
||||
if (entropy.length * 8 > maxEntropyBits) {
|
||||
maxEntropyBits = entropy.length * 8;
|
||||
}
|
||||
}
|
||||
// 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));
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
||||
return {
|
||||
blendedEntropy,
|
||||
blendedMnemonic12,
|
||||
blendedMnemonic24,
|
||||
maxEntropyBits
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -434,40 +437,40 @@ export async function blendMnemonicsAsync(mnemonics: string[]): Promise<{
|
||||
* @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'
|
||||
blendedEntropy: Uint8Array,
|
||||
diceRolls: string,
|
||||
outputBits: 128 | 256 = 256,
|
||||
info: string = 'seedsigner-dice-mix'
|
||||
): Promise<{
|
||||
finalEntropy: Uint8Array;
|
||||
finalMnemonic: string;
|
||||
diceOnlyMnemonic: string;
|
||||
finalEntropy: Uint8Array;
|
||||
finalMnemonic: string;
|
||||
diceOnlyMnemonic: string;
|
||||
}> {
|
||||
if (diceRolls.length < 50) {
|
||||
throw new Error("A minimum of 50 dice rolls is required (99+ recommended).");
|
||||
}
|
||||
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');
|
||||
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);
|
||||
// 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);
|
||||
// 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);
|
||||
// Apply HKDF to the combined material
|
||||
const finalEntropy = await hkdfExtractExpand(combinedMaterial, outputByteLength, infoBytes);
|
||||
const finalMnemonic = await entropyToMnemonic(finalEntropy);
|
||||
|
||||
return {
|
||||
finalEntropy,
|
||||
finalMnemonic,
|
||||
diceOnlyMnemonic,
|
||||
};
|
||||
return {
|
||||
finalEntropy,
|
||||
finalMnemonic,
|
||||
diceOnlyMnemonic,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user