mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-06 17:37:51 +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}
|
||||
|
||||
609
src/components/CameraEntropy.tsx
Normal file
609
src/components/CameraEntropy.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Camera, X, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { InteractionEntropy } from '../lib/interactionEntropy';
|
||||
|
||||
interface EntropyStats {
|
||||
shannon: number;
|
||||
variance: number;
|
||||
uniqueColors: number;
|
||||
brightnessRange: [number, number];
|
||||
rgbStats: {
|
||||
r: { mean: number; stddev: number };
|
||||
g: { mean: number; stddev: number };
|
||||
b: { mean: number; stddev: number };
|
||||
};
|
||||
histogram: number[]; // 10 buckets
|
||||
captureTimeMicros: number;
|
||||
interactionSamples: number;
|
||||
totalBits: number;
|
||||
dataSize: number;
|
||||
}
|
||||
|
||||
interface CameraEntropyProps {
|
||||
wordCount: 12 | 24;
|
||||
onEntropyGenerated: (mnemonic: string, stats: EntropyStats) => void;
|
||||
onCancel: () => void;
|
||||
interactionEntropy: InteractionEntropy;
|
||||
}
|
||||
|
||||
const CameraEntropy: React.FC<CameraEntropyProps> = ({
|
||||
wordCount,
|
||||
onEntropyGenerated,
|
||||
onCancel,
|
||||
interactionEntropy
|
||||
}) => {
|
||||
const [step, setStep] = useState<'permission' | 'capture' | 'processing' | 'stats'>('permission');
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [entropy, setEntropy] = useState(0);
|
||||
const [variance, setVariance] = useState(0);
|
||||
const [captureEnabled, setCaptureEnabled] = useState(false);
|
||||
const [stats, setStats] = useState<EntropyStats | null>(null);
|
||||
const [generatedMnemonic, setGeneratedMnemonic] = useState<string>('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>();
|
||||
|
||||
const requestCameraAccess = async () => {
|
||||
try {
|
||||
console.log('🎥 Requesting camera access...');
|
||||
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: true,
|
||||
audio: false
|
||||
});
|
||||
|
||||
console.log('✅ Camera stream obtained:', {
|
||||
tracks: mediaStream.getVideoTracks().map(t => ({
|
||||
label: t.label,
|
||||
enabled: t.enabled,
|
||||
readyState: t.readyState,
|
||||
settings: t.getSettings()
|
||||
}))
|
||||
});
|
||||
|
||||
setStream(mediaStream);
|
||||
setStep('capture');
|
||||
|
||||
// Don't set up video here - let useEffect handle it after render
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('❌ Camera access error:', err.name, err.message, err);
|
||||
setError(`Camera unavailable: ${err.message}`);
|
||||
setTimeout(() => onCancel(), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up video element when stream is available
|
||||
useEffect(() => {
|
||||
if (!stream || !videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
|
||||
console.log('📹 Setting up video element with stream...');
|
||||
|
||||
video.srcObject = stream;
|
||||
video.setAttribute('playsinline', '');
|
||||
video.setAttribute('autoplay', '');
|
||||
video.muted = true;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
console.log('✅ Video metadata loaded:', {
|
||||
videoWidth: video.videoWidth,
|
||||
videoHeight: video.videoHeight,
|
||||
readyState: video.readyState
|
||||
});
|
||||
|
||||
video.play()
|
||||
.then(() => {
|
||||
console.log('✅ Video playing:', {
|
||||
paused: video.paused,
|
||||
currentTime: video.currentTime
|
||||
});
|
||||
|
||||
// Wait for actual frame data
|
||||
setTimeout(() => {
|
||||
// Test if video is actually rendering
|
||||
const testCanvas = document.createElement('canvas');
|
||||
testCanvas.width = video.videoWidth;
|
||||
testCanvas.height = video.videoHeight;
|
||||
const testCtx = testCanvas.getContext('2d');
|
||||
|
||||
if (testCtx && video.videoWidth > 0 && video.videoHeight > 0) {
|
||||
testCtx.drawImage(video, 0, 0);
|
||||
const imageData = testCtx.getImageData(0, 0, Math.min(10, video.videoWidth), Math.min(10, video.videoHeight));
|
||||
const pixels = Array.from(imageData.data.slice(0, 40));
|
||||
console.log('🎨 First 40 pixel values:', pixels);
|
||||
|
||||
const allZero = pixels.every(p => p === 0);
|
||||
const allSame = pixels.every(p => p === pixels[0]);
|
||||
|
||||
if (allZero) {
|
||||
console.error('❌ All pixels are zero - video not rendering!');
|
||||
} else if (allSame) {
|
||||
console.warn('⚠️ All pixels same value - possible issue');
|
||||
} else {
|
||||
console.log('✅ Video has actual frame data');
|
||||
}
|
||||
}
|
||||
|
||||
startEntropyAnalysis();
|
||||
}, 300);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('❌ video.play() failed:', err);
|
||||
setError('Failed to start video preview: ' + err.message);
|
||||
});
|
||||
};
|
||||
|
||||
const handleVideoError = (err: any) => {
|
||||
console.error('❌ Video element error:', err);
|
||||
setError('Video playback error');
|
||||
};
|
||||
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.addEventListener('error', handleVideoError);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.removeEventListener('error', handleVideoError);
|
||||
};
|
||||
}, [stream]); // Run when stream changes
|
||||
|
||||
const startEntropyAnalysis = () => {
|
||||
console.log('🔍 Starting entropy analysis...');
|
||||
|
||||
const analyze = () => {
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
if (!video || !canvas) {
|
||||
// If we are in processing/stats step, don't warn, just stop
|
||||
// This prevents race conditions during capture
|
||||
return;
|
||||
}
|
||||
|
||||
// Critical: Wait for valid dimensions
|
||||
if (video.videoWidth === 0 || video.videoHeight === 0) {
|
||||
console.warn('⚠️ Video dimensions are 0, waiting...', {
|
||||
videoWidth: video.videoWidth,
|
||||
videoHeight: video.videoHeight,
|
||||
readyState: video.readyState
|
||||
});
|
||||
animationRef.current = requestAnimationFrame(analyze);
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) {
|
||||
console.error('❌ Failed to get canvas context');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set canvas size to match video
|
||||
if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
console.log('📐 Canvas resized to:', canvas.width, 'x', canvas.height);
|
||||
}
|
||||
|
||||
try {
|
||||
ctx.drawImage(video, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Check if we got actual data
|
||||
if (imageData.data.length === 0) {
|
||||
console.error('❌ ImageData is empty');
|
||||
animationRef.current = requestAnimationFrame(analyze);
|
||||
return;
|
||||
}
|
||||
|
||||
const { entropy: e, variance: v } = calculateQuickEntropy(imageData);
|
||||
|
||||
setEntropy(e);
|
||||
setVariance(v);
|
||||
setCaptureEnabled(e >= 7.5 && v >= 1000);
|
||||
|
||||
} catch (err) {
|
||||
console.error('❌ Error in entropy analysis:', err);
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(analyze);
|
||||
};
|
||||
|
||||
analyze();
|
||||
};
|
||||
|
||||
const calculateQuickEntropy = (imageData: ImageData): { entropy: number; variance: number } => {
|
||||
const data = imageData.data;
|
||||
const histogram = new Array(256).fill(0);
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
|
||||
// Sample every 16th pixel for performance
|
||||
for (let i = 0; i < data.length; i += 16) {
|
||||
const gray = Math.floor((data[i] + data[i + 1] + data[i + 2]) / 3);
|
||||
histogram[gray]++;
|
||||
sum += gray;
|
||||
count++;
|
||||
}
|
||||
|
||||
const mean = sum / count;
|
||||
|
||||
// Shannon entropy
|
||||
let entropy = 0;
|
||||
for (const h_count of histogram) {
|
||||
if (h_count > 0) {
|
||||
const p = h_count / count;
|
||||
entropy -= p * Math.log2(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Variance
|
||||
let variance = 0;
|
||||
for (let i = 0; i < data.length; i += 16) {
|
||||
const gray = Math.floor((data[i] + data[i + 1] + data[i + 2]) / 3);
|
||||
variance += Math.pow(gray - mean, 2);
|
||||
}
|
||||
variance = variance / count;
|
||||
|
||||
return { entropy, variance };
|
||||
};
|
||||
|
||||
const captureEntropy = async () => {
|
||||
if (!videoRef.current || !canvasRef.current) return;
|
||||
|
||||
// CRITICAL: Stop the analysis loop immediately
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
console.log('🛑 Stopped entropy analysis loop');
|
||||
}
|
||||
|
||||
setStep('processing');
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = videoRef.current.videoWidth;
|
||||
canvas.height = videoRef.current.videoHeight;
|
||||
ctx.drawImage(videoRef.current, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const captureTime = performance.now();
|
||||
|
||||
// Full entropy analysis
|
||||
const fullStats = await calculateFullEntropy(imageData, captureTime);
|
||||
|
||||
// Generate mnemonic from entropy
|
||||
const mnemonic = await generateMnemonicFromEntropy(fullStats, wordCount, canvas);
|
||||
|
||||
setStats(fullStats);
|
||||
setStep('stats');
|
||||
|
||||
// Stop camera
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
console.log('📷 Camera stopped');
|
||||
}
|
||||
|
||||
// Don't call onEntropyGenerated yet - let user review stats first
|
||||
setGeneratedMnemonic(mnemonic);
|
||||
};
|
||||
|
||||
const calculateFullEntropy = async (
|
||||
imageData: ImageData,
|
||||
captureTime: number
|
||||
): Promise<EntropyStats> => {
|
||||
const data = imageData.data;
|
||||
const pixels = data.length / 4;
|
||||
|
||||
const r: number[] = [], g: number[] = [], b: number[] = [];
|
||||
const histogram = new Array(10).fill(0);
|
||||
const colorSet = new Set<number>();
|
||||
let minBright = 255, maxBright = 0;
|
||||
const allGray: number[] = [];
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
r.push(data[i]);
|
||||
g.push(data[i + 1]);
|
||||
b.push(data[i + 2]);
|
||||
|
||||
const brightness = Math.floor((data[i] + data[i + 1] + data[i + 2]) / 3);
|
||||
allGray.push(brightness);
|
||||
const bucket = Math.floor(brightness / 25.6);
|
||||
histogram[Math.min(bucket, 9)]++;
|
||||
|
||||
minBright = Math.min(minBright, brightness);
|
||||
maxBright = Math.max(maxBright, brightness);
|
||||
|
||||
const color = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2];
|
||||
colorSet.add(color);
|
||||
}
|
||||
|
||||
const grayHistogram = new Array(256).fill(0);
|
||||
for (const gray of allGray) {
|
||||
grayHistogram[gray]++;
|
||||
}
|
||||
|
||||
let shannon = 0;
|
||||
for (const count of grayHistogram) {
|
||||
if (count > 0) {
|
||||
const p = count / pixels;
|
||||
shannon -= p * Math.log2(p);
|
||||
}
|
||||
}
|
||||
|
||||
const calcStats = (arr: number[]): { mean: number; stddev: number } => {
|
||||
const mean = arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
const variance = arr.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / arr.length;
|
||||
return { mean, stddev: Math.sqrt(variance) };
|
||||
};
|
||||
|
||||
const rgbStats = { r: calcStats(r), g: calcStats(g), b: calcStats(b) };
|
||||
const variance = calcStats(allGray).stddev ** 2;
|
||||
|
||||
return {
|
||||
shannon,
|
||||
variance,
|
||||
uniqueColors: colorSet.size,
|
||||
brightnessRange: [minBright, maxBright],
|
||||
rgbStats,
|
||||
histogram,
|
||||
captureTimeMicros: Math.floor((captureTime % 1) * 1000000),
|
||||
interactionSamples: interactionEntropy.getSampleCount().total,
|
||||
totalBits: 256,
|
||||
dataSize: data.length
|
||||
};
|
||||
};
|
||||
|
||||
const generateMnemonicFromEntropy = async (
|
||||
stats: EntropyStats,
|
||||
wordCount: 12 | 24,
|
||||
canvas: HTMLCanvasElement
|
||||
): Promise<string> => {
|
||||
// Mix multiple entropy sources
|
||||
const imageDataUrl = canvas.toDataURL(); // Now canvas is guaranteed not null
|
||||
|
||||
const interactionBytes = await interactionEntropy.getEntropyBytes();
|
||||
const cryptoBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
|
||||
const combined = [
|
||||
imageDataUrl,
|
||||
stats.captureTimeMicros.toString(),
|
||||
Array.from(interactionBytes).join(','),
|
||||
Array.from(cryptoBytes).join(','),
|
||||
performance.now().toString()
|
||||
].join('|');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(combined);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
|
||||
// Use bip39 to generate mnemonic from the collected entropy hash
|
||||
const { entropyToMnemonic } = await import('bip39');
|
||||
const entropyLength = wordCount === 12 ? 16 : 32;
|
||||
const finalEntropy = new Uint8Array(hash).slice(0, entropyLength);
|
||||
|
||||
// The bip39 library expects a hex string or a Buffer.
|
||||
const entropyHex = Buffer.from(finalEntropy).toString('hex');
|
||||
|
||||
return entropyToMnemonic(entropyHex);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup on unmount
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
}, [stream]);
|
||||
|
||||
const getStatusMessage = () => {
|
||||
if (entropy >= 7.0 && variance >= 800) {
|
||||
return { icon: CheckCircle2, text: '✅ Excellent entropy - ready!', color: '#39ff14' };
|
||||
} else if (entropy >= 6.0 && variance >= 500) {
|
||||
return { icon: AlertCircle, text: '🟡 Good - point to brighter area', color: '#ffd700' };
|
||||
} else if (entropy >= 5.0) {
|
||||
return { icon: AlertCircle, text: '🟠 Low - find textured surface', color: '#ff9500' };
|
||||
} else {
|
||||
return { icon: AlertCircle, text: '🔴 Too low - point at lamp/pattern', color: '#ff006e' };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{step === 'permission' && (
|
||||
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 space-y-4">
|
||||
<div className="text-center space-y-2">
|
||||
<Camera size={48} className="mx-auto text-[#00f0ff]" />
|
||||
<h3 className="text-sm font-bold text-[#00f0ff] uppercase">Camera Permission Needed</h3>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs text-[#6ef3f7]">
|
||||
<p>To generate entropy, we need:</p>
|
||||
<ul className="list-disc list-inside space-y-1 pl-2">
|
||||
<li>Camera access to capture pixel noise</li>
|
||||
<li>Image data processed locally</li>
|
||||
<li>Never stored or transmitted</li>
|
||||
<li>Camera auto-closes after use</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={requestCameraAccess} className="flex-1 py-2.5 bg-[#00f0ff] text-[#0a0a0f] rounded-lg font-bold text-sm hover:bg-[#00f0ff] hover:shadow-[0_0_20px_rgba(0,240,255,0.5)] transition-all">Allow Camera</button>
|
||||
<button onClick={onCancel} className="flex-1 py-2.5 bg-[#16213e] border-2 border-[#ff006e] text-[#ff006e] rounded-lg font-bold text-sm hover:bg-[#ff006e]/20 transition-all">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'capture' && (
|
||||
<div className="space-y-4">
|
||||
<div className="relative rounded-xl overflow-hidden border-2 border-[#00f0ff]/30 bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
playsInline
|
||||
autoPlay
|
||||
muted
|
||||
className="w-full"
|
||||
style={{
|
||||
maxHeight: '300px',
|
||||
objectFit: 'cover',
|
||||
border: '2px solid #00f0ff',
|
||||
backgroundColor: '#000'
|
||||
}}
|
||||
/>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="hidden"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 space-y-3">
|
||||
<div className="text-xs text-[#6ef3f7] space-y-1">
|
||||
<p className="font-bold text-[#00f0ff]">Instructions:</p>
|
||||
<p>Point camera at bright, textured surface (lamp, carpet, wall with pattern)</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[#00f0ff]">Entropy Quality:</span>
|
||||
<span className="font-mono text-[#00f0ff]">{entropy.toFixed(2)}/8.0</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#0a0a0f] rounded-full h-2 overflow-hidden">
|
||||
<div className="h-full transition-all" style={{ width: `${(entropy / 8) * 100}%`, backgroundColor: getStatusMessage().color }} />
|
||||
</div>
|
||||
<div className="text-xs font-medium" style={{ color: getStatusMessage().color }}>{getStatusMessage().text}</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={captureEntropy} disabled={!captureEnabled} className="flex-1 py-2.5 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white text-sm rounded-lg font-bold uppercase disabled:opacity-30 disabled:cursor-not-allowed hover:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all">
|
||||
<Camera className="inline mr-2" size={16} />Capture
|
||||
</button>
|
||||
<button onClick={onCancel} className="px-4 py-2.5 bg-[#16213e] border-2 border-[#ff006e] text-[#ff006e] rounded-lg font-bold text-sm hover:bg-[#ff006e]/20 transition-all"><X size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'processing' && (
|
||||
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 text-center space-y-3">
|
||||
<div className="animate-spin mx-auto w-12 h-12 border-4 border-[#00f0ff]/30 border-t-[#00f0ff] rounded-full" />
|
||||
<p className="text-sm text-[#00f0ff]">Processing entropy...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'stats' && stats && (
|
||||
<div className="p-4 bg-[#0a0a0f] rounded-xl border-2 border-[#39ff14] shadow-[0_0_30px_rgba(57,255,20,0.3)] space-y-4">
|
||||
<div className="flex items-center gap-2 text-[#39ff14]"><CheckCircle2 size={24} /><h3 className="text-sm font-bold uppercase">Entropy Analysis</h3></div>
|
||||
<div className="space-y-3 text-xs">
|
||||
<div><p className="text-[#00f0ff] font-bold mb-1">Primary Source:</p><p className="text-[#6ef3f7]">Camera Sensor Noise</p></div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">RANDOMNESS METRICS:</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 font-mono text-[10px]">
|
||||
<div>Shannon Entropy:</div><div className="text-[#39ff14]">{stats.shannon.toFixed(2)}/8.00</div>
|
||||
<div>Pixel Variance:</div><div className="text-[#39ff14]">{stats.variance.toFixed(1)}</div>
|
||||
<div>Unique Colors:</div><div className="text-[#39ff14]">{stats.uniqueColors.toLocaleString()}</div>
|
||||
<div>Brightness Range:</div><div className="text-[#39ff14]">{stats.brightnessRange[0]}-{stats.brightnessRange[1]}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">RGB DISTRIBUTION:</p>
|
||||
<div className="space-y-1 font-mono text-[10px]">
|
||||
<div className="flex justify-between"><span>Red:</span><span className="text-[#ff6b6b]">μ={stats.rgbStats.r.mean.toFixed(0)} σ={stats.rgbStats.r.stddev.toFixed(1)}</span></div>
|
||||
<div className="flex justify-between"><span>Green:</span><span className="text-[#51cf66]">μ={stats.rgbStats.g.mean.toFixed(0)} σ={stats.rgbStats.g.stddev.toFixed(1)}</span></div>
|
||||
<div className="flex justify-between"><span>Blue:</span><span className="text-[#339af0]">μ={stats.rgbStats.b.mean.toFixed(0)} σ={stats.rgbStats.b.stddev.toFixed(1)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">BRIGHTNESS HISTOGRAM:</p>
|
||||
<div className="flex items-end justify-between h-12 gap-0.5">{stats.histogram.map((val, i) => { const max = Math.max(...stats.histogram); const height = (val / max) * 100; return (<div key={i} className="flex-1 bg-[#00f0ff] rounded-t" style={{ height: `${height}%` }} />); })}</div>
|
||||
<div className="flex justify-between text-[9px] text-[#6ef3f7] mt-1"><span>Dark</span><span>Bright</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">TIMING ENTROPY:</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 font-mono text-[10px]">
|
||||
<div>Capture timing:</div><div className="text-[#39ff14]">...{stats.captureTimeMicros}μs</div>
|
||||
<div>Interaction samples:</div><div className="text-[#39ff14]">{stats.interactionSamples}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">MIXED WITH:</p>
|
||||
<div className="space-y-1 text-[#6ef3f7] text-[10px]">
|
||||
<div>- crypto.getRandomValues() ✓</div>
|
||||
<div>- performance.now() ✓</div>
|
||||
<div>- Mouse/keyboard timing ✓</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-[#00f0ff]/30">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[#00f0ff] font-bold">Total Entropy:</span>
|
||||
<span className="text-lg font-bold text-[#39ff14]">{stats.totalBits} bits</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">HOW SEED IS GENERATED:</p>
|
||||
<div className="space-y-1 text-[10px] text-[#6ef3f7]">
|
||||
<div>1. Camera captures {stats.uniqueColors.toLocaleString()} unique pixel colors</div>
|
||||
<div>2. Pixel data hashed with SHA-256 ({(stats.dataSize / 1024).toFixed(1)}KB raw data)</div>
|
||||
<div>3. Mixed with timing entropy ({stats.captureTimeMicros}μs precision)</div>
|
||||
<div>4. Combined with {stats.interactionSamples} user interaction samples</div>
|
||||
<div>5. Enhanced with crypto.getRandomValues() (32 bytes)</div>
|
||||
<div>6. Final hash → {wordCount === 12 ? '128' : '256'} bits → {wordCount} BIP39 words</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">GENERATED SEED:</p>
|
||||
<div className="p-3 bg-[#0a0a0f] rounded-lg border border-[#39ff1450]">
|
||||
<p className="font-mono text-[10px] text-[#39ff14] blur-sm hover:blur-none transition-all cursor-pointer"
|
||||
title="Hover to reveal">
|
||||
{generatedMnemonic}
|
||||
</p>
|
||||
<p className="text-[9px] text-[#6ef3f7] mt-1">
|
||||
⚠️ Hover to reveal - Write this down securely
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[#00f0ff30] space-y-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Now send to parent
|
||||
onEntropyGenerated(generatedMnemonic, stats);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
Continue with this Seed
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
// Reset and try again
|
||||
setStep('permission');
|
||||
setStats(null);
|
||||
setGeneratedMnemonic('');
|
||||
setEntropy(0);
|
||||
setVariance(0);
|
||||
}}
|
||||
className="w-full py-2 bg-[#16213e] border-2 border-[#00f0ff] text-[#00f0ff] rounded-lg text-sm font-bold hover:bg-[#00f0ff20] transition-all"
|
||||
>
|
||||
Retake Photo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-[#1a1a2e] border-2 border-[#ff006e] rounded-lg">
|
||||
<p className="text-xs text-[#ff006e]">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraEntropy;
|
||||
239
src/components/DiceEntropy.tsx
Normal file
239
src/components/DiceEntropy.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dices, CheckCircle2, AlertCircle, X } from 'lucide-react';
|
||||
import { InteractionEntropy } from '../lib/interactionEntropy';
|
||||
|
||||
interface DiceStats {
|
||||
rolls: string;
|
||||
length: number;
|
||||
distribution: number[];
|
||||
chiSquare: number;
|
||||
passed: boolean;
|
||||
interactionSamples: number;
|
||||
}
|
||||
|
||||
interface DiceEntropyProps {
|
||||
wordCount: 12 | 24;
|
||||
onEntropyGenerated: (mnemonic: string, stats: any) => void;
|
||||
onCancel: () => void;
|
||||
interactionEntropy: InteractionEntropy;
|
||||
}
|
||||
|
||||
const DiceEntropy: React.FC<DiceEntropyProps> = ({
|
||||
wordCount,
|
||||
onEntropyGenerated,
|
||||
onCancel,
|
||||
interactionEntropy
|
||||
}) => {
|
||||
const [rolls, setRolls] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [stats, setStats] = useState<DiceStats | null>(null);
|
||||
|
||||
const validateDiceRolls = (input: string): { valid: boolean; error: string } => {
|
||||
const clean = input.replace(/\s/g, '');
|
||||
|
||||
if (clean.length < 99) {
|
||||
return { valid: false, error: `Need at least 99 dice rolls (currently ${clean.length})` };
|
||||
}
|
||||
|
||||
if (/(\d)\1{6,}/.test(clean)) {
|
||||
return { valid: false, error: 'Too many repeated digits - roll again' };
|
||||
}
|
||||
|
||||
if (/(\d)(\d)\1\2\1\2\1\2/.test(clean)) {
|
||||
return { valid: false, error: 'Repeating pattern detected - roll again' };
|
||||
}
|
||||
|
||||
if (/(?:123456|654321)/.test(clean)) {
|
||||
return { valid: false, error: 'Sequential pattern detected - roll again' };
|
||||
}
|
||||
|
||||
const counts = Array(6).fill(0);
|
||||
for (const char of clean) {
|
||||
const digit = parseInt(char, 10);
|
||||
if (digit >= 1 && digit <= 6) counts[digit - 1]++;
|
||||
}
|
||||
|
||||
const expected = clean.length / 6;
|
||||
const threshold = expected * 0.4; // Allow 40% deviation
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (Math.abs(counts[i] - expected) > threshold) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Poor distribution: digit ${i + 1} appears ${counts[i]} times (expected ~${Math.round(expected)})`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const chiSquare = counts.reduce((sum, count) => {
|
||||
const diff = count - expected;
|
||||
return sum + (diff * diff) / expected;
|
||||
}, 0);
|
||||
|
||||
if (chiSquare > 15.5) { // p-value < 0.01 for 5 degrees of freedom
|
||||
return {
|
||||
valid: false,
|
||||
error: `Statistical test failed (χ²=${chiSquare.toFixed(2)}) - rolls too predictable`
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true, error: '' };
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const validation = validateDiceRolls(rolls);
|
||||
if (!validation.valid) {
|
||||
setError(validation.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setProcessing(true);
|
||||
|
||||
const clean = rolls.replace(/\s/g, '');
|
||||
|
||||
// Calculate stats
|
||||
const counts = Array(6).fill(0);
|
||||
for (const char of clean) {
|
||||
const digit = parseInt(char);
|
||||
if (digit >= 1 && digit <= 6) counts[digit - 1]++;
|
||||
}
|
||||
|
||||
const expected = clean.length / 6;
|
||||
const chiSquare = counts.reduce((sum, count) => {
|
||||
const diff = count - expected;
|
||||
return sum + (diff * diff) / expected;
|
||||
}, 0);
|
||||
|
||||
const diceStats: DiceStats = {
|
||||
rolls: clean,
|
||||
length: clean.length,
|
||||
distribution: counts,
|
||||
chiSquare,
|
||||
passed: true,
|
||||
interactionSamples: interactionEntropy.getSampleCount().total,
|
||||
};
|
||||
|
||||
// Generate mnemonic
|
||||
const mnemonic = await generateMnemonicFromDice(clean);
|
||||
|
||||
// Show stats first
|
||||
setStats(diceStats);
|
||||
setProcessing(false);
|
||||
|
||||
// Then notify parent after a brief delay so user sees stats
|
||||
setTimeout(() => {
|
||||
onEntropyGenerated(mnemonic, diceStats);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const generateMnemonicFromDice = async (diceRolls: string): Promise<string> => {
|
||||
const interactionBytes = await interactionEntropy.getEntropyBytes();
|
||||
const cryptoBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
|
||||
const sources = [
|
||||
diceRolls,
|
||||
performance.now().toString(),
|
||||
Array.from(interactionBytes).join(','),
|
||||
Array.from(cryptoBytes).join(',')
|
||||
];
|
||||
|
||||
const combined = sources.join('|');
|
||||
const data = new TextEncoder().encode(combined);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
|
||||
const { entropyToMnemonic } = await import('bip39');
|
||||
const entropyLength = wordCount === 12 ? 16 : 32;
|
||||
const finalEntropy = new Uint8Array(hash).slice(0, entropyLength);
|
||||
|
||||
const entropyHex = Buffer.from(finalEntropy).toString('hex');
|
||||
return entropyToMnemonic(entropyHex);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!stats && (
|
||||
<>
|
||||
<div className="p-4 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 space-y-3">
|
||||
<div className="flex items-center gap-2"><Dices size={20} className="text-[#00f0ff]" /><h3 className="text-sm font-bold text-[#00f0ff] uppercase">Dice Roll Entropy</h3></div>
|
||||
<div className="space-y-2 text-xs text-[#6ef3f7]">
|
||||
<p className="font-bold text-[#00f0ff]">Instructions:</p>
|
||||
<ul className="list-disc list-inside space-y-1 pl-2">
|
||||
<li>Roll a 6-sided die at least 99 times</li>
|
||||
<li>Enter each result (1-6) in order</li>
|
||||
<li>No spaces needed (e.g., 163452...)</li>
|
||||
<li>Pattern validation enabled</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest">Enter Dice Rolls</label>
|
||||
<textarea value={rolls} onChange={(e) => { setRolls(e.target.value.replace(/[^1-6\s]/g, '')); setError(''); }} placeholder="99+ dice rolls (e.g., 16345...)" className="w-full h-32 p-3 bg-[#0a0a0f] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-xs placeholder:text-[10px] placeholder:text-[#6ef3f7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all resize-none" />
|
||||
<p className="text-[10px] text-[#6ef3f7]">Current: {rolls.replace(/\s/g, '').length} rolls {rolls.replace(/\s/g, '').length >= 99 && ' ✓'}</p>
|
||||
</div>
|
||||
{error && (<div className="flex items-start gap-2 p-3 bg-[#0a0a0f] border border-[#ff006e] rounded-lg"><AlertCircle size={16} className="text-[#ff006e] shrink-0 mt-0.5" /><p className="text-xs text-[#ff006e]">{error}</p></div>)}
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handleGenerate} disabled={processing || rolls.replace(/\s/g, '').length < 99} className="flex-1 py-2.5 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white text-sm rounded-lg font-bold uppercase disabled:opacity-30 disabled:cursor-not-allowed hover:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all">{processing ? 'Processing...' : 'Generate Seed'}</button>
|
||||
<button onClick={onCancel} className="px-4 py-2.5 bg-[#16213e] border-2 border-[#ff006e] text-[#ff006e] rounded-lg font-bold text-sm hover:bg-[#ff006e]/20 transition-all"><X size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 p-3 bg-[#16213e] border border-[#00f0ff]/30 rounded-lg">
|
||||
<AlertCircle 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. Dice rolls are mixed with browser entropy and never stored or transmitted.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="p-4 bg-[#0a0a0f] rounded-xl border-2 border-[#39ff14] shadow-[0_0_30px_rgba(57,255,20,0.3)] space-y-4">
|
||||
<div className="flex items-center gap-2 text-[#39ff14]"><CheckCircle2 size={24} /><h3 className="text-sm font-bold uppercase">Dice Entropy Analysis</h3></div>
|
||||
<div className="space-y-3 text-xs">
|
||||
<div><p className="text-[#00f0ff] font-bold mb-1">Primary Source:</p><p className="text-[#6ef3f7]">Physical Dice Rolls</p></div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">ROLL STATISTICS:</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 font-mono text-[10px]">
|
||||
<div>Total rolls:</div><div className="text-[#39ff14]">{stats.length}</div>
|
||||
<div>Chi-square test:</div><div className="text-[#39ff14]">{stats.chiSquare.toFixed(2)} (pass < 15.5)</div>
|
||||
<div>Validation:</div><div className="text-[#39ff14]">{stats.passed ? '✅ Passed' : '❌ Failed'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">DISTRIBUTION:</p>
|
||||
<div className="space-y-2">
|
||||
{stats.distribution.map((count, i) => {
|
||||
const percent = (count / stats.length) * 100;
|
||||
const expected = 16.67;
|
||||
const deviation = Math.abs(percent - expected);
|
||||
const color = deviation < 5 ? '#39ff14' : deviation < 8 ? '#ffd700' : '#ff9500';
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex justify-between text-[10px] mb-1"><span>Die face {i + 1}:</span><span style={{ color }}>{count} ({percent.toFixed(1)}%)</span></div>
|
||||
<div className="w-full bg-[#0a0a0f] rounded-full h-1.5 overflow-hidden"><div className="h-full transition-all" style={{ width: `${percent}%`, backgroundColor: color }} /></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[9px] text-[#6ef3f7] mt-2">Expected: ~16.67% per face</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#00f0ff] font-bold mb-2">MIXED WITH:</p>
|
||||
<div className="space-y-1 text-[#6ef3f7] text-[10px]">
|
||||
<div>- crypto.getRandomValues() ✓</div>
|
||||
<div>- performance.now() ✓</div>
|
||||
<div>- Interaction timing ({stats.interactionSamples} samples) ✓</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-[#00f0ff]/30">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[#00f0ff] font-bold">Total Entropy:</span>
|
||||
<span className="text-lg font-bold text-[#39ff14]">256 bits</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiceEntropy;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Shield, RefreshCw, Lock, Unlock } from 'lucide-react';
|
||||
import { Shield, RefreshCw } from 'lucide-react';
|
||||
import SecurityBadge from './badges/SecurityBadge';
|
||||
import StorageBadge from './badges/StorageBadge';
|
||||
import ClipboardBadge from './badges/ClipboardBadge';
|
||||
@@ -28,7 +28,6 @@ interface HeaderProps {
|
||||
activeTab: 'create' | 'backup' | 'restore' | 'seedblender';
|
||||
onRequestTabChange: (tab: 'create' | 'backup' | 'restore' | 'seedblender') => void;
|
||||
encryptedMnemonicCache: any;
|
||||
handleLockAndClear: () => void;
|
||||
appVersion: string;
|
||||
isLocked: boolean;
|
||||
onToggleLock: () => void;
|
||||
@@ -45,7 +44,6 @@ const Header: React.FC<HeaderProps> = ({
|
||||
activeTab,
|
||||
onRequestTabChange,
|
||||
encryptedMnemonicCache,
|
||||
handleLockAndClear,
|
||||
appVersion,
|
||||
isLocked,
|
||||
onToggleLock,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 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, Lock, Key, ArrowRight } from 'lucide-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';
|
||||
@@ -113,19 +113,6 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
|
||||
}
|
||||
}, [incomingSeed]);
|
||||
|
||||
const handleLockAndClear = () => {
|
||||
setEntries([createNewEntry()]);
|
||||
setBlendedResult(null);
|
||||
setXorStrength(null);
|
||||
setBlendError('');
|
||||
setDiceRolls('');
|
||||
setDiceStats(null);
|
||||
setDicePatternWarning(null);
|
||||
setDiceOnlyMnemonic(null);
|
||||
setFinalMnemonic(null);
|
||||
setShowFinalQR(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const processEntries = async () => {
|
||||
setBlending(true);
|
||||
|
||||
73
src/lib/interactionEntropy.ts
Normal file
73
src/lib/interactionEntropy.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Collects entropy from user interactions (mouse, keyboard, touch)
|
||||
* Runs in background to enhance any entropy generation method
|
||||
*/
|
||||
export class InteractionEntropy {
|
||||
private samples: number[] = [];
|
||||
private lastEvent = 0;
|
||||
private startTime = performance.now();
|
||||
private sources = { mouse: 0, keyboard: 0, touch: 0 };
|
||||
|
||||
constructor() {
|
||||
this.initListeners();
|
||||
}
|
||||
|
||||
private initListeners() {
|
||||
const handleEvent = (e: MouseEvent | KeyboardEvent | TouchEvent) => {
|
||||
const now = performance.now();
|
||||
const delta = now - this.lastEvent;
|
||||
|
||||
if (delta > 0 && delta < 10000) { // Ignore huge gaps
|
||||
this.samples.push(delta);
|
||||
|
||||
if (e instanceof MouseEvent) {
|
||||
this.samples.push(e.clientX ^ e.clientY);
|
||||
this.sources.mouse++;
|
||||
} else if (e instanceof KeyboardEvent) {
|
||||
this.samples.push(e.key.codePointAt(0) ?? 0);
|
||||
this.sources.keyboard++;
|
||||
} else if (e instanceof TouchEvent && e.touches[0]) {
|
||||
this.samples.push(e.touches[0].clientX ^ e.touches[0].clientY);
|
||||
this.sources.touch++;
|
||||
}
|
||||
}
|
||||
this.lastEvent = now;
|
||||
|
||||
// Keep last 256 samples (128 pairs)
|
||||
if (this.samples.length > 256) {
|
||||
this.samples.splice(0, this.samples.length - 256);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleEvent);
|
||||
document.addEventListener('keydown', handleEvent);
|
||||
document.addEventListener('touchmove', handleEvent);
|
||||
}
|
||||
|
||||
async getEntropyBytes(): Promise<Uint8Array> {
|
||||
// Convert samples to entropy via SHA-256
|
||||
const data = new TextEncoder().encode(
|
||||
this.samples.join(',') + performance.now()
|
||||
);
|
||||
const hash = await crypto.subtle.digest('SHA-256', data);
|
||||
return new Uint8Array(hash);
|
||||
}
|
||||
|
||||
getSampleCount(): { mouse: number; keyboard: number; touch: number; total: number } {
|
||||
return {
|
||||
...this.sources,
|
||||
total: this.sources.mouse + this.sources.keyboard + this.sources.touch
|
||||
};
|
||||
}
|
||||
|
||||
getCollectionTime(): number {
|
||||
return performance.now() - this.startTime;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.samples = [];
|
||||
this.lastEvent = 0;
|
||||
this.startTime = performance.now();
|
||||
this.sources = { mouse: 0, keyboard: 0, touch: 0 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user