Make mouse/keyboard entropy mandatory for seed generation; collect 256 events with throttled mouse deltas, performance.now() timing, and system entropy sources

This commit is contained in:
Laan Tungir
2026-07-31 06:14:20 -04:00
parent 6e61c6a5a8
commit 0ceeb8468f
4 changed files with 191 additions and 92 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nostr_quantum_preparation",
"version": "0.1.6",
"version": "0.1.7",
"description": "A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.",
"main": "index.js",
"scripts": {
+39 -15
View File
@@ -162,6 +162,28 @@
word-break: break-word;
}
/* Mandatory entropy-collection state for the seed box. While collecting,
the box is an interactive zone (not a word grid): centered prompt,
crosshair cursor, focus outline for keyboard users. Once 64 events are
collected the .pq-seed-collecting class is removed and the box reverts
to the word-grid layout above. */
.pq-seed-display.pq-seed-collecting {
display: block;
min-height: 120px;
cursor: crosshair;
text-align: center;
}
.pq-seed-display.pq-seed-collecting:focus {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
}
.pq-seed-collect-prompt {
font-size: 14px;
color: var(--primary-color);
line-height: 1.6;
padding: 18px 10px;
}
.pq-seed-word { display: flex; align-items: center; gap: 8px; }
.pq-seed-number { color: var(--muted-color); font-size: 12px; min-width: 20px; }
@@ -422,7 +444,23 @@
<label style="margin-right: 12px;"><input type="radio" name="pqWordCount" value="256" id="pqWordCount24" checked> 24 words (recommended)</label>
<label><input type="radio" name="pqWordCount" value="128" id="pqWordCount12"> 12 words (testing only)</label>
</div>
<div class="pq-seed-display" id="pqSeedDisplay"></div>
<!--
Mandatory entropy collection zone.
Before generation: shows a prompt + progress bar and collects
mouse-move / keypress events. After 64 events the seed is
generated (CSPRNG + user entropy via SHA-256) and the box
switches to the word-grid layout. tabindex makes it keyboard-
focusable so laptop-only users can type into it.
-->
<div class="pq-seed-display pq-seed-collecting" id="pqSeedDisplay" tabindex="0">
<div class="pq-seed-collect-prompt" id="pqSeedCollectPrompt">
Move your mouse and/or press random keys inside this box to generate your seed phrase.
<div style="margin-top: 10px; height: 6px; background: var(--muted-color); border-radius: 3px; overflow: hidden;">
<div id="pqSeedEntropyBar" style="height: 100%; width: 0%; background: var(--accent-color); transition: width 0.2s;"></div>
</div>
<div id="pqSeedEntropyCount" style="margin-top: 6px; font-size: 12px; color: var(--muted-color);">0 / 64</div>
</div>
</div>
<div class="pq-info-text" style="margin-top: 12px; font-size: 13px; color: var(--accent-color);">
<strong>Verify out-of-band after linking:</strong> This seed phrase is your backup — but
this browser cannot cryptographically prove the published PQ keys came from it. After completing
@@ -430,20 +468,6 @@
independently, and confirm the public keys match the published event. This is the only way to
catch a compromised browser that might have substituted attacker keys.
</div>
<div class="pq-info-text" style="margin-top: 15px; font-size: 13px;">
<strong>Optional:</strong> Add extra entropy by moving your mouse and typing in the box below.
This mixes your input with the browser's random number generator, making the seed unpredictable
even if the browser's RNG is compromised. You can skip this — a seed has already been generated for you above.
</div>
<div id="pqEntropyBox" style="background: var(--secondary-color); color: var(--primary-color); border: var(--border); border-radius: var(--border-radius); padding: 15px; font-size: 13px; min-height: 60px; margin: 10px 0; cursor: text; text-align: left;" tabindex="0">
<span id="pqEntropyHint">Optional: click here and type random characters, move your mouse to add entropy...</span>
<div style="margin-top: 8px; height: 6px; background: var(--muted-color); border-radius: 3px; overflow: hidden;">
<div id="pqEntropyBar" style="height: 100%; width: 0%; background: var(--accent-color); transition: width 0.2s;"></div>
</div>
</div>
<div class="pq-button-row">
<button class="pq-button pq-button-secondary pq-hidden" id="pqRegenerateWithEntropyBtn" disabled>Generate with Extra Entropy</button>
</div>
<label class="pq-checkbox-row">
<input type="checkbox" id="pqSeedConfirmed" />
I have written down my seed phrase
+148 -73
View File
@@ -1,5 +1,4 @@
import {
generateSeedPhrase,
generateSeedPhraseWithEntropy,
mnemonicToSeed,
isValidMnemonic,
@@ -452,26 +451,93 @@
/* ================================================================
STEP 2: SEED PHRASE
================================================================ */
// Mandatory user-entropy collection. The seed box (pqSeedDisplay) is the
// collection zone: the user must move their mouse and/or press keys inside
// it until ENTROPY_REQUIRED_EVENTS samples are gathered. The seed is then
// generated by mixing CSPRNG output with the collected user entropy via
// SHA-256 (generateSeedPhraseWithEntropy), so the seed stays unpredictable
// even if the browser's CSPRNG is compromised. Either mouse or keyboard
// input counts toward the total, so laptop-only users aren't blocked.
const ENTROPY_REQUIRED_EVENTS = 256; // ~512 bits of true user entropy
// Minimum interval between accepted mousemove samples (ms). Browsers fire
// mousemove at ~60-1000+ Hz; spacing samples out reduces correlation
// between consecutive deltas (each sample carries more independent
// hand-jitter), increasing the true entropy per sample.
const MOUSE_SAMPLE_MIN_INTERVAL_MS = 32;
let userEntropyChunks = [];
let entropyCollecting = false;
let entropyCollected = 0;
let entropyListenersBound = false;
// Last mouse position, used to capture deltas (dx, dy) which hold the
// unpredictable hand-jitter entropy better than absolute coordinates.
let lastMouseX = null;
let lastMouseY = null;
// Timestamp of the last accepted mousemove sample (for throttling).
let lastMouseSampleTime = 0;
function getSelectedEntropyBits() {
const radio = document.querySelector('input[name="pqWordCount"]:checked');
return radio ? parseInt(radio.value, 10) : 256;
}
// Render the seed box in its collecting (pre-generation) state.
function renderSeedCollectingState() {
pqSeedDisplay.classList.add('pq-seed-collecting');
pqSeedDisplay.innerHTML =
'<div class="pq-seed-collect-prompt" id="pqSeedCollectPrompt">'
+ 'Move your mouse and/or press random keys inside this box to generate your seed phrase.'
+ '<div style="margin-top: 10px; height: 6px; background: var(--muted-color); border-radius: 3px; overflow: hidden;">'
+ '<div id="pqSeedEntropyBar" style="height: 100%; width: 0%; background: var(--accent-color); transition: width 0.2s;"></div>'
+ '</div>'
+ '<div id="pqSeedEntropyCount" style="margin-top: 6px; font-size: 12px; color: var(--muted-color);">0 / '
+ ENTROPY_REQUIRED_EVENTS + '</div>'
+ '</div>';
const bar = document.getElementById('pqSeedEntropyBar');
if (bar) bar.style.width = '0%';
}
function updateEntropyProgress() {
const bar = document.getElementById('pqSeedEntropyBar');
const count = document.getElementById('pqSeedEntropyCount');
const pct = Math.min(100, Math.round((entropyCollected / ENTROPY_REQUIRED_EVENTS) * 100));
if (bar) bar.style.width = pct + '%';
if (count) count.textContent = `${entropyCollected} / ${ENTROPY_REQUIRED_EVENTS}`;
}
// Collect low-entropy environment/fingerprint values and mix them into the
// seed. Each of these is low-entropy on its own (and a remote attacker can
// often query the same values), but together they add a few extra bits and
// cost nothing. They are mixed via SHA-256 alongside the CSPRNG and user
// input, so they can only help, never hurt.
function collectSystemEntropy() {
const parts = [];
try { parts.push('ua:' + navigator.userAgent); } catch (e) {}
try { parts.push('plat:' + navigator.platform); } catch (e) {}
try { parts.push('lang:' + navigator.language); } catch (e) {}
try { parts.push('langs:' + (navigator.languages || []).join(',')); } catch (e) {}
try { parts.push('sw:' + screen.width); } catch (e) {}
try { parts.push('sh:' + screen.height); } catch (e) {}
try { parts.push('scd:' + screen.colorDepth); } catch (e) {}
try { parts.push('dpr:' + window.devicePixelRatio); } catch (e) {}
try { parts.push('hwc:' + navigator.hardwareConcurrency); } catch (e) {}
try { parts.push('mem:' + navigator.deviceMemory); } catch (e) {}
try { parts.push('tz:' + Intl.DateTimeFormat().resolvedOptions().timeZone); } catch (e) {}
// performance.now() at collection time adds a little timing jitter.
try { parts.push('now:' + performance.now()); } catch (e) {}
return parts.join('|');
}
// Generate the seed from the collected user entropy + system entropy +
// CSPRNG and reveal the word grid in the same box.
function generateAndShowSeed() {
const entropyBits = getSelectedEntropyBits();
// Use user entropy if available, otherwise pure CSPRNG
if (userEntropyChunks.length > 0) {
const userEntropy = new TextEncoder().encode(userEntropyChunks.join(''));
pqMnemonic = generateSeedPhraseWithEntropy(userEntropy, entropyBits);
otsLog(`Seed generated with ${userEntropyChunks.length} entropy chunks from user input (${entropyBits}-bit).`);
} else {
pqMnemonic = generateSeedPhrase(entropyBits);
otsLog(`Seed generated (${entropyBits}-bit).`);
}
// Prepend system entropy so it is always mixed in, regardless of how
// many mouse/keyboard samples were collected.
const combined = collectSystemEntropy() + '\n' + userEntropyChunks.join('\n');
const userEntropy = new TextEncoder().encode(combined);
pqMnemonic = generateSeedPhraseWithEntropy(userEntropy, entropyBits);
otsLog(`Seed generated with ${userEntropyChunks.length} user entropy samples + system entropy (${entropyBits}-bit).`);
const words = pqMnemonic.split(' ');
pqSeedDisplay.classList.remove('pq-seed-collecting');
pqSeedDisplay.innerHTML = words.map((word, i) =>
`<div class="pq-seed-word"><span class="pq-seed-number">${i + 1}.</span>${word}</div>`
).join('');
@@ -479,46 +545,70 @@
pqSeedContinueBtn.disabled = true;
}
// Entropy collection from mouse and keyboard (optional)
function startEntropyCollection() {
const entropyBox = document.getElementById('pqEntropyBox');
const entropyBar = document.getElementById('pqEntropyBar');
const entropyHint = document.getElementById('pqEntropyHint');
const regenWithEntropyBtn = document.getElementById('pqRegenerateWithEntropyBtn');
const maxChunks = 64; // collect up to 64 events
let collected = 0;
// Reset the collection zone to its pre-generation state and (re)bind the
// mouse/keyboard listeners. Called when entering step 2, switching word
// counts, or switching back to the "Generate New Seed" panel.
function resetSeedCollection() {
userEntropyChunks = [];
entropyCollected = 0;
lastMouseX = null;
lastMouseY = null;
lastMouseSampleTime = 0;
pqMnemonic = null;
pqSeedConfirmed.checked = false;
pqSeedContinueBtn.disabled = true;
renderSeedCollectingState();
updateEntropyProgress();
bindEntropyListeners();
}
function updateBar() {
const pct = Math.min(100, Math.round((collected / maxChunks) * 100));
entropyBar.style.width = pct + '%';
if (collected >= maxChunks) {
entropyHint.textContent = 'Enough entropy collected. Click "Generate with Extra Entropy" to use it.';
} else if (collected > 0) {
entropyHint.textContent = `Collected ${collected}/${maxChunks} entropy samples. Keep going, or click "Generate with Extra Entropy" to use what you have.`;
}
// Show the "Generate with Extra Entropy" button once we have at least 1 chunk
if (collected > 0 && regenWithEntropyBtn) {
regenWithEntropyBtn.classList.remove('pq-hidden');
regenWithEntropyBtn.disabled = false;
}
}
// Bind mouse/keyboard collection listeners to the seed box. Idempotent.
function bindEntropyListeners() {
if (entropyListenersBound) return;
entropyListenersBound = true;
function collectEvent(data) {
if (collected >= maxChunks) return;
userEntropyChunks.push(data + ':' + Date.now() + ':' + Math.random());
collected++;
updateBar();
if (entropyCollected >= ENTROPY_REQUIRED_EVENTS) return;
// performance.now() gives sub-millisecond monotonic timing jitter,
// which is harder for a remote attacker to predict than Date.now().
userEntropyChunks.push(data + ':' + performance.now() + ':' + Math.random());
entropyCollected++;
updateEntropyProgress();
if (entropyCollected >= ENTROPY_REQUIRED_EVENTS) {
// Threshold reached — generate the seed immediately.
generateAndShowSeed();
}
}
entropyBox.addEventListener('mousemove', (e) => {
collectEvent(`m${e.clientX},${e.clientY}`);
pqSeedDisplay.addEventListener('mousemove', (e) => {
// Throttle: only accept a sample if enough time has passed since the
// last one. This doubles the effective interval between samples
// (browsers normally fire at ~16ms/frame), reducing correlation
// between consecutive deltas so each accepted sample carries more
// independent entropy.
const now = performance.now();
if (now - lastMouseSampleTime < MOUSE_SAMPLE_MIN_INTERVAL_MS) return;
lastMouseSampleTime = now;
// Capture deltas (dx, dy) between consecutive samples rather than
// absolute coordinates. The micro-jitter in deltas is the real
// unpredictable entropy in mouse movement; absolute positions are
// highly correlated between samples and bounded by the box size.
const dx = lastMouseX === null ? 0 : e.clientX - lastMouseX;
const dy = lastMouseY === null ? 0 : e.clientY - lastMouseY;
lastMouseX = e.clientX;
lastMouseY = e.clientY;
collectEvent(`m${dx},${dy}`);
});
entropyBox.addEventListener('keypress', (e) => {
pqSeedDisplay.addEventListener('keypress', (e) => {
collectEvent(`k${e.key}:${e.keyCode}`);
});
entropyBox.addEventListener('keydown', (e) => {
// Also capture non-printable keys
if (e.key.length > 1) collectEvent(`k${e.key}:${e.keyCode}`);
pqSeedDisplay.addEventListener('keydown', (e) => {
// Capture non-printable keys too (Arrow, Shift, etc.). Prevent default
// scrolling/behavior when the box is focused and still collecting.
if (entropyCollected < ENTROPY_REQUIRED_EVENTS) {
if (e.key.length > 1) e.preventDefault();
collectEvent(`k${e.key}:${e.keyCode}`);
}
});
}
@@ -528,16 +618,8 @@
document.getElementById('pqSeedOwnPanel').style.display = 'none';
document.getElementById('pqSeedToggleGenerate').classList.add('pq-seed-toggle-active');
document.getElementById('pqSeedToggleOwn').classList.remove('pq-seed-toggle-active');
// Generate a fresh seed when switching to the generate panel
userEntropyChunks = [];
generateAndShowSeed();
// Reset the entropy UI
const entropyBar = document.getElementById('pqEntropyBar');
const entropyHint = document.getElementById('pqEntropyHint');
const regenWithEntropyBtn = document.getElementById('pqRegenerateWithEntropyBtn');
if (entropyBar) entropyBar.style.width = '0%';
if (entropyHint) entropyHint.textContent = 'Optional: click here and type random characters, move your mouse to add entropy...';
if (regenWithEntropyBtn) { regenWithEntropyBtn.classList.add('pq-hidden'); regenWithEntropyBtn.disabled = true; }
// Entering / re-entering the generate panel resets the collection zone.
resetSeedCollection();
}
function showSeedOwnPanel() {
@@ -887,29 +969,21 @@
});
document.getElementById('pqStartBtn').addEventListener('click', () => {
userEntropyChunks = [];
generateAndShowSeed();
showView('pqSeedStep');
setStepActive(2);
startEntropyCollection();
// Reset the collection zone and start mandatory mouse/keyboard collection.
resetSeedCollection();
});
// Seed mode toggle
document.getElementById('pqSeedToggleGenerate').addEventListener('click', () => showSeedGeneratePanel());
document.getElementById('pqSeedToggleOwn').addEventListener('click', () => showSeedOwnPanel());
// Word-count selector: regenerate when the user switches between 12/24 words
// Word-count selector: switching 12/24 words resets the collection zone
// so the user re-collects entropy for the new word count.
document.querySelectorAll('input[name="pqWordCount"]').forEach((radio) => {
radio.addEventListener('change', () => {
userEntropyChunks = [];
generateAndShowSeed();
// Reset entropy UI
const entropyBar = document.getElementById('pqEntropyBar');
const entropyHint = document.getElementById('pqEntropyHint');
const regenWithEntropyBtn = document.getElementById('pqRegenerateWithEntropyBtn');
if (entropyBar) entropyBar.style.width = '0%';
if (entropyHint) entropyHint.textContent = 'Optional: click here and type random characters, move your mouse to add entropy...';
if (regenWithEntropyBtn) { regenWithEntropyBtn.classList.add('pq-hidden'); regenWithEntropyBtn.disabled = true; }
resetSeedCollection();
});
});
@@ -951,9 +1025,6 @@
}
});
// "Generate with Extra Entropy" — generates a fresh seed mixing CSPRNG + user entropy
document.getElementById('pqRegenerateWithEntropyBtn').addEventListener('click', () => { generateAndShowSeed(); });
pqSeedConfirmed.addEventListener('change', () => { pqSeedContinueBtn.disabled = !pqSeedConfirmed.checked; });
pqSeedContinueBtn.addEventListener('click', () => { setStepDone(2); derivePQKeys(); });
pqDeriveContinueBtn.addEventListener('click', () => { showView('pqSignStep'); setStepActive(4); });
@@ -1093,17 +1164,21 @@
pqEvent = null;
kind1Event = null; // F-L1: clear all secret references
userEntropyChunks = [];
entropyCollected = 0;
lastMouseX = null;
lastMouseY = null;
lastMouseSampleTime = 0;
pendingOtsBytes = null;
currentBlockHeight = 0;
pqRelays = DEFAULT_RELAYS.slice();
// G56-10: Clear DOM elements that displayed secret material
// G56-10: Clear DOM elements that displayed secret material.
// Reset the seed box to its collecting (pre-generation) state so no
// generated words remain visible after sign-out.
const seedDisplay = document.getElementById('pqSeedDisplay');
if (seedDisplay) seedDisplay.innerHTML = '';
if (seedDisplay) renderSeedCollectingState();
const seedInput = document.getElementById('pqSeedInput');
if (seedInput) seedInput.value = '';
const entropyHint = document.getElementById('pqEntropyHint');
if (entropyHint) entropyHint.textContent = 'Optional: click here and type random characters, move your mouse to add entropy...';
// G56-10: Clear the clipboard (in case the user copied the seed phrase)
try { navigator.clipboard.writeText(''); } catch (e) { /* clipboard may not be available */ }
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.1.6",
"VERSION_NUMBER": "0.1.6",
"BUILD_DATE": "2026-07-30T10:46:55.076Z"
"VERSION": "v0.1.7",
"VERSION_NUMBER": "0.1.7",
"BUILD_DATE": "2026-07-31T10:14:20.015Z"
}