diff --git a/src/App.tsx b/src/App.tsx index b7d06aa..38d1204 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 (
@@ -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() { )} ) : ( - + )}
- {/* Security Panel */} -
{/* Added space-y-2 wrapper */} - + {/* Security Panel */} + {activeTab !== 'seedblender' && ( +
{/* Added space-y-2 wrapper */} + + +
+ {/* Removed h3 */} + + {/* Encryption Mode Toggle */} +
+ + +

+ {encryptionMode === 'pgp' + ? 'Uses PGP keys or password' + : 'Uses passphrase only (Krux compatible)'} +

+
-
- {/* Removed h3 */} - - {/* Encryption Mode Toggle */} -
- - -

- {encryptionMode === 'pgp' - ? 'Uses PGP keys or password' - : 'Uses passphrase only (Krux compatible)'} -

-
- - {/* Krux-specific fields */} - {encryptionMode === 'krux' && activeTab === 'backup' && ( - <> -
- -
- setKruxLabel(e.target.value)} - readOnly={isReadOnly} - /> -
-

Label for identification (max 252 bytes)

-
- -
- -
- setKruxIterations(Number(e.target.value))} - min={10000} - step={10000} - readOnly={isReadOnly} - /> -
-

Higher = more secure but slower (default: 200,000)

-
- - )} - -
- -
- - activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)} - readOnly={isReadOnly} - /> -
-

- {encryptionMode === 'krux' - ? 'Required passphrase for Krux encryption' - : 'Symmetric encryption password (SKESK)'} -

-
- - - {activeTab === 'backup' && ( -
- -
- )} - -
- - {/* Action Button */} - {activeTab === 'backup' ? ( - - ) : ( - - )} -
-
+ {/* Krux-specific fields */} + {encryptionMode === 'krux' && activeTab === 'backup' && ( + <> +
+ +
+ setKruxLabel(e.target.value)} + readOnly={isReadOnly} + /> +
+

Label for identification (max 252 bytes)

+
+ +
+ +
+ setKruxIterations(Number(e.target.value))} + min={10000} + step={10000} + readOnly={isReadOnly} + /> +
+

Higher = more secure but slower (default: 200,000)

+
+ + )} + +
+ +
+ + activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)} + readOnly={isReadOnly} + /> +
+

+ {encryptionMode === 'krux' + ? 'Required passphrase for Krux encryption' + : 'Symmetric encryption password (SKESK)'} +

+
+ + + {activeTab === 'backup' && ( +
+ +
+ )} + +
+ + {/* Action Button */} + {activeTab === 'backup' ? ( + + ) : ( + + )} + + )} {/* QR Output */} {qrPayload && activeTab === 'backup' && ( diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 28f6808..053b1e7 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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 = ({ events, onOpenClipboardModal, activeTab, - setActiveTab, + onRequestTabChange, encryptedMnemonicCache, handleLockAndClear, appVersion, @@ -91,19 +91,19 @@ const Header: React.FC = ({ )} diff --git a/src/components/QRScanner.tsx b/src/components/QRScanner.tsx index 3d65d54..ddcd6d9 100644 --- a/src/components/QRScanner.tsx +++ b/src/components/QRScanner.tsx @@ -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); diff --git a/src/components/SeedBlender.tsx b/src/components/SeedBlender.tsx index 282d4e3..444064b 100644 --- a/src/components/SeedBlender.tsx +++ b/src/components/SeedBlender.tsx @@ -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 any>(func: F, waitFor: number) { let timeout: ReturnType | null = null; return (...args: Parameters): void => { @@ -27,33 +31,53 @@ function debounce any>(func: F, waitFor: number) { }; } -export function SeedBlender() { - // Step 1 State - const [mnemonics, setMnemonics] = useState(['']); - const [validity, setValidity] = useState>([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([createNewEntry()]); const [showQRScanner, setShowQRScanner] = useState(false); const [scanTargetIndex, setScanTargetIndex] = useState(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(''); const [blending, setBlending] = useState(false); - - // Step 3 State const [diceRolls, setDiceRolls] = useState(''); const [diceStats, setDiceStats] = useState(null); const [dicePatternWarning, setDicePatternWarning] = useState(null); const [diceOnlyMnemonic, setDiceOnlyMnemonic] = useState(null); - - // Step 4 State const [finalMnemonic, setFinalMnemonic] = useState(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 = [...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) => { + 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 ( <> -
- {/* Header */} +

Seed Blender

- +
- {/* Step 1: Input Mnemonics */}

Step 1: Input Mnemonics

- {mnemonics.map((mnemonic, index) => ( -
-
-