Files
nostr_quantum_preparation/audits/Fable5/audit.md
T

19 KiB
Raw Blame History

Post-Quantum Nostr — Project Audit (Fable5)

Date: 2026-07-25 Scope: Full repository review — concept/design documents (README.md, nip_proposal.md, why_what_how.md), core crypto module (www/js/pq-crypto.mjs, 2,134 lines), web app (www/js/index-app.mjs, www/index.html, www/verify.html), test suite (test/pq-crypto.test.mjs, 1,034 lines), and dependencies (package.json).


1. Executive Summary

Overall verdict: the project is viable as what it claims to be — a research prototype of a pre-quantum key-commitment scheme — and the implementation quality is unusually high for a prototype. The core idea is cryptographically sound, honestly scoped, and fills a real gap in the Nostr ecosystem. It is not yet a production system, and the documentation says so clearly and repeatedly, which is itself a strong signal of engineering maturity.

Dimension Assessment
Core concept soundness Strong — OTS-anchored earliest-wins precedence is the correct primitive for this threat model
Honesty of claims Excellent — limitations (G56-06 continuity, G56-04 explorer trust, browser boundary) are prominently disclosed
Code quality High — defensive parsing, fail-closed verification, size/recursion/budget limits, extensive tests
Production readiness Not ready — browser trust boundary, no companion protocols, single implementation, unaudited Falcon
Ecosystem viability Uncertain — depends entirely on adoption; the artifact is only useful if future clients honor it

2. Viability Assessment: Is the Idea Sound?

2.1 The threat model is correct

The project's framing of the problem is accurate:

  • Nostr's identity, authentication, and encryption all reduce to secp256k1, which Shor's algorithm breaks.
  • Bitcoin's hash-the-pubkey mitigation genuinely does not transfer to Nostr (the pubkey is load-bearing and revealed on every event) — the analysis in README.md is correct.
  • The collect-now-decrypt-later threat against NIP-04/NIP-44 is real, and the observation that NIP-44 offers zero PQ advantage over NIP-04 is accurate.

2.2 The core mechanism is the right one

The central design — publish a cross-signed key-link event now, anchor it to Bitcoin via OpenTimestamps, and resolve future conflicts by earliest valid Bitcoin anchor — is the strongest primitive available for this problem:

  • A future quantum attacker who recovers the secp256k1 key can forge signatures and publish a fraudulent link, but cannot backdate an OTS anchor. Producing an earlier anchor requires re-mining Bitcoin history or finding SHA-256 preimages, both infeasible even post-quantum.
  • Multi-scheme hedging (ML-DSA-44/65, SLH-DSA-128s, Falcon-512, ML-KEM-768) is a rational response to standardization uncertainty. The SIKE precedent cited is apt. Including hash-based SLH-DSA as the conservative fallback is exactly right.
  • The additive, two-event design (kind 1 announcement + kind 9999 proof carrier) requires no relay changes, no consensus, and degrades gracefully for legacy clients. This is the correct adoption strategy for Nostr.
  • Deterministic BIP32 derivation of PQ keys at pinned child indices, with a normative concatenation/truncation rule (nip_proposal.md), is well thought out — the truncation-rule pinning shows awareness of the interop failure modes that kill schemes like this.

2.3 Fundamental limits — mostly acknowledged, two under-weighted

The documentation is admirably honest that continuity is asserted, not proven (G56-06), that explorer-API trust is not light-client verification (G56-04), and that companion protocols (PQ event signing, rotation, encryption) do not exist yet. Two structural risks deserve more prominence than they currently get:

(a) Data availability is the real single point of failure. The entire post-quantum security argument reduces to: the genuine proof carrier (or at least its .ots proof and signed kind 1 JSON) must still be retrievable when the quantum break happens. Relays make no retention promises. Worse, a quantum attacker who recovers the secp key can sign NIP-09 deletion requests (kind 5) for the genuine kind 9999 events; relays that honor deletions would purge the real anchor while the attacker publishes fresh (later-anchored, but only surviving) links. "Earliest valid anchor wins" is only meaningful among anchors a client can still find. The mitigation (users/communities archive their own proof; publish widely; the proof is self-contained) exists but is not spelled out as a first-class protocol requirement. This should be a normative SHOULD in the NIP (e.g., "users MUST retain an offline copy of the signed kind 1 event and .ots proof; verifiers SHOULD accept out-of-band proof carriers").

(b) Adoption is the actual bet. The artifact only has value if, at quantum-break time, widely used clients implement the earliest-anchor-wins rule and honor the linked PQ keys. That is a social/ecosystem gamble, not a technical one. The mitigating factor is asymmetry of cost: publishing the link now is cheap and irreversible in the good sense — it can only help. As an option purchase the project is rational even under uncertain adoption. This is the correct way to evaluate viability, and by that standard the project is viable.

2.4 Verdict on viability

Viable as a research prototype and as a proposed NIP; premature as a user-facing product. The concept has no fatal flaw. The weakest links are (in order): browser trust boundary for key generation, relay data availability/deletion, explorer-API trust in verification, and ecosystem adoption. All are either acknowledged or fixable; none invalidate the core design.


3. Implementation Assessment

3.1 Strengths

  1. Defensive verification code. verifyNostrEvent() and verifyNIPQRContent() fail closed, never throw on untrusted input, validate structure before crypto, enforce exact NIP-01 shape, and reject oversized content before doing work (MAX_EVENT_JSON_SIZE, MAX_OTS_PROOF_SIZE).
  2. A real OTS parser, not pattern-matching. parseOtsFile() implements the binary format properly (varuints with overflow guards, op tree walking, attestation classification) with recursion-depth, branch-count, and operation-count budgets (G56-13). The Merkle-path verification in verifyOtsProof() correctly walks the op tree, binds the proof's target digest to the expected digest (F-C3), and compares the little-endian-reversed commitment against the block merkle root.
  3. Signer-output validation. validateSignerOutput() is a genuinely uncommon and valuable defense: it deep-compares the signed event against the template (order-sensitive tags), recomputes the ID, verifies the Schnorr signature, checks the pubkey, and rejects extra fields — closing the malicious-NIP-07-signer substitution channel (G56-05).
  4. Correct attestation tags. The Bitcoin (0588960d73d71901), Litecoin, and Pending (83dfe30d2ef90c8e) attestation tags at pq-crypto.mjs:1686 match the reference OpenTimestamps implementation.
  5. Multi-calendar redundancy. timestampEvent() submits to five calendars in parallel and merges fragments with 0xff continuation markers, matching the reference ots-cli multi-calendar format.
  6. Multi-explorer cross-check. fetchBitcoinBlockHeader() queries both Blockstream and mempool.space, fails on disagreement, and honestly labels the trust mode (multi-explorer-checked vs single-explorer-checked) rather than claiming trustlessness.
  7. Canonical selection logic. selectCanonicalProofCarrier() implements earliest-anchor-wins with deterministic tie-breaking, identity binding (G56-03), and a clearly-degraded pending fallback.
  8. Test suite quality. ~1,000 lines covering determinism, round-trips, tamper rejection, policy enforcement, identity binding, fuzzing of all untrusted-input parsers, and OTS fixtures from the vendored reference library. This is well above typical prototype standards.
  9. Audit-loop discipline. The codebase is threaded with finding IDs (G56-01 … G56-19, F-C3, F-H2, F-L3/L4) from prior audits, each with the fix documented inline. The remediation trail is visible and verifiable.

3.2 Findings

Severity scale: High = undermines the security story or interop; Medium = meaningful weakness with practical mitigations; Low = quality/robustness issue.

H-1. Non-canonical JSON hashing makes the OTS digest fragile (interop risk)

hashFullEvent() hashes JSON.stringify(signedEvent), which is key-insertion-order dependent. It round-trips today only because the kind 9999 content embeds the exact string and JSON.parseJSON.stringify preserves key order in JS. But:

  • Any second implementation (Rust, Python, Go) must replicate JavaScript's exact stringification (key order, number formatting, string escaping) or every verification fails.
  • A verifier that fetches the kind 1 event from a relay (rather than the embedded copy) will almost certainly get different key order and a hash mismatch.

Recommendation: define the OTS digest over a canonical serialization — the simplest choice is sha256 of the NIP-01 serialization array extended with id and sig (already canonical), or RFC 8785 JCS. Pin it normatively in the NIP. This is the single most important spec fix before a second implementation exists.

H-2. NIP-09 deletion + relay churn attack on the genuine anchor (design gap)

As described in §2.3(a): a post-quantum attacker with the recovered secp key can request deletion of the real kind 9999 events and flood replacements. The NIP's security argument implicitly assumes the genuine proof remains discoverable. Recommendation: add normative language — verifiers MUST accept proof carriers supplied out-of-band; users SHOULD archive the signed kind 1 JSON + .ots offline; PQ-aware relays SHOULD ignore kind 5 deletions targeting kind 9999.

M-1. Contradiction between NIP and implementation on unknown/missing algorithms

The NIP says clients "MUST ignore algorithm tags whose algorithm-id they do not recognize" and "verify whichever subset they support" (nip_proposal.md). The implementation (verifyNIPQRContent()) hard-codes all five current algorithms as mandatory, exactly once, and records unknown algorithms as valid: false results (which flips the legacy valid flag). Consequences: a future event adding a sixth registered algorithm fails this verifier's valid/policy checks, and an event legitimately omitting a broken scheme (the NIP's own revocation path, §"Revocation and broken schemes") fails policySufficient. Recommendation: parameterize the policy (mandatory set as versioned input), report unknown algorithms as ignored rather than failed, and align the NIP text with whatever policy is intended.

M-2. Falcon-512 in @noble/post-quantum is not covered by its audit

@noble/post-quantum@0.6.1 has audited ML-KEM/ML-DSA/SLH-DSA implementations, but Falcon support is newer and FIPS 206 is still a draft. Falcon's signing also involves floating-point FFT, which is notoriously hard to make constant-time and deterministic across platforms — a risk for a scheme whose keys must be reproducibly derivable from a seed for decades. Recommendation: document Falcon as the most provisional of the five links; add cross-platform determinism test vectors (same seed → same Falcon pubkey on multiple JS engines).

M-3. Server-assisted OTS upgrade is a soft centralization point

upgradeOts() posts the proof to laantungir.net/ots-upgrade (or same-origin). The upgraded proof is re-verified client-side, so a malicious helper cannot forge a confirmation — but it can lie about confirmed/changed, censor upgrades, and observe usage. Recommendation: verify the upgraded proof with verifyOtsProof() before persisting or republishing (confirm this is done in the app flow), and document the helper as replaceable/self-hostable.

M-4. Explorer-API trust for block headers (acknowledged)

G56-04 is honestly labeled, and the two-provider cross-check is a reasonable interim measure. Both providers could still collude/be compromised, and both calls originate from the same browser (a network-level MITM with TLS interception, or a hostile local proxy, defeats both). The vendored javascript-opentimestamps in resources/ plus a header-chain checkpoint approach would upgrade this materially. Keep it on the roadmap; it is correctly listed as unimplemented in README.md.

M-5. Browser as the trust boundary (acknowledged, unavoidable for now)

Seed generation, PQ key derivation, and PQ signing all happen in page JavaScript. The mitigations present — user-entropy mixing (generateSeedPhraseWithEntropy()), zeroization on sign-out (G56-10), out-of-band verification instructions, refusal to accept valuable existing mnemonics — are the right ones given the constraint, and the documentation is honest that zeroization is best-effort. The real fix (hardware/native signer with PQ derivation) is correctly identified as future work. Note also www/pq-crypto.bundle.js is a committed build artifact; without reproducible-build verification, users must trust the deployed bundle matches the audited source. The nginx security headers config in deploy/ suggests CSP awareness — ensure SRI or reproducible builds are part of the deploy story.

L-1. Documentation drift

  • README.md ("Client generates a 12-word phrase") and the mermaid flow at line 706 contradict the 24-word default in generateSeedPhrase().
  • The format-documentation comment at pq-crypto.mjs:1618 lists the pending attestation tag as 83df830d1c9d4a51, but the (correct) constant at line 1688 is 83dfe30d2ef90c8e. The comment is wrong, the code is right.
  • The deprecated alias buildKind11112Wrapper() and parameter names like kind11112Content are residue from an earlier kind number; rename for clarity.

L-2. Pending-candidate fallback ranks by forgeable created_at

selectCanonicalProofCarrier() orders pending candidates by created_at. Fine pre-quantum, meaningless post-quantum; the code can't do better, but callers/UIs must never present a pending-only selection as a trust decision. Verify the UI labels this state unambiguously.

L-3. base64ToBytes uses atob

base64ToBytes() accepts whatever atob tolerates (whitespace, missing padding in some engines), so two implementations could disagree on whether a tag is "malformed". Minor interop nit; a strict base64 validator would remove ambiguity.

L-4. Kind 9999 allocation is provisional

Acknowledged in the NIP. The 09999 "regular event" semantics (non-replaceable) are load-bearing for the append-only upgrade design — if maintainers allocate a kind in a replaceable range instead, the upgrade_of design must be revisited. Flag this dependency explicitly when submitting the NIP.

3.3 What is genuinely good here (worth preserving)

  • The append-only upgrade design (new kind 9999 + upgrade_of, never replace) is the right call: replaceable events would let an attacker overwrite the pending-era evidence.
  • The strict validForMigration vs. legacy valid split in verification results is careful API design that prevents "some checks passed" from being misread as "safe".
  • The research honesty — every marketing-adjacent sentence is counterweighted by a warning box. This project does not oversell, which is rare in this space.

4. Recommendations (priority order)

  1. Canonicalize the OTS digest (H-1) before any second implementation or NIP submission — this is a spec-breaking change that only gets more expensive.
  2. Add data-availability language to the NIP (H-2): out-of-band proof acceptance, offline archival, relay deletion-resistance guidance.
  3. Reconcile the algorithm policy between the NIP's ignore-unknown/verify-subset language and the implementation's mandatory-all-five policy (M-1); make the mandatory set versioned and configurable.
  4. Publish cross-implementation test vectors: mnemonic → all derived pubkeys → signed kind 1 → sha256 digest → expected verification results. This converts the pinned truncation rule from prose into executable conformance.
  5. Close the upgrade-helper loop (M-3): confirm client-side re-verification of upgraded proofs before publish, and document self-hosting.
  6. Roadmap the light-client (M-4): checkpointed header chain using the vendored OTS library, dropping explorer trust to header-relay trust.
  7. Fix documentation drift (L-1) — cheap, and stale docs erode the credibility the warning boxes have earned.
  8. Long-term: the project's own analysis is correct that the durable fix is PQ derivation and signing inside a hardware/native signer (Amber-class). The research docs in research/ show this is already being explored; that is the right direction.

5. Conclusion

Viability: yes, with eyes open. The concept is a sound, low-cost, irreversible-in-the-good-sense hedge: it commits PQ keys to a pre-quantum Bitcoin anchor today, so that if the ecosystem later needs it, the earliest-anchor-wins rule gives users a cryptographically distinguishable identity thread across the quantum break. Its value is contingent on adoption and on the genuine proofs remaining available — the two risks that deserve promotion into normative protocol text.

Implementation: high quality for its stage. Defensive parsing, fail-closed verification, real OTS binary handling, signer-output validation, honest trust labeling, and a serious test suite place this well above typical prototype code. The material issues found are a canonicalization fragility (H-1), a spec/implementation policy contradiction (M-1), and a set of acknowledged trust-boundary limitations that are inherent to a browser-based prototype rather than defects of this codebase.

The single most important next step is not more code — it is freezing a canonical serialization and publishing conformance vectors, so that the promise at the heart of the design ("any conforming implementation recovers the same keys and verifies the same proofs, decades from now") becomes testable.