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}

View 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;

View 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 &lt; 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;

View File

@@ -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,

View File

@@ -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);

View 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 };
}
}