mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
368 lines
17 KiB
TypeScript
368 lines
17 KiB
TypeScript
import React, { useMemo, useState } from "react";
|
|
import { Globe, Copy, ExternalLink, CheckCircle2, AlertCircle, X, Eye, EyeOff, Info } from "lucide-react";
|
|
import { InteractionEntropy } from "../lib/interactionEntropy";
|
|
import { entropyToMnemonic } from "../lib/seedblend";
|
|
|
|
type RandomOrgStats = {
|
|
source: "randomorg";
|
|
nRequested: number;
|
|
nUsed: number;
|
|
distribution: number[]; // counts for faces 1..6
|
|
interactionSamples: number;
|
|
totalBits: number;
|
|
};
|
|
|
|
interface RandomOrgEntropyProps {
|
|
wordCount: 12 | 24;
|
|
onEntropyGenerated: (mnemonic: string, stats: RandomOrgStats) => void;
|
|
onCancel: () => void;
|
|
interactionEntropy: InteractionEntropy;
|
|
}
|
|
|
|
function buildRequest(apiKey: string, n: number) {
|
|
return {
|
|
jsonrpc: "2.0",
|
|
method: "generateIntegers",
|
|
params: { apiKey, n, min: 1, max: 6, replacement: true, base: 10 },
|
|
id: 1,
|
|
};
|
|
}
|
|
|
|
function parseD6FromPaste(text: string): number[] {
|
|
const t = text.trim();
|
|
if (!t) throw new Error("Paste the random.org response JSON (or an array) first.");
|
|
|
|
// Allow direct array paste: [1,6,2,...]
|
|
if (t.startsWith("[") && t.endsWith("]")) {
|
|
const arr = JSON.parse(t);
|
|
if (!Array.isArray(arr)) throw new Error("Expected an array.");
|
|
return arr;
|
|
}
|
|
|
|
const obj = JSON.parse(t);
|
|
const data = obj?.result?.random?.data;
|
|
if (!Array.isArray(data)) throw new Error("Could not find result.random.data in pasted JSON.");
|
|
return data;
|
|
}
|
|
|
|
async function mnemonicFromD6(
|
|
d6: number[],
|
|
wordCount: 12 | 24,
|
|
interactionEntropy: InteractionEntropy
|
|
): Promise<string> {
|
|
const interactionBytes = await interactionEntropy.getEntropyBytes();
|
|
const cryptoBytes = crypto.getRandomValues(new Uint8Array(32));
|
|
|
|
// Keep it simple + consistent with your other sources: concatenate strings, SHA-256, slice entropy.
|
|
const combined = [
|
|
d6.join(""),
|
|
performance.now().toString(),
|
|
Array.from(interactionBytes).join(","),
|
|
Array.from(cryptoBytes).join(","),
|
|
].join("|");
|
|
|
|
const data = new TextEncoder().encode(combined);
|
|
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
|
|
const entropyLength = wordCount === 12 ? 16 : 32;
|
|
const finalEntropy = new Uint8Array(hash.slice(0, entropyLength));
|
|
|
|
return entropyToMnemonic(finalEntropy);
|
|
}
|
|
|
|
const RandomOrgEntropy: React.FC<RandomOrgEntropyProps> = ({
|
|
wordCount,
|
|
onEntropyGenerated,
|
|
onCancel,
|
|
interactionEntropy,
|
|
}) => {
|
|
const [apiKey, setApiKey] = useState("");
|
|
const [showKey, setShowKey] = useState(false);
|
|
|
|
const [n, setN] = useState(30); // min 30
|
|
const [paste, setPaste] = useState("");
|
|
|
|
const [error, setError] = useState<string>("");
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const [processing, setProcessing] = useState(false);
|
|
const [stats, setStats] = useState<RandomOrgStats | null>(null);
|
|
const [generatedMnemonic, setGeneratedMnemonic] = useState("");
|
|
|
|
const requestJson = useMemo(() => {
|
|
const key = apiKey.trim() || "PASTE_YOUR_API_KEY_HERE";
|
|
return JSON.stringify(buildRequest(key, n), null, 2);
|
|
}, [apiKey, n]);
|
|
|
|
const copyRequest = async () => {
|
|
setError("");
|
|
try {
|
|
await navigator.clipboard.writeText(requestJson);
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1200);
|
|
} catch {
|
|
setError("Clipboard write failed. Tap the JSON box to select all, then copy manually.");
|
|
}
|
|
};
|
|
|
|
const generate = async () => {
|
|
setError("");
|
|
setProcessing(true);
|
|
try {
|
|
const raw = parseD6FromPaste(paste);
|
|
|
|
if (raw.length < n) throw new Error(`Need at least ${n} D6 samples, got ${raw.length}.`);
|
|
|
|
const d6 = raw.slice(0, n);
|
|
const dist = [0, 0, 0, 0, 0, 0];
|
|
for (let i = 0; i < d6.length; i++) {
|
|
const v = d6[i];
|
|
if (!Number.isInteger(v) || v < 1 || v > 6) throw new Error(`Invalid D6 at index ${i}: ${String(v)}`);
|
|
dist[v - 1]++;
|
|
}
|
|
|
|
const mnemonic = await mnemonicFromD6(d6, wordCount, interactionEntropy);
|
|
|
|
setGeneratedMnemonic(mnemonic);
|
|
setStats({
|
|
source: "randomorg",
|
|
nRequested: n,
|
|
nUsed: d6.length,
|
|
distribution: dist,
|
|
interactionSamples: interactionEntropy.getSampleCount().total,
|
|
totalBits: 256,
|
|
});
|
|
} catch (e) {
|
|
setStats(null);
|
|
setGeneratedMnemonic("");
|
|
setError(e instanceof Error ? e.message : "Failed.");
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
|
{!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">
|
|
<Globe size={20} className="text-[#00f0ff]" />
|
|
<h3 className="text-sm font-bold text-[#00f0ff] uppercase">🌍 Random.org Entropy</h3>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-2 text-xs text-[#6ef3f7]">
|
|
<Info size={14} className="shrink-0 mt-0.5 text-[#00f0ff]" />
|
|
<p>
|
|
SeedPGP will not contact random.org. You run the request in another tab/tool and paste the response here.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest">random.org API key</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showKey ? "text" : "password"}
|
|
value={apiKey}
|
|
onChange={(e) => setApiKey(e.target.value)}
|
|
placeholder="Paste API key (optional; not stored)"
|
|
className="w-full pl-3 pr-10 py-2 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg text-[#00f0ff] text-xs placeholder-[#9d84b7] focus:outline-none focus:border-[#ff006e] transition-all"
|
|
autoCapitalize="none"
|
|
autoCorrect="off"
|
|
spellCheck={false}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="absolute right-2 top-2 text-[#6ef3f7] hover:text-[#00f0ff]"
|
|
onClick={() => setShowKey((s) => !s)}
|
|
>
|
|
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest">D6 samples</label>
|
|
<span className="text-xs text-[#00f0ff] font-mono">{n}</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={30}
|
|
max={200}
|
|
step={10}
|
|
value={n}
|
|
onChange={(e) => setN(parseInt(e.target.value, 10))}
|
|
className="w-full accent-[#ff006e]"
|
|
/>
|
|
<p className="text-[10px] text-[#6ef3f7]">Minimum 30. Step 10.</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest">Request JSON</label>
|
|
<div className="flex gap-2">
|
|
<a
|
|
href="https://api.random.org/json-rpc/2/request-builder"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg border-2 border-[#00f0ff]/50 text-[#00f0ff] text-[10px] hover:bg-[#00f0ff]/10 transition-all"
|
|
>
|
|
<ExternalLink size={12} />
|
|
Request Builder
|
|
</a>
|
|
<button
|
|
type="button"
|
|
onClick={copyRequest}
|
|
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg border-2 border-[#00f0ff]/50 text-[#00f0ff] text-[10px] hover:bg-[#00f0ff]/10 transition-all"
|
|
>
|
|
<Copy size={12} />
|
|
{copied ? "Copied" : "Copy"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<textarea
|
|
readOnly
|
|
value={requestJson}
|
|
onFocus={(e) => e.currentTarget.select()}
|
|
className="w-full h-28 md:h-36 p-2 md:p-3 bg-[#0a0a0f] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-[10px] text-[#00f0ff] focus:outline-none"
|
|
/>
|
|
<p className="text-[10px] text-[#6ef3f7]">
|
|
Endpoint: <span className="font-mono text-[#00f0ff]">https://api.random.org/json-rpc/1/invoke</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest">Paste response JSON</label>
|
|
<textarea
|
|
value={paste}
|
|
onChange={(e) => setPaste(e.target.value)}
|
|
placeholder="Paste JSON-RPC response, or paste a [1,6,2,...] array"
|
|
className="w-full h-28 md:h-36 p-2 md:p-3 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-[10px] text-[#00f0ff] placeholder-[#9d84b7] focus:outline-none focus:border-[#ff006e] transition-all"
|
|
/>
|
|
</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={generate}
|
|
className="flex-1 py-2.5 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white text-sm rounded-lg font-bold uppercase hover:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all"
|
|
>
|
|
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>
|
|
</>
|
|
)}
|
|
|
|
{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 && !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">Random.org 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]">random.org D6 integers (pasted manually)</p>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-[#00f0ff] font-bold mb-2">SAMPLES</p>
|
|
<div className="grid grid-cols-2 gap-2 font-mono text-[10px]">
|
|
<div>Requested</div><div className="text-[#39ff14]">{stats.nRequested}</div>
|
|
<div>Used</div><div className="text-[#39ff14]">{stats.nUsed}</div>
|
|
<div>Interaction samples</div><div className="text-[#39ff14]">{stats.interactionSamples}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-[#00f0ff] font-bold mb-2">DISTRIBUTION</p>
|
|
<div className="space-y-1 font-mono text-[10px]">
|
|
{stats.distribution.map((count, i) => {
|
|
const pct = (count / stats.nUsed) * 100;
|
|
return (
|
|
<div key={i} className="flex justify-between">
|
|
<span>Face {i + 1}</span>
|
|
<span className="text-[#39ff14]">{count} ({pct.toFixed(1)}%)</span>
|
|
</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-[#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">MIXED WITH</p>
|
|
<div className="space-y-1 text-[10px] 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]">{stats.totalBits} bits</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-[#00f0ff]/30 space-y-3">
|
|
<button
|
|
onClick={() => 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={() => {
|
|
setStats(null);
|
|
setGeneratedMnemonic("");
|
|
setPaste("");
|
|
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"
|
|
>
|
|
Paste a different response
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RandomOrgEntropy;
|