diff --git a/package.json b/package.json
index 37b5aee..bf75f68 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/www/index.html b/www/index.html
index 79fbf6f..f23d148 100644
--- a/www/index.html
+++ b/www/index.html
@@ -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 @@
24 words (recommended)
12 words (testing only)
-
+
+
+
+ Move your mouse and/or press random keys inside this box to generate your seed phrase.
+
+
0 / 64
+
+
Verify out-of-band after linking: 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.
-
- Optional: 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.
-
-
-
Optional: click here and type random characters, move your mouse to add entropy...
-
-
-
- Generate with Extra Entropy
-
I have written down my seed phrase
diff --git a/www/js/index-app.mjs b/www/js/index-app.mjs
index 3739bdb..464774d 100644
--- a/www/js/index-app.mjs
+++ b/www/js/index-app.mjs
@@ -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 =
+ ''
+ + 'Move your mouse and/or press random keys inside this box to generate your seed phrase.'
+ + '
'
+ + '
0 / '
+ + ENTROPY_REQUIRED_EVENTS + '
'
+ + '
';
+ 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) =>
`${i + 1}. ${word}
`
).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 */ }
diff --git a/www/js/version.json b/www/js/version.json
index 00cf3a3..b978e0c 100644
--- a/www/js/version.json
+++ b/www/js/version.json
@@ -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"
}