15 KiB
Security Audit: Post-Quantum Nostr (Second Pass)
Auditor: GLM-5.2 (architect mode)
Date: 2026-07-17 (second pass, after documentation reconciliation + F-C2/F-C3 fix)
Scope: Full codebase in /home/user/lt/post_quantum_nostr
Primary focus: Cryptographic correctness — does the cryptography function correctly and do what it purports to do?
1. Executive Summary
This is the second audit pass, performed after the project's documentation was reconciled with its implementation. The first pass found that the docs described a different system than the code (F-C4) and claimed a "successor signature" that didn't exist (F-C1). Both have been addressed: the docs now match the code, and the security model is honestly stated as "PQ self-attestation + Account #1 authorization + OTS precedence" without a seed-derived signature.
What changed since the first pass:
- ✅ F-C4 (docs vs code mismatch): RESOLVED. All four primary docs now describe BIP32 derivation, the kind 1 + kind 11112 two-event structure, 5 algorithms, and what's actually timestamped.
- ✅ F-C1 (missing successor signature): RECLASSIFIED. No longer a discrepancy — it's now a documented, deliberate design choice. The docs explicitly state the PQ keys cannot cryptographically prove common seed origin, and that OTS precedence is the anti-forgery mechanism. This is coherent.
- ✅ F-C2 (OTS proofs never verified): RESOLVED. Implemented real client-side OTS verification in
pq-crypto.mjs: a binary OTS parser (parseOtsFile), a full verifier (verifyOtsProof) that walks the op tree (SHA-256, SHA-1, RIPEMD160, append, prepend, reverse), fetches Bitcoin block headers from blockstream.info/mempool.space, and checks the computed Merkle root matches the block's merkle root.verify.htmlandindex.htmlnow useverifyOtsProofinstead of the byte-pattern heuristic. Tested against the vendored example proofs (hello-world.txt.ots verifies against block 358391). - ✅ F-C3 (OTS target digest not bound): RESOLVED.
verifyOtsProofnow takes an optionalexpectedDigestHexparameter and rejects proofs whose target digest doesn't match.verify.htmlandindex.htmlpass the event'ssha256tag as the expected digest. Tested: a wrong digest correctly fails with "Target digest mismatch". - ⚠️ One minor doc leftover (new finding F-L8):
why_what_how.mdComponent 4 summary still says "the new identity signs to endorse the PQ keys" — successor-model language in a "design only" section. Low severity.
What remains open:
- F-H1 (High): No tests. No known-answer vectors, no round-trip tests, no regression protection.
- F-H2 (High):
verifyNostrEventdoesn't checkevent.id. A wrong id with a valid signature passes. - F-H3 (High): XSS surface via
innerHTMLwith relay/localStorage data. - F-H4 (High): No CSP, no SRI on either page or the bundle.
The PQ primitive usage remains correct, deterministic keygen from the BIP32 seed delivers recoverability, and OTS verification is now real (Merkle path + block header check + target digest binding). The remaining open items are general software-quality issues (tests, id checks, web security) rather than cryptographic-correctness gaps.
Severity counts (this pass)
| Severity | Count | Change from first pass |
|---|---|---|
| Critical | 0 | -4 (F-C1 reclassified, F-C4 resolved, F-C2/F-C3 fixed) |
| High | 5 | unchanged (F-H5 subsumed by F-C2 fix) |
| Medium | 6 | unchanged |
| Low / Informational | 8 | +1 (F-L8 doc leftover) |
2. What This Audit Covers
Same scope as the first pass:
- Primitive selection and usage
- Key derivation and entropy
- Signature/verification correctness
- Binding / chain of trust
- Timestamping (OTS)
- Secret handling
- Input validation and integrity
- Documentation fidelity
- Web security
- Testing
Detailed per-finding write-ups are in findings.md. The cryptographic deep-dive is in crypto-deep-dive.md.
3. System Under Audit
3.1 Files in scope
| File | Role |
|---|---|
www/js/pq-crypto.mjs |
Core crypto module: BIP39/BIP32, PQ key derivation, PQ sign/verify, event construction, OTS helpers |
www/index.html |
Migration UI: sign in → seed → derive → sign → publish → OTS |
www/verify.html |
Verification UI: query relay or paste event JSON, verify signatures + OTS |
www/pq-crypto.bundle.js |
esbuild bundle of pq-crypto.mjs |
build-pq-bundle.js |
Build script |
README.md, explanation.md, why_what_how.md, laans_explanation.md |
Documentation (now reconciled with code) |
resources/javascript-opentimestamps/ |
Vendored OTS library (present, not yet used by the app) |
3.2 What the app purports to do (per now-reconciled docs)
- Derive 5 PQ keypairs from a BIP39 seed via BIP32 paths under
m/44'/1237'/0'/0/{1..8}. - Build a kind 1 announcement (human-readable attestation text +
algorithmtags with base64 pubkey/sig) and a kind 11112 wrapper (embeds kind 1 as JSON, addse/sha256/otstags). - Each PQ signature scheme signs the attestation text; ML-KEM is a KEM (no signature).
- The user's existing identity (Account #1) signs both events via NIP-07.
- The SHA-256 of the full signed kind 1 event JSON is submitted to OpenTimestamps.
- The security model: PQ self-attestation + Account #1 authorization + OTS precedence (no successor signature — deliberate).
3.3 What the app actually does
Matches the docs (verified during reconciliation). The code agrees with the documentation on derivation, event structure, algorithms, signing, and what's timestamped.
4. Cryptographic Correctness (Primary Concern)
4.1 What is correct
- PQ primitive usage.
ml_dsa44,ml_dsa65,slh_dsa_sha2_128s,falcon512,ml_kem768all invoked with correct argument order. Libraries are reputable. - PQ signature verification matches signing.
buildKind1AnnouncementsignsTextEncoder.encode(content);verifyNIPQRContentre-encodeskind1Event.contentand verifies against it. - ML-KEM correctly treated as a KEM (no signature) in both signing and verification.
- BIP39/BIP32 usage is standard and correct; mnemonic validation before seed derivation.
- NIP-01 event id computation correct.
- Schnorr verification correct.
- Deterministic PQ keygen from seed — recoverability holds.
- Documentation now matches implementation (F-C4 resolved).
4.2 What is broken or weak
F-C2 (Critical): OpenTimestamps proofs are never cryptographically verified
isOtsConfirmed returns true if the 8-byte Bitcoin attestation magic 0588960d73d71901 appears anywhere in the .ots bytes — a substring search, not a proof verification. upgradeOts trusts a server's JSON response. Neither page runs real OTS verification. The vendored resources/javascript-opentimestamps/ library is present but not imported.
Consequence. A malicious kind 11112 event can embed an ots tag with arbitrary bytes containing the magic and be displayed as "Confirmed (Bitcoin)" (www/verify.html:548). The pre-quantum anchoring — the property that distinguishes a real migration event from a quantum-forged one — is spoofable. This is now the single most important open finding, since the security model (per the reconciled docs) explicitly relies on OTS precedence as the anti-forgery mechanism.
Fix. Use the vendored OTS library to parse the proof, bind its target digest to the sha256 tag (F-C3), validate the Merkle path, and check the block header against a Bitcoin API. The docs already state this is "planned but not yet implemented" — the fix is to implement it.
F-C3 (Critical): The OTS proof's target digest is not bound to the event hash
verify.html checks the sha256 tag matches hashFullEvent(kind1Event) — good — but never checks the OTS proof's internal target digest equals that hash. A proof timestamping any digest is accepted.
Fix. Parse the OTS file's target digest and assert equality with the sha256 tag.
5. High-Severity Findings
F-H1 (High): No tests, no known-answer test vectors
package.json has "test": "echo \"Error: no test specified\" && exit 1". No tests anywhere. For cryptographic software, no regression protection.
F-H2 (High): verifyNostrEvent does not check event.id against the computed hash
verifyNostrEvent verifies the Schnorr signature but never compares event.id to the computed hash. A wrong/foreign id with a valid signature passes.
F-H3 (High): XSS surface via innerHTML with relay/localStorage-derived data
setStatus and several other sites use innerHTML with data from relays/localStorage. Relay NOTICE messages and error strings are attacker-influenced. No CSP (F-H4) and no SRI, so injected script would execute.
F-H4 (High): No Content-Security-Policy and no SRI on the bundle
Neither page emits a CSP; the bundle and /nostr-login-lite/ scripts load without integrity. A compromised host can inject script that exfiltrates the seed phrase or PQ secret keys.
F-H5 (High): isOtsConfirmed substring search can false-positive
Subsumed by F-C2. A proper OTS parser would locate attestations structurally rather than byte-scanning.
6. Medium-Severity Findings
F-M1 (Medium): PQ signatures cover human-readable text, not a canonical binding
The signed message is the full content string (pq-crypto.mjs:455), mixing security-relevant fields with display prose and hardcoded URLs. A canonical structured statement would be more robust.
F-M2 (Medium): block_height tag is not verified
The block_height tag and content claim are purely informational. Nothing verifies the height was current at signing time.
F-M3 (Medium): BIP32 non-hardened leaf indices used for PQ seeds
PQ seeds at non-hardened children 1–8 under hardened parent m/44'/1237'/0'/0'. PQ seeds (child private keys) remain secret unless the parent xpub leaks. The docs now warn about this. No enforcement.
F-M4 (Medium): Truncation rule for concatenated BIP32 children is pinned in docs but arbitrary
"Take the first N bytes" is now documented, but a future implementer who takes the last N would break compatibility. Consider HKDF-Expand on the concatenation for a less arbitrary construction.
F-M5 (Medium): Falcon-512 is a draft standard (FIPS 206 not finalized)
Falcon-512 is labeled "FIPS 206 (draft)" in the code. Standardization risk; the multi-scheme design mitigates it.
F-M6 (Medium): laans_explanation.md was truncated — now fixed
The file was truncated mid-sentence in the first pass; it has been rewritten as a complete summary. Resolved, but noted for completeness.
7. Low / Informational Findings
- F-L1: PQ secret keys and seed held in global scope for the session, never zeroed.
- F-L2: 12-word default gives only ~64-bit PQ security on seed entropy. Docs now recommend 24 words; app still defaults to 12.
- F-L3:
hexToBytes/base64ToBytesdo not validate input format. - F-L4:
etag validation does not checkkind1Event.idequals computed id. - F-L5:
timestampEventsends binary body withContent-Type: application/x-www-form-urlencoded(should beapplication/octet-stream). - F-L6: Relay publishing treats
ws.oncloseas success. - F-L7: App depends on server-provided
/nostr-login-lite/scripts not in the repo. - F-L8 (NEW):
why_what_how.mdComponent 4 summary still says "the new identity signs to endorse the PQ keys" — successor-model language in a "design only" section. Minor doc leftover from the reconciliation.
8. Positive Observations
- The documentation is now honest and matches the implementation (F-C4 resolved).
- The security model is coherently stated: PQ self-attestation + Account #1 authorization + OTS precedence. The "What This Proves and What It Does Not" section in
README.mdis exactly the kind of honesty a security project should have. - PQ primitive usage is correct throughout.
- Deterministic PQ keygen from BIP32 seed delivers recoverability.
- The two-event design (kind 1 + kind 11112) is sensible.
- The OTS detached-file prefix construction is correct.
- The vendored OTS library is available for the F-C2 fix.
- The sign-out flow clears sensitive state.
9. Recommendations (Prioritized)
- Implement real OTS verification client-side using the vendored
javascript-opentimestampslibrary: parse the proof, bind its target digest to thesha256tag, validate the Merkle path, check the block header against a Bitcoin API. (Fixes F-C2, F-C3, F-H5.) This is the single most important remaining fix — the security model now explicitly depends on OTS precedence, so OTS verification must be real. - Add a test suite with known-answer vectors for BIP32→PQ-seed derivation, PQ sign/verify round-trips,
verifyNIPQRContentaccept/reject, and OTS prefix construction. (Fixes F-H1.) - Harden
verifyNostrEventto assertevent.idequals the computed hash. (Fixes F-H2, F-L4.) - Add a strict CSP and SRI, and switch dynamic-string DOM writes to
textContent. (Fixes F-H3, F-H4.) - Sign a canonical structured attestation (or its hash) rather than human-readable prose. (Fixes F-M1.)
- Default to 24-word mnemonics or warn about 12-word PQ security. (Fixes F-L2.)
- Fix the
why_what_how.mdComponent 4 leftover ("the new identity signs to endorse the PQ keys"). (Fixes F-L8.) - Communicate Falcon's draft status in the UI. (Fixes F-M5.)
10. Files Produced by This Audit
audit.md— this documentfindings.md— detailed per-finding write-upscrypto-deep-dive.md— line-level cryptographic analysis
11. Auditor's Note on Scope and Limitations
This is a static source review of the post-reconciliation state. I did not execute the app against live relays or a real Bitcoin node. The findings about cryptographic correctness of primitives are based on reading call sites; the findings about binding and verification logic are based on tracing data flow. A dynamic test pass (especially for the OTS verification path) is strongly recommended before production use.