feat(blender): implement advanced blender features and fixes

This commit addresses several issues and implements new features for the Seed Blender based on user feedback.

- **Flexible QR Scanning**: `QRScanner` is now content-agnostic. `SeedBlender` detects QR content type (Plain Text, Krux, SeedPGP) and triggers the appropriate workflow.
- **Per-Row Decryption**: Replaces the global security panel with a per-row password input for encrypted mnemonics, allowing multiple different encrypted seeds to be used.
- **Data Loss Warning**: Implements a confirmation dialog that warns the user if they try to switch tabs with unsaved data in the blender, preventing accidental data loss.
- **Final Mnemonic Actions**: Adds 'Transfer to Backup' and 'Export as QR' buttons to the final mnemonic display, allowing the user to utilize the generated seed.
- **Refactors `SeedBlender` state management** around a `MnemonicEntry` interface for robustness and clarity.
This commit is contained in:
LC mac
2026-02-04 12:54:17 +08:00
parent b918d88a47
commit c2aeb4ce83
4 changed files with 325 additions and 439 deletions

View File

@@ -45,6 +45,7 @@ function App() {
const [backupMessagePassword, setBackupMessagePassword] = useState('');
const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
const [isBlenderDirty, setIsBlenderDirty] = useState(false);
const [publicKeyInput, setPublicKeyInput] = useState('');
const [privateKeyInput, setPrivateKeyInput] = useState('');
@@ -350,6 +351,18 @@ function App() {
setShowLockConfirm(false);
};
const handleRequestTabChange = (newTab: 'backup' | 'restore' | 'seedblender') => {
if (activeTab === 'seedblender' && isBlenderDirty) {
if (window.confirm("You have unsaved data in the Seed Blender. Are you sure you want to leave? All progress will be lost.")) {
setActiveTab(newTab);
setIsBlenderDirty(false); // Reset dirty state on leaving
}
// else: user cancelled, do nothing
} else {
setActiveTab(newTab);
}
};
return (
<div className="min-h-screen bg-slate-800 text-slate-100">
@@ -361,7 +374,7 @@ function App() {
events={clipboardEvents}
onOpenClipboardModal={() => setShowClipboardModal(true)}
activeTab={activeTab}
setActiveTab={setActiveTab}
onRequestTabChange={handleRequestTabChange}
encryptedMnemonicCache={encryptedMnemonicCache}
handleLockAndClear={handleLockAndClear}
appVersion={__APP_VERSION__}
@@ -478,151 +491,156 @@ function App() {
)}
</>
) : (
<SeedBlender />
<SeedBlender
onDirtyStateChange={setIsBlenderDirty}
setMnemonicForBackup={setMnemonic}
requestTabChange={handleRequestTabChange}
/>
)}
</div>
{/* Security Panel */}
<div className="space-y-2"> {/* Added space-y-2 wrapper */}
<label className="text-sm font-semibold text-slate-200 flex items-center gap-2">
<Lock size={14} /> SECURITY OPTIONS
</label>
{/* Security Panel */}
{activeTab !== 'seedblender' && (
<div className="space-y-2"> {/* Added space-y-2 wrapper */}
<label className="text-sm font-semibold text-slate-200 flex items-center gap-2">
<Lock size={14} /> SECURITY OPTIONS
</label>
<div className="p-5 bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border-2 border-slate-200 shadow-inner space-y-4">
{/* Removed h3 */}
{/* Encryption Mode Toggle */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Encryption Mode</label>
<select
value={encryptionMode}
onChange={(e) => setEncryptionMode(e.target.value as 'pgp' | 'krux')}
disabled={isReadOnly}
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all"
>
<option value="pgp">PGP (Asymmetric)</option>
<option value="krux">Krux KEF (Passphrase)</option>
</select>
<p className="text-[10px] text-slate-500 mt-1">
{encryptionMode === 'pgp'
? 'Uses PGP keys or password'
: 'Uses passphrase only (Krux compatible)'}
</p>
</div>
<div className="p-5 bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border-2 border-slate-200 shadow-inner space-y-4">
{/* Removed h3 */}
{/* Encryption Mode Toggle */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Encryption Mode</label>
<select
value={encryptionMode}
onChange={(e) => setEncryptionMode(e.target.value as 'pgp' | 'krux')}
disabled={isReadOnly}
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all"
>
<option value="pgp">PGP (Asymmetric)</option>
<option value="krux">Krux KEF (Passphrase)</option>
</select>
<p className="text-[10px] text-slate-500 mt-1">
{encryptionMode === 'pgp'
? 'Uses PGP keys or password'
: 'Uses passphrase only (Krux compatible)'}
</p>
</div>
{/* Krux-specific fields */}
{encryptionMode === 'krux' && activeTab === 'backup' && (
<>
<div className="space-y-2 pt-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Krux Label</label>
<div className="relative">
<input
type="text"
className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
isReadOnly ? 'blur-sm select-none' : ''
}`}
placeholder="e.g., My Seed 2026"
value={kruxLabel}
onChange={(e) => setKruxLabel(e.target.value)}
readOnly={isReadOnly}
/>
</div>
<p className="text-[10px] text-slate-500 mt-1">Label for identification (max 252 bytes)</p>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">PBKDF2 Iterations</label>
<div className="relative">
<input
type="number"
className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
isReadOnly ? 'blur-sm select-none' : ''
}`}
placeholder="e.g., 200000"
value={kruxIterations}
onChange={(e) => setKruxIterations(Number(e.target.value))}
min={10000}
step={10000}
readOnly={isReadOnly}
/>
</div>
<p className="text-[10px] text-slate-500 mt-1">Higher = more secure but slower (default: 200,000)</p>
</div>
</>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label>
<div className="relative">
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
<input
type="password"
className={`w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
isReadOnly ? 'blur-sm select-none' : ''
}`}
placeholder={encryptionMode === 'krux' ? "Required for Krux encryption" : "Optional password..."}
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
readOnly={isReadOnly}
/>
</div>
<p className="text-[10px] text-slate-500 mt-1">
{encryptionMode === 'krux'
? 'Required passphrase for Krux encryption'
: 'Symmetric encryption password (SKESK)'}
</p>
</div>
{activeTab === 'backup' && (
<div className="pt-3 border-t border-slate-300">
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={hasBip39Passphrase}
onChange={(e) => setHasBip39Passphrase(e.target.checked)}
disabled={isReadOnly}
className="rounded text-teal-600 focus:ring-2 focus:ring-teal-500 transition-all"
/>
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
BIP39 25th word active
</span>
</label>
</div>
)}
</div>
{/* Action Button */}
{activeTab === 'backup' ? (
<button
onClick={handleBackup}
disabled={!mnemonic || loading || isReadOnly}
className="w-full py-4 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 hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-teal-500 disabled:hover:to-cyan-600"
>
{loading ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<QrCode size={20} />
)}
{loading ? 'Generating...' : 'Generate QR Backup'}
</button>
) : (
<button
onClick={handleRestore}
disabled={!restoreInput || loading || isReadOnly}
className="w-full py-4 bg-gradient-to-r from-slate-800 to-slate-900 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-slate-900 hover:to-black transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<Unlock size={20} />
)}
{loading ? 'Decrypting...' : 'Decrypt & Restore'}
</button>
)}
</div>
</div>
{/* Krux-specific fields */}
{encryptionMode === 'krux' && activeTab === 'backup' && (
<>
<div className="space-y-2 pt-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Krux Label</label>
<div className="relative">
<input
type="text"
className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
isReadOnly ? 'blur-sm select-none' : ''
}`}
placeholder="e.g., My Seed 2026"
value={kruxLabel}
onChange={(e) => setKruxLabel(e.target.value)}
readOnly={isReadOnly}
/>
</div>
<p className="text-[10px] text-slate-500 mt-1">Label for identification (max 252 bytes)</p>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">PBKDF2 Iterations</label>
<div className="relative">
<input
type="number"
className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
isReadOnly ? 'blur-sm select-none' : ''
}`}
placeholder="e.g., 200000"
value={kruxIterations}
onChange={(e) => setKruxIterations(Number(e.target.value))}
min={10000}
step={10000}
readOnly={isReadOnly}
/>
</div>
<p className="text-[10px] text-slate-500 mt-1">Higher = more secure but slower (default: 200,000)</p>
</div>
</>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label>
<div className="relative">
<Lock className="absolute left-3 top-3 text-slate-400" size={16} />
<input
type="password"
className={`w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
isReadOnly ? 'blur-sm select-none' : ''
}`}
placeholder={encryptionMode === 'krux' ? "Required for Krux encryption" : "Optional password..."}
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
readOnly={isReadOnly}
/>
</div>
<p className="text-[10px] text-slate-500 mt-1">
{encryptionMode === 'krux'
? 'Required passphrase for Krux encryption'
: 'Symmetric encryption password (SKESK)'}
</p>
</div>
{activeTab === 'backup' && (
<div className="pt-3 border-t border-slate-300">
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={hasBip39Passphrase}
onChange={(e) => setHasBip39Passphrase(e.target.checked)}
disabled={isReadOnly}
className="rounded text-teal-600 focus:ring-2 focus:ring-teal-500 transition-all"
/>
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
BIP39 25th word active
</span>
</label>
</div>
)}
</div>
{/* Action Button */}
{activeTab === 'backup' ? (
<button
onClick={handleBackup}
disabled={!mnemonic || loading || isReadOnly}
className="w-full py-4 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 hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-teal-500 disabled:hover:to-cyan-600"
>
{loading ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<QrCode size={20} />
)}
{loading ? 'Generating...' : 'Generate QR Backup'}
</button>
) : (
<button
onClick={handleRestore}
disabled={!restoreInput || loading || isReadOnly}
className="w-full py-4 bg-gradient-to-r from-slate-800 to-slate-900 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-slate-900 hover:to-black transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<Unlock size={20} />
)}
{loading ? 'Decrypting...' : 'Decrypt & Restore'}
</button>
)}
</div>
)} </div>
{/* QR Output */}
{qrPayload && activeTab === 'backup' && (

View File

@@ -26,7 +26,7 @@ interface HeaderProps {
events: ClipboardEvent[];
onOpenClipboardModal: () => void;
activeTab: 'backup' | 'restore' | 'seedblender';
setActiveTab: (tab: 'backup' | 'restore' | 'seedblender') => void;
onRequestTabChange: (tab: 'backup' | 'restore' | 'seedblender') => void;
encryptedMnemonicCache: any;
handleLockAndClear: () => void;
appVersion: string;
@@ -42,7 +42,7 @@ const Header: React.FC<HeaderProps> = ({
events,
onOpenClipboardModal,
activeTab,
setActiveTab,
onRequestTabChange,
encryptedMnemonicCache,
handleLockAndClear,
appVersion,
@@ -91,19 +91,19 @@ const Header: React.FC<HeaderProps> = ({
)}
<button
className={`px-4 py-2 rounded-lg ${activeTab === 'backup' ? 'bg-teal-500 hover:bg-teal-600' : 'bg-slate-700 hover:bg-slate-600'}`}
onClick={() => setActiveTab('backup')}
onClick={() => onRequestTabChange('backup')}
>
Backup
</button>
<button
className={`px-4 py-2 rounded-lg ${activeTab === 'restore' ? 'bg-teal-500 hover:bg-teal-600' : 'bg-slate-700 hover:bg-slate-600'}`}
onClick={() => setActiveTab('restore')}
onClick={() => onRequestTabChange('restore')}
>
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')}
onClick={() => onRequestTabChange('seedblender')}
>
Seed Blender
</button>

View File

@@ -40,12 +40,11 @@ export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
aspectRatio: 1.0,
},
(decodedText) => {
if (decodedText.startsWith('SEEDPGP1:')) {
// Stop scanning after the first successful detection
if (decodedText) {
setSuccess(true);
onScanSuccess(decodedText);
stopCamera();
} else {
setError('QR code found, but not a valid SEEDPGP1 frame');
}
},
() => {
@@ -89,13 +88,9 @@ export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
// Try scanning with verbose mode
const decodedText = await html5QrCode.scanFile(file, true);
if (decodedText.startsWith('SEEDPGP1:')) {
setSuccess(true);
onScanSuccess(decodedText);
html5QrCode.clear();
} else {
setError(`Found QR code, but not SEEDPGP format: ${decodedText.substring(0, 30)}...`);
}
setSuccess(true);
onScanSuccess(decodedText);
html5QrCode.clear();
} catch (err: any) {
console.error('File scan error:', err);

View File

@@ -1,10 +1,15 @@
/**
* @file SeedBlender.tsx
* @summary Main component for the Seed Blending feature.
* @description This component provides a full UI for the multi-step seed blending process,
* handling various input formats, per-row decryption, and final output actions.
*/
import { useState, useEffect } from 'react';
import { QrCode, X, Plus, CheckCircle2, AlertTriangle, RefreshCw, Sparkles, EyeOff, Lock } from 'lucide-react';
import { QrCode, X, Plus, CheckCircle2, AlertTriangle, RefreshCw, Sparkles, EyeOff, Lock, Key, ArrowRight } from 'lucide-react';
import QRScanner from './QRScanner';
import { decryptFromSeed, detectEncryptionMode } from '../lib/seedpgp';
import { decryptFromKrux } from '../lib/krux';
import { QrDisplay } from './QrDisplay';
import {
blendMnemonicsAsync,
checkXorStrength,
@@ -18,7 +23,6 @@ import {
mixWithDiceAsync,
} 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>): void => {
@@ -27,33 +31,53 @@ function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
};
}
export function SeedBlender() {
// Step 1 State
const [mnemonics, setMnemonics] = useState<string[]>(['']);
const [validity, setValidity] = useState<Array<boolean | null>>([null]);
interface MnemonicEntry {
id: number;
rawInput: string;
decryptedMnemonic: string | null;
isEncrypted: boolean;
inputType: 'text' | 'seedpgp' | 'krux';
passwordRequired: boolean;
passwordInput: string;
error: string | null;
isValid: boolean | null;
}
let nextId = 0;
const createNewEntry = (): MnemonicEntry => ({
id: nextId++, rawInput: '', decryptedMnemonic: null, isEncrypted: false,
inputType: 'text', passwordRequired: false, passwordInput: '', error: null, isValid: null,
});
interface SeedBlenderProps {
onDirtyStateChange: (isDirty: boolean) => void;
setMnemonicForBackup: (mnemonic: string) => void;
requestTabChange: (tab: 'backup' | 'restore' | 'seedblender') => void;
}
export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestTabChange }: SeedBlenderProps) {
const [entries, setEntries] = useState<MnemonicEntry[]>([createNewEntry()]);
const [showQRScanner, setShowQRScanner] = useState(false);
const [scanTargetIndex, setScanTargetIndex] = useState<number | null>(null);
// Step 2 State
const [blendedResult, setBlendedResult] = useState<{ blendedEntropy: Uint8Array; 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);
// Step 3 State
const [diceRolls, setDiceRolls] = useState('');
const [diceStats, setDiceStats] = useState<DiceStats | null>(null);
const [dicePatternWarning, setDicePatternWarning] = useState<string | null>(null);
const [diceOnlyMnemonic, setDiceOnlyMnemonic] = useState<string | null>(null);
// Step 4 State
const [finalMnemonic, setFinalMnemonic] = useState<string | null>(null);
const [mixing, setMixing] = useState(false);
const [showFinalQR, setShowFinalQR] = useState(false);
useEffect(() => {
const isDirty = entries.some(e => e.rawInput.length > 0) || diceRolls.length > 0;
onDirtyStateChange(isDirty);
}, [entries, diceRolls, onDirtyStateChange]);
// Main clear function
const handleLockAndClear = () => {
setMnemonics(['']);
setValidity([null]);
setEntries([createNewEntry()]);
setBlendedResult(null);
setXorStrength(null);
setBlendError('');
@@ -62,108 +86,65 @@ export function SeedBlender() {
setDicePatternWarning(null);
setDiceOnlyMnemonic(null);
setFinalMnemonic(null);
}
setShowFinalQR(false);
};
// Effect to validate and blend mnemonics (Step 2)
useEffect(() => {
const processMnemonics = async () => {
const processEntries = async () => {
setBlending(true);
setBlendError('');
const validMnemonics = entries.map(e => e.decryptedMnemonic).filter((m): m is string => m !== null && m.length > 0);
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;
}
const validityPromises = entries.map(async (entry) => {
if (!entry.rawInput.trim()) return null;
if (entry.isEncrypted && !entry.decryptedMnemonic) return null; // Cannot validate until decrypted
const textToValidate = entry.decryptedMnemonic || entry.rawInput;
try {
await mnemonicToEntropy(textToValidate.trim());
return true;
} catch {
return false;
}
}));
});
const newValidity = await Promise.all(validityPromises);
setEntries(currentEntries => currentEntries.map((e, i) => ({ ...e, isValid: newValidity[i] })));
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);
}
setXorStrength(checkXorStrength(result.blendedEntropy));
} catch (e: any) { setBlendError(e.message); setBlendedResult(null); }
} else {
setBlendedResult(null);
setXorStrength(null);
}
setBlending(false);
};
debounce(processMnemonics, 300)();
}, [mnemonics]);
debounce(processEntries, 300)();
}, [JSON.stringify(entries.map(e => e.decryptedMnemonic))]);
// Effect to process dice rolls (Step 3)
useEffect(() => {
const processDice = async () => {
setDiceStats(calculateDiceStats(diceRolls));
const pattern = detectBadPatterns(diceRolls);
setDicePatternWarning(pattern.message || null);
setDicePatternWarning(detectBadPatterns(diceRolls).message || null);
if (diceRolls.length >= 50) {
try {
const diceBytes = diceToBytes(diceRolls);
const outputByteLength = blendedResult?.blendedEntropy.length === 32 ? 32 : 16;
const diceOnlyEntropy = await hkdfExtractExpand(diceBytes, outputByteLength, new TextEncoder().encode('dice-only'));
const mnemonic = await entropyToMnemonic(diceOnlyEntropy);
setDiceOnlyMnemonic(mnemonic);
} catch (e) {
setDiceOnlyMnemonic(null);
}
} else {
setDiceOnlyMnemonic(null);
}
const outputByteLength = (blendedResult && blendedResult.blendedEntropy.length >= 32) ? 32 : 16;
const diceOnlyEntropy = await hkdfExtractExpand(diceToBytes(diceRolls), outputByteLength, new TextEncoder().encode('dice-only'));
setDiceOnlyMnemonic(await entropyToMnemonic(diceOnlyEntropy));
} catch { setDiceOnlyMnemonic(null); }
} else { setDiceOnlyMnemonic(null); }
};
debounce(processDice, 200)();
}, [diceRolls, blendedResult]);
const handleAddMnemonic = () => {
setMnemonics([...mnemonics, '']);
setValidity([...validity, null]);
const updateEntry = (index: number, newProps: Partial<MnemonicEntry>) => {
setEntries(currentEntries => currentEntries.map((entry, i) => i === index ? { ...entry, ...newProps } : entry));
};
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 handleAddEntry = () => setEntries([...entries, createNewEntry()]);
const handleRemoveEntry = (id: number) => {
if (entries.length > 1) setEntries(entries.filter(e => e.id !== id));
else setEntries([createNewEntry()]);
};
const handleScan = (index: number) => {
@@ -172,243 +153,135 @@ export function SeedBlender() {
};
const handleScanSuccess = (scannedText: string) => {
if (scanTargetIndex !== null) {
handleMnemonicChange(scanTargetIndex, scannedText);
}
if (scanTargetIndex === null) return;
const mode = detectEncryptionMode(scannedText);
const isEncrypted = mode === 'pgp' || mode === 'krux';
updateEntry(scanTargetIndex, {
rawInput: scannedText, isEncrypted, inputType: isEncrypted ? mode : 'text',
passwordRequired: isEncrypted, decryptedMnemonic: isEncrypted ? null : scannedText, error: null,
});
setShowQRScanner(false);
setScanTargetIndex(null);
};
const handleDecrypt = async (index: number) => {
const entry = entries[index];
if (!entry.isEncrypted || !entry.passwordInput) return;
try {
let mnemonic: string;
if (entry.inputType === 'krux') {
mnemonic = (await decryptFromKrux({ kefHex: entry.rawInput, passphrase: entry.passwordInput })).mnemonic;
} else { // seedpgp
mnemonic = (await decryptFromSeed({ frameText: entry.rawInput, messagePassword: entry.passwordInput, mode: 'pgp' })).w;
}
updateEntry(index, { decryptedMnemonic: mnemonic, isEncrypted: false, passwordRequired: false, error: null });
} catch(e: any) {
updateEntry(index, { error: e.message || "Decryption failed" });
}
};
const handleFinalMix = async () => {
if (!blendedResult) return;
setMixing(true);
try {
const outputBits = blendedResult.blendedEntropy.length === 32 ? 256 : 128;
const outputBits = blendedResult.blendedEntropy.length >= 32 ? 256 : 128;
const result = await mixWithDiceAsync(blendedResult.blendedEntropy, diceRolls, outputBits);
setFinalMnemonic(result.finalMnemonic);
} catch(e) {
// handle error
} finally {
setMixing(false);
}
} catch(e) { setFinalMnemonic(null); } finally { setMixing(false); }
};
const handleClearFinal = () => {
setFinalMnemonic(null);
}
const handleTransfer = () => {
if (!finalMnemonic) return;
setMnemonicForBackup(finalMnemonic);
handleLockAndClear();
requestTabChange('backup');
};
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="space-y-6 pb-20">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-100">Seed Blender</h2>
<button
onClick={handleLockAndClear}
className="flex items-center gap-2 text-sm text-red-400 bg-slate-800/50 px-3 py-1.5 rounded-lg hover:bg-red-900/50 transition-colors"
>
<Lock size={16} />
<span>Lock/Clear</span>
</button>
<button onClick={handleLockAndClear} className="flex items-center gap-2 text-sm text-red-400 bg-slate-800/50 px-3 py-1.5 rounded-lg hover:bg-red-900/50"><Lock size={16} /><span>Lock/Clear</span></button>
</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.
{entries.map((entry, index) => (
<div key={entry.id} className="p-3 bg-slate-800/50 rounded-lg">
{entry.passwordRequired ? (
<div className="space-y-2">
<div className="flex items-center justify-between"><label className="text-sm font-semibold text-slate-200">Decrypt {entry.inputType.toUpperCase()} Mnemonic</label><button onClick={() => updateEntry(index, createNewEntry())} className="text-xs text-slate-400 hover:text-white">&times; Cancel</button></div>
<p className="text-xs text-slate-400 truncate">Payload: <code className="text-slate-300">{entry.rawInput.substring(0, 40)}...</code></p>
<div className="flex gap-2"><input type="password" placeholder="Enter passphrase to decrypt..." value={entry.passwordInput} onChange={(e) => updateEntry(index, { passwordInput: e.target.value })} className="w-full p-2 bg-slate-50 border-2 border-slate-200 rounded-lg text-sm font-mono text-slate-900 focus:outline-none focus:ring-2 focus:ring-purple-500" /><button onClick={() => handleDecrypt(index)} className="px-4 bg-purple-600 text-white rounded-lg font-semibold hover:bg-purple-700"><Key size={16}/></button></div>
{entry.error && <p className="text-xs text-red-400">{entry.error}</p>}
</div>
) : (
<div className="flex flex-col sm:flex-row items-start gap-2">
<div className="relative w-full">
<textarea value={entry.rawInput} onChange={(e) => updateEntry(index, { rawInput: e.target.value, decryptedMnemonic: e.target.value, isValid: null })} 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 ${getBorderColor(entry.isValid)}`} />
{entry.isValid === true && <CheckCircle2 className="absolute top-3 right-3 text-green-500" />}
{entry.isValid === 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"><QrCode size={20} /></button>
<button onClick={() => handleRemoveEntry(entry.id)} className="p-3 h-full bg-red-600/20 text-red-400 hover:bg-red-600/50 hover:text-white rounded-md"><X size={20} /></button>
</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>
))}
<button onClick={handleAddEntry} 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"><Plus size={16} /> Add Another Mnemonic</button>
</div>
</div>
<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 && 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>) : (<p className="text-sm text-slate-400">{blendError || '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>
)}
<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" />
{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>
{finalMnemonic ? (
<div className="p-4 bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-2xl shadow-lg">
<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={() => setFinalMnemonic(null)} className="p-2.5 hover:bg-green-100 rounded-xl"><EyeOff size={22} /></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 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 down immediately. Do not save it digitally.</span></div>
<div className="grid grid-cols-2 gap-3 mt-4">
<button onClick={() => setShowFinalQR(true)} className="w-full py-2.5 bg-slate-700 text-white rounded-lg font-semibold flex items-center justify-center gap-2"><QrCode size={16}/> Export as QR</button>
<button onClick={handleTransfer} className="w-full py-2.5 bg-teal-600 text-white rounded-lg font-semibold flex items-center justify-center gap-2"><ArrowRight size={16}/> Transfer to Backup</button>
</div>
</div>
) : (
<><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 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 disabled:opacity-50">{mixing ? <RefreshCw className="animate-spin" size={20} /> : <Sparkles size={20} />}{mixing ? 'Generating...' : 'Mix Mnemonic + Dice'}</button></>
)}
</div>
</div>
{/* QR Scanner Modal */}
{showQRScanner && (
<QRScanner
onScanSuccess={handleScanSuccess}
onClose={() => setShowQRScanner(false)}
/>
{showQRScanner && <QRScanner onScanSuccess={handleScanSuccess} onClose={() => setShowQRScanner(false)} />}
{showFinalQR && finalMnemonic && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setShowFinalQR(false)}>
<div className="bg-white rounded-2xl p-4" onClick={e => e.stopPropagation()}>
<QrDisplay value={finalMnemonic} />
</div>
</div>
)}
</>
);
}
}