Files
seedpgp-web/src/components/CameraEntropy.tsx
LC mac 586eabc361 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
2026-02-10 00:15:49 +08:00

609 lines
28 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;