Fix CameraEntropy video initialization and add stats review panel

- Fix videoRef timing issue by using useEffect for video setup
- Stop animation loop on capture to prevent infinite warnings
- Fix null canvas reference in generateMnemonicFromEntropy
- Add stats review panel with continue/retake options
- Add seed generation explanation and blurred preview
- Implement seed generation from camera noise/entropy bits and enhance dice rolls with detailed statistical analysis
This commit is contained in:
LC mac
2026-02-10 00:15:49 +08:00
parent 185efe454f
commit 586eabc361
6 changed files with 1095 additions and 175 deletions

View File

@@ -5,12 +5,16 @@ import {
CheckCircle2,
Lock,
AlertCircle,
Camera,
Dices,
Mic,
Unlock,
EyeOff,
FileKey,
Info,
} from 'lucide-react';
import { PgpKeyInput } from './components/PgpKeyInput';
import { useRef } from 'react';
import { QrDisplay } from './components/QrDisplay';
import QRScanner from './components/QRScanner';
import { validateBip39Mnemonic } from './lib/bip39';
@@ -25,6 +29,9 @@ import { StorageDetails } from './components/StorageDetails';
import { ClipboardDetails } from './components/ClipboardDetails';
import Footer from './components/Footer';
import { SeedBlender } from './components/SeedBlender';
import CameraEntropy from './components/CameraEntropy';
import DiceEntropy from './components/DiceEntropy';
import { InteractionEntropy } from './lib/interactionEntropy';
console.log("OpenPGP.js version:", openpgp.config.versionString);
@@ -47,8 +54,6 @@ function App() {
const [backupMessagePassword, setBackupMessagePassword] = useState('');
const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
const [isBlenderDirty, setIsBlenderDirty] = useState(false);
const [publicKeyInput, setPublicKeyInput] = useState('');
const [privateKeyInput, setPrivateKeyInput] = useState('');
const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
@@ -83,6 +88,13 @@ function App() {
const [seedForBlender, setSeedForBlender] = useState<string>('');
const [blenderResetKey, setBlenderResetKey] = useState(0);
// Entropy generation states
const [entropySource, setEntropySource] = useState<'camera' | 'dice' | 'audio' | null>(null);
const [entropyStats, setEntropyStats] = useState<any>(null);
const interactionEntropyRef = useRef(new InteractionEntropy());
const SENSITIVE_PATTERNS = ['key', 'mnemonic', 'seed', 'private', 'secret', 'pgp', 'password'];
const isSensitiveKey = (key: string): boolean => {
@@ -251,43 +263,26 @@ function App() {
}
};
const generateNewSeed = async () => {
try {
setLoading(true);
setError('');
// Handler for entropy generation
const handleEntropyGenerated = (mnemonic: string, stats: any) => {
setGeneratedSeed(mnemonic);
setEntropyStats(stats);
};
// Generate random entropy
const entropyLength = seedWordCount === 12 ? 16 : 32; // 128 bits for 12 words, 256 for 24
const entropy = new Uint8Array(entropyLength);
crypto.getRandomValues(entropy);
// Convert to mnemonic using your existing lib
const { entropyToMnemonic } = await import('./lib/seedblend');
const newMnemonic = await entropyToMnemonic(entropy);
setGeneratedSeed(newMnemonic);
// Set mnemonic for backup if that's the destination
if (seedDestination === 'backup') {
setMnemonic(newMnemonic);
} else if (seedDestination === 'seedblender') {
setSeedForBlender(newMnemonic);
}
// Auto-switch to chosen destination after generation
setTimeout(() => {
setActiveTab(seedDestination);
// Reset Create tab state after switching
setTimeout(() => {
setGeneratedSeed('');
}, 300);
}, 1500);
} catch (e) {
setError(e instanceof Error ? e.message : 'Seed generation failed');
} finally {
setLoading(false);
// Handler for sending to destination
const handleSendToDestination = () => {
if (seedDestination === 'backup') {
setMnemonic(generatedSeed);
setActiveTab('backup');
} else if (seedDestination === 'seedblender') {
setSeedForBlender(generatedSeed);
setActiveTab('seedblender');
}
// Reset Create tab
setGeneratedSeed('');
setEntropySource(null);
setEntropyStats(null);
};
const handleBackup = async () => {
@@ -467,24 +462,6 @@ function App() {
}
};
const handleLockAndClear = () => {
destroySessionKey();
setEncryptedMnemonicCache(null);
setMnemonic('');
setBackupMessagePassword('');
setRestoreMessagePassword('');
setPublicKeyInput('');
setPrivateKeyInput('');
setPrivateKeyPassphrase('');
setQrPayload('');
setRecipientFpr('');
setRestoreInput('');
setDecryptedRestoredMnemonic(null);
setError('');
setCopied(false);
setShowQRScanner(false);
};
const handleToggleLock = () => {
if (!isReadOnly) {
// About to lock - show confirmation
@@ -522,7 +499,6 @@ function App() {
setDecryptedRestoredMnemonic(null);
setError('');
setSeedForBlender('');
setIsBlenderDirty(false);
// Clear session
destroySessionKey();
setEncryptedMnemonicCache(null);
@@ -583,7 +559,6 @@ function App() {
activeTab={activeTab}
onRequestTabChange={handleRequestTabChange}
encryptedMnemonicCache={encryptedMnemonicCache}
handleLockAndClear={handleLockAndClear}
appVersion={__APP_VERSION__}
isLocked={isReadOnly}
onToggleLock={handleToggleLock}
@@ -619,18 +594,9 @@ function App() {
{/* Main Content Grid */}
<div className="space-y-6">
<div className="space-y-6">
<div className={activeTab === 'create' ? 'block' : 'hidden'}>
{activeTab === 'create' && (
<div className="space-y-6">
<div className="space-y-4">
<div className="w-full px-6 py-3 bg-gradient-to-r from-[#16213e] to-[#1a1a2e] border-l-4 border-[#00f0ff] rounded-r-lg">
<h2 className="text-xs font-bold text-[#00f0ff] uppercase tracking-widest text-left" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
Generate New Seed
</h2>
<p className="text-xs text-[#6ef3f7] mt-1 text-left">Create a fresh BIP39 mnemonic for a new wallet</p>
</div>
</div>
{/* Word count selector */}
{/* Seed Length Selector */}
<div className="space-y-3">
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest block text-center" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
Seed Length
@@ -659,100 +625,148 @@ function App() {
</div>
</div>
{/* Destination selector */}
<div className="space-y-3">
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest block text-center" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
Send Generated Seed To
</label>
<div className="grid grid-cols-2 gap-3 max-w-sm mx-auto">
<label className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${seedDestination === 'backup'
? 'bg-[#1a1a2e] border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]'
: 'bg-[#16213e] border-[#00f0ff]/30 hover:border-[#00f0ff]/50'
}`}>
<input
type="radio"
name="destination"
value="backup"
checked={seedDestination === 'backup'}
onChange={(e) => setSeedDestination(e.target.value as 'backup' | 'seedblender')}
className="hidden"
/>
<div className="text-center space-y-1">
<div className={`text-sm font-bold ${seedDestination === 'backup' ? 'text-[#00f0ff]' : 'text-[#9d84b7]'}`}
style={seedDestination === 'backup' ? { textShadow: '0 0 10px rgba(0,240,255,0.8)' } : undefined}>
📦 Backup
</div>
<p className="text-[10px] text-[#6ef3f7]">
Encrypt immediately
</p>
</div>
</label>
<label className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${seedDestination === 'seedblender'
? 'bg-[#1a1a2e] border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]'
: 'bg-[#16213e] border-[#00f0ff]/30 hover:border-[#00f0ff]/50'
}`}>
<input
type="radio"
name="destination"
value="seedblender"
checked={seedDestination === 'seedblender'}
onChange={(e) => setSeedDestination(e.target.value as 'backup' | 'seedblender')}
className="hidden"
/>
<div className="text-center space-y-1">
<div className={`text-sm font-bold ${seedDestination === 'seedblender' ? 'text-[#00f0ff]' : 'text-[#9d84b7]'}`}
style={seedDestination === 'seedblender' ? { textShadow: '0 0 10px rgba(0,240,255,0.8)' } : undefined}>
🎲 Seed Blender
</div>
<p className="text-[10px] text-[#6ef3f7]">
Use for XOR blending
</p>
</div>
</label>
</div>
</div>
{/* Generate button */}
<button
onClick={generateNewSeed}
disabled={loading || isReadOnly}
className="w-full py-3 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white text-sm rounded-xl font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:shadow-[0_0_30px_rgba(255,0,110,0.8)] active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed border border-[#ff006e]"
style={{ textShadow: '0 0 10px rgba(255,255,255,0.8)' }}
>
{loading ? (
<>
<RefreshCw className="animate-spin" size={20} />
Generating...
</>
) : (
<>
<RefreshCw size={20} />
Generate {seedWordCount}-Word Seed
</>
)}
</button>
{/* Display generated seed */}
{generatedSeed && (
<div className="p-6 bg-[#0a0a0f] border-2 border-[#39ff14] rounded-lg shadow-[0_0_30px_rgba(57,255,20,0.4)] space-y-4 animate-in zoom-in-95">
<div className="flex items-center justify-between">
<span className="font-bold text-sm text-[#39ff14] flex items-center gap-2" style={{ textShadow: '0 0 10px rgba(57,255,20,0.8)' }}>
<CheckCircle2 size={20} /> Generated Successfully
</span>
</div>
<div className="p-4 bg-[#16213e] rounded-lg border border-[#39ff14]/50">
<p className="font-mono text-xs text-[#39ff14] break-words leading-relaxed" style={{ textShadow: '0 0 5px rgba(57,255,20,0.5)' }}>
{generatedSeed}
{/* Entropy Source Selection */}
{!entropySource && !generatedSeed && (
<div className="space-y-4">
<div className="space-y-2">
<h3 className="text-xs font-bold text-[#00f0ff] uppercase tracking-widest text-center" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
Choose Entropy Source
</h3>
<p className="text-[10px] text-[#6ef3f7] text-center">
All methods enhanced with mouse/keyboard timing + browser crypto
</p>
</div>
<p className="text-xs text-[#6ef3f7] text-center">
Switching to {seedDestination === 'backup' ? 'Backup' : 'Seed Blender'} tab...
</p>
{entropyStats && (
<div className="text-xs text-center text-[#6ef3f7]">
Last entropy generated: {entropyStats.totalBits} bits
</div>
)}
<div className="space-y-3">
<button onClick={() => setEntropySource('camera')} className="w-full p-4 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg hover:bg-[#1a1a2e] hover:border-[#00f0ff] hover:shadow-[0_0_20px_rgba(0,240,255,0.3)] transition-all text-left">
<div className="flex items-center gap-3">
<Camera size={24} className="text-[#00f0ff]" />
<div>
<p className="text-sm font-bold text-[#00f0ff]">📷 Camera Entropy</p>
<p className="text-[10px] text-[#6ef3f7]">Point at bright, textured surface</p>
</div>
</div>
</button>
<button onClick={() => setEntropySource('dice')} className="w-full p-4 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg hover:bg-[#1a1a2e] hover:border-[#00f0ff] hover:shadow-[0_0_20px_rgba(0,240,255,0.3)] transition-all text-left">
<div className="flex items-center gap-3">
<Dices size={24} className="text-[#00f0ff]" />
<div>
<p className="text-sm font-bold text-[#00f0ff]">🎲 Dice Rolls</p>
<p className="text-[10px] text-[#6ef3f7]">Roll physical dice 99+ times</p>
</div>
</div>
</button>
<button onClick={() => setEntropySource('audio')} className="w-full p-4 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg hover:bg-[#1a1a2e] hover:border-[#00f0ff] hover:shadow-[0_0_20px_rgba(0,240,255,0.3)] transition-all text-left">
<div className="flex items-center gap-3">
<Mic size={24} className="text-[#00f0ff]" />
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-bold text-[#00f0ff]">🎤 Audio Noise</p>
<span className="px-2 py-0.5 bg-[#ff006e] text-white text-[9px] rounded font-bold">BETA</span>
</div>
<p className="text-[10px] text-[#6ef3f7]">Capture ambient sound entropy</p>
</div>
</div>
</button>
</div>
<div className="flex items-start gap-2 p-3 bg-[#16213e] border border-[#00f0ff]/30 rounded-lg">
<Info size={14} className="text-[#00f0ff] shrink-0 mt-0.5" />
<p className="text-[10px] text-[#6ef3f7]">
<strong className="text-[#00f0ff]">Privacy:</strong> All processing happens locally in your browser. Images/audio never stored or transmitted. This app is 100% stateless.
</p>
</div>
</div>
)}
{/* Camera Entropy Component */}
{entropySource === 'camera' && !generatedSeed && (
<CameraEntropy
wordCount={seedWordCount}
onEntropyGenerated={handleEntropyGenerated}
onCancel={() => setEntropySource(null)}
interactionEntropy={interactionEntropyRef.current}
/>
)}
{/* Dice Entropy Component */}
{entropySource === 'dice' && !generatedSeed && (
<DiceEntropy
wordCount={seedWordCount}
onEntropyGenerated={handleEntropyGenerated}
onCancel={() => setEntropySource(null)}
interactionEntropy={interactionEntropyRef.current}
/>
)}
{/* Audio Entropy Component - TODO */}
{entropySource === 'audio' && !generatedSeed && (
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#ff006e] text-center">
<p className="text-sm text-[#ff006e]">Audio entropy coming soon...</p>
<button onClick={() => setEntropySource(null)} className="mt-4 px-4 py-2 bg-[#16213e] border-2 border-[#ff006e] text-[#ff006e] rounded-lg text-sm hover:bg-[#ff006e]/20 transition-all">
Back
</button>
</div>
)}
{/* Generated Seed Display + Destination Selector */}
{generatedSeed && (
<div className="space-y-4">
<div className="p-6 bg-[#0a0a0f] border-2 border-[#39ff14] rounded-lg shadow-[0_0_30px_rgba(57,255,20,0.4)] space-y-4 animate-in zoom-in-95">
<div className="flex items-center justify-between">
<span className="font-bold text-sm text-[#39ff14] flex items-center gap-2" style={{ textShadow: '0 0 10px rgba(57,255,20,0.8)' }}>
<CheckCircle2 size={20} /> Generated Successfully
</span>
</div>
<div className="p-4 bg-[#16213e] rounded-lg border border-[#39ff14]/50">
<p className="font-mono text-xs text-[#39ff14] break-words leading-relaxed" style={{ textShadow: '0 0 5px rgba(57,255,20,0.5)' }}>
{generatedSeed}
</p>
</div>
</div>
{/* Destination Selector */}
<div className="space-y-3">
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest block text-center" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
Send Generated Seed To
</label>
<div className="grid grid-cols-2 gap-3 max-w-sm mx-auto">
<label className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${seedDestination === 'backup' ? 'bg-[#1a1a2e] border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]' : 'bg-[#16213e] border-[#00f0ff]/30 hover:border-[#00f0ff]/50'}`}>
<input type="radio" name="destination" value="backup" checked={seedDestination === 'backup'} onChange={(e) => setSeedDestination(e.target.value as 'backup' | 'seedblender')} className="hidden" />
<div className="text-center space-y-1">
<div className={`text-sm font-bold ${seedDestination === 'backup' ? 'text-[#00f0ff]' : 'text-[#9d84b7]'}`}>📦 Backup</div>
<p className="text-[10px] text-[#6ef3f7]">Encrypt immediately</p>
</div>
</label>
<label className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${seedDestination === 'seedblender' ? 'bg-[#1a1a2e] border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]' : 'bg-[#16213e] border-[#00f0ff]/30 hover:border-[#00f0ff]/50'}`}>
<input type="radio" name="destination" value="seedblender" checked={seedDestination === 'seedblender'} onChange={(e) => setSeedDestination(e.target.value as 'backup' | 'seedblender')} className="hidden" />
<div className="text-center space-y-1">
<div className={`text-sm font-bold ${seedDestination === 'seedblender' ? 'text-[#00f0ff]' : 'text-[#9d84b7]'}`}>🎨 Seed Blender</div>
<p className="text-[10px] text-[#6ef3f7]">Use for XOR blending</p>
</div>
</label>
</div>
</div>
{/* Send Button */}
<button onClick={handleSendToDestination} className="w-full py-3 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white text-sm rounded-xl font-bold uppercase hover:shadow-[0_0_30px_rgba(255,0,110,0.8)] transition-all">
Send to {seedDestination === 'backup' ? 'Backup' : 'Seed Blender'}
</button>
<button onClick={() => { setGeneratedSeed(''); setEntropySource(null); setEntropyStats(null); }} className="w-full py-2 bg-[#16213e] border-2 border-[#00f0ff] text-[#00f0ff] rounded-lg text-sm font-bold hover:bg-[#00f0ff]/20 transition-all">
Generate Another Seed
</button>
</div>
)}
</div>
</div>
)}
<div className={activeTab === 'backup' ? 'block' : 'hidden'}>
<div className="space-y-2">
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>BIP39 Mnemonic</label>
@@ -873,7 +887,7 @@ function App() {
<div className={activeTab === 'seedblender' ? 'block' : 'hidden'}>
<SeedBlender
key={blenderResetKey}
onDirtyStateChange={setIsBlenderDirty}
onDirtyStateChange={() => { }}
setMnemonicForBackup={setMnemonic}
requestTabChange={handleRequestTabChange}
incomingSeed={seedForBlender}