Files
nostr_quantum_preparation/audits/GLM5.2/crypto-deep-dive.md
T

15 KiB
Raw Blame History

Cryptographic Deep-Dive (Second Pass)

Companion to audit.md and findings.md. This document traces the cryptography line-by-line: entropy → seed → BIP32 → PQ keygen → signing → event construction → verification → OpenTimestamps.

Changes from the first pass: The binding analysis (§4.2, §8) reflects that the successor-signature gap (F-C1) is now a documented design choice. F-C4 (docs vs code) is resolved. F-C2 and F-C3 (OTS verification) are resolved — real client-side OTS verification has been implemented in pq-crypto.mjs with parseOtsFile() and verifyOtsProof(), and wired into verify.html and index.html. Tested against the vendored example proofs.

All line references are to www/js/pq-crypto.mjs unless noted otherwise.


1. Entropy and Seed Phrase

1.1 Pure-CSPRNG path

generateSeedPhrase calls generateMnemonic(wordlist, 128) — 128 bits → 12 words via crypto.getRandomValues. Standard and correct.

1.2 User-entropy path

generateSeedPhraseWithEntropy: 16 bytes CSPRNG + user entropy → SHA-256 → first 16 bytes (128 bits) → mnemonic. Reasonable mixing; SHA-256 is a strong mixer. Caveat (F-L2): 128-bit entropy gives only ~64-bit PQ security under Grover's. Docs now recommend 24 words; app defaults to 12.

1.3 Mnemonic → BIP39 seed

mnemonicToSeed validates then mnemonicToSeedSync (PBKDF2-HMAC-SHA512, 2048 iterations). Correct.

Verdict: Entropy and seed handling are correct. Only issue: 12-word default (F-L2).


2. BIP32 Key Derivation

2.1 secp256k1 (Account #2)

deriveSecp256k1FromSeed derives at m/44'/{accountIndex}'/0/0. Standard NIP-06. Correct. The resulting keypair is stored in pqSecpKeys and — per the now-documented design choice — not used to sign.

2.2 PQ seeds via BIP32 children

deriveBIP32Child derives a child at m/44'/1237'/0'/0/{idx} and returns the 32-byte private key. derivePQSeedFromBIP32 handles the multi-child case (concatenate + truncate to first N bytes).

Paths (pq-crypto.mjs:49):

  • ML-DSA-44: child 1 (32 B)
  • ML-DSA-65: child 2 (32 B)
  • SLH-DSA-128s: children 3+4 → 64 B → first 48 B
  • Falcon-512: children 5+6 → 64 B → first 48 B
  • ML-KEM-768: children 7+8 → 64 B

Correctness: BIP32 child derivation produces a 32-byte private key via HMAC-SHA512; using that as the seed for @noble/post-quantum's keygen(seed) is valid and deterministic. Concatenation + truncation is fine for the PQ seed. Determinism holds: same mnemonic → same PQ keypairs. Recoverability is satisfied.

Concerns:

  • F-M4: The "first N bytes" truncation rule is now documented but arbitrary. Must be pinned for compatibility (done in docs).
  • F-M3: Children 1–8 are non-hardened. PQ seeds safe unless parent xpub leaks. Docs now warn (resolved in reconciliation).

Verdict: The BIP32→PQ-seed construction is functionally correct and deterministic. Issues are documentation/compatibility (F-M4, now documented) and xpub hygiene (F-M3, now documented).


3. PQ Keygen

derivePQKeysFromSeed calls keygen(seed) for each algorithm. Correct API usage. Key sizes in PQ_KEY_INFO match FIPS specs. Deterministic.

Verdict: PQ keygen is correct and deterministic.


4. PQ Signing

buildKind1Announcement builds the human-readable content, then signs TextEncoder.encode(content) with each PQ scheme. Argument order correct. ML-KEM excluded (KEM).

Concerns:

  • F-M1: Signed message is the full human-readable content (prose + npub + block height + URLs). A canonical structured statement would be more robust.
  • Design choice (was F-C1): No secp256k1 successor signature. The PQ signatures alone bind the PQ keys to the attestation text. This is now documented as deliberate — the security model relies on OTS precedence, not a seed-derived signature.

Verdict: PQ signing is cryptographically correct. The binding is the documented "PQ self-attestation" model (weaker than successor-signature but coherent, and now honestly stated).


5. Event Construction and Hashing

5.1 Kind 1 announcement

buildKind1Announcement returns an unsigned template. secp256k1 signature added by window.nostr.signEvent (www/index.html:1032) with Account #1.

5.2 Event ID (NIP-01)

computeEventId: SHA-256(JSON.stringify([0, pubkey, created_at, kind, tags, content])). Correct NIP-01 canonical serialization.

5.3 Full-event hash (for OTS)

hashFullEvent: SHA-256(JSON.stringify(signedEvent)) — hashes the entire signed kind 1 event JSON (including id and sig). This is the digest submitted to OTS. Stable but relies on consistent JSON key ordering (minor fragility; a canonical JSON serialization would be more robust).

5.4 Kind 11112 wrapper

buildKind11112Wrapper: content = JSON.stringify(kind1Event), tags ['e', id], ['sha256', fullHash], ['ots', base64(proof)]. Signed by Account #1.

Verdict: Event construction and hashing are correct per NIP-01. Full-event hash for OTS is stable but relies on consistent JSON serialization.


6. Verification

6.1 verifyNostrEvent (secp256k1)

verifyNostrEvent: serialization matches computeEventId; schnorr.verify(sig, hash, pubkey) argument order correct.

  • F-H2: Does not check event.id against computed hash. A wrong id with valid signature passes.

6.2 verifyNIPQRContent (PQ + tags)

verifyNIPQRContent:

  1. Parses kind 1 from kind 11112 content. Validates fields and kind === 1.
  2. Verifies kind 1 secp256k1 signature via verifyNostrEvent.
  3. Computes computeEventId(kind1Event), checks e tag matches. (F-L4: does not check kind1Event.id equals computed id.)
  4. Computes hashFullEvent(kind1Event), checks sha256 tag matches.
  5. For each algorithm tag, re-encodes kind1Event.content and verifies the PQ signature. ML-KEM skipped. Correct.

Correctness of PQ verification: message matches signing (kind1Event.content); pubkey/sig from same tag; algorithm dispatch correct; results.every(r => r.valid) requires all pass.

Concerns:

  • F-H2 / F-L4: id checks incomplete.
  • Design choice (was F-C1): No successor signature to verify (documented as deliberate). The verifier trusts the algorithm tag's pubkey without an independent seed-binding. The PQ keys self-certify by signing the content text (which names the npub). This is the documented "PQ self-attestation" model.

Verdict: PQ signature verification is cryptographically correct. The binding is the documented self-attestation model. The id checks are incomplete (F-H2, F-L4).


7. OpenTimestamps

7.1 Submission

timestampEvent: POSTs 32-byte hash to {server}/digest, wraps the returned fragment with the OTS detached-file prefix (OTS_DETACHED_PREFIX = magic + version 1 + SHA-256 op tag 0x08) + 32-byte digest + fragment. Tries two calendar servers. Prefix construction matches the OTS binary format. isDetachedOtsFile validates the prefix. Correct.

F-L5: Content-Type: application/x-www-form-urlencoded with binary body; should be application/octet-stream. Works in practice.

7.2 Upgrade

upgradeOts: POSTs proof to a server, trusts JSON response for the upgrade. After upgrade, the result is now cryptographically verified client-side via verifyOtsProof (see §7.3).

7.3 Confirmation check and full verification (F-C2/F-C3 RESOLVED)

Previously: isOtsConfirmed byte-scanned for Bitcoin magic 0588960d73d71901 — a spoofable heuristic.

Now: isOtsConfirmed has been rewritten to use the new parseOtsFile() parser (with the old byte-pattern search retained only as a fallback for malformed proofs). Two new functions provide real cryptographic verification:

  • parseOtsFile(otsBytes) — parses the OTS binary format: validates the magic header, reads the version, file hash op (SHA-256/SHA-1/RIPEMD160), the target digest, and recursively walks the timestamp tree (handling the 0xff continuation marker per the reference implementation). For each attestation, it computes the commitment digest by applying the op tree (SHA-256, SHA-1, RIPEMD160, append, prepend, reverse) from the target to the attestation leaf. Returns {fileHashOp, targetDigest, attestations} where each attestation includes its computed merkle-root digest.

  • verifyOtsProof(otsBytes, expectedDigestHex) — performs full verification:

    1. Parses the OTS file.
    2. Binds the target digest to the expected digest (F-C3 fix): if expectedDigestHex is provided and doesn't match the proof's target digest, returns verified: false with a "Target digest mismatch" error.
    3. Finds Bitcoin attestations and fetches the corresponding block headers from blockstream.info / mempool.space (lite-client verification — trusts the API for the block header, which is independently verifiable against the Bitcoin PoW chain).
    4. Validates the Merkle root: checks that the computed commitment digest (reversed to little-endian, per Bitcoin convention) matches the block's merkle_root.
    5. Returns {verified, targetDigest, attestations, bitcoinAttestations, errors}.

Tested against vendored example proofs:

  • hello-world.txt.ots: verified=true, Bitcoin block 358391 (mined 2015-05-28).
  • incomplete.txt.ots, two-calendars.txt.ots, merkle1.txt.ots: correctly identified as pending (no Bitcoin attestation).
  • F-C3 binding test: wrong expected digest → correctly fails with "Target digest mismatch".

The vendored resources/javascript-opentimestamps/ library was not used directly (it has Node.js dependencies incompatible with the browser), but its binary format and verification logic were used as the reference for the new implementation. The new code uses @noble/hashes (SHA-256, SHA-1, RIPEMD160) and the browser's native fetch for Bitcoin block headers.

7.4 What the verify page does with OTS

www/verify.html: reads ots and sha256 tags; checks sha256 tag === hashFullEvent(kind1Event) (good); does a quick structural check via isOtsConfirmed for immediate UI feedback; then runs full async verification via verifyOtsProof(currentOtsBytes, sha256Tag[1]) — passing the sha256 tag as the expected digest (F-C3 binding). Displays "Verified (Bitcoin)" with block height + date only if the Merkle root matches the actual Bitcoin block header. Displays "Verification failed" if the proof is spoofed or the digest doesn't match.

www/index.html (polling): after upgradeOts, runs verifyOtsProof(pendingOtsBytes, sha256Tag[1]) and only marks "Confirmed" if verified === true. Falls back to the structural check with an "unverified" warning if full verification fails.

Verdict: OTS submission, file format construction, and now verification are all correct. The "confirmed" determination is a real Merkle-path + block-header check, not a byte-pattern match. F-C2, F-C3, and F-H5 are resolved. The security model's reliance on OTS precedence is now backed by actual cryptographic verification.


8. Summary: What the Cryptography Actually Proves (Post-Reconciliation)

After the documentation reconciliation, the docs and code now agree on what the system proves. A verifier of a kind 11112 event that passes the current verification can conclude:

  1. The kind 11112 wrapper has a valid secp256k1 Schnorr signature by the pubkey in the event. (Assuming F-H2 fixed: and its id matches its content.)
  2. The embedded kind 1 event has a valid secp256k1 Schnorr signature by its pubkey, its id matches the e tag, and the sha256 tag matches the full-event hash. (Assuming F-H2/F-L4 fixed.)
  3. Each PQ signature in the kind 1 tags is valid for the stated algorithm, pubkey, and the kind 1 content text.
  4. The content text names the user's npub and a block height, so the PQ signatures attest to that text.

What a verifier cannot conclude (and the docs now honestly state this):

  • The PQ keys cannot cryptographically prove they share a common seed origin (no successor signature — documented design choice).

What a verifier can now conclude (after the F-C2/F-C3 fix):

  • The event was timestamped in a real Bitcoin block. verifyOtsProof parses the OTS proof, binds the target digest to the event's sha256 tag, walks the Merkle path, fetches the block header from a Bitcoin API, and checks the computed Merkle root matches. A spoofed proof (fake magic bytes) is rejected; a proof for the wrong digest is rejected.

The system, as shipped and documented, now provides: PQ-key attestation over a human-readable statement, authorized by the user's existing secp256k1 identity, with a cryptographically verified OTS proof anchoring it to a pre-quantum Bitcoin block. The security argument is now coherent end-to-end: after a quantum break, a forged migration event cannot produce a valid OTS proof for a pre-quantum block, so the real event is distinguishable by its earlier Bitcoin anchor.


9. The Critical Path to Production-Readiness

F-C2/F-C3 (real OTS verification) is now DONE. The single most important cryptographic gap has been closed: OTS proofs are now cryptographically verified (Merkle path + block header + target digest binding), not byte-pattern matched. The security model's reliance on OTS precedence is now backed by actual verification.

Remaining items are general software-quality issues, not cryptographic-correctness gaps:

  • F-H1 (tests): add known-answer vectors and round-trip tests for regression protection.
  • F-H2 (id checks): verifyNostrEvent should assert event.id matches the computed hash.
  • F-H3/F-H4 (web security): add CSP/SRI and fix innerHTML XSS surface.
  • F-L8 (doc leftover): fix why_what_how.md Component 4 successor language.

10. Positive Cryptographic Properties

  • Reputable libraries (@noble/*, @scure/*) used with correct API calls.
  • PQ sign/verify argument order and message bytes match.
  • ML-KEM correctly treated as a KEM throughout.
  • BIP39 mnemonic validation before seed derivation.
  • NIP-01 event-id serialization correct.
  • OTS detached-file prefix construction correct.
  • Deterministic PQ keygen from BIP32 seed delivers recoverability.
  • Two-event structure (kind 1 + kind 11112) is sensible.
  • Verifier re-derives the kind 1 id and full hash rather than trusting tags blindly (for the parts it checks).
  • Documentation now matches implementation (F-C4 resolved) — the security model is honestly stated, including what it does and does not prove.
  • OTS proofs are now cryptographically verified (F-C2/F-C3 resolved) — parseOtsFile + verifyOtsProof parse the binary format, walk the op tree, fetch Bitcoin block headers, and validate the Merkle root. Tested against vendored example proofs.

These provide a solid foundation; the remaining gaps are general software quality (F-H1–F-H4, F-L8), not cryptographic correctness.