mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
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:
337
src/components/SeedBlender.tsx
Normal file
337
src/components/SeedBlender.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user