Files
seedpgp-web/src/components/DiceEntropy.tsx
2026-02-14 23:19:35 +08:00

291 lines
15 KiB
TypeScript

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 [generatedMnemonic, setGeneratedMnemonic] = useState<string>('');
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);
setGeneratedMnemonic(mnemonic); // Store mnemonic for later
setProcessing(false);
// DON'T call onEntropyGenerated yet - let user review stats first
};
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 max-h-[calc(100vh-200px)] overflow-y-auto">
{/* INPUT FORM - Show only when stats are NOT shown */}
{!stats && !processing && (
<>
<div className="p-3 md: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>Spaces are ignored (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-28 md:h-32 p-2 md:p-3 bg-[#0a0a0f] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-xs text-[#00f0ff] 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>
</>
)}
{/* PROCESSING STATE */}
{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>
)}
{/* STATS DISPLAY - Show after generation */}
{stats && !processing && (
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#39ff14] shadow-[0_0_30px_rgba(57,255,20,0.3)] space-y-4 mb-6">
<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-2 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)</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">GENERATED SEED:</p>
<div className="p-3 bg-[#0a0a0f] rounded-lg border border-[#39ff14]/50">
<p className="font-mono text-[10px] text-[#39ff14] blur-sensitive" 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>
<p className="text-[#00f0ff] font-bold mb-2">HOW SEED IS GENERATED:</p>
<div className="space-y-1 text-[10px] text-[#6ef3f7]">
<div>1. Physical dice rolls ({stats.length} values)</div>
<div>2. Statistical validation (χ²={stats.chiSquare.toFixed(2)})</div>
<div>3. Combined with timing entropy</div>
<div>4. Mixed with {stats.interactionSamples} 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">MIXED WITH:</p>
<div className="space-y-1 text-[#6ef3f7]">
<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>
{/* Action Buttons */}
<div className="pt-4 border-t border-[#00f0ff]/30 space-y-3">
<button
onClick={() => {
// 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
setStats(null); setGeneratedMnemonic(''); setRolls(''); setError('');
}}
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"
>
Roll Again
</button>
</div>
</div>
)}
</div>
);
};
export default DiceEntropy;