Compare commits

...
2 Commits
9 changed files with 116 additions and 19 deletions
+16
View File
@@ -206,6 +206,21 @@ testing. Users concerned about quantum attacks on the seed entropy itself should
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.
### Secret Material Lifetime and Zeroization (G56-10)
The web app derives the BIP39 seed, secp256k1 key, and five PQ secret keys in browser memory.
On sign-out, the app **zeroizes all `Uint8Array` secret buffers** (fills with zeros before
nulling references), **clears the DOM** (seed display, seed input, entropy hint), and **clears
the clipboard** (in case the user copied the seed phrase).
**Important limitation:** JavaScript garbage collection does **not** guarantee erasure. The
browser runtime may copy buffers internally, and zeroizing a `Uint8Array` only overwrites the
JS-visible buffer — the engine may retain copies in its own memory management. Browser
extensions, devtools memory inspectors, and page snapshots may still recover secrets during
the session. The real fix is moving key derivation and signing into a hardware or native signer
boundary (see G56-06 and G56-08). The zeroization implemented here is a best-effort reduction
of the exposure window, not a guarantee.
---
## Component 2: Multi-Scheme Cross-Signed Key-Link Events
@@ -814,6 +829,7 @@ The current implementation is a static web app (`www/`) that performs the full m
| Quantum-safe self-storage (Component 5) | OTP or symmetric-key encryption for kind 30078 data |
| PQ event authentication / rotation / revocation | Companion protocols for signing future events with PQ keys, rotating/revoking keys, PQ encryption |
| Independent implementation / test vectors | A second implementation reproducing canonical encoding, derivation, and selection |
| Relay event-size interop testing | The kind 1 announcement is ~2030 KiB; the kind 9999 proof carrier embeds it plus OTS proof data. The UI shows the event size and warns if it exceeds ~60 KiB (some relays reject large events). Testing against target relay policies and a compact binary format remain as future work. |
### Dependencies
+7 -7
View File
@@ -10,9 +10,9 @@
|---|---:|---:|---:|---:|
| Critical | 2 | 2 | 0 | 0 |
| High | 6 | 6 | 0 | 0 |
| Medium | 8 | 6 | 0 | 1 |
| Low / Info | 4 | 0 | 0 | 3 |
| **Total** | **20** | **14** | **0** | **4** |
| Medium | 8 | 7 | 0 | 0 |
| Low / Info | 4 | 3 | 0 | 0 |
| **Total** | **20** | **18** | **0** | **0** |
## Findings status
@@ -27,17 +27,17 @@
| G56-07 | High | Verifier throws on malformed relay/paste input | ✅ Fixed | v0.0.12, v0.0.19 | v0.0.12 wrapped base64/hex in try/catch + verifyNIPQRContent fail closed + verify.html call-site wrapping. v0.0.19 added structural validation + try/catch to `verifyNostrEvent()` (returns false on malformed input), wrapped `parseOtsFile()` body in try/catch (returns null), added `MAX_OTS_PROOF_SIZE` (1 MiB) and `MAX_EVENT_JSON_SIZE` (64 KiB) size guards, 23 fuzz/property tests (random JSON, random bytes, truncation, oversized inputs, wrong-typed objects) |
| G56-08 | High | Browser origin security insufficient for seed handling | ✅ Fixed | v0.0.23 | Code fixes: extracted all inline JS to external files (`index-app.mjs`, `verify-app.mjs`, `version-display.js`), removed `'unsafe-inline'` from `script-src` in CSP, added `frame-ancestors 'none'` + `<meta name="referrer" content="no-referrer">`, added SRI hashes (`integrity`/`crossorigin`) to nostr-login-lite scripts. Server headers applied to nginx `conf.d/default.conf` (scoped to `/post-quantum/` location only): HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, `Permissions-Policy`, COOP, CORP. Verified headers present on `/post-quantum/` and absent on other paths. Remaining (future): vendor signer scripts into repo for full source auditability |
| 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-10 | Medium | Secret material not zeroized, retained in globals/DOM/clipboard | ✅ Fixed | v0.0.26 | Sign-out now zeroizes all `Uint8Array` secret buffers (BIP39 seed, secp private key, 5 PQ secret keys) via `fill(0)` before nulling references; clears DOM (seed display, seed input, entropy hint); clears clipboard. Documented the JS zeroization limitation in README (garbage collection doesn't guarantee erasure; real fix is hardware/native signer boundary) |
| G56-11 | Medium | Publication advances despite zero relay acceptance | ✅ Fixed | v0.0.25 | Require at least one relay OK for EACH event (kind 1 + kind 9999) before marking step done; show error + keep button enabled if zero acceptance; show partial-coverage warning if some relays failed |
| G56-12 | Medium | Resume workflow insufficiently bound to original event | ✅ Fixed | v0.0.14, v0.0.25 | v0.0.14: fetch all candidates + match by event ID. v0.0.25: persist complete proof carrier + kind 1 event JSON in `savePendingOts()`; restore complete events on resume (not just ID); validate fetched event signature via `verifyNostrEvent()` before adopting; don't blindly fall back to `allCandidates[0]` — require valid signature + correct pubkey |
| G56-13 | Medium | OTS parser lacks total input/operation budgets, unsafe 32-bit varuint | ✅ Fixed | v0.0.25 | Replaced 32-bit bitwise `readVaruint()` with safe arithmetic (`Math.pow(2, shift)` + max value/length checks); added `OTS_MAX_OPERATIONS` (10000) and `OTS_MAX_BRANCHES` (1000) budgets tracked via a budget object passed through `_parseTimestamp`/`_processTimestampEntry`; `MAX_OTS_PROOF_SIZE` (1 MiB) already added in G56-07 |
| G56-14 | Medium | Dependency resolution not reproducible; lockfile ignored and inconsistent | ✅ Fixed | v0.0.25 | Removed `package-lock.json` from `.gitignore`; generated and committed `package-lock.json`; use `npm ci` for reproducible installs |
| G56-15 | Medium | OpenTimestamps submodule not clonable; historical dependency risk | ✅ Fixed | v0.0.25 | Created `.gitmodules` with correct URL (`https://github.com/opentimestamps/javascript-opentimestamps.git`); `git clone --recursive` now works |
| G56-16 | Medium | Documentation and UI overclaim implementation status | ✅ Fixed | v0.0.14v0.0.18 | Kind 9999 docs (v0.0.14v0.0.16); v0.0.17 fixed index.html claims + 24-word default; v0.0.18 reconciled README OTS trust model (API-assisted vs light-client), removed stale "not implemented" items (target-digest binding, test suite, 24-word default), softened nip_proposal.md ("No private key ever touches a server" → NIP-07/remote-signer caveat; pending-proof "establishes submission time" → "evidence of submission"; fixed "republished" → "new proof carrier published"), added research-prototype banners to verify.html + README + nip_proposal + explanation.md + laans_explanation.md + why_what_how.md + all 5 research/ docs |
| G56-17 | Low | Block-height statement not validated | ❌ Not started | — | Validate against OTS height or remove from signed content |
| G56-17 | Low | Block-height statement not validated | ✅ Fixed | v0.0.27 | Removed unverified `block_height` tag from signed content and "pre-quantum at Bitcoin block height N" from the attestation statement. The OTS proof is the real anchor; the claimed height was redundant and could be wrong (height 0 on API failure) |
| G56-18 | Low | Relay URL policy permits insecure ws:// | ✅ Won't fix | — | Nostr events are public — an attacker reading them over ws:// is not a security concern. The events contain no secrets. wss:// is recommended but not required. |
| G56-19 | Low | Missing event IDs accepted by library verifier | ❌ Not started | — | Require exact NIP-01 shape with 64-hex ID |
| G56-20 | Info | Large events may be rejected by relays; interoperability untested | ❌ Not started | — | Test against target relay policies |
| G56-19 | Low | Missing event IDs accepted by library verifier | ✅ Fixed | v0.0.27 | `verifyNostrEvent()` now requires `id` to be present and a 64-lowercase-hex string (was optional/truthy-check). 2 new tests: missing id rejected, malformed id rejected |
| G56-20 | Info | Large events may be rejected by relays; interoperability untested | ✅ Fixed | v0.0.27 | UI now shows event size (KiB) in the publish step and warns if >60 KiB (some relays reject large events). Documented relay size limits and future work (compact binary format, target relay policy testing) in README "Not Yet Implemented" table |
## Changes by version
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "post_quantum_nostr",
"version": "0.0.25",
"version": "0.0.27",
"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": {
+36
View File
@@ -217,6 +217,42 @@ describe('NIP-01 event id and verifyNostrEvent', () => {
const wrongIdEvent = { ...event, id: '0'.repeat(64), sig: '0'.repeat(128) };
assert.equal(m.verifyNostrEvent(wrongIdEvent), false, 'wrong id should fail verification');
});
// G56-19: missing event id must be rejected (id is now required, not optional)
test('G56-19: verifyNostrEvent rejects missing id', () => {
const mnemonic = m.generateSeedPhrase();
const seed = m.mnemonicToSeed(mnemonic);
const kp = m.deriveSecp256k1FromSeed(seed);
const pubkeyHex = m.bytesToHex(kp.publicKey);
const event = {
pubkey: pubkeyHex,
created_at: 1700000000,
kind: 1,
tags: [],
content: 'test event for missing id check'
};
// No id field at all — must be rejected
const noIdEvent = { ...event, sig: '0'.repeat(128) };
assert.equal(m.verifyNostrEvent(noIdEvent), false, 'missing id should fail verification');
});
// G56-19: malformed id (not 64 hex) must be rejected
test('G56-19: verifyNostrEvent rejects malformed id', () => {
const mnemonic = m.generateSeedPhrase();
const seed = m.mnemonicToSeed(mnemonic);
const kp = m.deriveSecp256k1FromSeed(seed);
const pubkeyHex = m.bytesToHex(kp.publicKey);
const event = {
id: 'short',
pubkey: pubkeyHex,
created_at: 1700000000,
kind: 1,
tags: [],
content: 'test event for malformed id check',
sig: '0'.repeat(128)
};
assert.equal(m.verifyNostrEvent(event), false, 'malformed id should fail verification');
});
});
// ============================================================================
+1
View File
@@ -516,6 +516,7 @@
<div class="pq-info-text" style="margin-top: 15px; margin-bottom: 5px;"><strong>Kind 9999 proof carrier event:</strong></div>
<div id="pqEventIdDisplay" style="margin: 5px 0; font-size: 11px; color: var(--primary-color); word-break: break-all;"></div>
<div class="pq-event-preview" id="pqSuccessEventPreview"></div>
<div class="pq-info-text" id="pqEventSizeWarning" style="display: none; font-size: 13px; margin-top: 8px;"></div>
<div class="pq-button-row" style="margin-top: 10px;">
<button class="pq-button pq-button-secondary" id="pqCopyProofCarrierBtn">Copy Proof Carrier Event</button>
</div>
+43
View File
@@ -585,6 +585,19 @@
document.getElementById('pqKind1Preview').textContent = JSON.stringify(kind1Event, null, 2);
pqEventIdDisplay.textContent = `Event ID: ${pqEvent.id}`;
document.getElementById('pqSuccessEventPreview').textContent = eventJsonStr;
// G56-20: Show event size and warn if it may exceed relay limits
const eventBytes = new Blob([eventJsonStr]).size;
const sizeKB = (eventBytes / 1024).toFixed(1);
const sizeWarning = document.getElementById('pqEventSizeWarning');
if (sizeWarning) {
if (eventBytes > 60000) {
sizeWarning.innerHTML = `<strong>⚠ Event size: ${sizeKB} KiB.</strong> Some relays may reject events larger than ~5060 KiB. If publication fails, try adding relays with higher limits.`;
sizeWarning.style.display = 'block';
} else {
sizeWarning.innerHTML = `Event size: ${sizeKB} KiB`;
sizeWarning.style.display = 'block';
}
}
setTimeout(() => { renderRelayList(); showView('pqPublishStep'); }, 1000);
} catch (error) {
@@ -952,6 +965,25 @@
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
try { await window.NOSTR_LOGIN_LITE.logout(); } catch (e) { console.warn('[post-quantum] NLL logout failed:', e); }
}
// G56-10: Zeroize secret typed arrays before nulling references.
// Note: JavaScript garbage collection does not guarantee erasure —
// this is a best-effort reduction of the exposure window. See README
// for the full limitation discussion.
function zeroize(arr) {
if (arr instanceof Uint8Array) {
arr.fill(0);
}
}
if (pqSeed) zeroize(pqSeed);
if (pqSecpKeys) {
if (pqSecpKeys.privateKey) zeroize(pqSecpKeys.privateKey);
if (pqSecpKeys.publicKey) zeroize(pqSecpKeys.publicKey);
}
if (pqKeys) {
for (const alg of ['mlDsa44', 'mlDsa65', 'slhDsa', 'falcon512', 'mlKem']) {
if (pqKeys[alg] && pqKeys[alg].secretKey) zeroize(pqKeys[alg].secretKey);
}
}
// Clear local state
currentPubkey = null;
pqMnemonic = null;
@@ -964,6 +996,17 @@
pendingOtsBytes = null;
currentBlockHeight = 0;
pqRelays = DEFAULT_RELAYS.slice();
// G56-10: Clear DOM elements that displayed secret material
const seedDisplay = document.getElementById('pqSeedDisplay');
if (seedDisplay) seedDisplay.innerHTML = '';
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 */ }
// Clear auth/session storage, then restore only the OTS workflow record.
try {
localStorage.clear();
+6 -5
View File
@@ -429,6 +429,8 @@ export function verifyNostrEvent(event) {
if (typeof event.kind !== 'number') return false;
if (!Array.isArray(event.tags)) return false;
if (typeof event.content !== 'string') return false;
// G56-19: Require exact NIP-01 shape — id must be present and 64 lowercase hex
if (typeof event.id !== 'string' || !/^[0-9a-f]{64}$/.test(event.id)) return false;
// Build the event hash (NIP-01 serialization)
const serialized = JSON.stringify([
@@ -441,8 +443,8 @@ export function verifyNostrEvent(event) {
]);
const hash = sha256(new TextEncoder().encode(serialized));
const computedId = bytesToHex(hash);
// F-H2: Verify event.id matches the computed hash
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
// F-H2 / G56-19: Verify event.id matches the computed hash (id is now required)
if (event.id.toLowerCase() !== computedId.toLowerCase()) {
return false;
}
const sig = hexToBytes(event.sig);
@@ -613,7 +615,7 @@ My current identity:
npub: ${npub}
hex: ${hexPubkey}
This attestation is established pre-quantum at Bitcoin block height ${blockHeight}.
This attestation is established pre-quantum and anchored to the Bitcoin blockchain via OpenTimestamps.
Post-quantum public keys in tags:
ML-DSA-44 (Dilithium, FIPS 204, NIST Level 2)
@@ -635,9 +637,8 @@ Created at: https://laantungir.net/post-quantum/`;
const slhDsaSig = signWithSLHDSA(statementBytes, pqKeys.slhDsa.secretKey);
const falconSig = signWithFalcon(statementBytes, pqKeys.falcon512.secretKey);
// Tags: block height + each PQ key's pubkey and signature
// G56-17: Removed unverified block_height tag — the OTS proof is the real anchor
const tags = [
['block_height', String(blockHeight)],
['algorithm', 'ml-dsa-44', bytesToBase64(pqKeys.mlDsa44.publicKey), bytesToBase64(mlDsa44Sig)],
['algorithm', 'ml-dsa-65', bytesToBase64(pqKeys.mlDsa65.publicKey), bytesToBase64(mlDsa65Sig)],
['algorithm', 'slh-dsa-128s', bytesToBase64(pqKeys.slhDsa.publicKey), bytesToBase64(slhDsaSig)],
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.0.25",
"VERSION_NUMBER": "0.0.25",
"BUILD_DATE": "2026-07-24T18:43:29.373Z"
"VERSION": "v0.0.27",
"VERSION_NUMBER": "0.0.27",
"BUILD_DATE": "2026-07-25T11:36:25.146Z"
}
+3 -3
View File
@@ -9768,6 +9768,7 @@ function verifyNostrEvent(event) {
if (typeof event.kind !== "number") return false;
if (!Array.isArray(event.tags)) return false;
if (typeof event.content !== "string") return false;
if (typeof event.id !== "string" || !/^[0-9a-f]{64}$/.test(event.id)) return false;
const serialized = JSON.stringify([
0,
event.pubkey,
@@ -9778,7 +9779,7 @@ function verifyNostrEvent(event) {
]);
const hash = sha256(new TextEncoder().encode(serialized));
const computedId = bytesToHex3(hash);
if (event.id && event.id.toLowerCase() !== computedId.toLowerCase()) {
if (event.id.toLowerCase() !== computedId.toLowerCase()) {
return false;
}
const sig = hexToBytes3(event.sig);
@@ -9880,7 +9881,7 @@ My current identity:
npub: ${npub}
hex: ${hexPubkey}
This attestation is established pre-quantum at Bitcoin block height ${blockHeight}.
This attestation is established pre-quantum and anchored to the Bitcoin blockchain via OpenTimestamps.
Post-quantum public keys in tags:
ML-DSA-44 (Dilithium, FIPS 204, NIST Level 2)
@@ -9899,7 +9900,6 @@ Created at: https://laantungir.net/post-quantum/`;
const slhDsaSig = signWithSLHDSA(statementBytes, pqKeys.slhDsa.secretKey);
const falconSig = signWithFalcon(statementBytes, pqKeys.falcon512.secretKey);
const tags = [
["block_height", String(blockHeight)],
["algorithm", "ml-dsa-44", bytesToBase64(pqKeys.mlDsa44.publicKey), bytesToBase64(mlDsa44Sig)],
["algorithm", "ml-dsa-65", bytesToBase64(pqKeys.mlDsa65.publicKey), bytesToBase64(mlDsa65Sig)],
["algorithm", "slh-dsa-128s", bytesToBase64(pqKeys.slhDsa.publicKey), bytesToBase64(slhDsaSig)],