Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc66a5fa3e | ||
|
|
5ebc6d8d2a |
@@ -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 ~20–30 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
|
||||
|
||||
|
||||
@@ -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.14–v0.0.18 | Kind 9999 docs (v0.0.14–v0.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
@@ -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": {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ~50–60 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();
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
|
||||
Reference in New Issue
Block a user