G56-09: default seed generation to 24 words with 12/24 selector; remove inaccurate quantum-safe claims; fix misleading republish wording for non-replaceable kind 9999 upgrades

This commit is contained in:
Laan Tungir
2026-07-24 08:44:55 -04:00
parent 7a5701ff51
commit fee9d6c344
10 changed files with 147 additions and 47 deletions
+7 -4
View File
@@ -108,7 +108,7 @@ The BIP39 seed is a 64-byte entropy string derived from a mnemonic via PBKDF2-HM
```mermaid
flowchart TD
SEED[BIP39 Seed - 64 bytes, quantum-safe]
SEED[BIP39 Seed - 64 bytes, recovery root]
SEED --> BIP32[BIP32 derivation - secp256k1 keypair]
SEED --> PQKEM[BIP32 derivation - ML-KEM keypair]
SEED --> PQDSA[BIP32 derivation - ML-DSA keypair]
@@ -173,10 +173,13 @@ This is not a separate or weaker construction — it IS BIP32. Using BIP32 paths
| Mnemonic length | Entropy | Post-quantum security (Grover's) | Assessment |
|---|---|---|---|
| 12 words | 128 bits | ~64 bits | Borderline |
| 24 words | 256 bits | ~128 bits | Adequate |
| 12 words | 128 bits | ~64 bits | Testing only — weaker under a quantum brute-force model |
| 24 words | 256 bits | ~128 bits | Recommended for a PQ recovery root |
For users concerned about quantum attacks on the seed entropy itself, 24-word mnemonics are recommended. The bigger risk to seed phrases is classical (theft, phishing), not quantum. (Note: the current implementation defaults to 12-word mnemonics; 24-word support is recommended for production use.)
The implementation defaults to 24-word (256-bit) mnemonics, with a 12-word option available for
testing. Users concerned about quantum attacks on the seed entropy itself should use 24 words. The
bigger risk to seed phrases is classical (theft, phishing), not quantum. Note that BIP39's PBKDF2
iteration count is low; the seed phrase should be treated as a high-value secret and stored offline.
---
+14 -3
View File
@@ -10,9 +10,9 @@
|---|---:|---:|---:|---:|
| Critical | 2 | 2 | 0 | 0 |
| High | 6 | 1 | 0 | 5 |
| Medium | 8 | 0 | 0 | 8 |
| Medium | 8 | 1 | 0 | 7 |
| Low / Info | 4 | 0 | 0 | 4 |
| **Total** | **20** | **3** | **0** | **17** |
| **Total** | **20** | **4** | **0** | **16** |
## Findings status
@@ -26,7 +26,7 @@
| G56-06 | High | New seed continuity asserted, not proven — no successor signature, no identity match | ❌ Not started | — | Protocol design decision needed |
| G56-07 | High | Verifier throws on malformed relay/paste input | ⚠️ Partially fixed | v0.0.12 | G56-01 fix wrapped base64/hex decoding in try/catch and made verifyNIPQRContent fail closed. Broader call-site wrapping in verify.html also done. Remaining: fuzz testing, all export functions |
| G56-08 | High | Browser origin security insufficient for seed handling | ❌ Not started | — | Needs CSP hardening, header fixes, asset pinning |
| G56-09 | Medium | 12-word seed default, warning removed | ❌ Not started | — | Default to 24 words or restore warning |
| G56-09 | Medium | 12-word seed default, warning removed | ✅ Fixed | v0.0.17 | Defaulted `generateSeedPhrase()` and `generateSeedPhraseWithEntropy()` to 24 words (256-bit); added 12/24-word selector UI; restored entropy-strength warning; removed "quantum-safe root of trust" / "Make your Nostr identity quantum-resistant" claims from index.html, README.md, nip_proposal.md; 4 new tests |
| G56-10 | Medium | Secret material not zeroized, retained in globals/DOM/clipboard | ❌ Not started | — | Minimize secret lifetime, overwrite typed arrays |
| G56-11 | Medium | Publication advances despite zero relay acceptance | ❌ Not started | — | Require relay OK for both events |
| G56-12 | Medium | Resume workflow insufficiently bound to original event | ⚠️ Partially fixed | v0.0.14 | Resume now fetches all candidates and matches by event ID. Remaining: persist complete immutable artifacts, validate all bindings before signing |
@@ -74,9 +74,20 @@
- Updated explanation.md, laans_explanation.md, why_what_how.md
- Cleaned up button IDs and import statements in index.html
### v0.0.17 — G56-09 fix
- `generateSeedPhrase()` and `generateSeedPhraseWithEntropy()` now default to 24 words (256-bit entropy)
- Both accept an `entropyBits` argument (128 or 256) and validate it
- Added 12/24-word radio selector to the seed-generation panel in `index.html`; toggling regenerates the seed
- Restored entropy-strength warning (12 words = ~64-bit under Grover, testing only; 24 words recommended)
- Removed inaccurate claims: "Make your Nostr identity quantum-resistant" → "Create an experimental pre-quantum PQ key commitment"; "quantum-safe root of trust" → "recovery root"; "quantum-safe seed phrase" → "seed phrase"
- Added research-prototype warning to the unauthenticated landing card
- Updated README.md entropy table and nip_proposal.md mermaid diagram
- Added 4 new tests (24-word default, 12-word option, invalid entropyBits rejection for both functions)
## Test count
- **Before remediation:** 38 tests
- **After G56-01:** 49 tests
- **After G56-02+03:** 61 tests
- **After G56-09:** 65 tests
- **All passing:** ✅
+1 -1
View File
@@ -41,7 +41,7 @@ The PQ keys are derived deterministically from a BIP39 seed phrase via BIP32, at
```mermaid
flowchart TD
SEED[BIP39 seed - quantum-safe root of trust] --> SECP[secp256k1 keypair - child 0]
SEED[BIP39 seed - recovery root] --> SECP[secp256k1 keypair - child 0]
SEED --> PQ[PQ keypairs - children 1..8]
SECP --> K1[Kind 1 announcement - attesting identity signs]
PQ --> K1
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "post_quantum_nostr",
"version": "0.0.16",
"version": "0.0.17",
"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": {
+27 -4
View File
@@ -31,18 +31,41 @@ const m = await import('../www/js/pq-crypto.mjs');
// ============================================================================
describe('BIP39 seed phrase', () => {
test('generateSeedPhrase produces a valid 12-word mnemonic', () => {
test('generateSeedPhrase defaults to a valid 24-word mnemonic', () => {
const mnemonic = m.generateSeedPhrase();
assert.ok(m.isValidMnemonic(mnemonic), 'generated mnemonic should be valid');
const words = mnemonic.split(' ');
assert.equal(words.length, 12, 'should be 12 words');
assert.equal(words.length, 24, 'default should be 24 words');
});
test('generateSeedPhraseWithEntropy produces a valid mnemonic', () => {
test('generateSeedPhrase(128) produces a valid 12-word mnemonic', () => {
const mnemonic = m.generateSeedPhrase(128);
assert.ok(m.isValidMnemonic(mnemonic), 'generated mnemonic should be valid');
assert.equal(mnemonic.split(' ').length, 12, 'should be 12 words');
});
test('generateSeedPhrase rejects invalid entropyBits', () => {
assert.throws(() => m.generateSeedPhrase(64), /entropyBits must be 128/);
assert.throws(() => m.generateSeedPhrase(192), /entropyBits must be 128/);
});
test('generateSeedPhraseWithEntropy defaults to a 24-word mnemonic', () => {
const userEntropy = new TextEncoder().encode('test entropy from user');
const mnemonic = m.generateSeedPhraseWithEntropy(userEntropy);
assert.ok(m.isValidMnemonic(mnemonic), 'mnemonic with entropy should be valid');
assert.equal(mnemonic.split(' ').length, 12);
assert.equal(mnemonic.split(' ').length, 24, 'default should be 24 words');
});
test('generateSeedPhraseWithEntropy(128) produces a 12-word mnemonic', () => {
const userEntropy = new TextEncoder().encode('test entropy from user');
const mnemonic = m.generateSeedPhraseWithEntropy(userEntropy, 128);
assert.ok(m.isValidMnemonic(mnemonic), 'mnemonic with entropy should be valid');
assert.equal(mnemonic.split(' ').length, 12, 'should be 12 words');
});
test('generateSeedPhraseWithEntropy rejects invalid entropyBits', () => {
const userEntropy = new TextEncoder().encode('test entropy');
assert.throws(() => m.generateSeedPhraseWithEntropy(userEntropy, 64), /entropyBits must be 128/);
});
test('generateSeedPhraseWithEntropy produces different mnemonics for different CSPRNG draws', () => {
+50 -12
View File
@@ -292,7 +292,7 @@
</div>
<div class="pq-checklist-item" id="step-2">
<span class="pq-checklist-box"></span>
<span>2. Generate a quantum-safe seed phrase</span>
<span>2. Generate a seed phrase</span>
</div>
<div class="pq-checklist-item" id="step-3">
<span class="pq-checklist-box"></span>
@@ -324,13 +324,18 @@
<div id="pqNotAuthed" class="pq-card">
<div class="pq-card-title">Post-Quantum Nostr</div>
<div class="pq-info-text" style="font-size: 16px; color: var(--primary-color);">
Make your Nostr identity quantum-resistant
Create an experimental pre-quantum PQ key commitment
</div>
<div class="pq-info-text">
Sign in with your Nostr signer to begin. Your npub won't change, your followers stay,
and your social graph is preserved. We'll generate a quantum-safe seed phrase and link
and your social graph is preserved. We'll generate a seed phrase and link
it to your current identity.
</div>
<div class="pq-info-text" style="font-size: 13px; color: var(--accent-color);">
<strong>Research prototype.</strong> Do not enter a valuable existing mnemonic. This tool
does not make your Nostr identity post-quantum secure on its own; it creates a commitment
that future PQ protocols can build on.
</div>
<div class="pq-info-text" id="noSignerWarning" style="color: var(--accent-color); display: none;">
No Nostr signer detected. Install a NIP-07 browser extension (like nos2x or Alby) and reload.
</div>
@@ -352,7 +357,7 @@
<div class="pq-info-text">
This tool will:
<ol>
<li>Generate a new <strong>seed phrase</strong> (your quantum-safe backup)</li>
<li>Generate a new <strong>seed phrase</strong> (your recovery backup)</li>
<li>Derive <strong>post-quantum keys</strong> from that seed via BIP32 paths (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768)</li>
<li>Sign a <strong>NIP-QR migration event</strong> linking your identity to the PQ keys</li>
<li>Publish the event to relays</li>
@@ -363,12 +368,17 @@
<!-- STEP 2: SEED PHRASE GENERATION -->
<div id="pqSeedStep" class="pq-card pq-hidden">
<div class="pq-card-title">Your Quantum-Safe Seed Phrase</div>
<div class="pq-card-title">Your Seed Phrase</div>
<div class="pq-info-text">
Your seed phrase is your <strong>quantum-safe root of trust</strong>. All post-quantum keys
Your seed phrase is your <strong>recovery root</strong>. All post-quantum keys
are derived from it. It is processed entirely in your browser and never sent to any server.
The code is open source and auditable.
</div>
<div class="pq-info-text" style="font-size: 13px; color: var(--accent-color);">
<strong>Back this up offline.</strong> Anyone with this phrase can derive your PQ private keys.
24 words (256 bits) is recommended; 12 words (128 bits) is offered for testing only and is
weaker under a quantum brute-force model.
</div>
<!-- Mode toggle + copy row (always visible) -->
<div class="pq-button-row" style="margin-bottom: 15px;">
@@ -379,6 +389,11 @@
<!-- Generate mode -->
<div id="pqSeedGeneratePanel">
<!-- Word-count selector -->
<div class="pq-info-text" style="margin-bottom: 10px; font-size: 13px;">
<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>
<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.
@@ -529,8 +544,9 @@
Bitcoin confirmation typically takes <strong>10-30 minutes</strong>. You can
<strong style="text-decoration: underline;">leave this page open</strong>
to watch the progress below — the polling log updates every minute. Once the Bitcoin attestation is
confirmed, this page will automatically re-sign and republish the kind 9999 event with the upgraded
proof. You're free to close the page; the workflow resumes on your next visit.
confirmed, this page will automatically sign and publish a <strong>new</strong> kind 9999 event carrying
the upgraded (confirmed) proof, referencing the original via an <code>upgrade_of</code> tag. The original
event remains on relays permanently. You're free to close the page; the workflow resumes on your next visit.
</div>
<div id="pqOtsStatus"></div>
@@ -940,14 +956,21 @@
let userEntropyChunks = [];
let entropyCollecting = false;
function getSelectedEntropyBits() {
const radio = document.querySelector('input[name="pqWordCount"]:checked');
return radio ? parseInt(radio.value, 10) : 256;
}
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);
otsLog(`Seed generated with ${userEntropyChunks.length} entropy chunks from user input.`);
pqMnemonic = generateSeedPhraseWithEntropy(userEntropy, entropyBits);
otsLog(`Seed generated with ${userEntropyChunks.length} entropy chunks from user input (${entropyBits}-bit).`);
} else {
pqMnemonic = generateSeedPhrase();
pqMnemonic = generateSeedPhrase(entropyBits);
otsLog(`Seed generated (${entropyBits}-bit).`);
}
const words = pqMnemonic.split(' ');
pqSeedDisplay.innerHTML = words.map((word, i) =>
@@ -1362,6 +1385,21 @@
document.getElementById('pqSeedToggleGenerate').addEventListener('click', () => showSeedGeneratePanel());
document.getElementById('pqSeedToggleOwn').addEventListener('click', () => showSeedOwnPanel());
// Word-count selector: regenerate when the user switches between 12/24 words
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; }
});
});
// Bring your own seed: real-time validation
const pqSeedInput = document.getElementById('pqSeedInput');
const pqSeedValidation = document.getElementById('pqSeedValidation');
@@ -1609,7 +1647,7 @@
}
// No proof in the event — OTS submission failed during step 4.
// Submit the kind 1 event hash to OpenTimestamps now, then republish the event with the proof.
// Submit the kind 1 event hash to OpenTimestamps now, then publish a new kind 9999 event with the proof.
const sha256Tag = pqEvent.tags && pqEvent.tags.find(t => t[0] === 'sha256');
if (!sha256Tag || !sha256Tag[1]) {
otsLog('ERROR: No sha256 tag found in event. Cannot timestamp.');
+28 -10
View File
@@ -70,11 +70,20 @@ const PQ_SEED_LENGTHS = {
// ============================================================================
/**
* Generate a new 12-word BIP39 mnemonic.
* @returns {string} 12-word seed phrase
* Generate a new BIP39 mnemonic.
*
* Defaults to 24 words (256 bits of entropy) so the recovery root is not the
* weakest link in a system whose PQ algorithms target NIST Category 1+.
* Pass `128` for `entropyBits` to generate a 12-word phrase (e.g. for testing).
*
* @param {number} [entropyBits=256] - 128 (12 words) or 256 (24 words)
* @returns {string} BIP39 seed phrase (24 words by default)
*/
export function generateSeedPhrase() {
return generateMnemonic(wordlist, 128); // 128 bits = 12 words
export function generateSeedPhrase(entropyBits = 256) {
if (entropyBits !== 128 && entropyBits !== 256) {
throw new Error('entropyBits must be 128 (12 words) or 256 (24 words)');
}
return generateMnemonic(wordlist, entropyBits);
}
/**
@@ -85,20 +94,29 @@ export function generateSeedPhrase() {
* compromised, the seed remains unpredictable to an attacker who doesn't have
* the user's entropy.
*
* Defaults to 24 words (256 bits). Pass `128` for `entropyBits` to generate a
* 12-word phrase.
*
* @param {Uint8Array} userEntropy - Additional entropy from the user (any length)
* @returns {string} A valid 12-word BIP39 mnemonic
* @param {number} [entropyBits=256] - 128 (12 words) or 256 (24 words)
* @returns {string} A valid BIP39 mnemonic (24 words by default)
*/
export function generateSeedPhraseWithEntropy(userEntropy) {
// Get 16 bytes (128 bits) of CSPRNG entropy
const csprngBytes = new Uint8Array(16);
export function generateSeedPhraseWithEntropy(userEntropy, entropyBits = 256) {
if (entropyBits !== 128 && entropyBits !== 256) {
throw new Error('entropyBits must be 128 (12 words) or 256 (24 words)');
}
const entropyBytes = entropyBits / 8; // 16 or 32
// Get CSPRNG entropy of the requested size
const csprngBytes = new Uint8Array(entropyBytes);
crypto.getRandomValues(csprngBytes);
// Combine CSPRNG entropy with user entropy via SHA-256
const combined = concatBytes(csprngBytes, userEntropy);
const hash = sha256(combined);
// Use the first 16 bytes (128 bits) of the hash as the entropy
const finalEntropy = hash.slice(0, 16);
// Use the first `entropyBytes` bytes of the hash as the entropy
const finalEntropy = hash.slice(0, entropyBytes);
return entropyToMnemonic(finalEntropy, wordlist);
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.0.16",
"VERSION_NUMBER": "0.0.16",
"BUILD_DATE": "2026-07-24T12:13:07.640Z"
"VERSION": "v0.0.17",
"VERSION_NUMBER": "0.0.17",
"BUILD_DATE": "2026-07-24T12:44:54.874Z"
}
+12 -5
View File
@@ -9609,15 +9609,22 @@ var PQ_SEED_LENGTHS = {
falcon512: 48,
mlKem: 64
};
function generateSeedPhrase() {
return generateMnemonic(wordlist, 128);
function generateSeedPhrase(entropyBits = 256) {
if (entropyBits !== 128 && entropyBits !== 256) {
throw new Error("entropyBits must be 128 (12 words) or 256 (24 words)");
}
return generateMnemonic(wordlist, entropyBits);
}
function generateSeedPhraseWithEntropy(userEntropy) {
const csprngBytes = new Uint8Array(16);
function generateSeedPhraseWithEntropy(userEntropy, entropyBits = 256) {
if (entropyBits !== 128 && entropyBits !== 256) {
throw new Error("entropyBits must be 128 (12 words) or 256 (24 words)");
}
const entropyBytes = entropyBits / 8;
const csprngBytes = new Uint8Array(entropyBytes);
crypto.getRandomValues(csprngBytes);
const combined = concatBytes4(csprngBytes, userEntropy);
const hash = sha256(combined);
const finalEntropy = hash.slice(0, 16);
const finalEntropy = hash.slice(0, entropyBytes);
return entropyToMnemonic(finalEntropy, wordlist);
}
function mnemonicToSeed(mnemonic, passphrase = "") {
+4 -4
View File
@@ -330,7 +330,7 @@
<div id="pqOtsInfo" class="pq-status pq-status-info"></div>
<div class="pq-button-row">
<button class="pq-button" id="pqOtsUpgradeBtn">Upgrade OTS Proof</button>
<button class="pq-button" id="pqOtsRepublishBtn" style="display:none;">Republish with Confirmed Proof</button>
<button class="pq-button" id="pqOtsRepublishBtn" style="display:none;">Publish Upgraded Event</button>
</div>
<div id="pqOtsUpgradeStatus"></div>
</div>
@@ -844,7 +844,7 @@
if (confirmed) {
const att = verifyResult.bitcoinAttestations[0];
const date = att ? new Date(att.time * 1000).toISOString().substring(0, 19) : 'unknown';
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Proof upgraded (${currentOtsBytes.length} bytes). You can republish the event with the confirmed proof below.`);
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Proof upgraded (${currentOtsBytes.length} bytes). You can publish a new kind 9999 event carrying the confirmed proof below; it will reference the original via an upgrade_of tag.`);
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'inline-block';
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
@@ -866,7 +866,7 @@
try {
if (!window.nostr || !window.nostr.signEvent) {
throw new Error('Nostr signer (window.nostr) not available. Use a NIP-07 signer extension to republish.');
throw new Error('Nostr signer (window.nostr) not available. Use a NIP-07 signer extension to publish the upgraded event.');
}
const upgradedTemplate = buildUpgradedEvent(currentEvent, currentOtsBytes);
@@ -913,7 +913,7 @@
// Re-verify the new event
await verifyEvent(signedEvent);
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Republish failed: ${error.message}`);
setStatus(otsUpgradeStatus, 'error', `Publish failed: ${error.message}`);
} finally {
otsRepublishBtn.disabled = false;
}