/** * @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, useCallback, useRef } from 'react'; import { QrCode, X, Plus, CheckCircle2, AlertTriangle, RefreshCw, Sparkles, EyeOff, Key, ArrowRight } from 'lucide-react'; import QRScanner from './QRScanner'; import { decryptFromSeed, detectEncryptionMode } from '../lib/seedpgp'; import { decryptFromKrux } from '../lib/krux'; import { decodeSeedQR } from '../lib/seedqr'; // New import import { QrDisplay } from './QrDisplay'; import { blendMnemonicsAsync, checkXorStrength, mnemonicToEntropy, DiceStats, calculateDiceStats, detectBadPatterns, diceToBytes, hkdfExtractExpand, entropyToMnemonic, mixWithDiceAsync, } from '../lib/seedblend'; function debounce any>(func: F, waitFor: number) { let timeout: ReturnType | null = null; return (...args: Parameters): void => { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => func(...args), waitFor); }; } interface MnemonicEntry { id: number; rawInput: string; decryptedMnemonic: string | null; isEncrypted: boolean; inputType: 'text' | 'seedpgp' | 'krux' | 'seedqr'; 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: 'create' | 'backup' | 'restore' | 'seedblender') => void; incomingSeed?: string; // NEW: seed from Create tab onSeedReceived?: () => void; // NEW: callback after seed added } export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestTabChange, incomingSeed, onSeedReceived }: SeedBlenderProps) { const processedSeedsRef = useRef>(new Set()); const [entries, setEntries] = useState([createNewEntry()]); const [showQRScanner, setShowQRScanner] = useState(false); const [scanTargetIndex, setScanTargetIndex] = useState(null); 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(''); const [blending, setBlending] = useState(false); const [diceRolls, setDiceRolls] = useState(''); const [diceStats, setDiceStats] = useState(null); const [dicePatternWarning, setDicePatternWarning] = useState(null); const [diceOnlyMnemonic, setDiceOnlyMnemonic] = useState(null); const [finalMnemonic, setFinalMnemonic] = useState(null); const [mixing, setMixing] = useState(false); const [showFinalQR, setShowFinalQR] = useState(false); const [copiedFinal, setCopiedFinal] = useState(false); const [targetWordCount, setTargetWordCount] = useState<12 | 24>(24); useEffect(() => { const isDirty = entries.some(e => e.rawInput.length > 0) || diceRolls.length > 0; onDirtyStateChange(isDirty); }, [entries, diceRolls, onDirtyStateChange]); const addSeedEntry = (seed: string) => { setEntries(currentEntries => { const emptyEntryIndex = currentEntries.findIndex(e => !e.rawInput.trim()); if (emptyEntryIndex !== -1) { return currentEntries.map((entry, index) => index === emptyEntryIndex ? { ...entry, rawInput: seed, decryptedMnemonic: seed, isValid: null, error: null } : entry ); } else { const newEntry = createNewEntry(); newEntry.rawInput = seed; newEntry.decryptedMnemonic = seed; return [...currentEntries, newEntry]; } }); }; useEffect(() => { if (incomingSeed && incomingSeed.trim()) { // Check if we've already processed this exact seed if (!processedSeedsRef.current.has(incomingSeed)) { const isDuplicate = entries.some(e => e.decryptedMnemonic === incomingSeed); if (!isDuplicate) { addSeedEntry(incomingSeed); processedSeedsRef.current.add(incomingSeed); } } // Always notify parent to clear the incoming seed onSeedReceived?.(); } }, [incomingSeed]); useEffect(() => { const processEntries = async () => { setBlending(true); setBlendError(''); const validMnemonics = entries.map(e => e.decryptedMnemonic).filter((m): m is string => m !== null && m.length > 0); const validityPromises = entries.map(async (entry) => { if (!entry.rawInput.trim()) return { isValid: null, error: null }; if (entry.isEncrypted && !entry.decryptedMnemonic) return { isValid: null, error: null }; const textToValidate = entry.decryptedMnemonic || entry.rawInput; try { await mnemonicToEntropy(textToValidate.trim()); return { isValid: true, error: null }; } catch (e: any) { return { isValid: false, error: e.message || "Invalid mnemonic" }; } }); const newValidationResults = await Promise.all(validityPromises); setEntries(currentEntries => currentEntries.map((e, i) => ({ ...e, isValid: newValidationResults[i]?.isValid ?? e.isValid, error: newValidationResults[i]?.error ?? e.error, }))); if (validMnemonics.length > 0) { try { const result = await blendMnemonicsAsync(validMnemonics); setBlendedResult(result); setXorStrength(checkXorStrength(result.blendedEntropy)); } catch (e: any) { setBlendError(e.message); setBlendedResult(null); } } else { setBlendedResult(null); } setBlending(false); }; debounce(processEntries, 300)(); }, [JSON.stringify(entries.map(e => e.decryptedMnemonic))]); useEffect(() => { const processDice = async () => { setDiceStats(calculateDiceStats(diceRolls)); setDicePatternWarning(detectBadPatterns(diceRolls).message || null); if (diceRolls.length >= 50) { try { 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 updateEntry = (index: number, newProps: Partial) => { setEntries(currentEntries => currentEntries.map((entry, i) => i === index ? { ...entry, ...newProps } : entry)); }; 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) => { setScanTargetIndex(index); setShowQRScanner(true); }; const handleScanSuccess = useCallback(async (scannedData: string | Uint8Array) => { if (scanTargetIndex === null) return; const scannedText = typeof scannedData === 'string' ? scannedData : Array.from(scannedData).map(b => b.toString(16).padStart(2, '0')).join(''); const mode = detectEncryptionMode(scannedText); let mnemonic = scannedText; let error: string | null = null; let inputType: 'text' | 'seedpgp' | 'krux' | 'seedqr' = 'text'; try { if (mode === 'seedqr') { mnemonic = await decodeSeedQR(scannedText); inputType = 'seedqr'; updateEntry(scanTargetIndex, { rawInput: mnemonic, decryptedMnemonic: mnemonic, isEncrypted: false, passwordRequired: false, inputType, error: null, }); } else if (mode === 'pgp' || mode === 'krux') { inputType = (mode === 'pgp' ? 'seedpgp' : mode); updateEntry(scanTargetIndex, { rawInput: scannedText, decryptedMnemonic: null, isEncrypted: true, passwordRequired: true, inputType, error: null, }); } else { // text or un-recognized updateEntry(scanTargetIndex, { rawInput: scannedText, decryptedMnemonic: scannedText, isEncrypted: false, passwordRequired: false, inputType: 'text', error: null, }); } } catch (e: any) { error = e.message || "Failed to process QR code"; updateEntry(scanTargetIndex, { rawInput: scannedText, error }); } setShowQRScanner(false); }, [scanTargetIndex]); const handleScanClose = useCallback(() => { setShowQRScanner(false); }, []); const handleScanError = useCallback((errMsg: string) => { if (scanTargetIndex !== null) { updateEntry(scanTargetIndex, { error: errMsg }); } }, [scanTargetIndex]); 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({ kefData: entry.rawInput, passphrase: entry.passwordInput })).mnemonic; } else { // seedpgp mnemonic = (await decryptFromSeed({ frameText: entry.rawInput, messagePassword: entry.passwordInput, mode: 'pgp' })).w; } updateEntry(index, { rawInput: mnemonic, 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 = targetWordCount === 12 ? 128 : 256; const result = await mixWithDiceAsync(blendedResult.blendedEntropy, diceRolls, outputBits); setFinalMnemonic(result.finalMnemonic); } catch (e) { setFinalMnemonic(null); } finally { setMixing(false); } }; const handleTransfer = () => { if (!finalMnemonic) return; // Set mnemonic for backup setMnemonicForBackup(finalMnemonic); // Switch to backup tab requestTabChange('backup'); // DON'T auto-clear - user can use "Reset All" button if they want to start fresh // This preserves the blended seed in case user wants to come back and export QR }; const copyFinalMnemonic = async () => { if (!finalMnemonic) return; try { await navigator.clipboard.writeText(finalMnemonic); setCopiedFinal(true); window.setTimeout(() => setCopiedFinal(false), 1200); } catch { // fallback: select manually const el = document.getElementById("final-mnemonic"); if (el) { const range = document.createRange(); range.selectNodeContents(el); const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(range); } } }; const getBorderColor = (isValid: boolean | null) => { if (isValid === true) return 'border-[#39ff14] focus:ring-[#39ff14]'; if (isValid === false) return 'border-[#ff006e] focus:ring-[#ff006e]'; return 'border-[#00f0ff]/50 focus:ring-[#00f0ff]'; }; return ( <>

Seed Blender

Step 1: Input Mnemonics

{entries.map((entry, index) => (
{entry.passwordRequired ? (

Payload: {entry.rawInput.substring(0, 40)}...

updateEntry(index, { passwordInput: e.target.value })} className="w-full p-2 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg text-sm font-mono text-[#00f0ff] placeholder-[#9d84b7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)]" />
{entry.error &&

{entry.error}

}
) : (
{/* Row 1: Textarea only */}