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:
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user