mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
Fix CameraEntropy video initialization and add stats review panel
- Fix videoRef timing issue by using useEffect for video setup - Stop animation loop on capture to prevent infinite warnings - Fix null canvas reference in generateMnemonicFromEntropy - Add stats review panel with continue/retake options - Add seed generation explanation and blurred preview - Implement seed generation from camera noise/entropy bits and enhance dice rolls with detailed statistical analysis
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user