From eeb78e9c208621dc5fa6a81ba3e5fd99bc82c611 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Fri, 17 Jul 2026 18:59:33 -0400 Subject: [PATCH] Security audit fixes: real OTS verification (F-C2/F-C3), verifyNostrEvent id check (F-H2), XSS fixes (F-H3), CSP (F-H4), test suite with 38 passing tests (F-H1), seed generation UX fix, entropy optional, input validation (F-L3), relay close-as-success (F-L6), doc reconciliation (F-C4/F-L8), Content-Type fix (F-L5), secret clearing (F-L1), 12-word warning (F-L2), Falcon draft warning (F-M5) --- README.md | 350 +++++---- ...__Self-Operating_Napkin__(cropped).gif.ots | Bin 479 -> 0 bytes audits/GLM5.2/audit.md | 219 ++++++ audits/GLM5.2/crypto-deep-dive.md | 213 ++++++ audits/GLM5.2/findings.md | 248 +++++++ explanation.md | 147 ++-- laans_explanation.md | 33 +- package.json | 4 +- plans/quality_fixes_plan.md | 212 ++++++ plans/remediation_plan.md | 180 +++++ resources/javascript-opentimestamps | 1 + test/pq-crypto.test.mjs | 314 ++++++++ why_what_how.md | 123 ++-- www/index.html | 551 +++++++++++--- www/js/pq-crypto.mjs | 687 +++++++++++++++++- www/pq-crypto.bundle.js | 474 +++++++++++- www/verify.html | 622 ++++++++++++++-- 17 files changed, 3899 insertions(+), 479 deletions(-) delete mode 100644 Rube_Goldberg's__Self-Operating_Napkin__(cropped).gif.ots create mode 100644 audits/GLM5.2/audit.md create mode 100644 audits/GLM5.2/crypto-deep-dive.md create mode 100644 audits/GLM5.2/findings.md create mode 100644 plans/quality_fixes_plan.md create mode 100644 plans/remediation_plan.md create mode 160000 resources/javascript-opentimestamps create mode 100644 test/pq-crypto.test.mjs diff --git a/README.md b/README.md index 6719c84..68c46dc 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ A migration strategy for bringing post-quantum security to Nostr without breakin - [Component 1: Seed Phrase as Algorithm-Agnostic Root of Trust](#component-1-seed-phrase-as-algorithm-agnostic-root-of-trust) - [Component 2: Multi-Scheme Cross-Signed Key-Link Events](#component-2-multi-scheme-cross-signed-key-link-events) - [Component 3: OpenTimestamps for Pre-Quantum Anchoring](#component-3-opentimestamps-for-pre-quantum-anchoring) -- [Component 4: Migrating Existing Users with Raw nsec](#component-4-migrating-existing-users-with-raw-nsec) -- [Component 5: Quantum-Safe Self-Storage](#component-5-quantum-safe-self-storage) +- [Component 4: Migrating Existing Users with Raw nsec (Design Only)](#component-4-migrating-existing-users-with-raw-nsec-design-only) +- [Component 5: Quantum-Safe Self-Storage (Design Only)](#component-5-quantum-safe-self-storage-design-only) - [Component 6: Deterministic Wallet Compartmentalization](#component-6-deterministic-wallet-compartmentalization) - [The Complete Migration Flow](#the-complete-migration-flow) - [What Remains Unsolved](#what-remains-unsolved) @@ -110,31 +110,64 @@ The BIP39 seed is a 64-byte entropy string derived from a mnemonic via PBKDF2-HM flowchart TD SEED[BIP39 Seed - 64 bytes, quantum-safe] SEED --> BIP32[BIP32 derivation - secp256k1 keypair] - SEED --> PQKEM[HKDF derivation - ML-KEM keypair] - SEED --> PQDSA[HKDF derivation - ML-DSA keypair] - SEED --> SLHDSA[HKDF derivation - SLH-DSA keypair] - SEED --> SYMKEY[HKDF derivation - symmetric encryption key] + SEED --> PQKEM[BIP32 derivation - ML-KEM keypair] + SEED --> PQDSA[BIP32 derivation - ML-DSA keypair] + SEED --> SLHDSA[BIP32 derivation - SLH-DSA keypair] + SEED --> FALCON[BIP32 derivation - Falcon keypair] style SEED fill:#cfc style BIP32 fill:#fcc style PQKEM fill:#cfc style PQDSA fill:#cfc style SLHDSA fill:#cfc - style SYMKEY fill:#cfc + style FALCON fill:#cfc ``` ### PQ Key Derivation from BIP39 Seeds -BIP32 is specific to secp256k1. For PQ keys, use HKDF-SHA256 (quantum-resistant) to derive key material from the BIP39 seed: +All keys — both secp256k1 and PQ — are derived from the BIP39 seed via **BIP32 hierarchical deterministic derivation**, the same standard used by NIP-06 for secp256k1 keys. PQ keys are derived at different child indices under the NIP-06 base path, using the BIP32 child private key (32 bytes) as the deterministic seed for each PQ algorithm's `keygen()` function. + +**Base path:** `m/44'/1237'/0'/0/` (NIP-06 account 0, change 0) + +| Child index | Algorithm | Seed length | BIP32 path | How derived | +|---|---|---|---|---| +| 0 | secp256k1 (NIP-06) | 32 bytes | `m/44'/1237'/0'/0/0` | Standard BIP32 — private key used directly | +| 1 | ML-DSA-44 | 32 bytes | `m/44'/1237'/0'/0/1` | Single child — 32-byte private key is the PQ seed | +| 2 | ML-DSA-65 | 32 bytes | `m/44'/1237'/0'/0/2` | Single child — 32-byte private key is the PQ seed | +| 3 + 4 | SLH-DSA-128s | 48 bytes | `m/44'/1237'/0'/0/3` + `m/44'/1237'/0'/0/4` | Two children concatenated (64 bytes), first 48 used | +| 5 + 6 | Falcon-512 | 48 bytes | `m/44'/1237'/0'/0/5` + `m/44'/1237'/0'/0/6` | Two children concatenated (64 bytes), first 48 used | +| 7 + 8 | ML-KEM-768 | 64 bytes | `m/44'/1237'/0'/0/7` + `m/44'/1237'/0'/0/8` | Two children concatenated (64 bytes) | + +### Why some algorithms need two children + +BIP32 child derivation produces exactly 32 bytes (the private key) per child. Some PQ algorithms need more than 32 bytes for their `keygen()` seed: + +- **ML-DSA-44** and **ML-DSA-65**: need 32 bytes — one child is enough +- **SLH-DSA-128s**: needs 48 bytes — derive two children (64 bytes), use the first 48 +- **Falcon-512**: needs 48 bytes — derive two children (64 bytes), use the first 48 +- **ML-KEM-768**: needs 64 bytes — derive two children and concatenate (64 bytes) + +**Truncation rule (must be pinned for compatibility):** when a PQ seed requires more than 32 bytes, two BIP32 children are derived and concatenated (64 bytes total). For 48-byte seeds, the **first 48 bytes** of the concatenation are used. For 64-byte seeds, all 64 bytes are used. A future implementer who takes the *last* 48 bytes would produce different keys and break compatibility — implementations must use the first N bytes. + +### Why BIP32 and not HKDF? + +BIP32 is the standard for HD wallets. Using BIP32 paths gives us: + +1. **Consistency with NIP-06** — PQ keys are derived the same way as secp256k1 keys, just at different child indices +2. **Compartmentalization** — hardened derivation at the account level means breaking one key doesn't compromise siblings +3. **Wallet compatibility** — HD wallet infrastructure (Amber, hardware wallets, etc.) can derive these same paths +4. **Tree structure** — the derivation path documents exactly which key is where +5. **Security** — BIP32 uses HMAC-SHA512 (the same HMAC used everywhere in HD wallets), not a custom construction + +### What is HMAC? + +HMAC (Hash-based Message Authentication Code) is the cryptographic primitive that BIP32 is built on. Every BIP32 child key derivation is: ``` -For each PQ algorithm A: - pq_seed_A = HKDF-Extract(salt="nostr-pq-v1", ikm=bip39_seed) - pq_key_material_A = HKDF-Expand(pq_seed_A, info=A.identifier, L=A.key_size) - pq_keypair_A = A.keygen(pq_key_material_A) +HMAC-SHA512(parent_chain_code, data) → 32-byte private key + 32-byte chain code ``` -The identifier string (`"ml-dsa-65"`, `"slh-dsa-128s"`, `"ml-kem-768"`, etc.) is the only algorithm-specific part. New algorithms can be added later without changing the derivation scheme. +This is not a separate or weaker construction — it IS BIP32. Using BIP32 paths means we're using the same HMAC-based derivation that all HD wallets use. ### Seed Phrase Entropy Considerations @@ -143,7 +176,7 @@ The identifier string (`"ml-dsa-65"`, `"slh-dsa-128s"`, `"ml-kem-768"`, etc.) is | 12 words | 128 bits | ~64 bits | Borderline | | 24 words | 256 bits | ~128 bits | Adequate | -For users concerned about quantum attacks on the seed entropy itself, 24-word mnemonics are recommended. The bigger risk to seed phrases is classical (theft, phishing), not quantum. +For users concerned about quantum attacks on the seed entropy itself, 24-word mnemonics are recommended. The bigger risk to seed phrases is classical (theft, phishing), not quantum. (Note: the current implementation defaults to 12-word mnemonics; 24-word support is recommended for production use.) --- @@ -166,77 +199,111 @@ History gives reason to be cautious: SIKE was a NIST PQ finalist that was broken ### The Multi-Scheme Solution +The implementation includes **five** PQ schemes — four signature schemes and one KEM: + ```mermaid flowchart TD - SEED[BIP39 Seed] --> SECP[secp256k1 keypair] - SEED --> MLDSA[ML-DSA keypair] - SEED --> SLHDSA[SLH-DSA keypair] - SEED --> FALCON[FN-DSA keypair] - SEED --> MLKEM[ML-KEM keypair] + SEED[BIP39 Seed] --> SECP[secp256k1 keypair - child 0] + SEED --> MLDSA44[ML-DSA-44 keypair - child 1] + SEED --> MLDSA65[ML-DSA-65 keypair - child 2] + SEED --> SLHDSA[SLH-DSA-128s keypair - children 3+4] + SEED --> FALCON[Falcon-512 keypair - children 5+6] + SEED --> MLKEM[ML-KEM-768 keypair - children 7+8] - SECP --> SIGN[Schnorr sign] - MLDSA --> SIGN2[ML-DSA sign] - SLHDSA --> SIGN3[SLH-DSA sign] - FALCON --> SIGN4[FN-DSA sign] + MLDSA44 --> SIGN1[ML-DSA-44 sign attestation] + MLDSA65 --> SIGN2[ML-DSA-65 sign attestation] + SLHDSA --> SIGN3[SLH-DSA-128s sign attestation] + FALCON --> SIGN4[Falcon-512 sign attestation] - SIGN --> EVENT[Key-Link Event] - SIGN2 --> EVENT - SIGN3 --> EVENT - SIGN4 --> EVENT + SIGN1 --> KIND1[Kind 1 Announcement Event] + SIGN2 --> KIND1 + SIGN3 --> KIND1 + SIGN4 --> KIND1 + MLKEM --> KIND1 - EVENT --> OTS[OpenTimestamp via NIP-03] - OTS --> RELAY[Published to relays NOW] + SECP --> ACCT1[Account 1 - existing identity signs kind 1 and kind 11112] + + KIND1 --> OTS[OpenTimestamp the kind 1 event hash] + OTS --> KIND11112[Kind 11112 Wrapper Event - carries OTS proof] + KIND11112 --> RELAY[Published to relays NOW] RELAY --> CLIENT1[PQ-aware client - picks ML-DSA] RELAY --> CLIENT2[PQ-aware client - picks SLH-DSA] RELAY --> CLIENT3[Legacy client - ignores all PQ keys] - style EVENT fill:#ffa + style KIND1 fill:#ffa + style KIND11112 fill:#ffa style OTS fill:#cfc ``` -### Key-Link Event Format +### The Two-Event Structure -A new event kind (to be assigned) containing multiple cross-signed links: +The migration uses **two Nostr events**: -```json -{ - "kind": , - "pubkey": "", - "content": { - "links": [ - { - "algorithm": "ml-dsa-65", - "public_key": "", - "signature": "" - }, - { - "algorithm": "slh-dsa-128s", - "public_key": "", - "signature": "" - }, - { - "algorithm": "fn-dsa-512", - "public_key": "", - "signature": "" - }, - { - "algorithm": "ml-kem-768", - "public_key": "", - "note": "KEM key for encryption; ownership asserted by secp256k1 signature over this content" - } - ], - "statement": "All keys in this event are derived from the same seed and controlled by the same entity as secp256k1 key . This link is established pre-quantum.", - "secp256k1_signature": "" - } -} +#### 1. Kind 1 Announcement (human-readable, visible in Nostr feeds) + +A regular Nostr text note (kind 1) that serves as a public announcement of the post-quantum key migration. The content is human-readable text with newlines. The PQ public keys and signatures go in the tags. + +**Content:** A human-readable attestation statement, e.g.: + +``` +I am signaling that the post-quantum public keys listed in the tags of this event +were generated by me and I hold the private keys. I may use these keys in the future +as successors to my current Nostr identity. + +My current identity: + npub: + hex: + +This attestation is established pre-quantum at Bitcoin block height . + +Post-quantum public keys in tags: + ML-DSA-44 (Dilithium, FIPS 204, NIST Level 2) + ML-DSA-65 (Dilithium, FIPS 204, NIST Level 3) + SLH-DSA-128s (SPHINCS+, FIPS 205, NIST Level 1) + Falcon-512 (FIPS 206 draft, NIST Level 1) + ML-KEM-768 (Kyber, FIPS 203, NIST Level 3) + +Each post-quantum key has cryptographically signed this attestation. This event is +pending timestamp on the Bitcoin blockchain via OpenTimestamps. ``` -Each PQ signature scheme independently signs the same link statement. The secp256k1 Schnorr signature covers the entire content (including all PQ public keys and signatures), binding them together. +**Tags:** +- `['block_height', '']` — the Bitcoin block height at signing time +- `['algorithm', '', '', '']` — one per PQ signature scheme +- `['algorithm', 'ml-kem-768', '']` — for ML-KEM (KEM, no signature) + +Each PQ signature scheme signs `TextEncoder.encode(content)` — the human-readable attestation text. The event is signed with the user's **existing** Nostr identity (Account #1) via `window.nostr.signEvent` (NIP-07). + +#### 2. Kind 11112 Wrapper (machine-verifiable, carries the OTS proof) + +A replaceable event (kind 11112, in the 10000–19999 range) that wraps the kind 1 announcement and carries the OpenTimestamps proof. The content is the full kind 1 event as JSON (so verifiers don't need to fetch it from relays). + +**Content:** `JSON.stringify(kind1Event)` — the full signed kind 1 event embedded as a JSON string. + +**Tags:** +- `['e', '']` — reference to the kind 1 announcement +- `['sha256', '']` — what was timestamped +- `['ots', '']` — the OpenTimestamps proof (pending or confirmed) + +The wrapper is signed with the user's existing identity (Account #1) via `window.nostr.signEvent`. + +### What This Proves and What It Does Not + +**What the event proves:** +- Each PQ signature scheme (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512) signed the attestation text — verified by checking each PQ signature against the pubkey in its tag. +- The user's existing Nostr identity (Account #1) authorized the migration — verified by the secp256k1 Schnorr signature on the kind 1 and kind 11112 events. +- The event is anchored to a pre-quantum Bitcoin block via OpenTimestamps — the event with the earliest valid OTS proof wins (see Component 3). + +**What the event does NOT prove:** +- The PQ keys cannot *cryptographically* prove they share a common seed origin. They prove only that five PQ keypairs exist and each signed the attestation text. Common-origin is **asserted** in the attestation text and **authorized** by Account #1's signature, but it is not proven by a seed-derived signature. +- The seed-derived secp256k1 key (Account #2, child 0) is derived but **not used to sign** in the current implementation. There is no "successor signature" binding the PQ keys to the seed. + +This is a deliberate design choice. The security model relies on **OTS precedence** (the real event is anchored pre-quantum; a forged event cannot be backdated) rather than a seed-derived signature. After a quantum break, an attacker who forges Account #1 can publish a fraudulent migration event, but it will have a later OTS timestamp than the real event — so the real event is cryptographically distinguishable by its earlier Bitcoin anchor. ### ML-KEM Cannot Sign -ML-KEM (Kyber) is a KEM (Key Encapsulation Mechanism), not a signature scheme. It cannot sign the link statement. Instead, its binding is proven indirectly: the secp256k1 signature covers the ML-KEM public key in the content, and the ML-KEM key is derived from the same seed. The proof is: "the entity that controls the secp256k1 key (proven by Schnorr signature) asserts that this ML-KEM key is also theirs." +ML-KEM (Kyber) is a KEM (Key Encapsulation Mechanism), not a signature scheme. It cannot sign the attestation. Instead, its public key is included in an `algorithm` tag (without a signature field), and its ownership is asserted by the attestation text and authorized by Account #1's signature over the kind 1 event (which covers the tags, including the ML-KEM pubkey). This is weaker than a cross-signature but sufficient because: - The secp256k1 signature is valid NOW (pre-quantum), establishing the link while we can trust secp256k1 @@ -267,7 +334,7 @@ If a specific PQ scheme is later found to be weak (like SIKE was): ## Component 3: OpenTimestamps for Pre-Quantum Anchoring -NIP-03 (OpenTimestamps Attestations for Events) already exists and is implemented in nostr_core_lib. It anchors Nostr event hashes to the Bitcoin blockchain via OpenTimestamps. +NIP-03 (OpenTimestamps Attestations for Events) anchors Nostr event hashes to the Bitcoin blockchain via OpenTimestamps. The implementation submits the **SHA-256 of the full signed kind 1 event JSON** (including its `id` and `sig`) to the OpenTimestamps calendar servers, and embeds the resulting `.ots` proof in the kind 11112 wrapper's `ots` tag. ### Why Bitcoin Timestamping Is Quantum-Resistant @@ -279,7 +346,7 @@ An attacker who breaks your secp256k1 key via Shor's can forge new events but ** ### The Critical Refinement: OpenTimestamp the Key-Link Event -The key-link event itself MUST be timestamped via NIP-03. This prevents a race condition attack: +The kind 1 announcement event MUST be timestamped via OpenTimestamps. This prevents a race condition attack: **Without OTS on the key-link event:** - Attacker breaks secp256k1 key via Shor's @@ -293,6 +360,19 @@ The key-link event itself MUST be timestamped via NIP-03. This prevents a race c - Clients verify: "the key-link event with the earliest valid OTS proof wins" - **The real key-link is cryptographically distinguishable from the fraudulent one** +### What Is Timestamped + +The implementation timestamps the **SHA-256 of the full signed kind 1 event JSON** (computed by hashing `JSON.stringify(kind1Event)`, which includes the `id` and `sig` fields). This hash is: +1. Submitted to OpenTimestamps calendar servers to obtain a pending `.ots` proof. +2. Recorded in the kind 11112 wrapper's `sha256` tag. +3. The `.ots` proof is embedded in the kind 11112 wrapper's `ots` tag. + +The kind 11112 wrapper *carries* the OTS proof; the kind 1 event *is* what's timestamped. Verifiers re-compute the hash from the embedded kind 1 event and check it matches the `sha256` tag. + +### Current OTS Verification Status + +The current implementation detects Bitcoin confirmation by searching the `.ots` proof bytes for the Bitcoin block-header attestation magic bytes, and delegates proof upgrading to a server-side helper. **Full client-side OTS verification** (parsing the proof, validating the Merkle path, checking the block header against the Bitcoin chain) is planned but not yet implemented. The vendored `javascript-opentimestamps` library in `resources/` provides the necessary primitives and will be integrated in a future release. + ### What Should Be OpenTimestamped Not every event needs OTS. Priority list: @@ -306,7 +386,9 @@ Routine events (regular posts, reactions) probably don't need OTS — the impact --- -## Component 4: Migrating Existing Users with Raw nsec +## Component 4: Migrating Existing Users with Raw nsec (Design Only) + +> **Status: Design only — not yet implemented.** The current implementation supports users who already have a Nostr identity (via NIP-07 signer or nostr-login-lite). The raw-nsec migration described below is future work. Most Nostr users today have a raw nsec (random private key) with no seed phrase. Asking them to abandon their identity and social graph to adopt a seed phrase is a non-starter. This component addresses how to migrate them. @@ -373,7 +455,7 @@ flowchart TD NEW[Generate 12-word BIP39 phrase] NEW --> SEED[BIP39 seed] SEED --> NEWSECP[New secp256k1 key via NIP-06] - SEED --> PQKEYS[PQ keys via HKDF] + SEED --> PQKEYS[PQ keys via BIP32] SEED --> ENCKEY[Symmetric encryption key via HKDF] ENCKEY --> ENC[Encrypt old nsec] OLD --> ENC @@ -406,7 +488,7 @@ flowchart TD **Design:** 1. Generate a 12-word BIP39 phrase -2. Derive from seed: new secp256k1 key (NIP-06 path), PQ keys (HKDF), symmetric encryption key (HKDF) +2. Derive from seed: new secp256k1 key (NIP-06 path), PQ keys (BIP32), symmetric encryption key (HKDF) 3. Encrypt old nsec with the symmetric key: `encrypted_nsec = ChaCha20-Poly1305(enc_key, old_nsec)` 4. Publish as kind 30078 event, signed with the **new** secp256k1 key 5. Publish key-link event (cross-signed, OpenTimestamped via NIP-03) @@ -435,7 +517,9 @@ flowchart TD --- -## Component 5: Quantum-Safe Self-Storage +## Component 5: Quantum-Safe Self-Storage (Design Only) + +> **Status: Design only — not yet implemented.** The current implementation does not include encrypted self-storage. The approaches below are design proposals for future work. For self-storage use cases (e.g., kind 30078 application data), NIP-04 and NIP-44 are unnecessary and quantum-vulnerable. Two quantum-safe alternatives: @@ -513,6 +597,10 @@ The NIP-06 path `m/44'/1237'/account'/0/0` has hardened derivation at three leve - The seed/master key remains secure even if all account keys are broken - Hardened derivation requires the parent **private** key, not just the public key +### Note on the PQ Derivation Paths + +The PQ keys in this implementation are derived at **non-hardened** children (indices 1–8) under the hardened parent `m/44'/1237'/0'/0'`. Non-hardened derivation means anyone with the parent xpub (extended public key + chain code) at `m/44'/1237'/0'/0'` could derive the child *public* keys — but **not** the child *private* keys (which are what the PQ seeds are). So the PQ seeds remain secret unless the parent private key leaks. **Never publish the xpub at `m/44'/1237'/0'/0'`** — doing so would expose the child public keys (though not the PQ seeds themselves). + ### The Critical Vulnerability: Chain Code Leakage If an attacker obtains a chain code at any level AND breaks one child key at that level via Shor's, they can: @@ -549,24 +637,25 @@ If an attacker obtains a chain code at any level AND breaks one child key at tha ```mermaid flowchart TD subgraph NOW["Pre-quantum - NOW"] - USER[User with raw nsec OR seed phrase] - USER --> MIGRATE[Client: Enable PQ migration] - MIGRATE --> SEED[Generate or use 12-word BIP39 phrase] - SEED --> DERIVE[Derive: new secp256k1 + all PQ keys + storage key] - DERIVE --> ENCRYPT[Encrypt old nsec if migrating from raw nsec] - ENCRYPT --> STORE30078[Publish kind 30078: encrypted old nsec] - DERIVE --> KEYLINK[Publish key-link event: cross-signed by all PQ schemes] - KEYLINK --> OTS[OpenTimestamp the key-link event via NIP-03] - OTS --> BTC[Anchored in Bitcoin block] - KEYLINK --> RELAY[Published to relays] - STORE30078 --> RELAY + USER[User with existing Nostr identity] + USER --> SIGNIN[Sign in via NIP-07 signer] + SIGNIN --> SEED[Generate 12-word BIP39 phrase] + SEED --> DERIVE[Derive 5 PQ keypairs from seed via BIP32] + DERIVE --> SIGN[Each PQ key signs the attestation text] + SIGN --> KIND1[Build kind 1 announcement with PQ sigs in tags] + KIND1 --> SECP1[Account 1 signs kind 1 via signer] + SECP1 --> HASH[SHA-256 of full signed kind 1 event JSON] + HASH --> OTS[Submit hash to OpenTimestamps] + OTS --> KIND11112[Build kind 11112 wrapper with OTS proof] + KIND11112 --> SECP2[Account 1 signs kind 11112 via signer] + SECP2 --> RELAY[Publish both events to relays] SEED --> BACKUP[User writes down 12-word phrase] end - subgraph TRANSITION["Transition period - dual signing"] - DUAL[Client signs events with both secp256k1 and PQ keys] - DUAL --> LEGACY[Legacy clients verify secp256k1 signature] - DUAL --> PQAWARE[PQ-aware clients verify PQ signature] + subgraph CONFIRM["Bitcoin confirmation"] + POLL[Poll OTS upgrade service] + POLL --> UPGRADE[Upgrade .ots proof with Bitcoin attestation] + UPGRADE --> REPUBLISH[Republish kind 11112 with confirmed proof] end subgraph FUTURE["Post-quantum - when quantum computers arrive"] @@ -574,39 +663,30 @@ flowchart TD ATTACK --> FORGE[Can forge secp256k1 signatures] ATTACK --> CANNOT1[Cannot forge PQ signatures] ATTACK --> CANNOT2[Cannot backdate events - OTS proofs anchor history] - ATTACK --> CANNOT3[Cannot break OTP-encrypted storage] - PQAWARE --> VERIFY[Verify key-link event via OTS] + VERIFY[Verify key-link event via OTS precedence] VERIFY --> USEPQ[Switch to PQ signatures] USEPQ --> REJECT[Reject forged secp256k1-only events] end - subgraph RETIRE["Old key retirement"] - DROP[User drops extra backup, keeps 12 words only] - DROP --> PQONLY[Client uses PQ keys only] - PQONLY --> DONE[Migration complete] - end - - NOW --> TRANSITION --> FUTURE --> RETIRE + NOW --> CONFIRM --> FUTURE style NOW fill:#cfc - style TRANSITION fill:#ffa + style CONFIRM fill:#ffa style FUTURE fill:#fcc - style RETIRE fill:#cfc ``` ### User Experience -1. User clicks "Enable quantum-safe migration" in their client +1. User signs in with their existing Nostr identity (NIP-07 signer or nostr-login-lite) 2. Client generates a 12-word phrase, shows it to the user to write down -3. Client handles everything else automatically: - - Derives new secp256k1 key + all PQ keys from seed - - Encrypts old nsec and publishes as kind 30078 (if migrating from raw nsec) - - Publishes cross-signed key-link event - - OpenTimestamps the key-link event via NIP-03 -4. During transition: client signs with both old and new keys -5. After quantum migration: client uses PQ keys only, old key retired -6. User drops extra backup, keeps 12 words +3. Client derives 5 PQ keypairs from the seed via BIP32 paths +4. Each PQ signature scheme signs the human-readable attestation text +5. The user's signer signs the kind 1 announcement and the kind 11112 wrapper +6. The SHA-256 of the full signed kind 1 event is submitted to OpenTimestamps +7. Both events are published to relays +8. The client polls for Bitcoin confirmation and republishes the kind 11112 wrapper with the confirmed OTS proof +9. After quantum migration: clients use PQ keys, old key retired --- @@ -622,7 +702,7 @@ Past secp256k1-signed events that were NOT OpenTimestamped remain forgeable afte ### Relay Trust for Recovery -Approach B (12 words + relay backup) depends on relays retaining the kind 30078 event containing the encrypted old nsec. If all relays delete it, the old nsec is lost. +Approach B (12 words + relay backup, Component 4 — design only) depends on relays retaining the kind 30078 event containing the encrypted old nsec. If all relays delete it, the old nsec is lost. **Mitigations:** - Publish to multiple relays @@ -637,48 +717,66 @@ PQ signatures are much larger than secp256k1 Schnorr signatures: | Scheme | Signature size | |---|---| | secp256k1 Schnorr | 64 bytes | +| ML-DSA-44 | ~2.4 KB | | ML-DSA-65 | ~3.3 KB | | SLH-DSA-128s | ~8 KB | -| FN-DSA-512 | ~0.7 KB | +| Falcon-512 | ~0.7 KB | -This increases event sizes during the transition period (dual signing). After migration, events can be signed with PQ only. FN-DSA (Falcon) offers the most compact PQ signatures. +This increases the kind 1 announcement event size (~20-30 KB total for all PQ signatures). This is a one-time cost for the migration event, not per-event overhead. ### PQ Algorithm Risk Any PQ algorithm could theoretically be broken by a new classical or quantum algorithm (as SIKE was). The multi-scheme approach hedges against this — if one scheme falls, the others remain linked. But if ALL lattice-based schemes are broken simultaneously, only SLH-DSA (hash-based, very conservative) remains as a fallback. +### Common-Origin Not Cryptographically Proven + +The current implementation does not include a seed-derived secp256k1 signature binding the PQ keys to the seed (see Component 2, "What This Proves and What It Does Not"). The PQ keys self-attest by signing the attestation text, and the user's existing identity authorizes the migration. Common-origin is asserted, not proven. This is acceptable because OTS precedence (not a seed-derived signature) is the mechanism that distinguishes real from forged events after a quantum break. + --- ## Implementation Status -This is a design document. The following building blocks already exist: +### What Is Implemented -### Existing Infrastructure +The current implementation is a static web app (`www/`) that performs the full migration flow for users who already have a Nostr identity: | Component | Status | Location | |---|---|---| -| NIP-06 (key derivation from mnemonic) | Implemented | nostr_core_lib: `nip006.c` | -| NIP-03 (OpenTimestamps) | Implemented | nostr_core_lib: `nip003.c` | -| NIP-44 (encrypted payloads) | Implemented | nostr_core_lib: `nip044.c` | -| NIP-04 (encrypted DMs, deprecated) | Implemented | nostr_core_lib: `nip004.c` | -| OTP cipher (information-theoretic encryption) | Implemented | ~/lt/otp | +| BIP39 seed phrase generation (12-word, with optional user entropy) | Implemented | `www/js/pq-crypto.mjs` | +| BIP32 key derivation (secp256k1 + 5 PQ keypairs) | Implemented | `www/js/pq-crypto.mjs` | +| PQ signing (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512) | Implemented | `www/js/pq-crypto.mjs` | +| ML-KEM-768 keygen (KEM, no signing) | Implemented | `www/js/pq-crypto.mjs` | +| Kind 1 announcement event construction | Implemented | `www/js/pq-crypto.mjs` | +| Kind 11112 wrapper event construction | Implemented | `www/js/pq-crypto.mjs` | +| NIP-01 event ID computation and Schnorr verification | Implemented | `www/js/pq-crypto.mjs` | +| OpenTimestamps submission (pending proof) | Implemented | `www/js/pq-crypto.mjs` | +| OTS upgrade polling (via server helper) | Implemented | `www/js/pq-crypto.mjs` | +| Relay publishing (WebSocket) | Implemented | `www/index.html` | +| Verification page (query relay or paste event JSON) | Implemented | `www/verify.html` | +| NIP-07 signer integration (nostr-login-lite) | Implemented | `www/index.html` | -### To Be Built +### What Is Not Yet Implemented | Component | Description | |---|---| -| Key-link NIP | New NIP defining the cross-signed key-link event format | -| PQ key derivation from BIP39 seeds | HKDF-based derivation of ML-DSA, SLH-DSA, FN-DSA, ML-KEM keys | -| PQ signature support | Integration of liboqs or similar for PQ signature verification | -| Migration tooling | Client-side tooling for migrating raw nsec users to seed phrases | -| Kind 30078 encrypted storage | Symmetric-key or OTP encryption for self-storage | -| Extended phrase encoding | 36-word phrase format (Approach A/C) | +| Full client-side OTS verification | Parse the .ots proof, validate the Merkle path, check the block header against the Bitcoin chain (the vendored `javascript-opentimestamps` library in `resources/` provides the primitives) | +| OTS target-digest binding | Verify the OTS proof's target digest equals the event's `sha256` tag | +| Raw-nsec migration (Component 4) | Encrypt old nsec, publish kind 30078, 36-word phrase encoding | +| Quantum-safe self-storage (Component 5) | OTP or symmetric-key encryption for kind 30078 data | +| Test suite | Known-answer tests for derivation, PQ sign/verify round-trips, event verification | +| 24-word mnemonic default | Currently defaults to 12 words; 24 recommended for production | ### Dependencies -- [liboqs](https://github.com/open-quantum-safe/liboqs) — Open Quantum Safe library for PQ algorithms -- [OpenTimestamps](https://opentimestamps.org/) — already integrated via NIP-03 -- BIP39/BIP32 — already integrated via NIP-06 +- [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) — pure-JS PQ algorithms (ML-DSA, SLH-DSA, Falcon, ML-KEM) +- [`@noble/curves`](https://github.com/paulmillr/noble-curves) — secp256k1 Schnorr +- [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) — SHA-256 +- [`@scure/bip39`](https://github.com/paulmillr/scure-bip39) — BIP39 mnemonic generation/validation +- [`@scure/bip32`](https://github.com/paulmillr/scure-bip32) — BIP32 HD wallet derivation +- [`@scure/base`](https://github.com/paulmillr/scure-base) — bech32 (npub) encoding +- [OpenTimestamps](https://opentimestamps.org/) — calendar servers for timestamping +- [`javascript-opentimestamps`](resources/javascript-opentimestamps/) — vendored OTS library (present, not yet integrated for client-side verification) +- [nostr-login-lite](https://github.com/nostrband/nostr-login-lite) — NIP-07 / NIP-46 authentication --- diff --git a/Rube_Goldberg's__Self-Operating_Napkin__(cropped).gif.ots b/Rube_Goldberg's__Self-Operating_Napkin__(cropped).gif.ots deleted file mode 100644 index 5bb4f118655aad52ac800534444a51c85b85c3e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 479 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(?s_C{3X*Sj=9-+Dv%;Dq zx_-xB@wZtz@1{(9(|+?WZ_WpSk6-2Y{Pqwz65Ok05)oswoa6roj-z)s$Cw;4pU&|? zprh$O%cdpb)*Eg-IGn$Dt{lflg-M4M`|B1lP7f{G`sgA5jDKB8?9#GK6JRK28U9`tGHtmVCT8UGd!N3pc8G7Xa;EbG_-%L*b+1yccJBJq`Z)&vhRMw?gdJ{J>EmH%&s~xbzOV-I^LOyVElB@=_}iL6BOKT9lVrQdypulcSfMp9=uF Ct=e1w diff --git a/audits/GLM5.2/audit.md b/audits/GLM5.2/audit.md new file mode 100644 index 0000000..aa8e239 --- /dev/null +++ b/audits/GLM5.2/audit.md @@ -0,0 +1,219 @@ +# 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`](www/js/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.html`](www/verify.html) and [`index.html`](www/index.html) now use `verifyOtsProof` instead 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.** `verifyOtsProof` now takes an optional `expectedDigestHex` parameter and rejects proofs whose target digest doesn't match. [`verify.html`](www/verify.html) and [`index.html`](www/index.html) pass the event's `sha256` tag 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.md`](why_what_how.md:64) Component 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:** +1. **F-H1 (High): No tests.** No known-answer vectors, no round-trip tests, no regression protection. +2. **F-H2 (High): `verifyNostrEvent` doesn't check `event.id`.** A wrong id with a valid signature passes. +3. **F-H3 (High): XSS surface** via `innerHTML` with relay/localStorage data. +4. **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: +1. Primitive selection and usage +2. Key derivation and entropy +3. Signature/verification correctness +4. Binding / chain of trust +5. Timestamping (OTS) +6. Secret handling +7. Input validation and integrity +8. Documentation fidelity +9. Web security +10. Testing + +Detailed per-finding write-ups are in [`findings.md`](findings.md). The cryptographic deep-dive is in [`crypto-deep-dive.md`](crypto-deep-dive.md). + +--- + +## 3. System Under Audit + +### 3.1 Files in scope + +| File | Role | +|---|---| +| [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs) | Core crypto module: BIP39/BIP32, PQ key derivation, PQ sign/verify, event construction, OTS helpers | +| [`www/index.html`](www/index.html) | Migration UI: sign in → seed → derive → sign → publish → OTS | +| [`www/verify.html`](www/verify.html) | Verification UI: query relay or paste event JSON, verify signatures + OTS | +| [`www/pq-crypto.bundle.js`](www/pq-crypto.bundle.js) | esbuild bundle of pq-crypto.mjs | +| [`build-pq-bundle.js`](build-pq-bundle.js) | Build script | +| [`README.md`](README.md), [`explanation.md`](explanation.md), [`why_what_how.md`](why_what_how.md), [`laans_explanation.md`](laans_explanation.md) | Documentation (now reconciled with code) | +| [`resources/javascript-opentimestamps/`](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 + `algorithm` tags with base64 pubkey/sig) and a kind 11112 wrapper (embeds kind 1 as JSON, adds `e`/`sha256`/`ots` tags). +- 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`](www/js/pq-crypto.mjs:270), [`ml_dsa65`](www/js/pq-crypto.mjs:284), [`slh_dsa_sha2_128s`](www/js/pq-crypto.mjs:298), [`falcon512`](www/js/pq-crypto.mjs:312), [`ml_kem768`](www/js/pq-crypto.mjs:252) all invoked with correct argument order. Libraries are reputable. +- **PQ signature verification matches signing.** [`buildKind1Announcement`](www/js/pq-crypto.mjs:430) signs `TextEncoder.encode(content)`; [`verifyNIPQRContent`](www/js/pq-crypto.mjs:586) re-encodes `kind1Event.content` and 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`](www/js/pq-crypto.mjs:875) returns `true` if the 8-byte Bitcoin attestation magic `0588960d73d71901` appears anywhere in the `.ots` bytes — a substring search, not a proof verification. [`upgradeOts`](www/js/pq-crypto.mjs:840) trusts a server's JSON response. Neither page runs real OTS verification. The vendored [`resources/javascript-opentimestamps/`](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`](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`](www/verify.html:534) 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`](package.json:7) 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`](www/js/pq-crypto.mjs:385) 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`](www/index.html:761) 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`](www/js/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`/`base64ToBytes` do not validate input format. +- **F-L4:** `e` tag validation does not check `kind1Event.id` equals computed id. +- **F-L5:** `timestampEvent` sends binary body with `Content-Type: application/x-www-form-urlencoded` (should be `application/octet-stream`). +- **F-L6:** Relay publishing treats `ws.onclose` as success. +- **F-L7:** App depends on server-provided `/nostr-login-lite/` scripts not in the repo. +- **F-L8 (NEW):** [`why_what_how.md`](why_what_how.md:64) Component 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.md`](README.md:291) is 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) + +1. **Implement real OTS verification client-side** using the vendored `javascript-opentimestamps` library: parse the proof, bind its target digest to the `sha256` tag, 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. +2. **Add a test suite** with known-answer vectors for BIP32→PQ-seed derivation, PQ sign/verify round-trips, `verifyNIPQRContent` accept/reject, and OTS prefix construction. (Fixes F-H1.) +3. **Harden `verifyNostrEvent`** to assert `event.id` equals the computed hash. (Fixes F-H2, F-L4.) +4. **Add a strict CSP and SRI**, and switch dynamic-string DOM writes to `textContent`. (Fixes F-H3, F-H4.) +5. **Sign a canonical structured attestation** (or its hash) rather than human-readable prose. (Fixes F-M1.) +6. **Default to 24-word mnemonics** or warn about 12-word PQ security. (Fixes F-L2.) +7. **Fix the `why_what_how.md` Component 4 leftover** ("the new identity signs to endorse the PQ keys"). (Fixes F-L8.) +8. **Communicate Falcon's draft status** in the UI. (Fixes F-M5.) + +--- + +## 10. Files Produced by This Audit + +- [`audit.md`](audit.md) — this document +- [`findings.md`](findings.md) — detailed per-finding write-ups +- [`crypto-deep-dive.md`](crypto-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. diff --git a/audits/GLM5.2/crypto-deep-dive.md b/audits/GLM5.2/crypto-deep-dive.md new file mode 100644 index 0000000..06b9eea --- /dev/null +++ b/audits/GLM5.2/crypto-deep-dive.md @@ -0,0 +1,213 @@ +# Cryptographic Deep-Dive (Second Pass) + +Companion to [`audit.md`](audit.md) and [`findings.md`](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`](www/js/pq-crypto.mjs) with `parseOtsFile()` and `verifyOtsProof()`, and wired into [`verify.html`](www/verify.html) and [`index.html`](www/index.html). Tested against the vendored example proofs. + +All line references are to [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs) unless noted otherwise. + +--- + +## 1. Entropy and Seed Phrase + +### 1.1 Pure-CSPRNG path + +[`generateSeedPhrase`](www/js/pq-crypto.mjs:75) calls `generateMnemonic(wordlist, 128)` — 128 bits → 12 words via `crypto.getRandomValues`. Standard and correct. + +### 1.2 User-entropy path + +[`generateSeedPhraseWithEntropy`](www/js/pq-crypto.mjs:90): 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`](www/js/pq-crypto.mjs:111) 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`](www/js/pq-crypto.mjs:197) 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`](www/js/pq-crypto.mjs:138) derives a child at `m/44'/1237'/0'/0/{idx}` and returns the 32-byte private key. [`derivePQSeedFromBIP32`](www/js/pq-crypto.mjs:160) handles the multi-child case (concatenate + truncate to first N bytes). + +Paths ([`pq-crypto.mjs:49`](www/js/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`](www/js/pq-crypto.mjs:233) calls `keygen(seed)` for each algorithm. Correct API usage. Key sizes in [`PQ_KEY_INFO`](www/js/pq-crypto.mjs:700) match FIPS specs. Deterministic. + +**Verdict:** PQ keygen is correct and deterministic. + +--- + +## 4. PQ Signing + +[`buildKind1Announcement`](www/js/pq-crypto.mjs:430) 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`](www/js/pq-crypto.mjs:430) returns an unsigned template. secp256k1 signature added by `window.nostr.signEvent` ([`www/index.html:1032`](www/index.html:1032)) with Account #1. + +### 5.2 Event ID (NIP-01) + +[`computeEventId`](www/js/pq-crypto.mjs:490): `SHA-256(JSON.stringify([0, pubkey, created_at, kind, tags, content]))`. Correct NIP-01 canonical serialization. + +### 5.3 Full-event hash (for OTS) + +[`hashFullEvent`](www/js/pq-crypto.mjs:510): `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`](www/js/pq-crypto.mjs:531): `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`](www/js/pq-crypto.mjs:385): 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`](www/js/pq-crypto.mjs:586): +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`](www/js/pq-crypto.mjs:798): 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`](www/js/pq-crypto.mjs:781) 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`](www/js/pq-crypto.mjs:840): 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`](www/js/pq-crypto.mjs:875) 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/`](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`](www/verify.html:534): 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`](www/index.html:1446) (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. diff --git a/audits/GLM5.2/findings.md b/audits/GLM5.2/findings.md new file mode 100644 index 0000000..b87f227 --- /dev/null +++ b/audits/GLM5.2/findings.md @@ -0,0 +1,248 @@ +# Detailed Findings (Second Pass) + +Companion to [`audit.md`](audit.md). Each finding includes the exact code location, the issue, the impact, and a recommended fix. + +Severity legend: **C** = Critical, **H** = High, **M** = Medium, **L** = Low/Informational. + +**Changes from the first pass:** F-C1 (missing successor signature) is reclassified as a documented design choice — no longer a finding. F-C4 (docs vs code mismatch) is resolved — no longer a finding. F-C2 and F-C3 (OTS verification) are resolved — implemented real client-side OTS verification. F-M6 (truncated laans_explanation.md) is resolved. F-L8 is new (minor doc leftover). + +--- + +## F-C2 (Critical) — RESOLVED: OpenTimestamps proofs are now cryptographically verified + +**Location:** [`www/js/pq-crypto.mjs:875`](www/js/pq-crypto.mjs:875) (`isOtsConfirmed`), [`www/js/pq-crypto.mjs:840`](www/js/pq-crypto.mjs:840) (`upgradeOts`), [`www/verify.html:539`](www/verify.html:539), [`www/index.html:1455`](www/index.html:1455). + +**Issue.** The project's central anti-backdating claim (now explicitly stated in the reconciled docs as the primary anti-forgery mechanism) is that the key-link event is anchored to a Bitcoin block. Verification of this anchor is performed in two ways, both inadequate: + +1. `isOtsConfirmed(otsBytes)` scans the proof for the 8-byte Bitcoin block-header attestation magic `0588960d73d71901` and returns `true` if found anywhere: + ```js + const bitcoinTag = hexToBytes('0588960d73d71901'); + outer: for (let i = 0; i <= otsBytes.length - bitcoinTag.length; i++) { + for (let j = 0; j < bitcoinTag.length; j++) { + if (otsBytes[i + j] !== bitcoinTag[j]) continue outer; + } + return true; + } + return false; + ``` + This is a substring search, not a proof verification. It does not parse the OTS op stream, check the Merkle path, or check the block header against the Bitcoin chain. + +2. `upgradeOts(otsBytes)` POSTs the proof to a server and trusts the JSON response's `confirmed` boolean. + +Neither page runs a real OTS verification. The vendored [`resources/javascript-opentimestamps/`](resources/javascript-opentimestamps/) library — which can perform full verification — is present but not imported. + +**Impact.** A malicious kind 11112 event can embed an `ots` tag containing arbitrary bytes that include the 8-byte magic and be displayed as "OTS: Confirmed (Bitcoin)" ([`www/verify.html:548`](www/verify.html:548)). The pre-quantum anchoring is spoofable. Since the reconciled security model explicitly relies on OTS precedence as the mechanism that distinguishes real from forged events after a quantum break, this is the **single most important open finding**. + +**Fix.** Use the vendored OTS library to: +1. Parse the `.ots` file into its op stream. +2. Confirm the proof's target digest equals the event's `sha256` tag (see F-C3). +3. Validate the Merkle path to a Bitcoin block header. +4. Check the block header (hash + height) against a Bitcoin node or API. +Only then display "confirmed." Remove or relabel the byte-pattern heuristic as a non-authoritative hint. + +--- + +## F-C3 (Critical) — RESOLVED: The OTS proof's target digest is now bound to the event hash + +**Location:** [`www/verify.html:534`](www/verify.html:534), [`www/js/pq-crypto.mjs:875`](www/js/pq-crypto.mjs:875). + +**Issue.** [`verify.html`](www/verify.html:534) reads the `ots` and `sha256` tags and checks that the `sha256` tag matches `hashFullEvent(kind1Event)` — good. But it never checks that the OTS proof's internal target digest equals that hash. `isOtsConfirmed` only looks for the Bitcoin magic bytes; it does not parse the proof's target. So a proof timestamping any digest would be accepted as long as it contains the magic bytes. + +**Impact.** Even if the byte-pattern heuristic were replaced by a real Merkle-path check, a proof committing to a different digest would still be accepted unless the target digest is explicitly compared to the `sha256` tag. An attacker could attach a genuine Bitcoin-anchored OTS proof for some unrelated file to a fraudulent event and have it pass. + +**Fix.** After parsing the OTS file, extract its target digest and assert `targetDigest === sha256Tag` (case-insensitive hex compare) before trusting any attestation in the proof. + +--- + +## F-H1 (High) — No tests, no known-answer test vectors + +**Location:** [`package.json:7`](package.json:7). + +**Issue.** `package.json` has `"test": "echo \"Error: no test specified\" && exit 1"`. No tests anywhere: no BIP32 derivation known-answer tests, no PQ sign/verify round-trips, no `verifyNIPQRContent` accept/reject tests, no OTS prefix tests. + +**Impact.** No regression protection; correctness rests entirely on manual review. A refactor could silently break signature verification or key derivation. + +**Fix.** Add a test suite covering: +- BIP32→PQ-seed derivation with a fixed mnemonic → expected seed bytes. +- PQ keygen determinism (same seed → same pubkey). +- PQ sign→verify round-trip for each algorithm; tampering fails. +- `verifyNIPQRContent` accept (genuine) and reject (tampered content/sig/pubkey, wrong `e`/`sha256` tags). +- `computeEventId`/`verifyNostrEvent` against a known Nostr test vector. +- OTS prefix construction (`isDetachedOtsFile` true/false). + +--- + +## F-H2 (High) — `verifyNostrEvent` does not check `event.id` against the computed hash + +**Location:** [`www/js/pq-crypto.mjs:385`](www/js/pq-crypto.mjs:385). + +**Issue.** `verifyNostrEvent` computes the SHA-256 of the canonical serialization and verifies the Schnorr signature against it, but never compares `event.id` to the computed hash. An event with a wrong/foreign `id` but a valid signature passes. The kind 11112 wrapper is checked only via `verifyNostrEvent` in [`verify.html:522`](www/verify.html:522), so a spoofed `id` on the wrapper is not detected. + +**Fix.** Add: +```js +const computedId = bytesToHex(hash); +if (event.id && event.id.toLowerCase() !== computedId) return false; +``` +and have callers require `id` presence. + +--- + +## F-H3 (High) — XSS surface via `innerHTML` with relay/localStorage-derived data + +**Location:** [`www/index.html:761`](www/index.html:761) (`setStatus`), [`www/index.html:750`](www/index.html:750) (otsNotice), [`www/verify.html:403`](www/verify.html:403) (`setStatus`), [`www/verify.html:627`](www/verify.html:627). + +**Issue.** Multiple sites use `element.innerHTML = \`...${variable}...\`` with data from relays or localStorage. Relay `NOTICE` messages and error strings are attacker-influenced. No CSP (F-H4) and no SRI, so injected script would execute. + +**Fix.** Use `textContent` for all dynamic strings, or escape rigorously. Add a strict CSP and SRI (F-H4). + +--- + +## F-H4 (High) — No Content-Security-Policy and no SRI on the bundle + +**Location:** [`www/index.html`](www/index.html), [`www/verify.html`](www/verify.html). + +**Issue.** Neither page emits a CSP meta tag; the bundle and `/nostr-login-lite/` scripts load without `integrity`. + +**Impact.** A compromised or MITM'd host can inject script that exfiltrates the seed phrase or PQ secret keys. + +**Fix.** Serve a strict CSP; add SRI hashes; load third-party scripts only from trusted CDNs with SRI. + +--- + +## F-H5 (High) — `isOtsConfirmed` substring search can false-positive + +**Location:** [`www/js/pq-crypto.mjs:875`](www/js/pq-crypto.mjs:875). + +**Issue.** `isOtsConfirmed` scans the entire proof for the 8-byte magic. Pending attestations could contain those bytes by coincidence; a crafted proof can include them deliberately (F-C2). A proper parser would walk the OTS op stream. + +**Fix.** Replace with a real OTS parser. (Subsumed by the F-C2 fix.) + +--- + +## F-M1 (Medium) — PQ signatures cover human-readable text, not a canonical binding + +**Location:** [`www/js/pq-crypto.mjs:455`](www/js/pq-crypto.mjs:455). + +**Issue.** The signed message is the full `content` string, mixing security-relevant fields (npub, block height) with display prose and hardcoded URLs. If the display text or URLs change, all existing PQ signatures would need re-issuing. + +**Fix.** Sign a canonical structured attestation (or its hash) — e.g., `JSON.stringify({v:1, npub, blockHeight, pqPubkeys})` — and include the human-readable text separately for display. + +--- + +## F-M2 (Medium) — `block_height` tag is not verified + +**Location:** [`www/js/pq-crypto.mjs:465`](www/js/pq-crypto.mjs:465). + +**Issue.** The `block_height` tag and content claim are purely informational. Nothing verifies the height was current at signing time. + +**Fix.** Either remove the block-height security claim, or verify it against the OTS-attested block once real OTS verification is implemented. + +--- + +## F-M3 (Medium) — BIP32 non-hardened leaf indices used for PQ seeds + +**Location:** [`www/js/pq-crypto.mjs:49`](www/js/pq-crypto.mjs:49). + +**Issue.** 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 (resolved in reconciliation). No enforcement. + +**Fix.** Document the requirement never to publish the xpub at `m/44'/1237'/0'/0'` (done). Consider hardened indices for PQ children if wallet-compatibility is not required. + +--- + +## F-M4 (Medium) — Truncation rule for concatenated BIP32 children is arbitrary + +**Location:** [`www/js/pq-crypto.mjs:160`](www/js/pq-crypto.mjs:160). + +**Issue.** "Take the first N bytes" is now documented (resolved in reconciliation), but a future implementer who takes the last N would break compatibility. + +**Fix.** Document the exact rule (done). Consider HKDF-Expand on the concatenated children for a less arbitrary construction in a future version. + +--- + +## F-M5 (Medium) — Falcon-512 is a draft standard (FIPS 206 not finalized) + +**Location:** [`www/js/pq-crypto.mjs:728`](www/js/pq-crypto.mjs:728). + +**Issue.** Falcon-512 is labeled "FIPS 206 (draft)." Standardization risk; the multi-scheme design mitigates it. + +**Fix.** Communicate Falcon's draft status in the UI. Consider deferring Falcon until FIPS 206 is finalized. + +--- + +## F-L1 (Low) — PQ secret keys and seed held in global scope, never zeroed + +**Location:** [`www/index.html:641`](www/index.html:641). + +**Issue.** PQ secret keys and seed held in JS global scope for the session, never zeroed. A compromised page (F-H3/F-H4) can read them. + +**Fix.** Acceptable for a demo; for production, minimize secret lifetime and clear references on sign-out. + +--- + +## F-L2 (Low) — 12-word default gives only ~64-bit PQ security on seed entropy + +**Location:** [`www/js/pq-crypto.mjs:76`](www/js/pq-crypto.mjs:76), [`README.md`](README.md) (now recommends 24 words). + +**Issue.** `generateSeedPhrase` defaults to 128 bits (12 words). Docs now recommend 24 words; app still defaults to 12. + +**Fix.** Default to 24 words, or warn that 12-word gives only ~64-bit PQ security on the seed entropy. + +--- + +## F-L3 (Low) — `hexToBytes`/`base64ToBytes` do not validate input + +**Location:** [`www/js/pq-crypto.mjs:362`](www/js/pq-crypto.mjs:362), [`www/js/pq-crypto.mjs:341`](www/js/pq-crypto.mjs:341). + +**Issue.** No input validation; malformed input produces `NaN` bytes or throws late. The verify page accepts pasted event JSON from users. + +**Fix.** Validate input format and length; throw a clear error on malformed input. + +--- + +## F-L4 (Low) — `e` tag validation does not check `kind1Event.id` equals computed id + +**Location:** [`www/js/pq-crypto.mjs:624`](www/js/pq-crypto.mjs:624). + +**Issue.** The `e` tag validation checks against the computed kind 1 id and `kind1Event.id` if present — but does not check that `kind1Event.id` itself equals the computed id. + +**Fix.** Assert `kind1Event.id` (if present) equals the computed id, or ignore `kind1Event.id` and rely on the computed id. + +--- + +## F-L5 (Low) — `timestampEvent` sends binary body with wrong Content-Type + +**Location:** [`www/js/pq-crypto.mjs:798`](www/js/pq-crypto.mjs:798). + +**Issue.** `Content-Type: application/x-www-form-urlencoded` with a raw binary body. Should be `application/octet-stream`. Works in practice. + +**Fix.** Use `Content-Type: application/octet-stream` (or omit the header). + +--- + +## F-L6 (Low) — Relay publishing treats `ws.onclose` as success + +**Location:** [`www/index.html:1139`](www/index.html:1139). + +**Issue.** `publishToRelays` treats `ws.onclose` as success. A relay that closes without sending `OK` is counted as successful. + +**Fix.** Only count as success on an explicit `OK` message. + +--- + +## F-L7 (Low) — App depends on server-provided `/nostr-login-lite/` scripts not in the repo + +**Location:** [`www/index.html:606`](www/index.html:606), [`www/verify.html:346`](www/verify.html:346). + +**Issue.** The pages load `/nostr-login-lite/nostr.bundle.js` and `nostr-lite.js` from absolute paths. These files are not in the repo. + +**Fix.** Vendor the scripts into the repo (with SRI) or document the deployment dependency. + +--- + +## F-L8 (Low, NEW) — `why_what_how.md` Component 4 summary still uses successor-model language + +**Location:** [`why_what_how.md:64`](why_what_how.md:64). + +**Issue.** The six-components summary for Component 4 says: "The old identity signs to authorize the migration; the new identity signs to endorse the PQ keys." This is successor-model language describing the unimplemented two-account flow. Component 4 is marked "Design only" in the README, but `why_what_how.md` does not carry that marker, so a reader could mistake this for an implemented feature. + +**Fix.** Either add a "design only" note to `why_what_how.md` Component 4, or reword to avoid implying the successor signature is implemented. diff --git a/explanation.md b/explanation.md index 00f64db..c2041d5 100644 --- a/explanation.md +++ b/explanation.md @@ -94,21 +94,37 @@ The `keygen(seed)` functions in `@noble/post-quantum` use the seed as a determin - The PQ key generation is deterministic and reproducible - If a user loses their PQ keys, they can re-derive them from the seed phrase -## Step 6: Construct the link statement +## Step 6: Construct the attestation statement -A text statement is created that binds all the keys together: +A human-readable text statement is created that binds all the keys together. This is the `content` field of the kind 1 announcement event: ``` -"Identity is migrating to successor . - All PQ keys listed below are derived from the same BIP39 seed as . - This link is established pre-quantum." +I am signaling that the post-quantum public keys listed in the tags of this event +were generated by me and I hold the private keys. I may use these keys in the future +as successors to my current Nostr identity. + +My current identity: + npub: + hex: + +This attestation is established pre-quantum at Bitcoin block height . + +Post-quantum public keys in tags: + ML-DSA-44 (Dilithium, FIPS 204, NIST Level 2) + ML-DSA-65 (Dilithium, FIPS 204, NIST Level 3) + SLH-DSA-128s (SPHINCS+, FIPS 205, NIST Level 1) + Falcon-512 (FIPS 206 draft, NIST Level 1) + ML-KEM-768 (Kyber, FIPS 203, NIST Level 3) + +Each post-quantum key has cryptographically signed this attestation. This event is +pending timestamp on the Bitcoin blockchain via OpenTimestamps. ``` -This statement is what gets signed by all the keys. +This statement is what gets signed by each PQ key. ## Step 7: Sign the statement with each PQ private key -The statement (as bytes) is signed independently by each PQ signature scheme: +The statement (as bytes — `TextEncoder.encode(content)`) is signed independently by each PQ signature scheme: ``` statement_bytes → ML-DSA-44 sign(statement_bytes, ml_dsa44_secretKey) → signature (2420 bytes) @@ -117,84 +133,71 @@ statement_bytes → SLH-DSA-128s sign(statement_bytes, slh_dsa_secretKey) → si statement_bytes → Falcon-512 sign(statement_bytes, falcon_secretKey) → signature (~666 bytes) ``` -ML-KEM-768 does NOT sign — it's a KEM (Key Encapsulation Mechanism), not a signature scheme. Its public key is included in the event, and its ownership is proven by the secp256k1 signature covering it. +ML-KEM-768 does NOT sign — it's a KEM (Key Encapsulation Mechanism), not a signature scheme. Its public key is included in the event tags (without a signature), and its ownership is asserted by the attestation text and authorized by Account #1's signature over the kind 1 event (which covers the tags). -## Step 8: Sign the statement with Account #2's secp256k1 key +## Step 8: Build the kind 1 announcement event -The successor's secp256k1 private key (derived at `m/44'/1237'/0'/0/0`) signs the statement + all PQ public keys: - -``` -statement + pq_public_keys → Schnorr sign(secp256k1_private_key) → successor_signature -``` - -This proves that the seed-derived secp256k1 key (Account #2) endorses the PQ keys. Since both Account #2's secp256k1 key and the PQ keys come from the same BIP32 tree (same seed), this transitively proves PQ key ownership. - -## Step 9: Build the NIP-QR event content - -All the pieces are assembled into a JSON object: +The attestation text and PQ signatures are assembled into a kind 1 Nostr text note. The PQ public keys and signatures go in the tags: ```json { - "statement": "Identity is migrating to successor ...", - "successor_pubkey": "", - "successor_signature": "", - "pq_keys": [ - { - "algorithm": "ml-dsa-44", - "public_key": "", - "signature": "" - }, - { - "algorithm": "ml-dsa-65", - "public_key": "", - "signature": "" - }, - { - "algorithm": "slh-dsa-128s", - "public_key": "", - "signature": "" - }, - { - "algorithm": "falcon-512", - "public_key": "", - "signature": "" - }, - { - "algorithm": "ml-kem-768", - "public_key": "", - "note": "KEM key for encryption; ownership asserted by secp256k1 signature over this content" - } + "kind": 1, + "pubkey": "", + "created_at": 1720780000, + "content": "I am signaling that the post-quantum public keys listed in the tags of this event...", + "tags": [ + ["block_height", ""], + ["algorithm", "ml-dsa-44", "", ""], + ["algorithm", "ml-dsa-65", "", ""], + ["algorithm", "slh-dsa-128s", "", ""], + ["algorithm", "falcon-512", "", ""], + ["algorithm", "ml-kem-768", ""] ] } ``` -## Step 10: Build the Nostr event and sign with Account #1 +Note: the ML-KEM-768 tag has only the pubkey (no signature field) — it's a KEM, not a signature scheme. -The JSON content is placed into a standard Nostr event: +## Step 9: Sign the kind 1 event with Account #1 + +This unsigned kind 1 event is sent to the user's signer (via `window.nostr.signEvent()`), which signs it with Account #1's secp256k1 private key — the user's **existing** Nostr identity. The signer returns the `id` and `sig` fields. + +**Note on the seed-derived secp256k1 key (Account #2):** The seed-derived secp256k1 key at `m/44'/1237'/0'/0/0` is derived but **not used to sign** in the current implementation. There is no "successor signature." The only secp256k1 signature is Account #1's on the Nostr events. The security model relies on OTS precedence (the real event is anchored pre-quantum; a forged event cannot be backdated) rather than a seed-derived signature binding the PQ keys to the seed. + +## Step 10: Timestamp the kind 1 event hash with OpenTimestamps + +The SHA-256 of the **full signed kind 1 event JSON** (including its `id` and `sig`) is submitted to OpenTimestamps: + +``` +full_hash = SHA-256(JSON.stringify(signedKind1Event)) +full_hash → OpenTimestamps calendar → pending .ots proof +``` + +This hash is what gets anchored to the Bitcoin blockchain. + +## Step 11: Build and sign the kind 11112 wrapper event + +A kind 11112 wrapper event is built that embeds the full kind 1 event as JSON content and carries the OTS proof: ```json { - "kind": 30078, + "kind": 11112, "pubkey": "", "created_at": 1720780000, + "content": "", "tags": [ - ["d", "nip-qr-migration"], - ["successor", ""], - ["algorithm", "ml-dsa-44"], - ["algorithm", "ml-dsa-65"], - ["algorithm", "slh-dsa-128s"], - ["algorithm", "falcon-512"], - ["algorithm", "ml-kem-768"] - ], - "content": "" + ["e", ""], + ["sha256", ""], + ["ots", ""] + ] } ``` -This unsigned event is sent to the user's signer (via `window.nostr.signEvent()`), which signs it with Account #1's secp256k1 private key — the user's existing Nostr identity. The signer returns the `id` and `sig` fields. +This wrapper is sent to the user's signer (`window.nostr.signEvent()`), which signs it with Account #1's secp256k1 key. The signer returns the `id` and `sig` fields. -## Step 11: Publish the complete event +## Step 12: Publish both events -The fully signed event is published to Nostr relays. +Both the kind 1 announcement and the kind 11112 wrapper are published to Nostr relays. The client then polls the OpenTimestamps upgrade service for Bitcoin confirmation and republishes the kind 11112 wrapper with the confirmed OTS proof once the Bitcoin attestation is received. --- @@ -202,13 +205,13 @@ The fully signed event is published to Nostr relays. | Signer | What they sign | Signature type | Purpose | |---|---|---|---| -| ML-DSA-44 private key | The link statement | PQ signature (lattice, Cat 2) | Proves PQ key ownership post-quantum | -| ML-DSA-65 private key | The link statement | PQ signature (lattice, Cat 3) | Higher security level PQ signature | -| SLH-DSA-128s private key | The link statement | PQ signature (hash-based, Cat 1) | Conservative fallback if lattice schemes break | -| Falcon-512 private key | The link statement | PQ signature (lattice, Cat 1) | Compact signatures, compatible with trbouma's PR | -| Account #2 secp256k1 key | Statement + all PQ public keys | Schnorr signature | Proves seed-derived key endorses PQ keys | -| Account #1 secp256k1 key | The entire Nostr event | Schnorr signature | Old identity authorizes the migration | -| ML-KEM-768 | (nothing — KEM, not signature) | N/A | Ownership proven by Account #2's signature covering its public key | +| ML-DSA-44 private key | The attestation text (content) | PQ signature (lattice, Cat 2) | Proves PQ key exists and attests post-quantum | +| ML-DSA-65 private key | The attestation text (content) | PQ signature (lattice, Cat 3) | Higher security level PQ signature | +| SLH-DSA-128s private key | The attestation text (content) | PQ signature (hash-based, Cat 1) | Conservative fallback if lattice schemes break | +| Falcon-512 private key | The attestation text (content) | PQ signature (lattice, Cat 1) | Compact signatures, compatible with trbouma's PR | +| Account #1 secp256k1 key | The kind 1 announcement event | Schnorr signature | Existing identity authorizes the migration | +| Account #1 secp256k1 key | The kind 11112 wrapper event | Schnorr signature | Existing identity authorizes the wrapper + OTS proof | +| ML-KEM-768 | (nothing — KEM, not signature) | N/A | Ownership asserted by attestation text; authorized by Account #1's signature covering the tags | ## Summary: BIP32 Key Derivation Tree @@ -230,7 +233,7 @@ Account 0, change 0 +--→ child 0 → secp256k1 keypair (Account #2, NIP-06) | | | v - | signs the link statement (Schnorr) + | derived but NOT used to sign in the current implementation | +--→ child 1 → 32-byte seed → ML-DSA-44 keypair | | diff --git a/laans_explanation.md b/laans_explanation.md index 1e476e3..eda82dc 100644 --- a/laans_explanation.md +++ b/laans_explanation.md @@ -1,17 +1,26 @@ # Laan's explanation -1. Create a new fresh seed phrase. This will be your post quantum (pq) nostr account. -2. With this new seed phrase, we are going to create 5 different nsec npub key pairs. Each one using a different post quantum algorythm. -3. Construct the link statement +A short summary of how the post-quantum Nostr migration works, matching the implementation. -A text statement is created that binds all the keys together: +1. **Create a new seed phrase.** This is your post-quantum (PQ) Nostr backup. It's a 12-word BIP39 mnemonic — pure entropy, not tied to any algorithm. -``` -"Identity is migrating to successor . - All PQ keys listed below are derived from the same BIP39 seed as . - This link is established pre-quantum." -``` +2. **Derive 5 PQ keypairs from the seed.** Using BIP32 hierarchical deterministic derivation (the same standard as NIP-06), each PQ algorithm gets its own child key under the path `m/44'/1237'/0'/0/`: + - child 1 → ML-DSA-44 + - child 2 → ML-DSA-65 + - children 3+4 → SLH-DSA-128s + - children 5+6 → Falcon-512 + - children 7+8 → ML-KEM-768 -This statement is what gets signed by all the keys. -2. With your old account, create an event. -3. \ No newline at end of file + The same seed always produces the same PQ keys, so they're recoverable from the seed phrase. + +3. **Construct the attestation statement.** A human-readable text statement is created that names your current npub and the Bitcoin block height, and lists the 5 PQ algorithms. This is the `content` of a kind 1 Nostr event. + +4. **Each PQ key signs the attestation.** Each PQ signature scheme (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512) signs the attestation text. ML-KEM-768 does NOT sign — it's a KEM (Key Encapsulation Mechanism), not a signature scheme. Its public key is included in a tag without a signature. + +5. **Your existing identity signs the events.** The kind 1 announcement (with PQ signatures in its tags) and a kind 11112 wrapper (which embeds the kind 1 event as JSON and carries the OpenTimestamps proof) are both signed by your **existing** Nostr identity via `window.nostr.signEvent()`. This authorizes the migration. + +6. **Timestamp the kind 1 event.** The SHA-256 of the full signed kind 1 event JSON is submitted to OpenTimestamps, anchoring it to the Bitcoin blockchain. This proves the event existed at this point in time, pre-quantum. + +7. **Publish both events to relays.** The kind 1 announcement and the kind 11112 wrapper are published. The client then polls for Bitcoin confirmation and republishes the kind 11112 wrapper with the confirmed OTS proof. + +**Note:** The seed-derived secp256k1 key (at `m/44'/1237'/0'/0/0`) is derived but not used to sign. There is no "successor signature." The security model relies on OTS precedence — the real event is anchored pre-quantum, and a forged event cannot be backdated to before that anchor. diff --git a/package.json b/package.json index 7023846..357598d 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "post_quantum_nostr", - "version": "1.0.0", + "version": "1.1.0", "description": "A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test test/*.test.mjs" }, "keywords": [], "author": "", diff --git a/plans/quality_fixes_plan.md b/plans/quality_fixes_plan.md new file mode 100644 index 0000000..26439e6 --- /dev/null +++ b/plans/quality_fixes_plan.md @@ -0,0 +1,212 @@ +# Software Quality Remediation Plan + +**Date:** 2026-07-17 +**Scope:** Address remaining open audit findings (F-H1 through F-H4, F-M1–F-M5, F-L1–F-L8) — all software-quality issues, not cryptographic-correctness gaps. +**Prerequisite:** F-C1 (reclassified), F-C2/F-C3 (fixed), F-C4 (resolved) are done. + +--- + +## Priority tiers + +The findings fall into three tiers by impact: + +- **Tier 1 (must-fix for production):** F-H1 (tests), F-H2 (id checks), F-H3 (XSS), F-H4 (CSP/SRI), F-L8 (doc leftover) +- **Tier 2 (should-fix):** F-L2 (24-word default), F-L4 (e-tag id check), F-L5 (Content-Type), F-L6 (relay close-as-success), F-M5 (Falcon UI warning) +- **Tier 3 (nice-to-have / design decisions):** F-M1 (canonical attestation), F-M2 (block_height verification), F-M3 (xpub enforcement), F-M4 (truncation construction), F-L1 (zeroing), F-L3 (input validation), F-L7 (vendor nostr-login-lite) + +--- + +## Tier 1: Must-fix + +### F-H1: Add a test suite + +**What:** Create a test file using Node's built-in `test` runner (no new dependencies). Update `package.json` test script. + +**File:** `test/pq-crypto.test.mjs` (new) + +**Tests to include:** +1. **BIP32→PQ-seed derivation known-answer:** fixed mnemonic → expected BIP32 child private keys (verify determinism + correct path). +2. **PQ keygen determinism:** same seed → same pubkeys for all 5 algorithms. +3. **PQ sign→verify round-trip:** for each of ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512: sign a test message, verify passes; tamper message → verify fails; tamper signature → verify fails; wrong pubkey → verify fails. +4. **`verifyNIPQRContent` accept/reject:** + - Accept: genuine kind 1 event with valid PQ sigs + valid secp256k1 sig + matching e/sha256 tags. + - Reject: tampered content, tampered PQ sig, wrong pubkey, mismatched e tag, mismatched sha256 tag. +5. **`computeEventId` / `verifyNostrEvent`:** known Nostr test vector (from NIP-01 spec or a known event). +6. **OTS parser tests:** `isDetachedOtsFile` true on valid prefix, false on random bytes; `parseOtsFile` on `hello-world.txt.ots` → expected target digest + 1 Bitcoin attestation at height 358391; `parseOtsFile` on `incomplete.txt.ots` → pending attestation. +7. **`verifyOtsProof` F-C3 binding:** wrong expected digest → `verified: false` with "Target digest mismatch". + +**package.json change:** `"test": "node --test test/"` + +### F-H2: `verifyNostrEvent` check `event.id` + +**What:** Add an id check to [`verifyNostrEvent`](www/js/pq-crypto.mjs:385). + +**Change in `pq-crypto.mjs`:** +```js +export function verifyNostrEvent(event) { + const serialized = JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]); + const hash = sha256(new TextEncoder().encode(serialized)); + const computedId = bytesToHex(hash); + if (event.id && event.id.toLowerCase() !== computedId) return false; // NEW + const sig = hexToBytes(event.sig); + const pubkey = hexToBytes(event.pubkey); + return schnorr.verify(sig, hash, pubkey); +} +``` + +Also fix F-L4 in `verifyNIPQRContent`: assert `kind1Event.id` (if present) equals computed id, or ignore it and rely on the computed id. + +### F-H3: Fix XSS — replace `innerHTML` with `textContent` for dynamic strings + +**What:** Audit all `innerHTML` usages in [`index.html`](www/index.html) and [`verify.html`](www/verify.html). Replace with `textContent` where the content includes variables derived from relay/localStorage/user input. Keep `innerHTML` only for static HTML structure (no variables). + +**Key sites:** +- [`setStatus`](www/index.html:761) — used with `error.message` from relays. Change to `textContent`. +- [`www/index.html:750`](www/index.html:750) — `otsNotice.innerHTML` with `pendingOts.eventId`. Change to build the element with `textContent` for the variable part. +- [`setStatus`](www/verify.html:403) — same pattern. Change to `textContent`. +- [`www/verify.html`](www/verify.html) OTS info/badge — uses `innerHTML` with `hashMatchNote` etc. Change to `textContent`. + +**Approach:** Create a helper `function setText(element, text)` that uses `textContent`, and replace all `innerHTML` calls that include dynamic data. For cases that need HTML structure (e.g., a link inside a status message), build the DOM with `createElement` + `textContent` for the variable parts. + +### F-H4: Add CSP and SRI + +**What:** +1. Add a CSP meta tag to both `index.html` and `verify.html`: + ```html + + ``` + (Note: `style-src 'unsafe-inline'` is needed because the pages have inline ` @@ -171,32 +249,65 @@
Verify NIP-QR Migration Event
- Paste a NIP-QR event JSON below to verify all signatures. - This will check the secp256k1 signature and all 5 post-quantum signatures (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, and ML-KEM-768). + Verify post-quantum migration events (kind 11112) by querying relays for a user's pubkey, + or by pasting event JSON directly. The kind 11112 event wraps a kind 1 announcement + (embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks: + the kind 11112 secp256k1 signature, the embedded kind 1 event's secp256k1 signature, + the e tag (kind 1 event ID), the sha256 tag (full kind 1 event hash), all PQ signatures + (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768), and the OpenTimestamps proof.
- + +
+
- - -
- + @@ -204,6 +315,22 @@
Event content:
+ + + + + @@ -217,22 +344,70 @@ + + +