50 KiB
GPT-5.6 Comprehensive Security and Usability Audit
Audit date: 2026-07-20
Audited workspace: post_quantum_nostr at root commit 9a975fcbc56e455a68ae16d734feeed76ba304b6
Application version: 0.0.11
Auditor: GPT-5.6
Scope: first-party application code, cryptographic and Nostr/OTS protocol design, browser security, dependencies, tests, documentation claims, live public deployment, and the OpenTimestamps submodule boundary.
Executive verdict
Do not use this implementation to protect a real Nostr identity yet. It is a useful research prototype with working PQ key generation/signature primitives and a promising OTS proof parser, but its verifier currently accepts events with no PQ signatures, treats missing signatures on declared signature algorithms as successful KEM-style entries, does not bind the outer wrapper identity to the embedded announcement identity, and does not implement its defining earliest valid OTS anchor wins selection rule. These are protocol-critical failures, not polish issues.
Conceptual verdict: the project is conditionally sound as a pre-quantum key-commitment and identity-link protocol, but it is not, by itself, a complete post-quantum migration for Nostr. Its central idea—have the current identity authorize PQ keys while secp256k1 is still trustworthy, anchor that authorization in Bitcoin, and later prefer the earliest valid anchor—is defensible if the protocol is redesigned to enforce immutable discovery, strict verification, key possession/continuity, and PQ-authorized rotation. Fixing only the implementation bugs would not be enough. Fixing the architectural requirements in the “Conceptual soundness assessment” below could yield a valid and useful project, but the result should initially be described as a PQ key pre-commitment/link registry, not as making the existing Nostr identity fully quantum-resistant.
The project did well in several areas:
- All 38 existing tests passed, including two live checks against Bitcoin API data.
- A fresh browser bundle was byte-for-byte identical to the committed bundle.
npm audit --omit=devreported no advisories for the currently installed root dependency graph.- The OTS target digest is bound to the expected event hash during full verification.
- The live bundle exactly matched the audited local bundle.
- A live calendar submission succeeded to all five configured calendars and parsed as a detached multi-calendar proof.
Those strengths do not compensate for fail-open acceptance in the key-link verifier. A user-facing statement such as “All signatures verified successfully” can currently be produced for a signed wrapper whose embedded announcement has zero PQ keys. The application also presents a latest replaceable event from relays, whereas the proposal says the canonical link is the event with the earliest verified Bitcoin anchor. That mismatch defeats the core post-quantum migration rule.
Severity summary
| Severity | Count | IDs |
|---|---|---|
| Critical | 2 | G56-01, G56-02 |
| High | 6 | G56-03 through G56-08 |
| Medium | 8 | G56-09 through G56-16 |
| Low / informational | 4 | G56-17 through G56-20 |
Findings at a glance
| ID | Severity | Finding | Confidence |
|---|---|---|---|
| G56-01 | Critical | Verifier accepts a migration with no PQ signatures and accepts missing signature fields as valid | Confirmed dynamically |
| G56-02 | Critical | Earliest-valid-anchor rule is not implemented; relay discovery selects the latest replaceable event | Confirmed |
| G56-03 | High | Outer wrapper identity is not bound to the embedded announcement identity | Confirmed dynamically |
| G56-04 | High | OTS “verification” trusts public HTTPS APIs and does not validate Bitcoin headers or proof of work | Confirmed |
| G56-05 | High | Signer output is reconstructed without validating returned identity, event ID, signature, or exact signed fields | Confirmed |
| G56-06 | High | The system creates a new seed for any signed-in identity without proving seed continuity or a successor key relationship | Confirmed design limitation |
| G56-07 | High | Verification is not robust against malformed untrusted relay/paste input and can throw | Confirmed dynamically |
| G56-08 | High | Browser origin is the key-generation trust boundary, but deploy hardening and signer assets are unaudited/unpinned | Confirmed |
| G56-09 | Medium | Twelve-word seed is the only generated option and its warning is removed in the audited workspace/live page | Confirmed |
| G56-10 | Medium | Secret material is retained in ordinary JavaScript objects/DOM/clipboard and is not zeroized | Confirmed |
| G56-11 | Medium | Publish workflow reports success and advances even when no relay accepts one or both events | Confirmed |
| G56-12 | Medium | Resume/upgrade state can fetch an attacker-controlled latest wrapper and lacks robust event/workflow binding | Confirmed |
| G56-13 | Medium | OTS parser lacks total input/operation budgets and uses unsafe 32-bit varuint arithmetic | Confirmed |
| G56-14 | Medium | Dependency resolution is not reproducible from the repository; lockfile is ignored and inconsistent | Confirmed |
| G56-15 | Medium | OpenTimestamps submodule is not clonable normally because .gitmodules is absent; historical dependency risk is unresolved |
Confirmed |
| G56-16 | Medium | Documentation and UI overclaim implementation status and security properties | Confirmed |
| G56-17 | Low | Block-height statement is not validated and can be zero/untrusted | Confirmed |
| G56-18 | Low | Relay URL policy permits insecure ws:// and arbitrary endpoints |
Confirmed |
| G56-19 | Low | Missing event IDs may be accepted by the library verifier | Confirmed dynamically |
| G56-20 | Informational | Large events may be rejected by relays; interoperability testing is absent | Confirmed size; relay behavior contextual |
Conceptual soundness assessment
Direct answer
Is the idea flawed from the start? No—but its valid scope is narrower than the current product language suggests.
The project starts from a real migration problem: after secp256k1 is broken, a fresh secp signature cannot distinguish the legitimate owner from an attacker. Creating and timestamping an authorization before that break is a legitimate way to establish historical precedence. A PQ key that signs the same canonical authorization provides durable proof that the corresponding PQ private key participated. Bitcoin/OpenTimestamps can provide an externally ordered existence proof without changing Nostr relays.
That gives a conceptually useful primitive:
Before quantum compromise, identity A authorized PQ key set K; the authorization existed no later than Bitcoin block H; future clients can use a deterministic conflict rule to resolve later forged claims.
However, that primitive does not automatically make identity A quantum-resistant. A complete migration also needs a standardized way to authenticate future events with K, rotate/revoke keys after secp256k1 is distrusted, encrypt to PQ keys, discover all historical claims despite relay replacement/deletion, and recover from lost or compromised PQ keys. Those pieces are currently outside the implementation.
What remains valid after remediation
If all audit recommendations and the architectural requirements in audit_mitigation.md are implemented, the project can validly provide:
- Pre-quantum commitment: proof that a Nostr identity authorized a canonical PQ key set before a verified Bitcoin block.
- PQ proof of possession: proof that each linked signature key participated in the canonical authorization, and an explicit KEM possession mechanism if ML-KEM ownership is claimed.
- Deterministic conflict resolution: selection of the earliest valid immutable anchor rather than the newest relay event.
- A migration root: a trustworthy starting key set for a separate PQ-event, PQ-encryption, and key-rotation protocol.
- Backward-compatible publication: legacy relays can carry opaque events without understanding PQ cryptography, subject to size and retention limits.
What remediation cannot make true without additional protocols
Even a perfectly implemented key-link protocol does not itself provide:
- PQ authentication for ordinary kind 0/1/3/etc. events;
- PQ confidentiality for NIP-04 or NIP-44 messages;
- automatic preservation of the social graph in clients that do not adopt identity-alias/successor semantics;
- revocation or rotation after secp256k1 becomes untrustworthy;
- availability if all relays and user archives lose the immutable announcement/proof;
- protection from seed theft, malicious signers, compromised web origins, or broken PQ implementations;
- consensus across Nostr clients about algorithm policy or canonical identity succession.
The project therefore needs companion specifications for PQ-signed events, key lifecycle, encryption, and client identity mapping before it can accurately claim a complete Nostr PQ migration.
Architectural conditions for a sound protocol
The concept is sound only if all of these invariants are made normative and implemented:
- Canonical signed object: PQ signatures and the current secp identity must cover one domain-separated, versioned, deterministic structure containing the old identity, complete ordered key set, algorithm identifiers, protocol version, creation context, and rotation policy. Human-readable prose may be displayed but must not be the normative signed representation.
- Strict proof policy: a verifier must distinguish structural validity, cryptographic validity, algorithm-policy sufficiency, OTS validity, and canonical-selection status. Missing signatures can never become successful KEM entries.
- Identity equality: queried author, wrapper author, embedded author, and canonical old-identity field must match.
- Immutable candidates: the initial authorization must not depend on a replaceable event that can hide prior history. An upgraded proof may reference the same immutable authorization digest, but cannot replace the candidate itself.
- Complete discovery: clients must collect candidates across relays/archives and apply a deterministic earliest-valid-anchor rule, including tie-breaks and confirmation-depth policy.
- Possession and recoverability: every signature key must prove possession. If the project promises seed recovery, the canonical statement must bind a seed-derived successor proof or the UI must explicitly avoid claiming common seed origin. KEM possession requires a defined challenge/decapsulation proof or must be described only as an authorized encryption key, not proven possession.
- Post-break authority: after the transition cutoff, secp-only updates must not supersede the canonical PQ root. Rotation/revocation must require a threshold or policy over already-authorized PQ keys.
- Future event semantics: a separate specification must define exactly how PQ keys authenticate future Nostr events and how those events bind to NIP-01 IDs, kinds, tags, replay domains, and old identities.
- Availability: users and clients must export, mirror, and validate the immutable authorization plus OTS proof independently of any one relay or web service.
- Trust-explicit Bitcoin verification: implementations must state whether they use a local node, a validated header chain, or trusted explorer APIs. Security labels must match that model.
- Algorithm agility with policy: multiple algorithms may hedge cryptanalytic risk, but clients need mandatory minimum sets, deprecation rules, downgrade resistance, and deterministic handling of unknown schemes.
- Secure key boundary: production derivation/signing should occur in a dedicated signer or hardware boundary, not an updateable general-purpose web origin holding a long-lived seed.
Bottom line
With those conditions and the finding-specific fixes, this can become a valid PQ migration foundation. Without them, repairing individual code defects would leave a system that generates valid PQ signatures but lacks a trustworthy rule for what those signatures mean over time. The current project is therefore salvageable and conceptually promising, but architecturally incomplete.
Scope, baseline, and trust boundaries
First-party runtime surface
- Cryptographic module:
pq-crypto.mjs - Generated browser bundle:
pq-crypto.bundle.js - Migration page and state machine:
index.html - Verification page and relay query flow:
verify.html - Tests:
pq-crypto.test.mjs - Build script:
build-pq-bundle.js - Protocol proposal:
nip_proposal.md - User-facing claims:
README.md
External trust boundaries
- Web origin / deployment operator controls the JavaScript that receives or generates a BIP39 mnemonic and PQ private keys.
- nostr-login-lite / NIP-07/NIP-46 signer supplies the current identity and signs events.
- Nostr relays receive, retain, filter, and return untrusted events.
- OTS calendars receive event digests and return pending timestamp fragments.
- Same-origin
/ots-upgradehelper receives OTS proof bytes and returns upgraded bytes; its implementation is absent from the repository. - Blockstream and mempool.space APIs supply the purported Bitcoin block hash, Merkle root, and timestamp.
- npm dependencies and committed generated bundle implement all cryptographic primitives in the browser.
- OpenTimestamps submodule supplies example proofs used by tests, but is not runtime-integrated.
Audit limitations
- No browser automation framework or complete signer fixture is present, so the real NIP-07/NIP-46 modal flow was reviewed statically rather than driven end to end.
- The
/ots-upgradeserver source and nginx/deployment configuration are not in the repository. Only black-box HTTP behavior could be checked. - No real Nostr identity was used and no migration event was published during the audit.
- The workspace was already dirty: user changes existed in
index.html,verify.html, andnip_proposal.md, plus unrelated deleted audit files. The report evaluates the working tree, not a pristine commit. - Root advisory results describe the installed graph at audit time, not every graph permitted by semver ranges.
Detailed findings
G56-01 — Verifier accepts no PQ signatures and missing signatures as successful
Severity: Critical
Confidence: Confirmed dynamically
The central verifier initializes results with only secp256k1, e, and SHA-256 checks, then loops over whatever algorithm tags happen to exist. It never requires any algorithm tag, never requires the four expected signature algorithms, and never validates uniqueness or policy. See verifyNIPQRContent() and its unconditional aggregate at results.every().
Worse, any algorithm tag lacking a fourth field is marked valid as a KEM, regardless of its algorithm identifier:
if (algorithm === 'ml-kem-768' || !sigBase64) {
results.push({ algorithm, valid: true, note: 'KEM (no signature to verify)' });
continue;
}
This logic is at verifyNIPQRContent().
Dynamic probes established:
- A validly secp-signed embedded kind 1 event with
tags: []returnedvalid: true. ml-dsa-44,ml-dsa-65,slh-dsa-128s, andfalcon-512tags containing only a public-key field each returnedvalid: truewith noteKEM (no signature to verify).- An arbitrary unknown algorithm with no signature also returned valid.
The UI then presents All signatures verified successfully! when this incomplete result is true at verify.html.
Impact: The verifier can bless an event that provides no post-quantum authentication at all. Such an event does not establish the advertised migration and cannot support post-quantum continuity.
Remediation: Define and enforce a verification policy. At minimum:
- Require exactly one tag for each mandatory supported algorithm.
- Require exact field counts and decoded byte lengths.
- Permit no-signature handling only for the exact
ml-kem-768identifier. - Reject duplicate known algorithms.
- Treat unknown algorithms as ignored—not valid evidence—and report them separately.
- Return distinct structural validity, cryptographic validity, policy sufficiency, and OTS validity fields.
- Add adversarial tests for empty tags, missing signatures, duplicate algorithms, unknown algorithms, and malformed encodings.
Verification criterion: No event can receive a migration-valid result unless the configured required PQ signature set verifies successfully.
G56-02 — The defining earliest-valid-anchor rule is not implemented
Severity: Critical
Confidence: Confirmed
The proposal makes earliest verified Bitcoin anchor selection the mechanism that defeats a later quantum forgery at nip_proposal.md. The implementation instead queries kind 11112 with limit: 1 and describes it as “latest” at verify.html. Across relays, it chooses the largest created_at value at verify.html. The resume path also requests one event at index.html and selects newest created_at across relays at index.html.
Kind 11112 is replaceable, as documented in pq-crypto.mjs. A later forged or accidental replacement can therefore hide the historical event from ordinary relay queries. Even before a quantum break, a compromised current key can publish a newer wrapper. After a break, the attacker can do so trivially. The application does not gather all candidate announcements/proofs, verify them, compare Bitcoin heights, or preserve a stable immutable discovery record.
Impact: The implemented discovery behavior can select the exact later event that the OTS precedence design says must lose. This breaks the central migration security argument.
Remediation: Redesign discovery and storage before production use:
- Use a non-replaceable canonical announcement or an append-only indexed form for immutable candidates.
- Fetch all candidates from multiple relays, not
limit: 1. - Fully validate each candidate and select the earliest valid Bitcoin anchor according to a precise tie-break rule.
- Define how upgraded OTS proofs refer to an immutable announcement without replacing or obscuring history.
- Cache/export the canonical proof so relay retention does not determine identity continuity.
- Implement PQ-authorized rotation separately.
Verification criterion: Given multiple candidates—including a later forged secp-valid candidate—the client deterministically chooses the candidate with the earliest valid Bitcoin anchor.
G56-03 — Outer wrapper identity is not bound to embedded identity
Severity: High
Confidence: Confirmed dynamically
verifyNIPQRContent() validates the embedded kind 1 event and tags but receives no outer event object and therefore cannot require outer.pubkey === embedded.pubkey. verifyEvent() separately validates the outer signature and calls the content verifier without performing this identity equality check.
A dynamic probe built a valid embedded event signed by key A, wrapped it in a valid kind 11112 event signed by key B, and observed:
- outer signature valid:
true - embedded verification valid:
true
Impact: A wrapper authored by one identity can carry another identity’s announcement and appear as a collection of passing checks. Relay query by author B can therefore display A’s embedded link under B’s result page. OTS upgrading/republishing may further re-sign mismatched content.
Remediation: Require equality among:
- requested relay-query author,
- outer wrapper
pubkey, - embedded kind 1
pubkey, - identity named in the human-readable content (or preferably a canonical structured identity field).
Also require correct outer kind and exact tag cardinality.
G56-04 — Bitcoin verification trusts public API JSON and does not validate block headers or proof of work
Severity: High
Confidence: Confirmed
fetchBitcoinBlockHeader() fetches a block hash by height and then a block JSON object from one of two public APIs. It extracts only a Merkle root and timestamp at pq-crypto.mjs. verifyOtsProof() compares the OTS commitment to that API-provided Merkle root.
Despite comments calling this “independently verifiable against the Bitcoin PoW chain” at pq-crypto.mjs, the implementation does not fetch raw 80-byte headers, hash a header, check its hash against target, verify chain linkage, validate accumulated work, or pin checkpoints. Both providers are contacted over HTTPS and are simply trusted to report honest data.
Impact: Compromise, interception at a trusted origin, coordinated false responses, or API semantic changes can cause a false “Bitcoin verified” result. Two fallback URLs are resilience, not trust minimization, because acceptance requires only one response.
Remediation: Accurately label the current mode as “verified against a trusted block explorer.” For stronger verification, use a local Bitcoin node or a real light client with headers, chainwork, checkpoints, and consensus validation. At minimum compare multiple independent providers and fail on disagreement, but do not call that trustless cryptographic Bitcoin verification.
G56-05 — Signer output is trusted and reconstructed without validation
Severity: High
Confidence: Confirmed
After window.nostr.signEvent, the migration flow copies id, pubkey, and sig from the signer but takes created_at, kind, content, and tags from the local template at index.html. It does not check:
- signer-returned pubkey equals the authenticated
currentPubkey, - returned ID equals
computeEventId()for the final reconstructed event, - returned signature verifies,
- signer did not alter fields,
- the wrapper signer matches the announcement signer.
The same trust is repeated for the wrapper at index.html and upgraded events at index.html. The verify page republishes signer output directly at verify.html, but also does not validate it before publishing.
Impact: A buggy, malicious, or incompatible signer can cause invalid or wrong-identity events to be displayed and published as successful. Reconstructing a hybrid object can pair a signer’s signature with different fields.
Remediation: Treat signer output as untrusted. Validate complete returned events, exact field equality against approved templates, pubkey equality, event ID, and Schnorr signature before any display, persistence, or publish.
G56-06 — New seed continuity is asserted, not proven
Severity: High
Confidence: Confirmed design limitation
The app generates or accepts an arbitrary seed after signing into an existing identity. It derives a seed-based secp key at deriveSecp256k1FromSeed() but does not use that key to sign, and does not verify that an entered seed derives the currently signed-in Nostr identity. The documentation acknowledges no successor signature at README.md.
For a raw-nsec identity, an old-identity signature plus pre-quantum OTS can establish that the old identity authorized listed PQ keys. It cannot prove those keys are recoverable from the mnemonic shown to the user or that all listed keys share one seed. For a mnemonic-based existing identity, failing to compare the derived secp public key wastes a strong continuity check.
Impact: A compromised browser can show one mnemonic while publishing attacker-chosen PQ keys; the old signer approves only a very large event whose key material is hard for a human to inspect. The user may believe the written mnemonic is a recoverable root when it is not cryptographically bound as such.
Remediation: Separate two modes:
- Existing mnemonic identity: derive the NIP-06 secp key and require it to equal the signer pubkey before proceeding.
- Raw-nsec migration: define a canonical structured migration statement and add a proof-of-possession/successor signature from a seed-derived key, plus recovery self-test before publishing.
A hardware signer or dedicated PQ signer should ideally derive/sign without exposing the mnemonic to a web origin.
G56-07 — Untrusted verifier input can throw instead of failing closed
Severity: High
Confidence: Confirmed dynamically
The verification page accepts pasted JSON and relay events. verifyNIPQRContent() assumes arrays and valid base64. Examples confirmed during testing:
- malformed base64 threw
InvalidCharacterError, - non-array wrapper tags threw
TypeError: kind11112Tags.find is not a function, - missing signatures caused
verifyNostrEvent()to throw, nullor wrong-shaped events threw.
Relay-driven verification at verify.html invokes the verifier without the paste handler’s try/catch; malformed relay content can reject the click callback and leave inconsistent UI. PQ decoders can also receive attacker-selected large or malformed data without preflight size checks.
Impact: Remote relays can cause denial of service, misleading partial results, or expensive parsing/cryptographic work.
Remediation: Add strict schemas and byte-size limits before cryptography. Every exported verification function should return structured failure for untrusted input, never throw for ordinary malformed data. Add fuzz/property tests and maximum event/proof sizes.
G56-08 — Browser origin security is insufficient for handling a root seed
Severity: High
Confidence: Confirmed
The page states the seed is processed entirely in the browser at index.html. That means the web origin—not merely the reviewed source—is trusted with the seed. The live response lacked HSTS, X-Frame-Options/CSP frame-ancestors, Referrer-Policy, Permissions-Policy, and other defense-in-depth headers. The meta CSP allows 'unsafe-inline' scripts at index.html and verify.html, materially weakening XSS protection.
Two large same-origin signer scripts are loaded outside this repository at index.html and verify.html. They have no SRI and could not be source-audited here. Live sizes were 328,444 and 230,498 bytes. Any server compromise or update to these assets can exfiltrate the mnemonic and all PQ keys. The source for /ots-upgrade and server configuration is also absent.
Impact: A single origin compromise defeats the entire root-of-trust design. HTTPS alone does not make web-based seed entry equivalent to a hardware or native signer.
Remediation: Do not recommend entering a valuable existing mnemonic into this web app. Move derivation and PQ signing into a dedicated signer/hardware boundary. Also extract inline scripts, enforce nonce/hash CSP without 'unsafe-inline', add frame-ancestors 'none', HSTS, Referrer-Policy, Permissions-Policy, pinned/vendored signer assets, reviewed deployment configuration, and release integrity documentation.
G56-09 — Generated seed defaults to 12 words and the visible warning is absent
Severity: Medium
Confidence: Confirmed
generateSeedPhrase() and generateSeedPhraseWithEntropy() always produce 128-bit entropy/12 words. The project itself estimates only about 64-bit security under an idealized Grover model at README.md. The audited working tree removed the prior visible warning from the generation screen, and the live page matched this working tree.
Impact: Users receive “quantum-safe root of trust” messaging while the only generated option falls below the project’s own preferred PQ security level.
Remediation: Default to 24 words for a claimed long-term PQ root, retain a prominent explanation, and provide a deliberate entropy-strength selector. Be careful not to imply that BIP39’s low iteration count protects weak user-created phrases.
G56-10 — Secret lifetime and exposure are not minimized
Severity: Medium
Confidence: Confirmed
The mnemonic, BIP39 seed, derived secp private key, and five PQ secret keys are retained in global JavaScript variables at index.html. The mnemonic is rendered in the DOM at index.html and can be copied to the system clipboard at index.html. Sign-out nulls references at index.html, but does not overwrite typed arrays or clear the seed display/input/clipboard. Ordinary JS garbage collection offers no reliable erasure.
Impact: Browser extensions, injected scripts, devtools, clipboard managers, page snapshots, and memory inspection can recover high-value long-lived secrets.
Remediation: Prefer signer-contained keys. If browser handling remains, minimize scope/lifetime, avoid deriving an unused secp private key, overwrite typed arrays where possible, clear all DOM/input references immediately after use, avoid clipboard encouragement, add mnemonic confirmation/recovery challenge, and document that zeroization cannot be guaranteed in JavaScript.
G56-11 — Publication advances despite zero relay acceptance
Severity: Medium
Confidence: Confirmed
After publishing, the code counts successes and failures but unconditionally displays Published!, disables the button, marks step 5 done, and exposes the next step at index.html. There is no requirement that either event reach any relay.
Impact: Users can believe migration succeeded when the announcement, wrapper, or both are unavailable. The two-event pair can also be split across relays.
Remediation: Require policy thresholds for both events, distinguish partial failure, retain retry controls, verify by querying accepted events back, and provide downloadable backups. Do not mark complete unless at least one configured durable relay stores both exact events.
G56-12 — Resume workflow is insufficiently bound to the original event
Severity: Medium
Confidence: Confirmed
Only OTS metadata/proof is persisted by savePendingOts(), not the complete immutable original wrapper. On resume, the code queries relays for the latest kind 11112 and adopts it at index.html. It does not require that the fetched event ID equal the persisted ID, that its SHA-256 target matches the persisted proof, or that it passes complete verification before becoming pqEvent.
There is also an ID transition bug: after an upgraded replacement is signed, savePendingOts() stores the new wrapper event ID. The OTS proof, however, targets the embedded kind 1 hash, while comments and metadata call eventId “The NIP-QR event id” at pq-crypto.mjs. This overloaded identifier complicates safe recovery.
Impact: A resume can upgrade or republish the wrong wrapper/content, especially after replacements or hostile relay responses.
Remediation: Persist the complete signed announcement, wrapper, target digest, owner, and exact relay receipts under separate immutable IDs. On resume, validate every binding before signing or publishing. Never substitute a merely newer relay event.
G56-13 — OTS parser resource and integer limits are incomplete
Severity: Medium
Confidence: Confirmed
The parser allows recursion depth 512 at pq-crypto.mjs, but has no overall proof-size limit, total operation count, total branches, or total hash-work budget. readVaruint() uses JavaScript bitwise operations, which are signed 32-bit and wrap for sufficiently large encodings. The continuation loop can process many branches, and each operation allocates/hash-processes attacker-controlled data.
Impact: Pasted or relay-sourced proofs can consume excessive CPU/memory or create malformed length/height semantics.
Remediation: Reject proofs above a small protocol maximum; cap operations, branches, argument bytes, and total hashing; parse varints with safe-number checks or BigInt; reject non-canonical and overlong encodings; require complete input consumption; fuzz the parser.
G56-14 — Root dependency graph is not reproducible from the repository
Severity: Medium
Confidence: Confirmed
package.json uses caret ranges for all dependencies. package-lock.json exists locally but is explicitly ignored by .gitignore and is not tracked. Its root metadata says version 1.0.0, whereas the manifest says 0.0.11, showing it is stale or generated under a different package state.
npm audit --omit=dev found zero vulnerabilities for the installed graph, and the bundle rebuilt identically. Those are positive observations for this machine only. A clean install later may resolve different code, and there is no committed integrity map connecting source dependencies to the released bundle.
Remediation: Commit an up-to-date lockfile, use npm ci, pin release dependencies, add CI audit/build/test jobs, produce an SBOM/provenance attestation, and document how the deployed bundle hash maps to a source revision.
G56-15 — OpenTimestamps submodule and historical dependencies are not reproducible
Severity: Medium
Confidence: Confirmed
The root tree records resources/javascript-opentimestamps/ as gitlink commit c07ba8be0dae4e8721a84f32820abf7b2547a6ce, but the repository has no .gitmodules mapping. git submodule status failed with “no submodule mapping found.” The already-present nested repository points to the upstream GitHub project, but a fresh clone cannot initialize it normally.
Its package metadata is version 0.4.9 and contains old ranges including request, moment-timezone, minimatch, and other historical packages. No lockfile is present, so its historical dependency graph could not be audited reproducibly. This code is not runtime-integrated, but its example proofs are required by root tests.
Remediation: Restore a valid .gitmodules entry or vendor a clearly licensed immutable snapshot. Pin and document the commit, remove claims that it is integrated, and either modernize/audit its dependency graph or retain only the test vectors with provenance and hashes.
G56-16 — Documentation and UI overclaim status and guarantees
Severity: Medium
Confidence: Confirmed
Examples:
README.mdsays full client-side OTS verification is not implemented, but code now implements an API-trusting Merkle-root check.README.mdstill lists full OTS verification, target binding, and the test suite as not implemented, although all exist in some form.nip_proposal.mdsays all OTS verification happens in the browser and “No private key ever touches a server,” but signer modes may be remote and the implementation relies on an unreviewed server helper and public APIs.index.htmlsays “Make your Nostr identity quantum-resistant,” while the project only links keys and does not define PQ signing for routine events or PQ messaging.verify.htmlsays it verifies all signatures, despite G56-01.- The claim that a pending OTS proof establishes submission time at
nip_proposal.mdis too strong: a pending calendar attestation is not a Bitcoin timestamp and depends on calendar behavior.
Impact: Users and reviewers may treat the prototype as production-ready or misunderstand its trust model.
Remediation: Maintain an explicit security model and implementation matrix generated from tests. Use “research prototype,” distinguish API-assisted verification from Bitcoin light-client verification, distinguish key linkage from identity becoming quantum-resistant, and label design-only research documents prominently.
G56-17 — Claimed Bitcoin block height is unverified
Severity: Low
Confidence: Confirmed
Block height comes from one mempool.space request at index.html. Failure returns zero, yet signing continues at index.html. The verifier does not compare the block_height tag or content statement to the OTS attested block.
Remediation: Remove the claimed height from signed content or validate it against the eventual OTS result. Do not sign a “pre-quantum at height 0” statement.
G56-18 — Insecure/arbitrary relay endpoints are allowed
Severity: Low
Confidence: Confirmed
The add-relay validator explicitly accepts ws:// at index.html. The verify page does not validate schemes before new WebSocket() at verify.html.
Impact: On an HTTP/local deployment, users can transmit public metadata to plaintext relays; arbitrary endpoints also increase privacy leakage and browser-network probing surface.
Remediation: Require wss:// outside explicit localhost development, canonicalize URLs, cap count/length, and explain that relay queries reveal identity interest and client IP.
G56-19 — Event ID is optional in library verification
Severity: Low
Confidence: Confirmed dynamically
verifyNostrEvent() checks ID equality only when event.id is truthy at pq-crypto.mjs. A dynamically created event with a valid signature but omitted id returned true.
The embedded verifier currently requires neither id explicitly in its structural condition at pq-crypto.mjs nor at the core verifier. While the e tag later supplies some binding for wrappers, this exported primitive’s behavior is surprising and unsafe for callers.
Remediation: Require exact NIP-01 shape and a 64-hex ID by default. If ID-less template verification is needed, expose a separate clearly named function.
G56-20 — Event-size interoperability is untested
Severity: Informational
Confidence: Confirmed size; relay behavior contextual
A fixed-mnemonic unsigned kind 1 announcement was approximately 27,500 bytes before id/sig; the wrapper embeds that JSON and adds proof data. Relay limits vary, and the application’s default relays may reject large events. Existing tests do not publish/query a representative event or enforce a size budget.
Remediation: Test against target relay policies, report exact size before signer approval, select suitable relays, and consider a canonical compact binary or referenced commitment format while preserving independent availability.
Cryptographic and protocol assessment
Positive observations
- BIP39 generation uses the dependency’s CSPRNG-backed
generateMnemonic(). - User entropy is mixed with fresh CSPRNG bytes through SHA-256 rather than replacing the CSPRNG at
generateSeedPhraseWithEntropy(). - PQ derivation is deterministic and domain-separated by child paths at
PQ_DERIVATION_PATHS. - The first-48-byte concatenation rule is explicit at
derivePQSeedFromBIP32(). - Four PQ sign/verify round trips and tampering cases passed.
- NIP-01 event serialization is conventional at
computeEventId(). - Full-event hashing consistently uses
JSON.stringifyof the embedded signed object athashFullEvent(), and the exact JSON string is embedded in the wrapper. - OTS target digest binding is enforced before Bitcoin-attestation processing at
verifyOtsProof(). - The known historical OTS proof verified against live API data, and pending proofs were rejected as Bitcoin-unconfirmed.
Interoperability and design cautions
- There are no independent known-answer values for the BIP32 child private keys or resulting PQ public keys. Existing “determinism” tests compare the implementation with itself, not another implementation.
- Deriving standardized PQ seeds directly from BIP32 secp scalars is bespoke and should receive cryptographic review. Rejection behavior in BIP32 means output is not simply an unconstrained PRF byte stream; HKDF with explicit labels may be easier to specify independently.
- Falcon remains a draft/complex implementation choice. Multi-scheme hedging helps only if verification policy actually requires and distinguishes schemes.
- The attestation signs human-readable content while keys live in tags. The secp signature binds tags, but each PQ signature does not bind its own algorithm identifier, public-key set, wrapper kind, or OTS digest. A canonical structured statement would make cross-client policy safer.
- ML-KEM ownership is not proven by a KEM operation. The old secp event authorizes the public key at publication time, but no decapsulation proof establishes possession.
- The security argument depends on long-term availability of an immutable announcement and proof. Replaceable events and relay retention are poor foundations without export/archive mechanisms.
Browser and operational assessment
Live deployment observations
At audit time:
https://laantungir.net/post-quantum/andverify.htmlreturned HTTP 200.- Live HTML content lengths matched local working-tree lengths.
- Live bundle SHA-256 exactly matched local:
c14d40088d02e89f0d9aa8f1643ce31d687a4837a2eb0ab1b19406b69ecbfc32. - The server exposed nginx
1.24.0 (Ubuntu)and permissiveAccess-Control-Allow-Origin: *. - No HSTS, frame restriction, Referrer-Policy, Permissions-Policy, or other reviewed security headers appeared in HEAD responses.
/ots-upgraderejected an empty POST with HTTP 400 and{"error": "invalid body length"}, which is a positive input check. Its implementation and broader limits remain unaudited.- Public mempool.space and Blockstream endpoints were reachable.
State-machine concerns
- OTS polling can overlap if an upgrade request lasts longer than the 60-second interval; no in-flight lock exists at
startOtsPolling(). - Sign-out calls
localStorage.clear()andsessionStorage.clear()atindex.html, which can delete unrelated same-origin application data. - The OTS helper response is accepted as arbitrary base64 proof bytes and then parsed/verified; this is acceptable only if parser budgets are fixed.
- The verify page can offer republishing after OTS verification, but does not require the connected signer identity to equal the original wrapper identity before requesting a signature.
- The relay query accepts the first
EVENTwithout checking subscription ID or filter conformance atverify.html; a malicious relay can send arbitrary events, relying on later validation that is presently incomplete.
Dependency and supply-chain assessment
Root graph
Installed direct versions were:
@noble/curves2.2.0@noble/hashes2.2.0@noble/post-quantum0.6.1@scure/base2.2.0@scure/bip322.2.0@scure/bip392.2.0esbuild0.28.1
All reported MIT licenses. esbuild has an install script/native platform package, which is normal but relevant to build trust. No root runtime advisory was reported by npm for the installed graph.
Conclusions
- The committed browser bundle reduces runtime npm resolution risk for the deployed static page, and it rebuilt identically on the audit machine.
- However, ignored/stale lock metadata means future builds are not reproducible.
- The same-origin signer scripts are a major additional supply-chain surface not represented in
package.json. - The nested OpenTimestamps repository is a broken gitlink boundary without root submodule metadata.
- No SBOM, signed release, SRI, or CI provenance connects the live assets to a reviewed revision.
Test and execution evidence
Environment
- Node:
v24.14.1 - npm:
11.11.0 - Root commit:
9a975fcbc56e455a68ae16d734feeed76ba304b6 - Nested OTS commit:
c07ba8be0dae4e8721a84f32820abf7b2547a6ce
Existing tests
Command: npm test
Result: 38 passed, 0 failed, duration about 22.7 seconds.
Coverage included:
- BIP39 generation and validation
- deterministic PQ key generation
- four PQ sign/verify/tamper suites
- basic NIP-01 ID handling
- hex input checks
- OTS fixture parsing
- OTS target digest mismatch rejection
- live block-explorer checks for historical Bitcoin block 358391
Important gaps:
- no fully valid kind 1 + wrapper construction/verification round trip,
- no required-algorithm policy tests,
- no malformed/fuzz tests,
- no wrapper/embedded identity mismatch test,
- no signer-return validation test,
- no earliest-anchor candidate selection test,
- no real browser/signer/relay end-to-end test,
- no independent PQ derivation known-answer vectors,
- no OTS calendar-fragment compatibility test in the committed suite.
Build reproducibility
Commands:
cp www/pq-crypto.bundle.js /tmp/post_quantum_nostr.bundle.before.js
node build-pq-bundle.js
cmp /tmp/post_quantum_nostr.bundle.before.js www/pq-crypto.bundle.js
Result: byte-for-byte identical. Both SHA-256 values were:
c14d40088d02e89f0d9aa8f1643ce31d687a4837a2eb0ab1b19406b69ecbfc32
Live OTS calendar submission
A synthetic 32-byte digest was submitted through timestampEvent(). All five configured calendars returned fragments. The merged detached proof was 1,078 bytes, parsed with the exact submitted target digest, and contained five pending branches. This validates current network reachability and basic merge/parser compatibility, not future Bitcoin confirmation or trustlessness.
Adversarial probes
The following were executed against the module without modifying source:
- empty PQ algorithm set accepted as valid,
- signature algorithms with omitted signatures accepted as KEM/no-signature entries,
- unknown omitted-signature algorithm accepted,
- outer/embedded identity mismatch accepted by separate checks,
- missing event ID accepted if signature was valid,
- malformed base64 and wrong-shaped tags caused exceptions.
These probes are the basis for G56-01, G56-03, G56-07, and G56-19.
Audited file hashes
| File | SHA-256 |
|---|---|
package.json |
63882474ff056e46bfcf9194f9a0eb8d56662f0042b3692b35c8e1b8d2ce62e3 |
local ignored package-lock.json |
60cfaa1dce7de67763a8fae3695cc173f8910a3de7ca53890ae9269f4ead00b9 |
index.html |
0ae7436b621526662f8315b892ddc921c3e00b6dcac7e08c2d5dba1c7d75717b |
verify.html |
70069d0f3998864a411ce4a52269de9212b2121888bf40c93e3e71edd58494a3 |
pq-crypto.mjs |
241cb3fb2d4ef3b7b05c7aa1757c67361a4e0a1c809c3dbafe3153d84e3bd7e5 |
pq-crypto.bundle.js |
c14d40088d02e89f0d9aa8f1643ce31d687a4837a2eb0ab1b19406b69ecbfc32 |
pq-crypto.test.mjs |
107f9d04e4dd77e4f37ab203ace804c64bfa4d84e8334101586d322bdd311351 |
Prioritized remediation roadmap
Release blocker: protocol validity
- Fix G56-01 with a strict schema and mandatory algorithm policy.
- Fix G56-03 by binding all identities and requested authors.
- Implement G56-02 with immutable candidate discovery and earliest-valid-anchor selection.
- Validate signer output and exact event fields (G56-05).
- Make every verifier fail closed under malformed input and add budgets (G56-07/G56-13).
Release blocker: security model
- Decide and formally specify mnemonic-existing-identity versus raw-nsec migration modes (G56-06).
- Replace API-trusting “Bitcoin cryptographic verification” claims or implement a real node/light-client trust model (G56-04).
- Move seed/PQ private-key operations into a signer boundary; until then, label the web origin risk prominently (G56-08/G56-10).
Operational hardening
- Require relay acceptance for both events and verify read-back (G56-11).
- Persist immutable complete workflow artifacts with exact bindings (G56-12).
- Default to 24 words and restore warning/confirmation UX (G56-09).
- Enforce WSS and privacy-aware endpoint validation (G56-18).
- Add deployment headers, strict CSP, asset pinning, reviewed
/ots-upgradesource, and release integrity.
Reproducibility and assurance
- Commit a correct lockfile and repair the OpenTimestamps submodule (G56-14/G56-15).
- Add independent derivation vectors, adversarial tests, browser integration tests, relay tests, and fuzzing.
- Reconcile all documentation and UI claims with actual behavior (G56-16).
- Obtain independent cryptographic/protocol review before users anchor irreversible identity claims.
Safe-use recommendation
At its current stage, the project is suitable for:
- local experimentation with throwaway identities,
- generating test vectors,
- researching Nostr/PQ migration formats,
- exercising OpenTimestamps parsing and API-assisted checks.
It is not suitable for:
- entering an existing valuable BIP39 mnemonic,
- making irreversible migration claims for a real identity,
- relying on the verifier’s green/success result,
- asserting that an identity is now quantum-resistant,
- treating the OTS result as independently validated Bitcoin consensus,
- relying on relay discovery to recover the canonical earliest migration.
A prudent user should wait until the critical/high findings are fixed, the architectural invariants above are implemented, a new adversarial test suite passes, deployment/source provenance is reproducible, and an independent human cryptography/protocol audit validates the revised design.