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