feat(blender): add Seed Blender feature

Implements a new 'Seed Blender' feature that allows users to securely combine multiple BIP39 mnemonics and enhance them with dice roll entropy.

- Adds a new 'Seed Blender' tab to the main UI.
- Implements a multi-step workflow for inputting mnemonics (manual/QR) and dice rolls.
- Provides live validation and previews for blended seeds and dice-only entropy.
- Includes statistical analysis of dice rolls (chi-square, distribution) and pattern detection for quality assessment.
- The core logic is a 1-to-1 port of the reference Python implementation, using the Web Crypto API for browser compatibility and Node.js for testing.
- A full suite of unit tests ported from the reference implementation ensures correctness and deterministic outputs.
This commit is contained in:
LC mac
2026-02-04 02:37:32 +08:00
parent e3ade8eab1
commit ec722befef
6 changed files with 3003 additions and 4 deletions

View File

@@ -25,8 +25,8 @@ interface HeaderProps {
sessionItems: StorageItem[];
events: ClipboardEvent[];
onOpenClipboardModal: () => void;
activeTab: 'backup' | 'restore';
setActiveTab: (tab: 'backup' | 'restore') => void;
activeTab: 'backup' | 'restore' | 'seedblender';
setActiveTab: (tab: 'backup' | 'restore' | 'seedblender') => void;
encryptedMnemonicCache: any;
handleLockAndClear: () => void;
appVersion: string;
@@ -101,6 +101,12 @@ const Header: React.FC<HeaderProps> = ({
>
Restore
</button>
<button
className={`px-4 py-2 rounded-lg ${activeTab === 'seedblender' ? 'bg-teal-500 hover:bg-teal-600' : 'bg-slate-700 hover:bg-slate-600'}`}
onClick={() => setActiveTab('seedblender')}
>
Seed Blender
</button>
</div>
</div>

View File

@@ -0,0 +1,337 @@
/**
* @file SeedBlender.tsx
* @summary Main component for the Seed Blending feature.
*/
import { useState, useEffect } from 'react';
import { QrCode, X, Plus, CheckCircle2, AlertTriangle } from 'lucide-react';
import QRScanner from './QRScanner';
import { blendMnemonicsAsync, checkXorStrength, mnemonicToEntropy } from '../lib/seedblend';
// A simple debounce function
function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
let timeout: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<F>): Promise<ReturnType<F>> =>
new Promise(resolve => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => resolve(func(...args)), waitFor);
});
}
export function SeedBlender() {
const [mnemonics, setMnemonics] = useState<string[]>(['']);
const [validity, setValidity] = useState<Array<boolean | null>>([null]);
const [showQRScanner, setShowQRScanner] = useState(false);
const [scanTargetIndex, setScanTargetIndex] = useState<number | null>(null);
// State for Step 2
const [blendedResult, setBlendedResult] = useState<{ blendedMnemonic12: string; blendedMnemonic24?: string; } | null>(null);
const [xorStrength, setXorStrength] = useState<{ isWeak: boolean; uniqueBytes: number; } | null>(null);
const [blendError, setBlendError] = useState<string>('');
const [blending, setBlending] = useState(false);
// Effect to validate and blend mnemonics
useEffect(() => {
const processMnemonics = async () => {
setBlending(true);
setBlendError('');
const filledMnemonics = mnemonics.map(m => m.trim()).filter(m => m.length > 0);
if (filledMnemonics.length === 0) {
setBlendedResult(null);
setXorStrength(null);
setValidity(mnemonics.map(() => null));
setBlending(false);
return;
}
const newValidity: Array<boolean | null> = [...mnemonics].fill(null);
const validMnemonics: string[] = [];
await Promise.all(mnemonics.map(async (mnemonic, index) => {
if (mnemonic.trim()) {
try {
await mnemonicToEntropy(mnemonic.trim());
newValidity[index] = true;
validMnemonics.push(mnemonic.trim());
} catch (e) {
newValidity[index] = false;
}
}
}));
setValidity(newValidity);
if (validMnemonics.length > 0) {
try {
const result = await blendMnemonicsAsync(validMnemonics);
const strength = checkXorStrength(result.blendedEntropy);
setBlendedResult(result);
setXorStrength(strength);
} catch (e: any) {
setBlendError(e.message);
setBlendedResult(null);
setXorStrength(null);
}
} else {
setBlendedResult(null);
setXorStrength(null);
}
setBlending(false);
};
// Debounce the processing to avoid running on every keystroke
const debouncedProcess = debounce(processMnemonics, 300);
debouncedProcess();
}, [mnemonics]);
const handleAddMnemonic = () => {
setMnemonics([...mnemonics, '']);
setValidity([...validity, null]);
};
const handleMnemonicChange = (index: number, value: string) => {
const newMnemonics = [...mnemonics];
newMnemonics[index] = value;
setMnemonics(newMnemonics);
};
const handleRemoveMnemonic = (index: number) => {
if (mnemonics.length > 1) {
const newMnemonics = mnemonics.filter((_, i) => i !== index);
const newValidity = validity.filter((_, i) => i !== index);
setMnemonics(newMnemonics);
setValidity(newValidity);
} else {
setMnemonics(['']);
setValidity([null]);
}
};
const handleScan = (index: number) => {
setScanTargetIndex(index);
setShowQRScanner(true);
};
const handleScanSuccess = (scannedText: string) => {
if (scanTargetIndex !== null) {
handleMnemonicChange(scanTargetIndex, scannedText);
}
setShowQRScanner(false);
setScanTargetIndex(null);
};
const getBorderColor = (isValid: boolean | null) => {
if (isValid === true) return 'border-green-500 focus:ring-green-500';
if (isValid === false) return 'border-red-500 focus:ring-red-500';
return 'border-slate-200 focus:ring-teal-500';
}
return (
<>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-100">Seed Blender</h2>
</div>
{/* Step 1: Input Mnemonics */}
<div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600">
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 1: Input Mnemonics</h3>
<div className="space-y-4">
{mnemonics.map((mnemonic, index) => (
<div key={index} className="flex flex-col sm:flex-row items-start gap-2">
<div className="relative w-full">
<textarea
value={mnemonic}
onChange={(e) => handleMnemonicChange(index, e.target.value)}
placeholder={`Mnemonic #${index + 1} (12 or 24 words)`}
className={`w-full h-28 sm:h-24 p-3 pr-10 bg-slate-50 border-2 rounded-lg text-sm font-mono text-slate-900 placeholder:text-slate-400 focus:outline-none transition-all resize-none ${getBorderColor(validity[index])}`}
data-sensitive={`Mnemonic #${index + 1}`}
/>
{validity[index] === true && <CheckCircle2 className="absolute top-3 right-3 text-green-500" />}
{validity[index] === false && <AlertTriangle className="absolute top-3 right-3 text-red-500" />}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => handleScan(index)}
className="p-3 h-full bg-purple-600/20 text-purple-300 hover:bg-purple-600/50 hover:text-white rounded-md transition-colors flex items-center justify-center"
aria-label="Scan QR Code"
>
<QrCode size={20} />
</button>
<button
onClick={() => handleRemoveMnemonic(index)}
className="p-3 h-full bg-red-600/20 text-red-400 hover:bg-red-600/50 hover:text-white rounded-md transition-colors flex items-center justify-center"
aria-label="Remove Mnemonic"
>
<X size={20} />
</button>
</div>
</div>
))}
<button
onClick={handleAddMnemonic}
className="w-full py-2.5 bg-slate-600/70 hover:bg-slate-600 rounded-lg font-semibold flex items-center justify-center gap-2 transition-colors"
>
<Plus size={16} /> Add Another Mnemonic
</button>
</div>
</div>
{/* Step 2: Blended Preview */}
<div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600 min-h-[10rem]">
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 2: Blended Preview</h3>
{blending && <p className="text-sm text-slate-400">Blending...</p>}
{blendError && <p className="text-sm text-red-400">{blendError}</p>}
{!blending && !blendError && blendedResult && (
<div className="space-y-4 animate-in fade-in">
{xorStrength?.isWeak && (
<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-300 rounded-lg text-sm flex gap-3">
<AlertTriangle />
<div>
<span className="font-bold">Weak XOR Result:</span> Detected only {xorStrength.uniqueBytes} unique bytes. This can happen if seeds are identical or too similar.
</div>
</div>
)}
<div className="space-y-1">
<label className="text-xs font-semibold text-slate-400">Blended Mnemonic (12-word)</label>
<p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">
{blendedResult.blendedMnemonic12}
</p>
</div>
{blendedResult.blendedMnemonic24 && (
<div className="space-y-1">
<label className="text-xs font-semibold text-slate-400">Blended Mnemonic (24-word)</label>
<p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">
{blendedResult.blendedMnemonic24}
</p>
</div>
)}
</div>
)}
{!blending && !blendError && !blendedResult && (
<p className="text-sm text-slate-400">Previews will appear here once you enter one or more valid mnemonics.</p>
)}
</div>
{/* Step 3: Input Dice Rolls */}
<div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600">
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 3: Input Dice Rolls</h3>
<div className="space-y-4">
<textarea
value={diceRolls}
onChange={(e) => setDiceRolls(e.target.value.replace(/[^1-6]/g, ''))}
placeholder="Enter 99+ dice rolls (e.g., 16345...)"
className="w-full h-32 p-3 bg-slate-50 border-2 border-slate-200 rounded-lg text-lg font-mono text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all resize-none"
/>
{dicePatternWarning && (
<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-300 rounded-lg text-sm flex gap-3">
<AlertTriangle />
<p><span className="font-bold">Warning:</span> {dicePatternWarning}</p>
</div>
)}
{diceStats && diceStats.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
<div className="p-3 bg-slate-800 rounded-lg">
<p className="text-xs text-slate-400">Rolls</p>
<p className="text-lg font-bold">{diceStats.length}</p>
</div>
<div className="p-3 bg-slate-800 rounded-lg">
<p className="text-xs text-slate-400">Entropy (bits)</p>
<p className="text-lg font-bold">{diceStats.estimatedEntropyBits.toFixed(1)}</p>
</div>
<div className="p-3 bg-slate-800 rounded-lg">
<p className="text-xs text-slate-400">Mean</p>
<p className="text-lg font-bold">{diceStats.mean.toFixed(2)}</p>
</div>
<div className="p-3 bg-slate-800 rounded-lg">
<p className="text-xs text-slate-400">Chi-Square</p>
<p className={`text-lg font-bold ${diceStats.chiSquare > 11.07 ? 'text-amber-400' : ''}`}>{diceStats.chiSquare.toFixed(2)}</p>
</div>
</div>
)}
{diceOnlyMnemonic && (
<div className="space-y-1 pt-2">
<label className="text-xs font-semibold text-slate-400">Dice-Only Preview Mnemonic</label>
<p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">
{diceOnlyMnemonic}
</p>
</div>
)}
</div>
</div>
{/* Step 4: Final Mnemonic */}
<div className="p-6 bg-slate-900/70 rounded-xl border-2 border-teal-500/50 shadow-lg">
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 4: Generate Final Mnemonic</h3>
{!finalMnemonic ? (
<>
<p className="text-sm text-slate-400 mb-4">
Once you have entered valid mnemonics and at least 50 dice rolls, you can generate the final, hardened mnemonic.
</p>
<button
onClick={handleFinalMix}
disabled={!blendedResult || !diceRolls || diceRolls.length < 50 || mixing}
className="w-full py-3 bg-gradient-to-r from-teal-500 to-cyan-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-teal-600 hover:to-cyan-700 transition-all shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{mixing ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<Sparkles size={20} />
)}
{mixing ? 'Generating...' : 'Mix Mnemonic + Dice'}
</button>
</>
) : (
<div className="p-4 bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-2xl shadow-lg animate-in zoom-in-95">
<div className="flex items-center justify-between mb-4">
<span className="font-bold text-green-700 flex items-center gap-2 text-lg">
<CheckCircle2 size={22} /> Final Mnemonic Generated
</span>
<button
onClick={handleClearFinal}
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
>
<EyeOff size={22} /> Hide & Clear
</button>
</div>
<div className="p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm">
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words">
{finalMnemonic}
</p>
</div>
<div className="mt-4 p-3 bg-red-500/10 text-red-300 rounded-lg text-xs flex gap-2">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<span>
<strong>Security Warning:</strong> Write this mnemonic down immediately on paper or metal. Do not save it digitally. Clear when done.
</span>
</div>
</div>
)}
</div>
</div>
{/* QR Scanner Modal */}
{showQRScanner && (
<QRScanner
onScanSuccess={handleScanSuccess}
onClose={() => setShowQRScanner(false)}
/>
)}
</>
);
}