Compare commits

...
13 Commits
Author SHA1 Message Date
Laan Tungir 8039af473a Add inline braille spinner next to 'Wait for Bitcoin confirmation' label during OTS polling 2026-07-26 11:52:36 -04:00
Laan Tungir 2a1ddf3b17 Move site to /quantum-prep/, fix laantungir relay trailing-slash 301 WebSocket failure, swap damus→sendit.nosflare.com relay, add takeaway intro to index.html, add braille spinner to step-6 OTS polling 2026-07-26 11:44:07 -04:00
Laan Tungir 68050fc8a3 Fix signEvent: strip non-NIP-01 statementBytes field from kind 1 template before sending to signer (validateSignerOutput rejects extra fields) 2026-07-25 13:00:12 -04:00
Laan Tungir 14dc8a063c Fix bundle import path: ./pq-crypto.bundle.js → ../pq-crypto.bundle.js (bundle is at www/ root, not www/js/) 2026-07-25 11:14:53 -04:00
Laan Tungir 401c88511e Fable5 audit remediation: canonical OTS digest (H-1), anchor availability (H-2), algorithm policy reconciliation (M-1), trust-mode transparency (M-3/M-4/L-2), strict base64 (L-3), conformance vectors; 32 new tests, 137/137 passing 2026-07-25 11:04:08 -04:00
Laan Tungir cc66a5fa3e G56-17/19/20: remove unverified block_height tag from signed content, require 64-hex event id in verifyNostrEvent, show event size + relay limit warning in publish step, document relay limits in README 2026-07-25 07:36:25 -04:00
Laan Tungir 5ebc6d8d2a G56-10: zeroize secret typed arrays on sign-out (fill(0) before null), clear DOM seed display/input/entropy hint, clear clipboard, document JS zeroization limitation in README 2026-07-25 07:30:14 -04:00
Laan Tungir 5d6dbc9ecd G56-11/12/13/14/15/18: relay acceptance required for both events, resume validates signatures + persists complete events, OTS parser safe varuint + operation/branch budgets, commit lockfile + fix .gitmodules, G56-18 won't-fix (public events) 2026-07-24 14:43:29 -04:00
Laan Tungir 66babc5a7a G56-08: apply nginx security headers on server (HSTS, nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP, CORP) scoped to /post-quantum/ only — all High-severity findings now addressed 2026-07-24 13:14:32 -04:00
Laan Tungir 87a8893785 G56-08: browser hardening — extract inline JS to external files (index-app.mjs, verify-app.mjs, version-display.js), remove unsafe-inline from script-src CSP, add frame-ancestors none + referrer-policy, SRI hashes on signer scripts, nginx security headers config 2026-07-24 11:09:50 -04:00
Laan Tungir 5bc36c37e7 G56-06: document honest continuity position (asserted not proven — no in-browser crypto can prove seed-to-key binding when browser is the adversary); reject Mode 1 (no signers accept seeds) and Mode 2 (fakeable by same browser); add out-of-band verification recommendation to UI 2026-07-24 10:30:29 -04:00
Laan Tungir f29f63950b G56-04: multi-explorer cross-check (fail on disagreement) + trustMode field in verifyOtsProof result (multi-explorer-checked/single-explorer-checked/structural-only) + UI trust-mode labels + remove misleading lite-client comments 2026-07-24 10:15:01 -04:00
Laan Tungir a1e1759704 G56-05: validateSignerOutput() helper — checks all fields, verifies no template field changed, pubkey matches identity, recomputes ID, verifies Schnorr sig, rejects extra fields; replaced reconstruction at all 4 signEvent() call sites; 15 new tests 2026-07-24 09:42:06 -04:00
29 changed files with 6376 additions and 2804 deletions
-1
View File
@@ -7,7 +7,6 @@ build/
# Node.js
node_modules/
package-lock.json
# Source maps
*.bundle.js.map
+3
View File
@@ -0,0 +1,3 @@
[submodule "resources/javascript-opentimestamps"]
path = resources/javascript-opentimestamps
url = https://github.com/opentimestamps/javascript-opentimestamps.git
+127 -619
View File
@@ -1,18 +1,57 @@
# Post-Quantum Nostr
# Nostr Quantum Preparation
> **⚠️ Research prototype.** This project is an experimental pre-quantum key-commitment and
> identity-link system. It does **not**, by itself, make a Nostr identity post-quantum secure:
> it links PQ keys to an existing identity and anchors that link in Bitcoin. Complete migration
> requires companion protocols (PQ event authentication, rotation, encryption, client adoption)
> that are not yet specified or implemented. Do not enter a valuable existing mnemonic into the
> web app. See the [implementation status](#implementation-status) section for what exists today.
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.
## TLDR
### Problem
Quantum computers will break secp256k1 which nostr relies on for its public private key pair. This means that given an npub, a quantum computer will be able to derive your nsec, read all your encrypted data and sign events as you.
We have new cryptography that we think is secure in a post quantum world that we can use in nostr, but the field is still evolving, and we have no idea of how much time we have left to migrate nostr. It could be we have 1 year or 100 years until quantum computers arrive. This makes settling on new cryptography difficult.
### Solution
This project is an interim solution to the problem for those who are concerned about keeping their identities secure through the transition, and also to give developers breathing room.
This project takes what I have found as the likely post quantum algorithms, and will generate for you a new public private key pair for all of them.
I have currently used the following post quantum algorithms:
- ML-DSA-44
- ML-DSA-65
- SLH-DSA-128s
- Falcon-512
- ML-KEM-768
This app will generate public-private keypairs for each of these, publish on nostr your public key for each, and sign it with your current Nostr identity via your favorite signer (the nsec never leaves the signer).
You are making a statement to the world: "Here are post quantum pubkeys. If quantum computers hit, you know that the person signing using these can only be me."
This entire kind 1 event is then hashed and stamped into a block on Bitcoin using OpenTimestamps.
The OpenTimestamps pending proof is published to nostr as a (new) kind 9999 event. If the app is left open, it will watch for the confirmation that your event has been timestamped on Bitcoin, and then publish a second kind 9999 with the upgraded timestamp.
You can then store the new seed phrase that was generated, along with the kind 9999 event(s), and if quantum computers suddenly appear, you have an identity that could only have been created by you.
It's interesting to note that even if a nostr consensus decides not to use any of these algorithms, as long as any of them are resistant to quantum attacks, you can use it to prove your transition from your current nostr identity to your new nostr identity.
I invite you to try it out, and give me any feedback. Run through it with a test identity before you use it on your real identity.
Note that I consider using this app low stakes, in that you are not revealing to the app your current private key. You are using a nostr signer. And also, you don't HAVE to use these public keys in the future. They are just sitting out there in case you ever need them. And if you screw up, or lose your seed phrase, just run the whole thing again.
https://laantungir.net/quantum-prep/
> **⚠️ Warning.** Do not enter a valuable existing mnemonic into the web app. This project links
> post-quantum keys to an existing Nostr identity and anchors that link in Bitcoin. It does **not**,
> by itself, make a Nostr identity post-quantum secure: complete migration requires companion
> protocols (PQ event authentication, rotation, encryption, client adoption) that are not yet
> specified or implemented. See the [implementation status](#implementation-status) section for what
> exists today.
A preparation strategy for the eventual post-quantum migration of Nostr — without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.
## Table of Contents
- [The Problem](#the-problem)
- [Why Bitcoin's Hash-the-Pubkey Trick Doesn't Work for Nostr](#why-bitcoins-hash-the-pubkey-trick-doesnt-work-for-nostr)
- [The Strategy: Overview](#the-strategy-overview)
- [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)
@@ -20,7 +59,6 @@ A migration strategy for bringing post-quantum security to Nostr without breakin
- [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)
- [Implementation Status](#implementation-status)
@@ -28,52 +66,9 @@ A migration strategy for bringing post-quantum security to Nostr without breakin
## The Problem
Nostr's cryptography is built entirely on secp256k1:
Nostr's identity, authentication, and encryption (NIP-04 and NIP-44) all rely on secp256k1. **Shor's algorithm** breaks secp256k1 in polynomial time on a sufficiently large quantum computer, so an attacker who has recorded any of your events can recover your private key, forge signatures, decrypt your messages, and impersonate you.
- **Identity**: Your pubkey IS your identity — it's in every event, it's your npub, it's what filters use, it's what NIP-05 verifies, it's what the social graph is built on.
- **Authentication**: Every event is signed with a Schnorr signature over secp256k1. Signature verification requires the public key, which is published in plaintext on every event.
- **Encryption (NIP-04)**: Uses secp256k1 ECDH to derive a shared secret, then AES-256-CBC. The shared X coordinate is used directly as the AES key. The IV is published in plaintext.
- **Encryption (NIP-44)**: Uses secp256k1 ECDH to derive a shared secret, then HKDF → ChaCha20 + HMAC-SHA256. The nonce is published in plaintext inside the payload.
**Shor's algorithm** breaks the elliptic curve discrete logarithm problem on secp256k1 in polynomial time. With a sufficiently large fault-tolerant quantum computer, an attacker who has recorded any Nostr event can:
1. Extract the public key from the event.
2. Run Shor's algorithm to recover the private key.
3. Forge signatures, decrypt all NIP-04/NIP-44 messages, and impersonate the user.
### NIP-04 vs NIP-44: No Quantum Advantage
NIP-44 is a substantial classical-security upgrade over NIP-04 (authenticated encryption, key separation, per-message keys, better cipher, length-hiding padding), but it offers **zero** post-quantum security advantage. Both derive their shared secret from secp256k1 ECDH. Everything NIP-44 adds (HKDF, per-message nonces, ChaCha20, HMAC-SHA256, custom padding) sits downstream of the broken ECDH step.
The NIP-44 spec itself acknowledges this:
> No post-quantum security: a powerful quantum computer would be able to decrypt the messages
The symmetric primitives in both NIPs (AES-256, ChaCha20, HMAC-SHA256, HKDF-SHA256) are adequately post-quantum (~128-bit PQ security via Grover's quadratic speedup). The weakness is exclusively the secp256k1 ECDH key agreement, which has zero post-quantum security in both NIPs.
---
## Why Bitcoin's Hash-the-Pubkey Trick Doesn't Work for Nostr
Bitcoin has limited quantum resistance because addresses are hashes of pubkeys (RIPEMD160(SHA256(pubkey))), and pubkeys are only revealed at spend time. This works because of three properties that Nostr does not have:
| Property | Bitcoin | Nostr |
|---|---|---|
| Identifier | Address (hash of pubkey) | Pubkey directly |
| Pubkey revealed | At spend time only | On every event |
| After reveal | UTXO consumed, key discarded | Identity persists, key reused |
In Nostr, the pubkey is load-bearing in ways it isn't in Bitcoin:
1. **The pubkey IS the identity.** It's in every event, it's your npub, it's what filters use, it's what NIP-05 verifies, it's what the social graph is built on.
2. **The pubkey must be revealed for every event.** Schnorr signature verification requires the public key. You can't verify a signature with a hash of the pubkey.
3. **Identity is never consumed.** You publish thousands of events over years with the same pubkey. There's no "consume and rotate" moment.
If Nostr published hash(pubkey) instead, you'd get quantum resistance for exactly **zero seconds** — the time between key generation and your first event publication, at which point the pubkey must be revealed for signature verification.
The only ways to avoid revealing the pubkey (ZK-SNARKs, giving up public verifiability, one-time keys per event) are impractical or fundamentally change what Nostr is.
**The real fix is post-quantum signatures and post-quantum key agreement, not hashing the pubkey.** This document describes how to get there without breaking Nostr.
NIP-44 is a real classical-security upgrade over NIP-04, but it offers **zero** post-quantum advantage: both derive their shared secret from secp256k1 ECDH, and everything NIP-44 adds (HKDF, ChaCha20, HMAC, padding) sits downstream of that broken step. The symmetric primitives themselves (AES-256, ChaCha20, HMAC-SHA256) are adequately post-quantum; the weakness is exclusively the ECDH key agreement.
---
@@ -83,7 +78,7 @@ The only ways to avoid revealing the pubkey (ZK-SNARKs, giving up public verifia
flowchart TD
subgraph SECURITY["Security Layers - All Quantum-Resistant"]
L1[Layer 1: Seed phrase - PQ-safe root of trust]
L2[Layer 2: Key-link event + OTS - PQ-safe identity migration]
L2[Layer 2: Key-link event + OTS - PQ-safe identity preparation]
L3[Layer 3: PQ signatures - PQ-safe future authentication]
L4[Layer 4: OTS on important events - PQ-safe historical provenance]
L5[Layer 5: OTP or PQ encryption - PQ-safe confidentiality]
@@ -94,7 +89,7 @@ flowchart TD
L2 --> L4
```
The strategy uses existing Nostr infrastructure (NIP-06, NIP-03) plus one new event type (a key-link NIP). No new cryptographic primitives are needed for the migration itself — PQ algorithms are used additively, and the community doesn't need to agree on which one to use.
The strategy uses existing Nostr infrastructure (NIP-06, NIP-03) plus one new event type (a key-link NIP). No new cryptographic primitives are needed for the preparation itself — PQ algorithms are used additively, and the community doesn't need to agree on which one to use.
### Design Principles
@@ -109,30 +104,9 @@ The strategy uses existing Nostr infrastructure (NIP-06, NIP-03) plus one new ev
## Component 1: Seed Phrase as Algorithm-Agnostic Root of Trust
The BIP39 seed is a 64-byte entropy string derived from a mnemonic via PBKDF2-HMAC-SHA512 (2048 iterations). This is a symmetric KDF no public-key cryptography involved. A quantum computer provides no advantage beyond Grover's quadratic speedup.
The BIP39 seed is 64 bytes of entropy derived from a mnemonic via PBKDF2-HMAC-SHA512 a symmetric KDF with no public-key cryptography, so a quantum computer gains nothing beyond Grover's quadratic speedup. The seed is algorithm-agnostic: you can derive any key type from it.
**The seed is algorithm-agnostic.** It's just entropy. You can derive any key type from it:
```mermaid
flowchart TD
SEED[BIP39 Seed - 64 bytes, recovery root]
SEED --> BIP32[BIP32 derivation - secp256k1 keypair]
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 FALCON fill:#cfc
```
### PQ Key Derivation from BIP39 Seeds
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.
All keys — both secp256k1 and PQ — are derived from the BIP39 seed via **BIP32 hierarchical deterministic derivation**, the same standard NIP-06 uses for secp256k1. 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()`.
**Base path:** `m/44'/1237'/0'/0/` (NIP-06 account 0, change 0)
@@ -145,504 +119,134 @@ All keys — both secp256k1 and PQ — are derived from the BIP39 seed via **BIP
| 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
**Truncation rule (pinned for compatibility):** when a PQ seed needs more than 32 bytes, two BIP32 children are derived and concatenated (64 bytes total). For 48-byte seeds, the **first 48 bytes** are used; for 64-byte seeds, all 64 bytes are used. Implementations must use the first N bytes — taking the last 48 would produce different keys and break compatibility.
BIP32 child derivation produces exactly 32 bytes (the private key) per child. Some PQ algorithms need more than 32 bytes for their `keygen()` seed:
> **⚠️ Continuity is asserted, not proven (G56-06).** The old Nostr identity *authorizes* the PQ
> keys by signing an event that contains them. This is an authorization, not a cryptographic proof
> that the PQ keys were derived from the seed phrase shown to the user. A compromised browser could
> display one mnemonic while publishing attacker-controlled PQ keys. **The only real proof is
> out-of-band verification:** after the key link is published, take the seed phrase to a *different* device,
> derive the keys independently, and confirm the public keys match the published event. This is the
> one step a compromised browser cannot fake.
- **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)
### Seed entropy
**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.
The implementation defaults to 24-word (256-bit) mnemonics, with a 12-word option for testing. Under Grover's quadratic speedup, 12 words leaves only ~64 bits of PQ security; 24 words gives ~128 bits. Use 24 words for a real PQ recovery root. The bigger risk to seed phrases is classical (theft, phishing), not quantum — treat the phrase as a high-value secret and store it offline.
### Why BIP32 and not HKDF?
### Secret material lifetime
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:
```
HMAC-SHA512(parent_chain_code, data) → 32-byte private key + 32-byte chain code
```
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
| Mnemonic length | Entropy | Post-quantum security (Grover's) | Assessment |
|---|---|---|---|
| 12 words | 128 bits | ~64 bits | Testing only — weaker under a quantum brute-force model |
| 24 words | 256 bits | ~128 bits | Recommended for a PQ recovery root |
The implementation defaults to 24-word (256-bit) mnemonics, with a 12-word option available for
testing. Users concerned about quantum attacks on the seed entropy itself should use 24 words. The
bigger risk to seed phrases is classical (theft, phishing), not quantum. Note that BIP39's PBKDF2
iteration count is low; the seed phrase should be treated as a high-value secret and stored offline.
On sign-out, the app zeroizes all `Uint8Array` secret buffers (BIP39 seed, secp private key, five PQ secret keys), clears the DOM, and clears the clipboard. **JavaScript garbage collection does not guarantee erasure** — the browser runtime may retain copies internally. The real fix is moving key derivation and signing into a hardware or native signer boundary; the in-browser zeroization is a best-effort reduction of the exposure window, not a guarantee.
---
## Component 2: Multi-Scheme Cross-Signed Key-Link Events
This is the core innovation. Instead of choosing one PQ algorithm, **cross-sign with all viable PQ schemes now.** Let consensus emerge later.
This is the core innovation. Instead of choosing one PQ algorithm, **cross-sign with all viable PQ schemes now** and let consensus emerge later. The PQ standardization landscape is still evolving (ML-DSA and ML-KEM are finalized, Falcon is still draft, SIKE was a NIST finalist broken in 2022 on a laptop), so hedging across schemes hedges against any single scheme falling.
### The Standardization Uncertainty Problem
The key link uses **two Nostr events**:
The PQ standardization landscape is still evolving:
### 1. Kind 1 Announcement (human-readable, visible in Nostr feeds)
| Scheme | NIST Status | Concern |
|---|---|---|
| ML-DSA (Dilithium) | FIPS 204, finalized 2024 | Lattice-based — could have undiscovered structural weaknesses |
| SLH-DSA (SPHINCS+) | FIPS 205, finalized 2024 | Hash-based, very conservative, but huge signatures (8-50 KB) |
| FN-DSA (Falcon) | FIPS 206, still draft | Compact but complex FFT, implementation concerns |
| ML-KEM (Kyber) | FIPS 203, finalized 2024 | Lattice-based — same class as Dilithium |
A regular Nostr text note whose `content` is a human-readable attestation naming the user's npub and listing the PQ algorithms. The PQ public keys and signatures go in `algorithm` tags. Each PQ signature scheme signs `TextEncoder.encode(content)`; the event is signed with the user's **existing** Nostr identity via `window.nostr.signEvent` (NIP-07).
History gives reason to be cautious: SIKE was a NIST PQ finalist that was broken in 2022 using a classical algorithm that ran in an hour on a laptop. If the Nostr community had picked SIKE early, we'd be back to square one.
### 2. Kind 9999 Proof Carrier (machine-verifiable, carries the OTS proof)
### The Multi-Scheme Solution
A non-replaceable event (kind 9999, in the 09999 range) whose `content` is the full signed kind 1 event as JSON (so verifiers don't need to fetch it from relays) and whose tags carry the OpenTimestamps proof. Each publication is permanent — upgrades publish a *new* kind 9999 with an `upgrade_of` tag referencing the previous one; both remain on relays. The non-replaceable range is load-bearing for this append-only design.
The implementation includes **five** PQ schemes — four signature schemes and one KEM:
**Tags:** `['e', '<kind1 id>']`, `['sha256', '<hex canonical digest of the signed kind 1 event>']`, `['digest_version', '<version>']`, `['ots', '<base64 .ots proof>']`, `['ots_status', 'pending'|'confirmed']`, and optionally `['upgrade_of', '<previous proof carrier id>']`. The wrapper is signed with the user's existing identity.
```mermaid
flowchart TD
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]
### What this proves and what it does not
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]
**Proves:**
- Each PQ signature scheme (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512) signed the attestation text — verified against the pubkey in its tag.
- The user's existing Nostr identity authorized the key link — verified by the secp256k1 Schnorr signature on both events.
- The event is anchored to a pre-quantum Bitcoin block via OpenTimestamps — the event with the earliest valid OTS proof wins.
SIGN1 --> KIND1[Kind 1 Announcement Event]
SIGN2 --> KIND1
SIGN3 --> KIND1
SIGN4 --> KIND1
MLKEM --> KIND1
**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. Common-origin is **asserted** in the attestation and **authorized** by the existing identity's signature, not proven by a seed-derived signature.
- The seed-derived secp256k1 key (child 0) is derived but **not used to sign**. There is no "successor signature."
SECP --> ACCT1[Account 1 - existing identity signs kind 1 and kind 9999]
This is deliberate. 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.
KIND1 --> OTS[OpenTimestamp the kind 1 event hash]
OTS --> KIND9999[Kind 9999 Proof Carrier - carries OTS proof]
KIND9999 --> RELAY[Published to relays NOW]
### ML-KEM cannot sign
RELAY --> CLIENT1[PQ-aware client - picks ML-DSA]
RELAY --> CLIENT2[PQ-aware client - picks SLH-DSA]
RELAY --> CLIENT3[Legacy client - ignores all PQ keys]
ML-KEM (Kyber) is a KEM (Key Encapsulation Mechanism), not a signature scheme, so it cannot sign the attestation. Its public key is included in an `algorithm` tag without a signature field; its ownership is asserted by the attestation text and authorized by the existing identity's signature over the kind 1 event (which covers the tags). This is weaker than a cross-signature but sufficient: the secp256k1 signature is valid now (pre-quantum), and the ML-KEM key is for encryption, not authentication — a false ownership claim is self-correcting (the claimant couldn't decrypt messages sent to that key).
style KIND1 fill:#ffa
style KIND9999 fill:#ffa
style OTS fill:#cfc
```
### Revocation
### The Two-Event Structure
The migration uses **two Nostr events**:
#### 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: <npub>
hex: <hex pubkey>
This attestation is established pre-quantum at Bitcoin block height <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.
```
**Tags:**
- `['block_height', '<height>']` — the Bitcoin block height at signing time
- `['algorithm', '<algorithm>', '<base64 pubkey>', '<base64 signature>']` — one per PQ signature scheme
- `['algorithm', 'ml-kem-768', '<base64 pubkey>']` — 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 9999 Proof Carrier (machine-verifiable, carries the OTS proof)
A non-replaceable event (kind 9999, in the 09999 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). Each publication is permanent — upgrades publish a new event with an `upgrade_of` tag referencing the previous one.
**Content:** `JSON.stringify(kind1Event)` — the full signed kind 1 event embedded as a JSON string.
**Tags:**
- `['e', '<kind1 event id>']` — reference to the kind 1 announcement
- `['sha256', '<hex SHA-256 of the full signed kind 1 event JSON>']` — what was timestamped
- `['ots', '<base64 .ots proof>']` — 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 9999 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 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
- The ML-KEM key is used for encryption, not authentication — a false claim of ownership would just mean someone can't decrypt messages sent to you, which is self-correcting
### Why This Is Better Than Picking One Scheme
| Concern | Pick-one approach | Multi-scheme approach |
|---|---|---|
| Scheme gets broken later | Migration fails, need new link | Other links still valid |
| Standardization changes | May need to re-link | Already covered |
| Consensus not reached | Can't proceed | Proceed immediately |
| Event size | Small | Larger (~20-30 KB), but one-time cost |
| Client complexity | Support one scheme | Support any subset |
| Race condition risk | Must publish before quantum | Published now, all schemes |
Event size is a non-issue because this is a **one-time publication**, not per-event overhead.
### Revocation: If a Scheme Is Broken Later
If a specific PQ scheme is later found to be weak (like SIKE was):
1. **Clients simply stop trusting the broken scheme** — no revocation event needed; clients just ignore links to algorithms on a known-broken list
2. The remaining links are still valid
3. Optionally, publish a new key-link event without the broken scheme, signed by the remaining valid PQ keys
If a PQ scheme is later found to be weak (like SIKE), clients simply stop trusting it — no revocation event needed. The remaining links stay valid. A user may optionally publish a new key-link event omitting the broken scheme, signed by the still-valid PQ keys.
---
## Component 3: OpenTimestamps for Pre-Quantum Anchoring
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 9999 proof carrier's `ots` tag.
NIP-03 (OpenTimestamps Attestations for Events) anchors Nostr event hashes to the Bitcoin blockchain. The implementation submits the **canonical digest** of the fully signed kind 1 event to OpenTimestamps calendar servers and embeds the resulting `.ots` proof in the kind 9999 proof carrier's `ots` tag. The proof carrier *carries* the proof; the kind 1 event *is* what's timestamped.
### Why Bitcoin Timestamping Is Quantum-Resistant
The canonical digest (version 1) is `sha256(JSON.stringify([0, pubkey, created_at, kind, tags, content, id, sig]))` — the NIP-01 event-ID serialization array extended with the `id` and `sig` fields. This is reproducible by any conforming implementation (including non-JS ones and verifiers that re-fetch the event from a relay with different key order), unlike `sha256(JSON.stringify(event))` which is key-order-dependent. The proof carrier records the digest version in a `digest_version` tag; verifiers fail closed on unknown versions. Legacy v0 proof carriers (pre-canonicalization) committed to `sha256(JSON.stringify(event))` and are accepted only for historical inspection, not as valid for the key link.
The Bitcoin proof-of-work chain is quantum-resistant — not because Bitcoin's signatures are PQ-safe (they're not for revealed pubkeys), but because **re-mining historical blocks is infeasible even with a quantum computer.** Grover's algorithm gives only a quadratic speedup on mining, which doesn't help retroactively alter past blocks.
### Why Bitcoin timestamping is quantum-resistant
An attacker who breaks your secp256k1 key via Shor's can forge new events but **cannot backdate an event to before a specific Bitcoin block** — they'd need to produce a valid OTS proof anchoring it to a past block, which requires either:
- Re-mining that Bitcoin block (impossible, even with a quantum computer, due to cumulative proof-of-work)
- Finding a SHA256 preimage for the event hash (still infeasible with Grover's quadratic speedup on 256-bit hashes)
Bitcoin's proof-of-work chain is quantum-resistant not because Bitcoin's signatures are PQ-safe (they're not, for revealed pubkeys) but because **re-mining historical blocks is infeasible even with a quantum computer.** Grover's gives only a quadratic speedup on mining, which doesn't help retroactively alter past blocks. An attacker who breaks your secp256k1 key via Shor's can forge new events but **cannot backdate an event to before a specific Bitcoin block** — they'd need to re-mine that block (impossible) or find a SHA-256 preimage (still infeasible).
### The Critical Refinement: OpenTimestamp the Key-Link Event
The kind 1 announcement event MUST be timestamped via OpenTimestamps. This prevents a race condition attack:
### Why the key-link event must be anchored
**Without OTS on the key-link event:**
- Attacker breaks secp256k1 key via Shor's
- Attacker publishes a fraudulent key-link event linking your secp256k1 key to their PQ key
- Attacker publishes a fraudulent key-link event linking your key to their PQ key
- Both real and fraudulent events have valid secp256k1 signatures (key is compromised)
- Clients can't distinguish them — `created_at` is forgeable
**With OTS on the key-link event:**
- Your real key-link event is anchored to Bitcoin block N (before quantum computers)
- Attacker's fraudulent event is published later and cannot produce an OTS proof for block N or earlier
- Your real event is anchored to Bitcoin block N (before quantum computers)
- The attacker's fraudulent event is published later and cannot produce an OTS proof for block N or earlier
- 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
### Current OTS verification status
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 9999 proof carrier's `sha256` tag.
3. The `.ots` proof is embedded in the kind 9999 proof carrier's `ots` tag.
> **Trust model.** The current OTS verification is **API-assisted**, not a full Bitcoin light-client
> verification. Treat results accordingly.
The kind 9999 proof carrier *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.
The current implementation parses the `.ots` proof, validates the Merkle path against a block header from a public Bitcoin explorer API (mempool.space), and detects confirmation by searching the proof bytes for the block-header attestation magic. Proof *upgrading* (asking calendars for a confirmed proof) is delegated to a server-side helper.
### Current OTS Verification Status
> **Research prototype — trust model.** The current OTS verification is **API-assisted**, not a
> full Bitcoin light-client verification. Treat results accordingly.
The current implementation:
- Parses the `.ots` proof and validates the Merkle path against a block header obtained from a
public Bitcoin explorer API (mempool.space).
- Detects Bitcoin confirmation by searching the proof bytes for the block-header attestation magic
bytes and confirming the attested block height via the same API.
- Delegates proof *upgrading* (asking calendars for a confirmed proof) to a server-side helper.
**What is NOT done:**
- **Full light-client verification** — validating the block header's proof-of-work, difficulty, and
chain linkage independently, without trusting an explorer API. The vendored
`javascript-opentimestamps` library in `resources/` provides primitives for this and will be
integrated in a future release.
- **Multi-explorer cross-checking** — comparing independent APIs and rejecting disagreement.
Because the block header is trusted from a single API, a compromised or malicious explorer could
falsify a confirmation. The UI labels this as "API-checked," not "cryptographically verified on
Bitcoin."
### What Should Be OpenTimestamped
Not every event needs OTS. Priority list:
1. **Key-link events** — absolutely critical, foundation of PQ migration
2. **Identity-defining events** — NIP-05 verification, profile events (kind 0)
3. **Important content** — contracts, announcements, evidence
4. **High-value events** — financial events (NIP-60 Cashu wallets, zaps)
Routine events (regular posts, reactions) probably don't need OTS — the impact of retroactive forgery is low.
**Not yet done:** full light-client verification (validating block headers, proof-of-work, difficulty, and chain linkage independently, without trusting an explorer API) and multi-explorer cross-checking. Because the block header is trusted from a single API, a compromised explorer could falsify a confirmation — the UI labels this as "API-checked," not "cryptographically verified on Bitcoin." The vendored `javascript-opentimestamps` library in `resources/` provides the primitives for full light-client verification, which is planned for a future release.
---
## 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.
> **Status: Design only — not yet implemented.** The current implementation supports users who already have a Nostr identity via a NIP-07 signer. The raw-nsec preparation 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.
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. The challenge is that a raw nsec is 32 random bytes — it can't be "derived" from a seed (that's a preimage problem), so it must be **encoded** (directly or encrypted) alongside a new seed phrase.
### The Challenge
Three approaches have been designed:
A secp256k1 private key is 32 bytes (256 bits). BIP39 encodes 11 bits per word. Encoding the old nsec requires 24 extra words. The old nsec was generated randomly — it cannot be "derived" from a seed (that's a preimage problem). It must be **encoded** (directly or encrypted).
- **Approach A — 36-word phrase:** Generate a 12-word BIP39 seed, derive an encryption key from it, encrypt the old nsec with ChaCha20, and encode the 32-byte ciphertext as 24 more BIP39 words. The full 36-word phrase recovers both keys; the first 12 alone recover the PQ keys after the eventual migration.
- **Approach B — 12 words + relay backup (recommended):** Generate a 12-word phrase, derive the new secp key, PQ keys, and a symmetric encryption key, encrypt the old nsec, and publish it as a kind 30078 event signed with the new key. Recovery needs only the 12 words plus relay access to fetch and decrypt the event.
- **Approach C — Hybrid:** Primary backup is 12 words (relay recovery); optional fallback is also writing the 24 extra encrypted-nsec words in case relays are unavailable.
### Approach A: Extended Phrase (36 words)
```mermaid
flowchart LR
subgraph CREATION["One-time migration"]
OLD[Old nsec - 32 bytes]
NEW[Generate 12-word BIP39 phrase]
NEW --> SEED[BIP39 seed]
SEED --> ENCKEY[Derive encryption key via HKDF]
ENCKEY --> ENC[Encrypt old nsec: ChaCha20]
OLD --> ENC
ENC --> CIPHER[32 bytes encrypted nsec]
CIPHER --> WORDS[Encode as 24 BIP39 words + checksum]
NEW --> PHRASE[Full phrase: 12 + 24 = 36 words]
WORDS --> PHRASE
end
subgraph RECOVERY["Recovery with full phrase"]
FULL[36 words entered]
FULL --> FIRST[First 12 words]
FULL --> LAST[Last 24 words]
FIRST --> SEED2[BIP39 seed]
SEED2 --> ENCKEY2[Derive encryption key]
LAST --> CIPHER2[Decode to 32 bytes]
ENCKEY2 --> DEC[Decrypt]
CIPHER2 --> DEC
DEC --> OLDRECOV[Old nsec recovered]
SEED2 --> PQKEYS[PQ keys derived]
end
subgraph FUTURE["After migration - drop old key"]
FIRST12[First 12 words only]
FIRST12 --> SEED3[BIP39 seed]
SEED3 --> PQKEYS2[PQ keys only]
end
```
**Design:**
1. Generate a standard 12-word BIP39 phrase (128 bits entropy)
2. Derive a 32-byte encryption key from the BIP39 seed: `enc_key = HKDF-Extract(salt="nostr-legacy-key", ikm=bip39_seed)`
3. Encrypt the old nsec: `encrypted_nsec = ChaCha20(enc_key, nonce=0, old_nsec)` → 32 bytes
4. Encode the 32-byte encrypted nsec as 24 BIP39 words (with its own checksum)
5. Full phrase: 12 words (new seed) + 24 words (encrypted old nsec) = 36 words
**Recovery with 36 words:** First 12 → BIP39 seed → PQ keys + encryption key. Last 24 → decode → decrypt → old nsec. Both keys recovered.
**Recovery with 12 words (after migration):** First 12 → PQ keys only. Old nsec no longer needed.
**Why encrypt the old nsec?** The extra 24 words are meaningless without the first 12 (partial compromise protection). If someone steals only the last 24 words: useless. If someone steals only the first 12 words: they get PQ keys but not the old nsec.
### Approach B: 12 Words + Relay Backup (Recommended)
```mermaid
flowchart TD
subgraph SETUP["One-time migration"]
OLD[Old nsec]
NEW[Generate 12-word BIP39 phrase]
NEW --> SEED[BIP39 seed]
SEED --> NEWSECP[New secp256k1 key via NIP-06]
SEED --> PQKEYS[PQ keys via BIP32]
SEED --> ENCKEY[Symmetric encryption key via HKDF]
ENCKEY --> ENC[Encrypt old nsec]
OLD --> ENC
ENC --> EVENT30078[kind 30078: encrypted old nsec]
NEWSECP --> SIGN1[Sign event with new secp256k1 key]
SIGN1 --> EVENT30078
EVENT30078 --> RELAY[Publish to relays]
OLD --> KEYLINK[Key-link event: cross-signed]
NEWSECP --> KEYLINK
PQKEYS --> KEYLINK
KEYLINK --> OTS[OpenTimestamp via NIP-03]
OTS --> BTC[Anchored in Bitcoin]
KEYLINK --> RELAY
end
subgraph RECOVER["Recovery from 12 words only"]
WORDS[12 words entered]
WORDS --> SEED2[BIP39 seed]
SEED2 --> NEWSECP2[New secp256k1 key]
SEED2 --> PQKEYS2[PQ keys]
SEED2 --> ENCKEY2[Encryption key]
NEWSECP2 --> FETCH[Fetch kind 30078 from relays]
FETCH --> CIPHER[Encrypted old nsec]
ENCKEY2 --> DEC[Decrypt]
CIPHER --> DEC
DEC --> OLDRECOV[Old nsec recovered]
end
```
**Design:**
1. Generate a 12-word BIP39 phrase
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)
6. User backs up only 12 words
**Recovery:** 12 words → derive new key + encryption key → fetch kind 30078 from relays → decrypt → old nsec. Both keys recovered from 12 words + relay access.
**No circular dependency:** The kind 30078 event is found by the new pubkey (derived from the 12-word seed). The new secp256k1 key is the "storage identity." The old nsec is just data inside the event.
### Approach C: Hybrid (Best of Both)
- Primary backup: 12-word phrase (PQ keys + relay recovery of old nsec)
- Optional fallback: also write down 24 extra words (encrypted old nsec) in case relays are unavailable
- If relays available: 12 words suffice
- If relays down: 12 + 24 = 36 words recover everything
### Comparison
| Property | 36-word phrase | 12-word + relay backup | Hybrid |
|---|---|---|---|
| Backup size | 36 words | 12 words | 12 or 36 words |
| Relay dependency | None | Yes | Optional |
| Custom format | Yes | No | Optional |
| Self-contained | Yes | No | Yes (with 36) |
| Quantum-safe | Yes | Yes | Yes |
All three are quantum-safe. Approach B is recommended because it minimizes what the user must back up.
---
## 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.
> **Status: Design only — not yet implemented.**
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:
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 are proposed:
### Option A: One-Time Pad (Information-Theoretic Security)
- **One-time pad (OTP):** Information-theoretic security — the strongest guarantee in cryptography, stronger than any computational security including post-quantum. The pad must be truly random, as long as the data, never reused, and stored securely (e.g., USB drive). Self-storage sidesteps OTP's usual key-distribution problem since you're encrypting for yourself. Pad reuse is fatal: reusing any section breaks the encryption.
- **Symmetric key from seed:** Derive a 256-bit key from the BIP39 seed (independent of secp256k1) via HKDF and encrypt with ChaCha20-Poly1305 or AES-256-GCM. Simpler than OTP (no pad management), computationally PQ-secure (~128-bit), and adequate for most use cases.
A one-time pad provides **information-theoretic security** — the strongest security guarantee in cryptography, stronger than any computational security (including post-quantum). It's a mathematical proof of perfect secrecy: no computer that could ever exist, in any universe governed by the laws of physics, can break it.
**Requirements:**
- Pad must be truly random (hardware RNG recommended, e.g., TrueRNG)
- Pad must be as long as the data
- Pad must never be reused
- Pad must be stored securely (e.g., USB drive)
**For self-storage, OTP is a natural fit** because you sidestep OTP's biggest limitation (key distribution between parties) — you're encrypting for yourself, so the pad lives on your device.
**What a quantum attacker can and cannot do with OTP-encrypted kind 30078:**
- ❌ Read your stored data — OTP is information-theoretically secure
- ✅ Forge your signature — Shor's breaks secp256k1 (identity compromised)
- ✅ Impersonate you, delete/replace your events
- ❌ Decrypt your stored data — even with your private key, the data is protected by the pad
This gives **clean separation**: quantum breakage of your secp256k1 key compromises your identity/authentication but NOT your stored data's confidentiality.
**Critical warning: pad reuse is fatal.** If any pad section is used twice, XOR of the two ciphertexts equals XOR of the two plaintexts — the encryption breaks. Multi-device sync requires an atomic reservation protocol for pad sections.
### Option B: Symmetric Key from Seed (Computational PQ Security)
Derive a 256-bit symmetric key from the BIP39 seed (independent of secp256k1) and encrypt with ChaCha20-Poly1305 or AES-256-GCM:
```
storage_key = HKDF-Extract(salt="nostr-storage-v1", ikm=bip39_seed)
ciphertext = ChaCha20-Poly1305(storage_key, nonce, plaintext)
```
This is post-quantum because:
- HKDF-SHA256 is symmetric (~128-bit PQ security)
- ChaCha20 / AES-256 are symmetric (~128-bit PQ security)
- The secp256k1 key is never part of the encryption path
**Tradeoff vs OTP:** Simpler (no pad management, no USB drive), but computationally secure rather than information-theoretically secure. Adequate for most use cases.
### Comparison
| Property | OTP | Symmetric key from seed |
|---|---|---|
| Security level | Information-theoretic (perfect secrecy) | Computational, ~128-bit PQ |
| Key material | Pad as long as all data | Single 256-bit key |
| Storage overhead | Pad on USB drive | Just the seed phrase |
| Quantum resistance | Unconditional | Conditional (Grover's is optimal) |
| Future-proofing | Permanent | Could theoretically fall to new quantum algorithms |
| Multi-device | Requires pad sync + offset coordination | Just share the seed |
| Practicality | More complex | Simpler |
OTP gives clean separation: a quantum break of your secp256k1 key compromises identity/authentication but **not** stored-data confidentiality.
---
## Component 6: Deterministic Wallet Compartmentalization
BIP32/BIP39 deterministic wallets (NIP-06) provide meaningful quantum compartmentalization through their tree structure.
BIP32/BIP39 deterministic wallets (NIP-06) provide meaningful quantum compartmentalization through their tree structure. The key insight is that **chain codes are secret** — when Shor's recovers a leaf private key from its published public key, the attacker gets only that key. To climb the tree they need the parent chain code, which is a symmetric HMAC-SHA512 primitive with no quantum advantage. Without chain codes, the attacker is stuck at the leaf level.
### The Core Protection: Chain Codes Are Secret
When Shor's algorithm recovers a leaf private key from its published public key, the attacker gets only that key. To climb the tree (recover the parent private key), they need the parent chain code:
```
parent_private_key = child_private_key - IL mod n
where IL = HMAC-SHA512(parent_chain_code, parent_public_key || index)[:32]
```
The parent chain code is NOT published in normal Nostr usage. HMAC-SHA512 is a symmetric primitive — a quantum computer provides no advantage. **Without chain codes, the attacker is stuck at the leaf level.**
### The Firewall Effect of Hardened Derivation
The NIP-06 path `m/44'/1237'/account'/0/0` has hardened derivation at three levels. This means:
- Breaking all keys in Account 0 does NOT compromise Account 1 (hardened at `account'` level)
- 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 18) 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:
1. Compute `IL` using the chain code + child public key + index
2. Recover the parent private key
3. Derive ALL children at that level
4. Continue climbing if parent chain codes are also known
**Where chain codes leak:** published xpubs (extended public keys), wallet software that exports xpubs, backup exposure. In standard Nostr usage, chain codes are not published.
### Summary
The NIP-06 path `m/44'/1237'/account'/0/0` has hardened derivation at three levels, so breaking all keys in Account 0 does **not** compromise Account 1, and the seed/master key remains secure even if all account keys are broken.
| Scenario | Compromised? |
|---|---|
@@ -653,127 +257,29 @@ If an attacker obtains a chain code at any level AND breaks one child key at tha
| Seed phrase stolen (classical) | Everything |
| Seed safe, quantum attacker, no chain codes leaked | Only published keys |
### Recommendations
1. Use separate accounts for separate Nostr identities — hardened derivation contains the damage
2. Never publish xpubs — chain codes are the keys to the kingdom
3. Use 24-word mnemonics for adequate PQ security on the seed entropy
4. Don't reuse keys across services — each identity gets its own account index
5. Assume published keys WILL be broken — plan for containment, not prevention
---
## The Complete Migration Flow
```mermaid
flowchart TD
subgraph NOW["Pre-quantum - NOW"]
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 --> KIND9999[Build kind 9999 proof carrier with OTS proof]
KIND9999 --> SECP2[Account 1 signs kind 9999 via signer]
SECP2 --> RELAY[Publish both events to relays]
SEED --> BACKUP[User writes down 12-word phrase]
end
subgraph CONFIRM["Bitcoin confirmation"]
POLL[Poll OTS upgrade service]
POLL --> UPGRADE[Upgrade .ots proof with Bitcoin attestation]
UPGRADE --> REPUBLISH[Publish new kind 9999 with confirmed proof]
end
subgraph FUTURE["Post-quantum - when quantum computers arrive"]
ATTACK[Attacker breaks secp256k1 via Shors]
ATTACK --> FORGE[Can forge secp256k1 signatures]
ATTACK --> CANNOT1[Cannot forge PQ signatures]
ATTACK --> CANNOT2[Cannot backdate events - OTS proofs anchor history]
VERIFY[Verify key-link event via OTS precedence]
VERIFY --> USEPQ[Switch to PQ signatures]
USEPQ --> REJECT[Reject forged secp256k1-only events]
end
NOW --> CONFIRM --> FUTURE
style NOW fill:#cfc
style CONFIRM fill:#ffa
style FUTURE fill:#fcc
```
### User Experience
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 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 9999 proof carrier
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 publishes a new kind 9999 proof carrier with the confirmed OTS proof (append-only — the original remains on relays)
9. After quantum migration: clients use PQ keys, old key retired
**Recommendations:** use separate accounts for separate identities; never publish xpubs (chain codes are the keys to the kingdom); use 24-word mnemonics; don't reuse keys across services; assume published keys *will* be broken and plan for containment, not prevention.
---
## What Remains Unsolved
### Historical Event Authenticity (Partially Solved)
Past secp256k1-signed events that were NOT OpenTimestamped remain forgeable after quantum break. An attacker can create fake old events that appear to be from you.
**Mitigation:** OpenTimestamp important events now (NIP-03). Events with valid OTS proofs are cryptographically anchored to a pre-quantum timestamp and cannot be forged retroactively. Events without OTS proofs become untrustworthy after quantum break.
**Not fully solved:** Routine events (regular posts, reactions) that aren't worth OpenTimestamping will become forgeable. The impact is low for most content, but high for events used as evidence, contracts, or historical records.
### Relay Trust for Recovery
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
- Use relays you control or trust
- Use the hybrid approach (Approach C) with 36-word fallback
- The event is small and signed, so it's easy to preserve
### PQ Signature Size
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 |
| Falcon-512 | ~0.7 KB |
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.
- **Historical event authenticity (partially solved):** Past secp256k1-signed events that were *not* OpenTimestamped remain forgeable after a quantum break. OpenTimestamping important events now (NIP-03) anchors them cryptographically; routine events that aren't worth timestamping will become forgeable, but the impact is low for most content.
- **Relay trust for recovery:** Approach B (Component 4, design only) depends on relays retaining the kind 30078 event containing the encrypted old nsec. Mitigations: publish to multiple relays, use relays you control, or use the hybrid 36-word fallback.
- **PQ signature size:** PQ signatures are much larger than secp256k1 Schnorr (64 bytes) — ML-DSA-44 ~2.4 KB, ML-DSA-65 ~3.3 KB, SLH-DSA-128s ~8 KB, Falcon-512 ~0.7 KB. The kind 1 announcement is ~2030 KB total, a one-time cost for the key-link event.
- **PQ algorithm risk:** Any PQ algorithm could theoretically be broken (as SIKE was). The multi-scheme approach hedges; if all lattice-based schemes fall simultaneously, only SLH-DSA (hash-based, very conservative) remains.
- **Common-origin not cryptographically proven:** See Component 2. OTS precedence, not a seed-derived signature, is the mechanism that distinguishes real from forged events after a quantum break.
---
## Implementation Status
### What Is Implemented
### What is implemented
The current implementation is a static web app (`www/`) that performs the full migration flow for users who already have a Nostr identity:
The current implementation is a static web app (`www/`) that performs the full preparation flow for users who already have a Nostr identity:
| Component | Status | Location |
|---|---|---|
| BIP39 seed phrase generation (12-word, with optional user entropy) | Implemented | `www/js/pq-crypto.mjs` |
| BIP39 seed phrase generation (24-word default, 12-word option, 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` |
@@ -786,16 +292,18 @@ The current implementation is a static web app (`www/`) that performs the full m
| Verification page (query relay or paste event JSON) | Implemented | `www/verify.html` |
| NIP-07 signer integration (nostr-login-lite) | Implemented | `www/index.html` |
### What Is Not Yet Implemented
### What is not yet implemented
| Component | Description |
|---|---|
| Full light-client Bitcoin verification | Validate block headers, proof-of-work, difficulty, and chain linkage independently of an explorer API (the vendored `javascript-opentimestamps` library in `resources/` provides primitives). Current path trusts a single explorer API for the header. |
| Multi-explorer cross-checking | Compare independent Bitcoin APIs and reject disagreement |
| Raw-nsec migration (Component 4) | Encrypt old nsec, publish kind 30078, 36-word phrase encoding |
| Raw-nsec preparation (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 |
| PQ event authentication / rotation / revocation | Companion protocols for signing future events with PQ keys, rotating/revoking keys, PQ encryption |
| Independent implementation / test vectors | A second implementation reproducing canonical encoding, derivation, and selection |
| Relay event-size interop testing | The kind 1 announcement is ~2030 KiB; the kind 9999 proof carrier embeds it plus OTS proof data. The UI shows the event size and warns if it exceeds ~60 KiB (some relays reject large events). Testing against target relay policies and a compact binary format remain as future work. |
| Reproducible-build verification of the deployed bundle | The deployed `www/pq-crypto.bundle.js` is a build artifact produced by `build-pq-bundle.js` from the audited source in `www/js/pq-crypto.mjs`. Users must currently trust that the deployed bundle matches the source. Reproducible-build verification (so anyone can rebuild the bundle byte-for-byte and confirm the deployed file matches) is on the roadmap. |
### Dependencies
@@ -828,4 +336,4 @@ The current implementation is a static web app (`www/`) that performs the full m
## License
This document is released into the public domain. The migration strategy described herein is intended as a community resource for the Nostr ecosystem.
This document is released into the public domain. The preparation strategy described herein is intended as a community resource for the Nostr ecosystem.
+153
View File
@@ -0,0 +1,153 @@
# Post-Quantum Nostr — Project Audit (Fable5)
**Date:** 2026-07-25
**Scope:** Full repository review — concept/design documents (`README.md`, `nip_proposal.md`, `why_what_how.md`), core crypto module (`www/js/pq-crypto.mjs`, 2,134 lines), web app (`www/js/index-app.mjs`, `www/index.html`, `www/verify.html`), test suite (`test/pq-crypto.test.mjs`, 1,034 lines), and dependencies (`package.json`).
---
## 1. Executive Summary
**Overall verdict: the project is viable as what it claims to be — a research prototype of a pre-quantum key-commitment scheme — and the implementation quality is unusually high for a prototype.** The core idea is cryptographically sound, honestly scoped, and fills a real gap in the Nostr ecosystem. It is **not** yet a production system, and the documentation says so clearly and repeatedly, which is itself a strong signal of engineering maturity.
| Dimension | Assessment |
|---|---|
| Core concept soundness | **Strong** — OTS-anchored earliest-wins precedence is the correct primitive for this threat model |
| Honesty of claims | **Excellent** — limitations (G56-06 continuity, G56-04 explorer trust, browser boundary) are prominently disclosed |
| Code quality | **High** — defensive parsing, fail-closed verification, size/recursion/budget limits, extensive tests |
| Production readiness | **Not ready** — browser trust boundary, no companion protocols, single implementation, unaudited Falcon |
| Ecosystem viability | **Uncertain** — depends entirely on adoption; the artifact is only useful if future clients honor it |
---
## 2. Viability Assessment: Is the Idea Sound?
### 2.1 The threat model is correct
The project's framing of the problem is accurate:
- Nostr's identity, authentication, and encryption all reduce to secp256k1, which Shor's algorithm breaks.
- Bitcoin's hash-the-pubkey mitigation genuinely does not transfer to Nostr (the pubkey is load-bearing and revealed on every event) — the analysis in [`README.md`](README.md:56) is correct.
- The collect-now-decrypt-later threat against NIP-04/NIP-44 is real, and the observation that NIP-44 offers zero PQ advantage over NIP-04 is accurate.
### 2.2 The core mechanism is the right one
The central design — publish a cross-signed key-link event **now**, anchor it to Bitcoin via OpenTimestamps, and resolve future conflicts by **earliest valid Bitcoin anchor** — is the strongest primitive available for this problem:
- A future quantum attacker who recovers the secp256k1 key can forge signatures and publish a fraudulent link, but **cannot backdate an OTS anchor**. Producing an earlier anchor requires re-mining Bitcoin history or finding SHA-256 preimages, both infeasible even post-quantum.
- Multi-scheme hedging (ML-DSA-44/65, SLH-DSA-128s, Falcon-512, ML-KEM-768) is a rational response to standardization uncertainty. The SIKE precedent cited is apt. Including hash-based SLH-DSA as the conservative fallback is exactly right.
- The additive, two-event design (kind 1 announcement + kind 9999 proof carrier) requires no relay changes, no consensus, and degrades gracefully for legacy clients. This is the correct adoption strategy for Nostr.
- Deterministic BIP32 derivation of PQ keys at pinned child indices, with a normative concatenation/truncation rule ([`nip_proposal.md`](nip_proposal.md:79)), is well thought out — the truncation-rule pinning shows awareness of the interop failure modes that kill schemes like this.
### 2.3 Fundamental limits — mostly acknowledged, two under-weighted
The documentation is admirably honest that continuity is *asserted, not proven* (G56-06), that explorer-API trust is not light-client verification (G56-04), and that companion protocols (PQ event signing, rotation, encryption) do not exist yet. Two structural risks deserve more prominence than they currently get:
**(a) Data availability is the real single point of failure.**
The entire post-quantum security argument reduces to: *the genuine proof carrier (or at least its `.ots` proof and signed kind 1 JSON) must still be retrievable when the quantum break happens*. Relays make no retention promises. Worse, a quantum attacker who recovers the secp key can sign **NIP-09 deletion requests (kind 5)** for the genuine kind 9999 events; relays that honor deletions would purge the real anchor while the attacker publishes fresh (later-anchored, but *only surviving*) links. "Earliest valid anchor wins" is only meaningful among anchors a client can still find. The mitigation (users/communities archive their own proof; publish widely; the proof is self-contained) exists but is not spelled out as a first-class protocol requirement. **This should be a normative SHOULD in the NIP** (e.g., "users MUST retain an offline copy of the signed kind 1 event and .ots proof; verifiers SHOULD accept out-of-band proof carriers").
**(b) Adoption is the actual bet.**
The artifact only has value if, at quantum-break time, widely used clients implement the earliest-anchor-wins rule and honor the linked PQ keys. That is a social/ecosystem gamble, not a technical one. The mitigating factor is asymmetry of cost: publishing the link now is cheap and irreversible in the good sense — it can only help. As an *option purchase* the project is rational even under uncertain adoption. This is the correct way to evaluate viability, and by that standard the project **is viable**.
### 2.4 Verdict on viability
**Viable as a research prototype and as a proposed NIP; premature as a user-facing product.** The concept has no fatal flaw. The weakest links are (in order): browser trust boundary for key generation, relay data availability/deletion, explorer-API trust in verification, and ecosystem adoption. All are either acknowledged or fixable; none invalidate the core design.
---
## 3. Implementation Assessment
### 3.1 Strengths
1. **Defensive verification code.** [`verifyNostrEvent()`](www/js/pq-crypto.mjs:423) and [`verifyNIPQRContent()`](www/js/pq-crypto.mjs:828) fail closed, never throw on untrusted input, validate structure before crypto, enforce exact NIP-01 shape, and reject oversized content before doing work (`MAX_EVENT_JSON_SIZE`, `MAX_OTS_PROOF_SIZE`).
2. **A real OTS parser, not pattern-matching.** [`parseOtsFile()`](www/js/pq-crypto.mjs:1705) implements the binary format properly (varuints with overflow guards, op tree walking, attestation classification) with recursion-depth, branch-count, and operation-count budgets (G56-13). The Merkle-path verification in [`verifyOtsProof()`](www/js/pq-crypto.mjs:1989) correctly walks the op tree, binds the proof's target digest to the expected digest (F-C3), and compares the little-endian-reversed commitment against the block merkle root.
3. **Signer-output validation.** [`validateSignerOutput()`](www/js/pq-crypto.mjs:477) is a genuinely uncommon and valuable defense: it deep-compares the signed event against the template (order-sensitive tags), recomputes the ID, verifies the Schnorr signature, checks the pubkey, and rejects extra fields — closing the malicious-NIP-07-signer substitution channel (G56-05).
4. **Correct attestation tags.** The Bitcoin (`0588960d73d71901`), Litecoin, and Pending (`83dfe30d2ef90c8e`) attestation tags at [`pq-crypto.mjs:1686`](www/js/pq-crypto.mjs:1686) match the reference OpenTimestamps implementation.
5. **Multi-calendar redundancy.** [`timestampEvent()`](www/js/pq-crypto.mjs:1471) submits to five calendars in parallel and merges fragments with `0xff` continuation markers, matching the reference `ots-cli` multi-calendar format.
6. **Multi-explorer cross-check.** [`fetchBitcoinBlockHeader()`](www/js/pq-crypto.mjs:1901) queries both Blockstream and mempool.space, fails on disagreement, and honestly labels the trust mode (`multi-explorer-checked` vs `single-explorer-checked`) rather than claiming trustlessness.
7. **Canonical selection logic.** [`selectCanonicalProofCarrier()`](www/js/pq-crypto.mjs:1172) implements earliest-anchor-wins with deterministic tie-breaking, identity binding (G56-03), and a clearly-degraded pending fallback.
8. **Test suite quality.** ~1,000 lines covering determinism, round-trips, tamper rejection, policy enforcement, identity binding, fuzzing of all untrusted-input parsers, and OTS fixtures from the vendored reference library. This is well above typical prototype standards.
9. **Audit-loop discipline.** The codebase is threaded with finding IDs (G56-01 … G56-19, F-C3, F-H2, F-L3/L4) from prior audits, each with the fix documented inline. The remediation trail is visible and verifiable.
### 3.2 Findings
Severity scale: **High** = undermines the security story or interop; **Medium** = meaningful weakness with practical mitigations; **Low** = quality/robustness issue.
#### H-1. Non-canonical JSON hashing makes the OTS digest fragile (interop risk)
[`hashFullEvent()`](www/js/pq-crypto.mjs:686) hashes `JSON.stringify(signedEvent)`, which is key-insertion-order dependent. It round-trips today only because the kind 9999 `content` embeds the exact string and `JSON.parse``JSON.stringify` preserves key order in JS. But:
- Any second implementation (Rust, Python, Go) must replicate JavaScript's exact stringification (key order, number formatting, string escaping) or every verification fails.
- A verifier that fetches the kind 1 event from a relay (rather than the embedded copy) will almost certainly get different key order and a hash mismatch.
**Recommendation:** define the OTS digest over a canonical serialization — the simplest choice is `sha256` of the **NIP-01 serialization array** extended with `id` and `sig` (already canonical), or RFC 8785 JCS. Pin it normatively in the NIP. This is the single most important spec fix before a second implementation exists.
#### H-2. NIP-09 deletion + relay churn attack on the genuine anchor (design gap)
As described in §2.3(a): a post-quantum attacker with the recovered secp key can request deletion of the real kind 9999 events and flood replacements. The NIP's security argument implicitly assumes the genuine proof remains *discoverable*. **Recommendation:** add normative language — verifiers MUST accept proof carriers supplied out-of-band; users SHOULD archive the signed kind 1 JSON + `.ots` offline; PQ-aware relays SHOULD ignore kind 5 deletions targeting kind 9999.
#### M-1. Contradiction between NIP and implementation on unknown/missing algorithms
The NIP says clients "MUST ignore `algorithm` tags whose `algorithm-id` they do not recognize" and "verify whichever subset they support" ([`nip_proposal.md`](nip_proposal.md:150)). The implementation ([`verifyNIPQRContent()`](www/js/pq-crypto.mjs:975)) hard-codes all five current algorithms as **mandatory, exactly once**, and records unknown algorithms as `valid: false` results (which flips the legacy `valid` flag). Consequences: a future event adding a sixth registered algorithm fails this verifier's `valid`/policy checks, and an event legitimately omitting a broken scheme (the NIP's own revocation path, §"Revocation and broken schemes") fails `policySufficient`. **Recommendation:** parameterize the policy (mandatory set as versioned input), report unknown algorithms as `ignored` rather than failed, and align the NIP text with whatever policy is intended.
#### M-2. Falcon-512 in `@noble/post-quantum` is not covered by its audit
`@noble/post-quantum@0.6.1` has audited ML-KEM/ML-DSA/SLH-DSA implementations, but Falcon support is newer and FIPS 206 is still a draft. Falcon's signing also involves floating-point FFT, which is notoriously hard to make constant-time and deterministic across platforms — a risk for a scheme whose keys must be *reproducibly* derivable from a seed for decades. **Recommendation:** document Falcon as the most provisional of the five links; add cross-platform determinism test vectors (same seed → same Falcon pubkey on multiple JS engines).
#### M-3. Server-assisted OTS upgrade is a soft centralization point
[`upgradeOts()`](www/js/pq-crypto.mjs:1539) posts the proof to `laantungir.net/ots-upgrade` (or same-origin). The upgraded proof *is* re-verified client-side, so a malicious helper cannot forge a confirmation — but it can lie about `confirmed`/`changed`, censor upgrades, and observe usage. **Recommendation:** verify the upgraded proof with [`verifyOtsProof()`](www/js/pq-crypto.mjs:1989) before persisting or republishing (confirm this is done in the app flow), and document the helper as replaceable/self-hostable.
#### M-4. Explorer-API trust for block headers (acknowledged)
G56-04 is honestly labeled, and the two-provider cross-check is a reasonable interim measure. Both providers could still collude/be compromised, and both calls originate from the same browser (a network-level MITM with TLS interception, or a hostile local proxy, defeats both). The vendored `javascript-opentimestamps` in `resources/` plus a header-chain checkpoint approach would upgrade this materially. Keep it on the roadmap; it is correctly listed as unimplemented in [`README.md`](README.md:826).
#### M-5. Browser as the trust boundary (acknowledged, unavoidable for now)
Seed generation, PQ key derivation, and PQ signing all happen in page JavaScript. The mitigations present — user-entropy mixing ([`generateSeedPhraseWithEntropy()`](www/js/pq-crypto.mjs:104)), zeroization on sign-out (G56-10), out-of-band verification instructions, refusal to accept valuable existing mnemonics — are the right ones given the constraint, and the documentation is honest that zeroization is best-effort. The real fix (hardware/native signer with PQ derivation) is correctly identified as future work. Note also `www/pq-crypto.bundle.js` is a committed build artifact; without reproducible-build verification, users must trust the deployed bundle matches the audited source. The nginx security headers config in `deploy/` suggests CSP awareness — ensure SRI or reproducible builds are part of the deploy story.
#### L-1. Documentation drift
- [`README.md`](README.md:746) ("Client generates a 12-word phrase") and the mermaid flow at line 706 contradict the 24-word default in [`generateSeedPhrase()`](www/js/pq-crypto.mjs:82).
- The format-documentation comment at [`pq-crypto.mjs:1618`](www/js/pq-crypto.mjs:1618) lists the pending attestation tag as `83df830d1c9d4a51`, but the (correct) constant at line 1688 is `83dfe30d2ef90c8e`. The comment is wrong, the code is right.
- The deprecated alias [`buildKind11112Wrapper()`](www/js/pq-crypto.mjs:743) and parameter names like `kind11112Content` are residue from an earlier kind number; rename for clarity.
#### L-2. Pending-candidate fallback ranks by forgeable `created_at`
[`selectCanonicalProofCarrier()`](www/js/pq-crypto.mjs:1296) orders pending candidates by `created_at`. Fine pre-quantum, meaningless post-quantum; the code can't do better, but callers/UIs must never present a pending-only selection as a trust decision. Verify the UI labels this state unambiguously.
#### L-3. `base64ToBytes` uses `atob`
[`base64ToBytes()`](www/js/pq-crypto.mjs:360) accepts whatever `atob` tolerates (whitespace, missing padding in some engines), so two implementations could disagree on whether a tag is "malformed". Minor interop nit; a strict base64 validator would remove ambiguity.
#### L-4. Kind 9999 allocation is provisional
Acknowledged in the NIP. The 09999 "regular event" semantics (non-replaceable) are load-bearing for the append-only upgrade design — if maintainers allocate a kind in a replaceable range instead, the `upgrade_of` design must be revisited. Flag this dependency explicitly when submitting the NIP.
### 3.3 What is genuinely good here (worth preserving)
- The **append-only upgrade design** (new kind 9999 + `upgrade_of`, never replace) is the right call: replaceable events would let an attacker overwrite the pending-era evidence.
- The **strict `validForMigration` vs. legacy `valid` split** in verification results is careful API design that prevents "some checks passed" from being misread as "safe".
- The **research honesty** — every marketing-adjacent sentence is counterweighted by a warning box. This project does not oversell, which is rare in this space.
---
## 4. Recommendations (priority order)
1. **Canonicalize the OTS digest** (H-1) before any second implementation or NIP submission — this is a spec-breaking change that only gets more expensive.
2. **Add data-availability language to the NIP** (H-2): out-of-band proof acceptance, offline archival, relay deletion-resistance guidance.
3. **Reconcile the algorithm policy** between the NIP's ignore-unknown/verify-subset language and the implementation's mandatory-all-five policy (M-1); make the mandatory set versioned and configurable.
4. **Publish cross-implementation test vectors**: mnemonic → all derived pubkeys → signed kind 1 → sha256 digest → expected verification results. This converts the pinned truncation rule from prose into executable conformance.
5. **Close the upgrade-helper loop** (M-3): confirm client-side re-verification of upgraded proofs before publish, and document self-hosting.
6. **Roadmap the light-client** (M-4): checkpointed header chain using the vendored OTS library, dropping explorer trust to header-relay trust.
7. **Fix documentation drift** (L-1) — cheap, and stale docs erode the credibility the warning boxes have earned.
8. **Long-term:** the project's own analysis is correct that the durable fix is PQ derivation and signing inside a hardware/native signer (Amber-class). The research docs in `research/` show this is already being explored; that is the right direction.
---
## 5. Conclusion
**Viability: yes, with eyes open.** The concept is a sound, low-cost, irreversible-in-the-good-sense hedge: it commits PQ keys to a pre-quantum Bitcoin anchor today, so that *if* the ecosystem later needs it, the earliest-anchor-wins rule gives users a cryptographically distinguishable identity thread across the quantum break. Its value is contingent on adoption and on the genuine proofs remaining available — the two risks that deserve promotion into normative protocol text.
**Implementation: high quality for its stage.** Defensive parsing, fail-closed verification, real OTS binary handling, signer-output validation, honest trust labeling, and a serious test suite place this well above typical prototype code. The material issues found are a canonicalization fragility (H-1), a spec/implementation policy contradiction (M-1), and a set of acknowledged trust-boundary limitations that are inherent to a browser-based prototype rather than defects of this codebase.
The single most important next step is not more code — it is freezing a canonical serialization and publishing conformance vectors, so that the promise at the heart of the design ("any conforming implementation recovers the same keys and verifies the same proofs, decades from now") becomes testable.
+213
View File
@@ -0,0 +1,213 @@
# Fable5 Audit Mitigation Plan
**Companion to:** [`audit.md`](audit.md:1)
**Date:** 2026-07-25
**Scope:** Remediate the findings from the Fable5 audit ([`audits/Fable5/audit.md`](audit.md:1)). This plan is scoped to the Fable5 findings only; it complements (does not supersede) the GPT-5.6 mitigation plan in [`audits/GPT5.6/audit_mitigation.md`](../GPT5.6/audit_mitigation.md:1), which addresses an overlapping but distinct set of earlier findings (G56-xx).
## Finding inventory
| ID | Severity | Title | Status |
|---|---|---|---|
| H-1 | High | Non-canonical JSON hashing makes the OTS digest fragile | Open |
| H-2 | High | NIP-09 deletion + relay churn attack on the genuine anchor | Open |
| M-1 | Medium | NIP vs implementation contradiction on unknown/missing algorithms | Open |
| M-2 | Medium | Falcon-512 not covered by `@noble/post-quantum` audit | Open |
| M-3 | Medium | Server-assisted OTS upgrade is a soft centralization point | Open |
| M-4 | Medium | Explorer-API trust for block headers (acknowledged) | Open |
| M-5 | Medium | Browser as the trust boundary (acknowledged, unavoidable for now) | Open |
| L-1 | Low | Documentation drift | Open |
| L-2 | Low | Pending-candidate fallback ranks by forgeable `created_at` | Open |
| L-3 | Low | `base64ToBytes` uses `atob` (lenient) | Open |
| L-4 | Low | Kind 9999 allocation is provisional | Open |
## Non-negotiable invariants (Fable5-specific)
These are the properties the remediation must establish. Each finding maps to at least one.
| Invariant | Required property | Findings covered |
|---|---|---|
| F-I1 Canonical digest | The OTS target digest is computed over a byte-exact, cross-implementation-reproducible serialization, not engine-dependent `JSON.stringify`. | H-1 |
| F-I2 Anchor availability | The genuine proof carrier remains discoverable and verifiable even if relays delete it or flood replacements. | H-2 |
| F-I3 Policy/version coherence | The mandatory-algorithm set is versioned and explicit; the NIP text and the verifier agree on how unknown and missing algorithms are treated. | M-1 |
| F-I4 Dependency transparency | Each PQ primitive's audit coverage and determinism guarantees are documented; the most provisional primitive is labeled as such. | M-2 |
| F-I5 Trust-mode transparency | Every verification result names its trust mode (Bitcoin-verified / API-checked / structural-only / pending-only) and the verifier re-checks any server-assisted upgrade before persisting. | M-3, M-4 |
| F-I6 Browser-boundary honesty | The UI and docs state plainly what the browser can and cannot guarantee; secrets are scoped and zeroized; out-of-band verification is the recommended continuity proof. | M-5 |
| F-I7 Doc/code agreement | Documentation matches the implemented defaults and constants. | L-1 |
| F-I8 No trust in forgeable clocks | Pending-only selections are never presented as trust decisions. | L-2 |
| F-I9 Strict input decoding | Base64/hex decoders reject malformed input uniformly across implementations. | L-3 |
| F-I10 Kind-allocation safety | The chosen event kind's replaceability semantics are documented as load-bearing for the append-only upgrade design. | L-4 |
## Phased remediation
### Phase F-0 — Documentation and claim hygiene (no code behavior change)
**Findings addressed:** L-1, L-4, parts of M-2, M-5.
These are cheap, low-risk, and protect the credibility the warning boxes have earned. Do them first.
1. **L-1 doc drift:**
- [`README.md`](../../README.md:746): change "Client generates a 12-word phrase" and the mermaid node at line 706 to "24-word (256-bit) BIP39 phrase" to match [`generateSeedPhrase()`](../../www/js/pq-crypto.mjs:82) default.
- [`pq-crypto.mjs:1618`](../../www/js/pq-crypto.mjs:1618): fix the comment listing the pending attestation tag as `83df830d1c9d4a51` — the correct constant at line 1688 is `83dfe30d2ef90c8e`. The code is right; the comment is wrong.
- Rename the deprecated alias [`buildKind11112Wrapper()`](../../www/js/pq-crypto.mjs:743) and the `kind11112Content`/`kind11112Tags` parameter names in [`verifyNIPQRContent()`](../../www/js/pq-crypto.mjs:828) to `proofCarrier`/`proofCarrierContent`/`proofCarrierTags`. Keep a thin deprecated alias for one release.
2. **L-4 kind allocation:** add a "Kind allocation" subsection to [`nip_proposal.md`](../../nip_proposal.md:171) stating that kind 9999's non-replaceable semantics (09999 regular-event range) are load-bearing for the append-only `upgrade_of` design, and that if maintainers allocate a different kind it MUST also be non-replaceable.
3. **M-2 Falcon transparency:** add a "Algorithm maturity" note to [`nip_proposal.md`](../../nip_proposal.md:273) and the README algorithm table marking Falcon-512 as the most provisional link (draft FIPS 206, float-FFT determinism risk, not in the audited subset of `@noble/post-quantum`).
4. **M-5 browser-boundary honesty:** the warning boxes already exist; add one explicit sentence to the README "Implementation Status" section stating that the committed bundle `www/pq-crypto.bundle.js` is a build artifact and that reproducible-build verification of the deployed bundle is on the roadmap.
**Acceptance:** `grep -rn "12-word" README.md why_what_how.md` returns nothing contradictory; the attestation-tag comment matches the constant; Falcon is labeled provisional in the NIP.
### Phase F-1 — Canonical OTS digest (the spec-breaking fix)
**Findings addressed:** H-1. This is the single most important change and must land before any second implementation or NIP submission.
**Design decision to make first:** choose the canonical serialization. Two viable options:
- **Option A (recommended, minimal):** hash the NIP-01 serialization array extended with `id` and `sig`. That is, the digest is `sha256(JSON.stringify([0, pubkey, created_at, kind, tags, content, id, sig]))` over the already-canonical NIP-01 array form. Key order is fixed by the array; number/string formatting is the only residual ambiguity, which is far smaller than object-key-order ambiguity and is already implicit in NIP-01 event-ID computation.
- **Option B (stronger, more work):** RFC 8785 JSON Canonicalization Scheme over the event object, or deterministic CBOR.
Recommend **Option A** because it reuses the existing NIP-01 canonicalization that every Nostr implementation already reproduces, minimizing the interop surface.
**Implementation steps:**
1. Add `canonicalEventDigest(event)` to [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1) computing the Option-A digest.
2. Replace [`hashFullEvent()`](../../www/js/pq-crypto.mjs:686) usages:
- In [`buildProofCarrier()`](../../www/js/pq-crypto.mjs:715): the `sha256` tag becomes `canonicalEventDigest(kind1Event)`.
- In [`verifyNIPQRContent()`](../../www/js/pq-crypto.mjs:951) and [`selectCanonicalProofCarrier()`](../../www/js/pq-crypto.mjs:1182): verify the `sha256` tag against `canonicalEventDigest`.
- In [`timestampEvent()`](../../www/js/pq-crypto.mjs:1471): submit `canonicalEventDigest` to the calendars.
3. Update [`nip_proposal.md`](../../nip_proposal.md:207) "OpenTimestamps anchoring" section: replace "SHA-256 of the full signed kind 1 event JSON" with the normative canonical-serialization definition and a worked byte-level example.
4. Add a versioned `digest_version` tag (`['digest_version', '1']`) to the proof carrier so a future change is detectable. Verifiers accept version 1; unknown versions fail closed.
5. Keep `hashFullEvent()` as a deprecated internal alias for one release to avoid breaking already-published v0 events; mark them as legacy and verify them under a clearly-labeled legacy path.
**Required tests:**
- Known-answer: a fixed kind 1 event → expected canonical digest (pinned in the test file).
- Re-serialization invariance: `JSON.parse` then re-`canonicalEventDigest` yields the same digest as the original object.
- A relay-fetched event with reordered keys still verifies (the regression test for H-1).
- `digest_version` tag missing or unknown → `validForMigration: false`.
- Legacy v0 digest path is labeled and isolated.
**Acceptance:** a kind 1 event re-serialized with different key order produces the same canonical digest and still verifies; the NIP text contains a byte-level normative definition.
### Phase F-2 — Anchor availability and deletion resistance
**Findings addressed:** H-2.
1. **NIP normative language** (in [`nip_proposal.md`](../../nip_proposal.md:171)):
- Add a "Data availability" subsection stating:
- Verifiers MUST accept proof carriers supplied out-of-band (pasted JSON, imported archive file), not only from relays.
- Users SHOULD retain an offline copy of the signed kind 1 event JSON and the `.ots` proof.
- PQ-aware relays SHOULD ignore NIP-09 (kind 5) deletion requests targeting kind 9999 proof carriers.
- A proof carrier's validity does not depend on relay retention.
2. **Implementation:**
- In [`www/verify.html`](../../www/verify.html:1) and [`www/js/verify-app.mjs`](../../www/js/verify-app.mjs:1): ensure the existing paste-JSON verification path is presented as a first-class, equally-trusted input alongside relay queries. Add an "Import archive" entry point that accepts a `.json` or `.ots` file.
- In [`www/js/index-app.mjs`](../../www/js/index-app.mjs:1): after a successful publication, offer a "Download proof archive" button that saves `{kind1Event, proofCarrierEvent, otsProof, digestVersion, createdAt}` as a single JSON file the user can store offline and later feed to the verifier.
3. **Relay deletion-resistance:** document in the NIP that kind 9999 is in the regular (non-replaceable, non-deletable-by-policy) range and recommend PQ-aware relays treat kind 5 over kind 9999 as a no-op. This is a relay-policy recommendation, not a client code change.
**Required tests:**
- A proof carrier supplied via paste-JSON verifies identically to one fetched from a relay.
- An imported archive file with a valid kind 1 + proof carrier + `.ots` verifies end-to-end without any relay contact.
- A kind 5 deletion event referencing a kind 9999 does not affect the verifier's selection (the verifier never consults kind 5).
**Acceptance:** the verifier accepts out-of-band proofs as normatively equivalent to relay-fetched proofs; the NIP states this; the user can download and re-import a self-contained archive.
### Phase F-3 — Algorithm policy reconciliation
**Findings addressed:** M-1.
1. **Define a versioned policy object** in a new small module `www/js/nip-qr-policy.mjs`:
```js
export const POLICY_V1 = {
version: 1,
mandatorySignatureAlgorithms: ['ml-dsa-44', 'ml-dsa-65', 'slh-dsa-128s', 'falcon-512'],
kemAlgorithms: ['ml-kem-768'],
// algorithms not in either set are 'ignored' — neither credit nor failure
};
```
2. **Refactor [`verifyNIPQRContent()`](../../www/js/pq-crypto.mjs:828)** to take a `policy` argument (default `POLICY_V1`):
- Unknown algorithms are reported as `ignored` with `valid: null` (not `false`), and do NOT flip the legacy `valid` flag.
- Missing mandatory algorithms are policy failures.
- The NIP's revocation path (a new event omitting a broken scheme) is honored by allowing a future `POLICY_V2` that drops a scheme; the verifier rejects events that claim a policy version it doesn't know.
3. **Reconcile the NIP text** ([`nip_proposal.md`](../../nip_proposal.md:150) and the "Revocation" section): replace "verify whichever subset they support" with "verify against the versioned policy declared in the event; unknown algorithms are ignored; missing mandatory algorithms are failures." Add a `policy_version` tag to the kind 1 event.
4. **UI:** [`www/verify.html`](../../www/verify.html:1) must display the policy version and the per-algorithm verdict (`valid` / `invalid` / `ignored`).
**Required tests:**
- Event with all v1 mandatory algorithms present and valid → `policySufficient: true`.
- Event missing one mandatory algorithm → `policySufficient: false`.
- Event with an extra unknown algorithm → unknown reported as `ignored`, `policySufficient` still true if the rest pass.
- Event declaring `policy_version: 2` against a v1-only verifier → fail closed with "unsupported policy version".
- Event omitting a scheme (revocation scenario) under a future `POLICY_V2` that drops it → passes.
**Acceptance:** the NIP text and the verifier agree; the revocation path described in the NIP actually works under a versioned policy.
### Phase F-4 — Trust-mode transparency and upgrade-helper hardening
**Findings addressed:** M-3, M-4, L-2.
1. **M-3 upgrade helper:** in [`www/js/index-app.mjs`](../../www/js/index-app.mjs:1), after every [`upgradeOts()`](../../www/js/pq-crypto.mjs:1539) call, run [`verifyOtsProof()`](../../www/js/pq-crypto.mjs:1989) on the returned proof before persisting or republishing. Only persist if `verified: true` OR the proof is structurally valid and still pending. Never trust the helper's `confirmed`/`changed` fields alone. Document the helper as replaceable/self-hostable in the README.
2. **M-4 explorer trust:** the `trustMode` field already exists in [`verifyOtsProof()`](../../www/js/pq-crypto.mjs:1989). Surface it in the verify UI as a distinct, prominent label (`Bitcoin-verified` / `API-checked (2 providers)` / `API-checked (1 provider)` / `Structural only` / `Pending only`). Add a roadmap note pointing to the vendored `resources/javascript-opentimestamps/` for a future light-client path.
3. **L-2 pending fallback:** in [`selectCanonicalProofCarrier()`](../../www/js/pq-crypto.mjs:1296), keep the `created_at` ordering for pending candidates but mark the result with `canonicalTrustMode: 'pending-only'`. The verify UI must display a pending-only selection with a warning that it is NOT a trust decision and must not be treated as canonical until a Bitcoin attestation is verified.
**Required tests:**
- Upgrade helper returns `confirmed: true` but the proof fails `verifyOtsProof` → client does not persist/republish; error logged.
- Upgrade helper returns a still-pending proof → client persists as pending, does not label confirmed.
- `verifyOtsProof` result with `trustMode: 'single-explorer-checked'` → UI label reflects it.
- Pending-only canonical selection → result carries `canonicalTrustMode: 'pending-only'`; UI shows the warning.
**Acceptance:** no server-assisted claim becomes a published "confirmed" state without client-side cryptographic re-verification; every verification result displays its trust mode; pending-only selections are labeled as non-decisions.
### Phase F-5 — Input decoding strictness
**Findings addressed:** L-3.
1. Replace [`base64ToBytes()`](../../www/js/pq-crypto.mjs:360) with a strict implementation that rejects whitespace, missing padding, and non-base64 characters uniformly. Use `@scure/base` (already a dependency) `base64.decode` which is strict, or implement an explicit validator.
2. Audit all callers in [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1) and the app files: ensure they treat a thrown `Error` from `base64ToBytes` as "malformed input → fail closed", which the verifier already does via `safeBase64ToBytes`.
**Required tests:**
- `base64ToBytes('')` throws.
- `base64ToBytes('abc')` (missing padding) throws.
- `base64ToBytes('aGVsbG8=\n')` (whitespace) throws.
- `base64ToBytes('aGVsbG8=')` returns `hello`.
- A proof carrier with a whitespace-padded base64 `ots` tag → `validForMigration: false` (not silently accepted).
**Acceptance:** malformed base64 is rejected uniformly; no lenient-decoding interop gap remains.
### Phase F-6 — Cross-implementation conformance vectors (the long-horizon fix)
**Findings addressed:** supports H-1, M-1, M-2; this is the "most valuable next step" from the audit conclusion.
1. Create `test/vectors/` with a pinned, versioned set of JSON files:
- `seed-to-pubkeys.v1.json`: a fixed mnemonic → all five derived pubkeys (hex). This pins the BIP32 truncation rule and the Falcon determinism question (M-2) concretely.
- `kind1-event.v1.json`: a fixed signed kind 1 event → its canonical digest (post-Phase-F-1) and the expected per-algorithm verification results.
- `proof-carrier.v1.json`: a fixed proof carrier → expected `verifyNIPQRContent` result including `policySufficient`, `identityBound`, `validForMigration`.
2. Add a test that loads these vectors and asserts the implementation reproduces them exactly.
3. Publish the vectors in the NIP repo so a second implementation (Rust/Python/Go) can be validated against them.
**Acceptance:** the test suite pins the canonical encoding, derivation, and verification results; a second implementation can be conformance-tested without reading the JS source.
## Sequencing and dependencies
```mermaid
flowchart LR
F0[F-0 Doc hygiene] --> F1[F-1 Canonical digest]
F1 --> F3[F-3 Policy reconciliation]
F1 --> F6[F-6 Conformance vectors]
F2[F-2 Anchor availability] --> F6
F3 --> F6
F4[F-4 Trust transparency] --> F6
F5[F-5 Strict decoding] --> F6
```
- F-0 first (cheap, protects credibility).
- F-1 next (spec-breaking; gates F-6 and any NIP submission).
- F-2, F-3, F-4, F-5 can proceed in parallel after F-1.
- F-6 last (depends on the canonical digest and policy decisions being frozen).
## Out of scope for this plan
- Light-client Bitcoin header verification (M-4 roadmap item; tracked separately).
- Hardware/native PQ signer (M-5 long-term fix; tracked in `research/amber_integration_plan.md`).
- PQ event authentication, rotation, revocation, encryption companion protocols (the project's own "What Remains Unsolved" section).
- The GPT-5.6 findings (G56-xx) already remediated or tracked in [`audits/GPT5.6/audit_mitigation.md`](../GPT5.6/audit_mitigation.md:1).
+105
View File
@@ -0,0 +1,105 @@
# Fable5 Audit Remediation Progress
**Date:** 2026-07-25
**Companion to:** [`audit.md`](audit.md:1) and [`mitigation_plan.md`](mitigation_plan.md:1)
**Test status:** 137/137 passing (105 original + 32 new F-Dx tests)
## Summary
All Fable5 audit findings have been remediated. The work proceeded in six phases (F-0 through F-6), matching the mitigation plan. No findings remain open.
## Phase-by-phase status
| Phase | Findings | Status | Tests added |
|---|---|---|---|
| F-0 | L-1, L-4, M-2 (doc), M-5 (doc) | ✅ Done | 0 (docs/comments only) |
| F-1 | H-1 (canonical OTS digest) | ✅ Done | 11 (F-D1) |
| F-2 | H-2 (anchor availability) | ✅ Done | 6 (F-D2) |
| F-3 | M-1 (algorithm policy) | ✅ Done | 4 (F-D3) + 1 updated |
| F-4 | M-3, M-4, L-2 | ✅ Done | 2 (F-D4/L-2) |
| F-5 | L-3 (strict base64) | ✅ Done | 7 (F-D5) |
| F-6 | conformance vectors | ✅ Done | 2 (F-D6) |
## What changed
### Phase F-0 — Documentation and claim hygiene
- [`README.md`](../../README.md:706): fixed 12-word → 24-word drift in the migration flow mermaid + user-experience steps + implementation-status table.
- [`why_what_how.md`](../../why_what_how.md:180): fixed 12-word → 24-word.
- [`www/js/pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1618): fixed the wrong pending-attestation-tag comment (`83df830d1c9d4a51``83dfe30d2ef90c8e`); the code constant was already correct.
- Renamed `kind11112*` internal parameter names to `proofCarrier*` in `verifyNIPQRContent`.
- [`nip_proposal.md`](../../nip_proposal.md:177): added "Kind allocation is load-bearing" subsection (L-4).
- [`nip_proposal.md`](../../nip_proposal.md:284): marked Falcon-512 as the most provisional link (M-2).
- [`README.md`](../../README.md:833): added reproducible-build verification roadmap note (M-5).
### Phase F-1 — Canonical OTS digest (H-1, the spec-breaking fix)
- Added [`canonicalEventDigest()`](../../www/js/pq-crypto.mjs:691): `sha256(JSON.stringify([0, pubkey, created_at, kind, tags, content, id, sig]))` — cross-implementation-reproducible, reuses the canonical NIP-01 array form.
- Added `CANONICAL_DIGEST_VERSION = 1` and a `digest_version` tag to proof carriers; verifiers fail closed on unknown/missing versions.
- Legacy v0 events (no `digest_version`, key-order-dependent `hashFullEvent`) remain inspectable but are NOT `validForMigration`.
- Wired `buildProofCarrier`, `verifyNIPQRContent`, `selectCanonicalProofCarrier`, and the app's timestamping path to use the canonical digest.
- [`nip_proposal.md`](../../nip_proposal.md:209): added normative "Canonical digest" section with worked example + versioning language.
- `hashFullEvent()` retained as a deprecated legacy alias.
- 11 new tests: determinism, key-reorder invariance (the H-1 regression test), re-serialization, `buildProofCarrier` emits `digest_version`, v1 carrier valid, legacy v0 not valid for migration, unknown version fails closed, missing version → legacy, duplicate version fails, v1-with-wrong-hash fails, field validation.
### Phase F-2 — Anchor availability and deletion resistance (H-2)
- [`nip_proposal.md`](../../nip_proposal.md:269): added "Data availability (normative)" subsection — out-of-band proof acceptance, offline archival, NIP-09 deletion-resistance guidance for PQ-aware relays.
- Added [`buildProofArchive()`](../../www/js/pq-crypto.mjs:2334) / [`parseProofArchive()`](../../www/js/pq-crypto.mjs:2373): self-contained archive (kind 1 event + proof carrier + OTS proof + digest version) for offline verification.
- [`www/index.html`](../../www/index.html:535): added "Download Proof Archive" button; wired in [`index-app.mjs`](../../www/js/index-app.mjs:120).
- [`www/verify.html`](../../www/verify.html:315): added "Import archive" file input; wired in [`verify-app.mjs`](../../www/js/verify-app.mjs:455) to parse + verify the extracted proof carrier end-to-end without any relay.
- 6 new tests: archive round-trip, re-verification without relay, wrong type/version rejected, malformed input rejected, invalid inputs rejected.
### Phase F-3 — Algorithm policy reconciliation (M-1)
- Created [`www/js/nip-qr-policy.mjs`](../../www/js/nip-qr-policy.mjs:1): versioned `POLICY_V1` with mandatory signature + KEM sets, helper functions.
- Refactored `verifyNIPQRContent` to take a `policy` option (default `POLICY_V1`); unknown algorithms now report `valid: null` (ignored) instead of `false`, matching the NIP's "verify whichever subset they support" while keeping the mandatory set strict.
- The NIP's revocation path now actually works under a future versioned policy (a custom `POLICY_V2` dropping a broken scheme allows an event omitting it — tested).
- [`nip_proposal.md`](../../nip_proposal.md:150): added "Algorithm policy (versioned)" subsection; reconciled the revocation section to reference it.
- `policyVersion` added to the verification result object.
- 4 new tests + 1 existing test updated (unknown algorithm: `false``null`).
### Phase F-4 — Trust-mode transparency and upgrade-helper hardening (M-3, M-4, L-2)
- M-3 (upgrade-helper re-verification): already satisfied — the app runs `verifyOtsProof` on the upgraded proof before republishing; a failed verification does not republish.
- M-4 (explorer trust labeling): already satisfied — the verify UI surfaces `trustMode` prominently with honest labels ("multi-explorer-checked (cross-verified)", "single-explorer-checked (trusted API)", "block header is trusted from explorer API(s), not independently verified against PoW").
- L-2 (pending-only selection): `selectCanonicalProofCarrier` now returns `canonicalTrustMode: 'pending-only'` for pending-only selections and `'none'` when no candidates; [`verify-app.mjs`](../../www/js/verify-app.mjs:403) displays a prominent warning that a pending-only selection is NOT a trust decision.
- 2 new tests: pending-only labeled, no-candidates labeled.
### Phase F-5 — Input decoding strictness (L-3)
- Replaced [`base64ToBytes()`](../../www/js/pq-crypto.mjs:361) (lenient `atob`) with strict `@scure/base` `base64.decode` — rejects whitespace, missing padding, and non-base64 characters uniformly, removing the interop ambiguity.
- 7 new tests: valid round-trip, empty throws, missing padding throws, whitespace throws, non-base64 chars throw, non-string throws, tampered ots tag rejected by `base64ToBytes` and `verifyOtsProof`.
### Phase F-6 — Cross-implementation conformance vectors
- Created [`test/vectors/`](../../test/vectors/) with a pinned, versioned `seed-to-pubkeys.v1.json`: fixed mnemonic → all five derived PQ pubkeys + secp256k1 pubkey + BIP39 seed, with normative notes on the truncation rule.
- Created [`test/vectors/generate-vectors.mjs`](../../test/vectors/generate-vectors.mjs): a script that regenerates the vector file from the reference implementation (so a second implementation can be conformance-tested without reading JS source).
- 2 new tests: vector file is loadable and well-formed; implementation reproduces the pinned pubkeys exactly.
## Files changed
| File | Change |
|---|---|
| [`README.md`](../../README.md:1) | 12→24 word fixes, reproducible-build note |
| [`why_what_how.md`](../../why_what_how.md:1) | 12→24 word fix |
| [`nip_proposal.md`](../../nip_proposal.md:1) | Canonical digest (normative), digest_version tag, data availability, kind allocation, algorithm policy (versioned), Falcon provisional, revocation reconciliation, example proof carrier updated |
| [`www/js/pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1) | `canonicalEventDigest()`, `CANONICAL_DIGEST_VERSION`, `digest_version` tag in `buildProofCarrier`, verifier enforcement, `selectCanonicalProofCarrier` v1/legacy split + `canonicalTrustMode`, `buildProofArchive()`/`parseProofArchive()`, strict `base64ToBytes`, policy import, attestation-tag comment fix, param renames |
| [`www/js/nip-qr-policy.mjs`](../../www/js/nip-qr-policy.mjs:1) | New — versioned `POLICY_V1` + helpers |
| [`www/js/index-app.mjs`](../../www/js/index-app.mjs:1) | `canonicalEventDigest` import + timestamp path, `buildProofArchive` import + download button |
| [`www/js/verify-app.mjs`](../../www/js/verify-app.mjs:1) | `parseProofArchive` import + archive import handler, pending-only `canonicalTrustMode` warning |
| [`www/index.html`](../../www/index.html:1) | "Download Proof Archive" button |
| [`www/verify.html`](../../www/verify.html:1) | "Import archive" file input |
| [`www/pq-crypto.bundle.js`](../../www/pq-crypto.bundle.js:1) | Rebuilt (includes all new exports) |
| [`test/pq-crypto.test.mjs`](../../test/pq-crypto.test.mjs:1) | 32 new tests (F-D1…F-D6) + 1 updated test |
| [`test/vectors/seed-to-pubkeys.v1.json`](../../test/vectors/seed-to-pubkeys.v1.json:1) | New — pinned conformance vector |
| [`test/vectors/generate-vectors.mjs`](../../test/vectors/generate-vectors.mjs:1) | New — vector generator script |
## Findings status
| ID | Severity | Title | Status |
|---|---|---|---|
| H-1 | High | Non-canonical JSON hashing makes the OTS digest fragile | ✅ Fixed (F-D1) |
| H-2 | High | NIP-09 deletion + relay churn attack on the genuine anchor | ✅ Fixed (F-D2) |
| M-1 | Medium | NIP vs implementation contradiction on unknown/missing algorithms | ✅ Fixed (F-D3) |
| M-2 | Medium | Falcon-512 not covered by `@noble/post-quantum` audit | ✅ Documented (F-0) + conformance vector (F-D6) |
| M-3 | Medium | Server-assisted OTS upgrade is a soft centralization point | ✅ Verified already-safe (F-4) |
| M-4 | Medium | Explorer-API trust for block headers (acknowledged) | ✅ Verified already-labeled (F-4) |
| M-5 | Medium | Browser as the trust boundary (acknowledged, unavoidable for now) | ✅ Documented (F-0) |
| L-1 | Low | Documentation drift | ✅ Fixed (F-0) |
| L-2 | Low | Pending-candidate fallback ranks by forgeable `created_at` | ✅ Fixed (F-D4) |
| L-3 | Low | `base64ToBytes` uses `atob` (lenient) | ✅ Fixed (F-D5) |
| L-4 | Low | Kind 9999 allocation is provisional | ✅ Documented (F-0) |
+219
View File
@@ -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 18 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.
+213
View File
@@ -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 18 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-H1F-H4, F-L8), not cryptographic correctness.
+248
View File
@@ -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 18 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.
+66 -18
View File
@@ -9,10 +9,10 @@
| Severity | Total | Fixed | In Progress | Not Started |
|---|---:|---:|---:|---:|
| Critical | 2 | 2 | 0 | 0 |
| High | 6 | 2 | 0 | 4 |
| Medium | 8 | 2 | 0 | 6 |
| Low / Info | 4 | 0 | 0 | 4 |
| **Total** | **20** | **6** | **0** | **14** |
| High | 6 | 6 | 0 | 0 |
| Medium | 8 | 7 | 0 | 0 |
| Low / Info | 4 | 3 | 0 | 0 |
| **Total** | **20** | **18** | **0** | **0** |
## Findings status
@@ -21,23 +21,23 @@
| G56-01 | Critical | Verifier accepts no PQ signatures and missing signatures as successful | ✅ Fixed | v0.0.12 | Strict mandatory algorithm policy, fail-closed on malformed input, structured result fields, 11 adversarial tests |
| G56-02 | Critical | Earliest-valid-anchor rule not implemented; relay discovery selects latest replaceable event | ✅ Fixed | v0.0.14v0.0.16 | Switched to non-replaceable kind 9999, append-only upgrades with `upgrade_of` tag, `selectCanonicalProofCarrier()` function, removed `limit: 1` from relay queries, updated all docs |
| G56-03 | High | Outer wrapper identity not bound to embedded identity | ✅ Fixed | v0.0.14 | Added `outerPubkey` and `expectedAuthor` options to `verifyNIPQRContent()`, `identityBound` field in result, `validForMigration` requires identity binding, 4 identity binding tests |
| G56-04 | High | Bitcoin verification trusts public API JSON, doesn't validate headers/PoW | ❌ Not started | — | Needs trust-mode labeling or real header validation |
| G56-05 | High | Signer output reconstructed without validating returned ID/sig/pubkey/fields | ❌ Not started | — | Needs signer-validation helper |
| G56-06 | High | New seed continuity asserted, not proven — no successor signature, no identity match | ❌ Not started | — | Protocol design decision needed |
| G56-04 | High | Bitcoin verification trusts public API JSON, doesn't validate headers/PoW | ✅ Fixed | v0.0.21 | Tier 1+2: rewrote `fetchBitcoinBlockHeader()` to query ALL providers and cross-check merkle roots (fail on disagreement); added `trustMode` field to `verifyOtsProof()` result (`multi-explorer-checked` / `single-explorer-checked` / `structural-only`); updated verify.html + index.html UI to display trust mode label; removed misleading "lite-client verification" / "independently verifiable" comments. Full light-client PoW verification (Tier 3) remains as future work. 2 test assertions added (trustMode present on verified proof, structural-only on pending) |
| G56-05 | High | Signer output reconstructed without validating returned ID/sig/pubkey/fields | ✅ Fixed | v0.0.20 | Added `validateSignerOutput()` helper: checks all required fields present + typed, verifies no template field (content/tags/created_at/kind) changed, verifies pubkey equals expected identity, recomputes + verifies event ID, verifies Schnorr signature, rejects extra fields. Replaced reconstruction at all 4 `signEvent()` call sites (2 in index.html, 2 in verify.html) with validation + retain-exact-signed-object. 15 new tests (mutated content/tags/kind/created_at, wrong pubkey, wrong ID, invalid sig, extra field, missing field, non-object, malformed id/sig, tag element changed) |
| G56-06 | High | New seed continuity asserted, not proven — no successor signature, no identity match | ✅ Fixed (design decision: assert-only) | v0.0.22 | Design decision: continuity is *asserted* by the old identity's signature, not *proven* by cryptography. No in-browser cryptographic trick can prove the seed and published PQ keys are bound, because a compromised browser controls both the seed display and all signatures. Mode 1 (mnemonic-match) rejected: no current Nostr signers accept seed phrases, so it would require typing a valuable mnemonic into a web page — unacceptable risk. Mode 2 (successor secp signature) rejected: a malicious browser can fake the secp signature just as easily as PQ signatures — adds no real binding. Fix: documented the honest position in README.md + nip_proposal.md (continuity is asserted not proven; only out-of-band verification on a different device can prove the seed matches the published keys); added UI recommendation in index.html seed step to verify out-of-band after migration |
| G56-07 | High | Verifier throws on malformed relay/paste input | ✅ Fixed | v0.0.12, v0.0.19 | v0.0.12 wrapped base64/hex in try/catch + verifyNIPQRContent fail closed + verify.html call-site wrapping. v0.0.19 added structural validation + try/catch to `verifyNostrEvent()` (returns false on malformed input), wrapped `parseOtsFile()` body in try/catch (returns null), added `MAX_OTS_PROOF_SIZE` (1 MiB) and `MAX_EVENT_JSON_SIZE` (64 KiB) size guards, 23 fuzz/property tests (random JSON, random bytes, truncation, oversized inputs, wrong-typed objects) |
| G56-08 | High | Browser origin security insufficient for seed handling | ❌ Not started | — | Needs CSP hardening, header fixes, asset pinning |
| G56-08 | High | Browser origin security insufficient for seed handling | ✅ Fixed | v0.0.23 | Code fixes: extracted all inline JS to external files (`index-app.mjs`, `verify-app.mjs`, `version-display.js`), removed `'unsafe-inline'` from `script-src` in CSP, added `frame-ancestors 'none'` + `<meta name="referrer" content="no-referrer">`, added SRI hashes (`integrity`/`crossorigin`) to nostr-login-lite scripts. Server headers applied to nginx `conf.d/default.conf` (scoped to `/post-quantum/` location only): HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, `Permissions-Policy`, COOP, CORP. Verified headers present on `/post-quantum/` and absent on other paths. Remaining (future): vendor signer scripts into repo for full source auditability |
| G56-09 | Medium | 12-word seed default, warning removed | ✅ Fixed | v0.0.17 | Defaulted `generateSeedPhrase()` and `generateSeedPhraseWithEntropy()` to 24 words (256-bit); added 12/24-word selector UI; restored entropy-strength warning; removed "quantum-safe root of trust" / "Make your Nostr identity quantum-resistant" claims from index.html, README.md, nip_proposal.md; 4 new tests |
| G56-10 | Medium | Secret material not zeroized, retained in globals/DOM/clipboard | ❌ Not started | — | Minimize secret lifetime, overwrite typed arrays |
| G56-11 | Medium | Publication advances despite zero relay acceptance | ❌ Not started | — | Require relay OK for both events |
| G56-12 | Medium | Resume workflow insufficiently bound to original event | ⚠️ Partially fixed | v0.0.14 | Resume now fetches all candidates and matches by event ID. Remaining: persist complete immutable artifacts, validate all bindings before signing |
| G56-13 | Medium | OTS parser lacks total input/operation budgets, unsafe 32-bit varuint | ❌ Not started | — | Add size/operation/branch limits, safe varuint parsing |
| G56-14 | Medium | Dependency resolution not reproducible; lockfile ignored and inconsistent | ❌ Not started | — | Commit lockfile, use npm ci, add CI |
| G56-15 | Medium | OpenTimestamps submodule not clonable; historical dependency risk | ❌ Not started | — | Fix .gitmodules or vendor immutable snapshot |
| G56-10 | Medium | Secret material not zeroized, retained in globals/DOM/clipboard | ✅ Fixed | v0.0.26 | Sign-out now zeroizes all `Uint8Array` secret buffers (BIP39 seed, secp private key, 5 PQ secret keys) via `fill(0)` before nulling references; clears DOM (seed display, seed input, entropy hint); clears clipboard. Documented the JS zeroization limitation in README (garbage collection doesn't guarantee erasure; real fix is hardware/native signer boundary) |
| G56-11 | Medium | Publication advances despite zero relay acceptance | ✅ Fixed | v0.0.25 | Require at least one relay OK for EACH event (kind 1 + kind 9999) before marking step done; show error + keep button enabled if zero acceptance; show partial-coverage warning if some relays failed |
| G56-12 | Medium | Resume workflow insufficiently bound to original event | ✅ Fixed | v0.0.14, v0.0.25 | v0.0.14: fetch all candidates + match by event ID. v0.0.25: persist complete proof carrier + kind 1 event JSON in `savePendingOts()`; restore complete events on resume (not just ID); validate fetched event signature via `verifyNostrEvent()` before adopting; don't blindly fall back to `allCandidates[0]` — require valid signature + correct pubkey |
| G56-13 | Medium | OTS parser lacks total input/operation budgets, unsafe 32-bit varuint | ✅ Fixed | v0.0.25 | Replaced 32-bit bitwise `readVaruint()` with safe arithmetic (`Math.pow(2, shift)` + max value/length checks); added `OTS_MAX_OPERATIONS` (10000) and `OTS_MAX_BRANCHES` (1000) budgets tracked via a budget object passed through `_parseTimestamp`/`_processTimestampEntry`; `MAX_OTS_PROOF_SIZE` (1 MiB) already added in G56-07 |
| G56-14 | Medium | Dependency resolution not reproducible; lockfile ignored and inconsistent | ✅ Fixed | v0.0.25 | Removed `package-lock.json` from `.gitignore`; generated and committed `package-lock.json`; use `npm ci` for reproducible installs |
| G56-15 | Medium | OpenTimestamps submodule not clonable; historical dependency risk | ✅ Fixed | v0.0.25 | Created `.gitmodules` with correct URL (`https://github.com/opentimestamps/javascript-opentimestamps.git`); `git clone --recursive` now works |
| G56-16 | Medium | Documentation and UI overclaim implementation status | ✅ Fixed | v0.0.14v0.0.18 | Kind 9999 docs (v0.0.14v0.0.16); v0.0.17 fixed index.html claims + 24-word default; v0.0.18 reconciled README OTS trust model (API-assisted vs light-client), removed stale "not implemented" items (target-digest binding, test suite, 24-word default), softened nip_proposal.md ("No private key ever touches a server" → NIP-07/remote-signer caveat; pending-proof "establishes submission time" → "evidence of submission"; fixed "republished" → "new proof carrier published"), added research-prototype banners to verify.html + README + nip_proposal + explanation.md + laans_explanation.md + why_what_how.md + all 5 research/ docs |
| G56-17 | Low | Block-height statement not validated | ❌ Not started | — | Validate against OTS height or remove from signed content |
| G56-18 | Low | Relay URL policy permits insecure ws:// | ❌ Not started | — | Require wss:// in production |
| G56-19 | Low | Missing event IDs accepted by library verifier | ❌ Not started | — | Require exact NIP-01 shape with 64-hex ID |
| G56-20 | Info | Large events may be rejected by relays; interoperability untested | ❌ Not started | — | Test against target relay policies |
| G56-17 | Low | Block-height statement not validated | ✅ Fixed | v0.0.27 | Removed unverified `block_height` tag from signed content and "pre-quantum at Bitcoin block height N" from the attestation statement. The OTS proof is the real anchor; the claimed height was redundant and could be wrong (height 0 on API failure) |
| G56-18 | Low | Relay URL policy permits insecure ws:// | ✅ Won't fix | — | Nostr events are public — an attacker reading them over ws:// is not a security concern. The events contain no secrets. wss:// is recommended but not required. |
| G56-19 | Low | Missing event IDs accepted by library verifier | ✅ Fixed | v0.0.27 | `verifyNostrEvent()` now requires `id` to be present and a 64-lowercase-hex string (was optional/truthy-check). 2 new tests: missing id rejected, malformed id rejected |
| G56-20 | Info | Large events may be rejected by relays; interoperability untested | ✅ Fixed | v0.0.27 | UI now shows event size (KiB) in the publish step and warns if >60 KiB (some relays reject large events). Documented relay size limits and future work (compact binary format, target relay policy testing) in README "Not Yet Implemented" table |
## Changes by version
@@ -100,6 +100,50 @@
- verify.html relay path already had try/catch (G56-01); now defense-in-depth with internal fail-closed
- 23 new fuzz/property tests: random JSON shapes to `verifyNIPQRContent` (1000 iterations), random bytes to `parseOtsFile` (2000 iterations), random objects to `verifyNostrEvent` (500 iterations), truncated valid-prefix bytes, oversized inputs, wrong-typed objects, `isOtsConfirmed` fuzz
### v0.0.20 — G56-05 fix (signer output validation)
- Added `validateSignerOutput(signedEvent, template, expectedPubkey)` helper in `pq-crypto.mjs`:
- Checks all 7 required NIP-01 fields present + correctly typed
- Verifies id (64 hex), pubkey (64 hex), sig (128 hex) format
- Verifies no template field changed (content, tags deep-compare, created_at, kind)
- Verifies returned pubkey equals expected identity
- Recomputes event ID and verifies match
- Verifies Schnorr signature
- Rejects extra unexpected fields
- Returns the validated signed object (not a reconstruction)
- Replaced reconstruction at all 4 `signEvent()` call sites:
- `index.html`: kind 1 announcement (was mixing template + signer fields), proof carrier (same), upgrade (now validated)
- `verify.html`: upgrade (now validated)
- 15 new tests: valid output, mutated content/tags/kind/created_at, wrong pubkey, wrong ID, invalid sig, extra field, missing field, non-object, malformed id/sig, tag element changed, no-expectedPubkey backward compat
### v0.0.21 — G56-04 fix (Bitcoin trust-mode labeling + multi-explorer cross-check)
- Rewrote `fetchBitcoinBlockHeader()` to query ALL providers (blockstream + mempool) and cross-check their merkle roots; fails on disagreement (turns fallback-redundancy into a cross-check)
- Added `trustMode` field to `verifyOtsProof()` return value: `multi-explorer-checked` (both providers agreed), `single-explorer-checked` (only one responded), `structural-only` (no Bitcoin attestation verified)
- Updated verify.html (2 locations) and index.html (1 location) to display the trust mode label alongside verification results
- Removed misleading "lite-client verification" / "independently verifiable against the Bitcoin PoW chain" comments from code
- Full light-client PoW/difficulty/chain-linkage verification (Tier 3) remains as future work
- 2 new test assertions: trustMode present + explorer-checked on verified proof, structural-only on pending proof
### v0.0.22 — G56-06 fix (seed continuity: honest claims + out-of-band verification)
- Design decision: continuity is *asserted* by the old identity's signature, not *proven* by cryptography
- Mode 1 (mnemonic-match) rejected: no current Nostr signers accept seed phrases; would require typing a valuable mnemonic into a web page — unacceptable risk
- Mode 2 (successor secp signature) rejected: a malicious browser can fake the secp signature just as easily as PQ signatures — adds no real binding
- Documented the honest position in README.md (Component 1 section) and nip_proposal.md: continuity is asserted not proven; only out-of-band verification on a different device can prove the seed matches the published keys
- Added UI recommendation in index.html seed step: "Verify out-of-band after migration — take your seed phrase to a different device, derive the keys, confirm the public keys match"
- No new tests (documentation + UI only)
### v0.0.23 — G56-08 fix (browser hardening — code-fixable parts)
- Extracted all inline `<script>` blocks from index.html and verify.html into external files:
- `www/js/version-display.js` (self-initializing version display)
- `www/js/index-app.mjs` (main index.html application module, ~1237 lines)
- `www/js/verify-app.mjs` (main verify.html application module, ~609 lines)
- Removed `'unsafe-inline'` from `script-src` in CSP (both pages); kept for `style-src` (many inline styles remain)
- Added `frame-ancestors 'none'` to CSP (clickjacking protection)
- Added `<meta name="referrer" content="no-referrer">` to both pages
- Added SRI hashes (`integrity="sha384-..."` + `crossorigin="anonymous"`) to nostr-login-lite scripts
- Created `deploy/nginx-security-headers.conf` documenting required server-side headers (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP, CORP)
- Remaining: apply nginx config on server; vendor signer scripts into repo for full auditability
- No new tests (HTML/deployment changes)
## Test count
- **Before remediation:** 38 tests
@@ -108,4 +152,8 @@
- **After G56-09:** 65 tests
- **After G56-16:** 65 tests (docs-only)
- **After G56-07:** 88 tests
- **After G56-05:** 103 tests
- **After G56-04:** 103 tests (2 assertions added to existing tests)
- **After G56-06:** 103 tests (docs + UI only)
- **After G56-08:** 103 tests (HTML/deployment changes)
- **All passing:** ✅
+35
View File
@@ -0,0 +1,35 @@
# Nginx security headers for nostr-quantum-preparation (G56-08)
#
# Add these to the nginx server block for laantungir.net/quantum-prep.
# These headers cannot be fully enforced from HTML meta tags alone —
# they must be set by the server.
# HSTS: force HTTPS for 1 year, include subdomains, preload-ready
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Prevent MIME-type sniffing
add_header X-Content-Type-Options "nosniff" always;
# Prevent clickjacking (also enforced via CSP frame-ancestors 'none' in HTML)
add_header X-Frame-Options "DENY" always;
# Don't leak the URL via referrer headers (also set via <meta name="referrer"> in HTML)
add_header Referrer-Policy "no-referrer" always;
# Restrict browser features — this web app needs none of these
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" always;
# Cross-Origin Opener Policy: isolate browsing context
add_header Cross-Origin-Opener-Policy "same-origin" always;
# Cross-Origin Resource Policy: restrict resource loading to same-origin
add_header Cross-Origin-Resource-Policy "same-origin" always;
# Note: The CSP is set via <meta http-equiv="Content-Security-Policy"> in the HTML
# files. If you prefer to set it via nginx instead (recommended for production),
# replace the meta tag with:
#
# add_header Content-Security-Policy "default-src 'self'; script-src 'self'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none';" always;
#
# Setting CSP via nginx is stronger because it cannot be stripped by an HTML
# injection that modifies the <head>.
+1 -1
View File
@@ -59,7 +59,7 @@ done
# Get the latest git tag (authoritative version)
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo "🔒 Post-Quantum Nostr — Version Increment"
echo "🔒 Nostr Quantum Preparation — Version Increment"
echo "=========================================="
echo "Latest tag: $LATEST_TAG"
echo "Bump type: $BUMP_TYPE"
+81 -13
View File
@@ -39,6 +39,17 @@ A user with an existing Nostr identity (the **attesting identity**) publishes tw
The PQ keys are derived deterministically from a BIP39 seed phrase via BIP32, at fixed child indices under the NIP-06 base path. This means a user who has backed up their seed phrase can always recover the same PQ keys, on any conforming implementation.
> **⚠️ Continuity is asserted, not proven.** The old identity *authorizes* the PQ keys by signing an
> event that contains them. This is an authorization, not a cryptographic proof that the PQ keys were
> derived from the seed phrase shown to the user. A compromised browser could display one mnemonic
> while publishing attacker-controlled PQ keys, and any in-browser signature could be faked by the
> same browser. The only real proof is **out-of-band verification**: the user takes their seed phrase
> to a different device, derives the keys independently, and confirms the public keys match the
> published event. Verifying the mnemonic against the current identity (deriving the NIP-06 secp key
> and requiring it to match) would provide a stronger proof, but no current Nostr signers accept seed
> phrases, so this would require typing a valuable mnemonic into a web page — an unacceptable risk.
> This mode is deferred until hardware PQ signers or seed-accepting signers exist.
```mermaid
flowchart TD
SEED[BIP39 seed - recovery root] --> SECP[secp256k1 keypair - child 0]
@@ -136,7 +147,13 @@ The following `algorithm-id` strings are defined by this NIP:
| `falcon-512` | 206 (draft) | Signature | 897 bytes | ~666 bytes |
| `ml-kem-768` | 203 | KEM | 1184 bytes | n/a (ciphertext 1088 bytes) |
Future NIPs may register additional `algorithm-id` values and additional BIP32 child indices. Implementations MUST ignore `algorithm` tags whose `algorithm-id` they do not recognize.
Future NIPs may register additional `algorithm-id` values and additional BIP32 child indices. Implementations MUST ignore `algorithm` tags whose `algorithm-id` they do not recognize — an unknown algorithm is neither valid evidence nor a failure; it is reported as `ignored` and does not affect the policy verdict.
### Algorithm policy (versioned)
The mandatory set of algorithms is versioned and explicit. This NIP defines **policy version 1**, whose mandatory signature algorithms are `ml-dsa-44`, `ml-dsa-65`, `slh-dsa-128s`, and `falcon-512`, and whose KEM algorithm is `ml-kem-768`. A proof carrier is `validForMigration` only when every mandatory signature algorithm is present exactly once with a valid signature, the KEM algorithm is present exactly once, and all present PQ proofs verify.
A future NIP revision MAY define a new policy version that drops a scheme later found to be weak (the revocation path). The kind 1 event SHOULD include a `policy_version` tag recording which policy version it was authored under; verifiers evaluate the event against the policy version they support and fail closed on unsupported versions. This makes a future change detectable rather than silently breaking verification.
### Example
@@ -165,6 +182,10 @@ Kind 9999 is non-replaceable: each publication is permanent on relays. Upgrades
The kind number is provisional and subject to maintainer allocation.
### Kind allocation is load-bearing
The non-replaceable semantics of the 09999 regular-event range are **load-bearing** for this NIP's append-only upgrade design. The `upgrade_of` mechanism depends on every published proof carrier remaining permanently on relays so that the earliest-valid-anchor rule has the full candidate set to choose from. If maintainers allocate a kind in a replaceable range (e.g. 1000019999 or 30000+) instead, a single publication could overwrite a prior proof carrier, which would let a post-quantum attacker (who has recovered the secp256k1 key) replace the genuine pending-era evidence with a later-anchored substitute. Any final kind allocation MUST therefore be non-replaceable, and PQ-aware relays SHOULD treat NIP-09 (kind 5) deletion requests targeting proof carriers as a no-op.
### Content
`JSON.stringify(kind1Event)` — the full signed kind 1 event embedded as a JSON string. This lets verifiers validate the kind 1 event, its PQ signatures, and its secp256k1 signature without fetching it from relays.
@@ -172,7 +193,8 @@ The kind number is provisional and subject to maintainer allocation.
### Tags
- `["e", "<kind 1 event id>"]` — reference to the kind 1 announcement.
- `["sha256", "<hex SHA-256 of the full signed kind 1 event JSON>"]` — the digest that was submitted to OpenTimestamps.
- `["sha256", "<hex canonical digest of the signed kind 1 event>"]` — the digest that was submitted to OpenTimestamps. See [Canonical digest](#canonical-digest-normative) for the normative construction; it is NOT `sha256(JSON.stringify(event))` (which is key-order-dependent and not cross-implementation-reproducible).
- `["digest_version", "<version>"]` — the canonical-digest version that produced the `sha256` tag (currently `"1"`). Verifiers MUST reject missing or unsupported versions.
- `["ots", "<base64 .ots proof>"]` — the OpenTimestamps proof (pending or confirmed).
### Example
@@ -184,7 +206,8 @@ The kind number is provisional and subject to maintainer allocation.
"content": "<JSON string of the full signed kind 1 event>",
"tags": [
["e", "<kind 1 event id>"],
["sha256", "<hex SHA-256 of full signed kind 1 event JSON>"],
["sha256", "<hex canonical digest of the signed kind 1 event>"],
["digest_version", "1"],
["ots", "<base64 .ots proof>"]
],
"sig": "<attesting identity Schnorr signature>"
@@ -193,10 +216,46 @@ The kind number is provisional and subject to maintainer allocation.
## OpenTimestamps anchoring
The kind 1 announcement MUST be timestamped via OpenTimestamps (NIP-03). The digest submitted to the calendar servers is the **SHA-256 of the full signed kind 1 event JSON** — i.e. `sha256(JSON.stringify(kind1Event))`, where the serialized event includes its `id` and `sig` fields. This digest is recorded in the wrapper's `sha256` tag, and the resulting `.ots` proof is embedded in the wrapper's `ots` tag.
The kind 1 announcement MUST be timestamped via OpenTimestamps (NIP-03). The digest submitted to the calendar servers is the **canonical digest** of the fully signed kind 1 event, recorded in the proof carrier's `sha256` tag. The resulting `.ots` proof is embedded in the proof carrier's `ots` tag.
The wrapper *carries* the proof; the kind 1 event *is* what is timestamped.
### Canonical digest (normative)
The OTS target digest MUST be reproducible by any conforming implementation, including non-JavaScript implementations and verifiers that re-fetch the kind 1 event from a relay (which may return the event object with different key order). Hashing `JSON.stringify(event)` is NOT reproducible because object key order is engine-dependent. This NIP therefore defines a canonical digest.
**Digest version 1 (current).** The digest is SHA-256 of the NIP-01 serialization array extended with the `id` and `sig` fields:
```
sha256( JSON.stringify( [ 0, pubkey, created_at, kind, tags, content, id, sig ] ) )
```
where:
- `pubkey` is the 64-character lowercase hex pubkey,
- `created_at` and `kind` are JSON integers,
- `tags` is the JSON array of tag arrays (in event order),
- `content` is the JSON string,
- `id` is the 64-character lowercase hex event ID,
- `sig` is the 128-character lowercase hex Schnorr signature.
This reuses the already-canonical NIP-01 array form (the same form every Nostr implementation reproduces to compute event IDs), extended with the two fields that complete a signed event. Array element order is fixed by construction, so the only residual formatting ambiguity is number/string serialization, which is already implicit in NIP-01 event-ID computation and is well-defined for the integer and string field types used here.
**Versioning.** The proof carrier MUST include a `digest_version` tag recording which canonicalization produced the digest:
- `["digest_version", "1"]` — the version-1 construction above.
Verifiers MUST reject proof carriers whose `digest_version` is missing (legacy v0 events, see below) or is an unsupported version. A future version MUST be defined by a separate NIP revision and MUST be detectable from this tag.
**Legacy v0 events.** Proof carriers published before this canonicalization carry no `digest_version` tag and commit to `sha256(JSON.stringify(kind1Event))` (key-order-dependent). Verifiers MAY inspect these for historical completeness but MUST NOT treat them as valid for migration. A v0 event can be upgraded to v1 by publishing a new kind 9999 proof carrier (with `upgrade_of` referencing the original) that carries the v1 canonical digest and a freshly submitted OTS proof for that digest.
**Worked example.** For a kind 1 event with `id = "abc...123"` (64 hex), `sig = "def...456"` (128 hex), `pubkey = "012...789"` (64 hex), `created_at = 1700000000`, `kind = 1`, `tags = [["algorithm","ml-dsa-44","...","..."]]`, `content = "I am signaling..."`, the canonical digest is:
```
sha256( JSON.stringify( [ 0, "012...789", 1700000000, 1, [["algorithm","ml-dsa-44","...","..."]], "I am signaling...", "abc...123", "def...456" ] ) )
```
Implementations MUST compute this byte-for-byte identically. Cross-implementation conformance vectors are published with the reference implementation.
### Why the kind 1 event must be anchored
Without an OTS anchor on the key-link event, a future quantum attacker who breaks the secp256k1 key can publish a fraudulent key-link event linking the identity to the attacker's PQ keys. Both the real and fraudulent events would have valid secp256k1 signatures (the key is compromised), and `created_at` is forgeable, so clients could not distinguish them.
@@ -215,6 +274,15 @@ An OTS proof may be **pending** (not yet anchored in a Bitcoin block) or **confi
Clients SHOULD perform full client-side OTS verification: parse the proof, walk the op tree, validate the Merkle path, and check the attested Merkle root against the actual Bitcoin block header for the attested height. A client that cannot perform full verification MAY fall back to checking that a Bitcoin attestation is structurally present in the proof, but MUST label such a link as unverified.
### Data availability (normative)
The earliest-valid-anchor rule is only meaningful among proof carriers a client can still find. Relays make no retention promises, and a post-quantum attacker who recovers the secp256k1 key can sign NIP-09 (kind 5) deletion requests targeting the genuine kind 9999 proof carriers. To keep the genuine anchor discoverable:
- Verifiers MUST accept proof carriers supplied out-of-band (pasted JSON, imported archive file) as normatively equivalent to relay-fetched proof carriers. A proof carrier's validity does not depend on relay retention.
- Users SHOULD retain an offline copy of the signed kind 1 event JSON and the `.ots` proof. The reference implementation offers a "Download proof archive" function for this purpose.
- PQ-aware relays SHOULD ignore NIP-09 (kind 5) deletion requests targeting kind 9999 proof carriers. Kind 9999 is in the regular (non-replaceable) event range; its append-only semantics are load-bearing (see [Kind allocation is load-bearing](#kind-allocation-is-load-bearing)).
- Communities and individuals SHOULD mirror proof carriers they care about. The proof carrier is self-contained (it embeds the kind 1 event and the OTS proof), so mirroring is a simple copy.
## Client behavior
### PQ-aware clients
@@ -224,11 +292,11 @@ A PQ-aware client, when it encounters a kind 9999 proof carrier for a pubkey it
1. Parses the embedded kind 1 event from the proof carrier's `content`.
2. Verifies the attesting identity's secp256k1 signature on both the kind 1 and the kind 9999 events.
3. Verifies that the proof carrier's pubkey matches the embedded kind 1 event's pubkey (identity binding).
3. Verifies the `sha256` tag matches `sha256(JSON.stringify(kind1Event))`.
4. For each `algorithm` tag with a signature, verifies the PQ signature against the pubkey in the tag, over `TextEncoder.encode(kind1Event.content)`.
5. Verifies the OTS proof in the `ots` tag against the `sha256` digest.
6. Records the link: attesting identity → {set of PQ pubkeys}, anchored at the verified Bitcoin block height.
7. If multiple wrappers exist for the same identity, applies the earliest-valid-anchor rule.
4. Verifies the `digest_version` tag is present and supported, and that the `sha256` tag matches the canonical digest of the embedded kind 1 event (see [Canonical digest](#canonical-digest-normative)).
5. For each `algorithm` tag with a signature, verifies the PQ signature against the pubkey in the tag, over `TextEncoder.encode(kind1Event.content)`.
6. Verifies the OTS proof in the `ots` tag against the `sha256` digest.
7. Records the link: attesting identity → {set of PQ pubkeys}, anchored at the verified Bitcoin block height.
8. If multiple proof carriers exist for the same identity, applies the earliest-valid-anchor rule. Proof carriers may be gathered from relays, local archives, or user-supplied files (see [Data availability](#data-availability-normative)).
After a quantum break of secp256k1, the client trusts the PQ keys from the earliest-anchored link for any future PQ-signed events or PQ-encrypted messages from that identity.
@@ -255,9 +323,9 @@ This is a deliberate design choice. The threat model is backdating of fraudulent
If a specific PQ scheme is later found to be weak (as SIKE was in 2022):
1. Clients stop trusting the broken scheme — no revocation event is required. Clients simply ignore `algorithm` tags whose `algorithm-id` is on a locally-maintained broken-scheme list.
1. Clients stop trusting the broken scheme — no revocation event is required. Clients simply ignore `algorithm` tags whose `algorithm-id` is on a locally-maintained broken-scheme list (an unknown/ignored algorithm is neither valid evidence nor a failure; see [Algorithm policy](#algorithm-policy-versioned)).
2. The remaining links stay valid.
3. A user MAY publish a new key-link event omitting the broken scheme, signed by the still-valid PQ keys from the prior link.
3. A user MAY publish a new key-link event omitting the broken scheme, signed by the still-valid PQ keys from the prior link. This is the revocation path described under [Algorithm policy](#algorithm-policy-versioned): a future policy version drops the broken scheme from the mandatory set, so an event omitting it passes under the new policy while still failing under the old policy until clients upgrade.
## Algorithms: why this set
@@ -266,7 +334,7 @@ If a specific PQ scheme is later found to be weak (as SIKE was in 2022):
| ML-DSA-44 | Lattice-based signature, FIPS 204, NIST Category 2. Compatibility with proposals that pick ML-DSA-44 alone. |
| ML-DSA-65 | Lattice-based signature, FIPS 204, NIST Category 3. Primary PQ signature. |
| SLH-DSA-128s | Hash-based signature, FIPS 205, NIST Category 1. Very conservative assumptions; the fallback if all lattice schemes break. Large signatures (7856 bytes) are acceptable for a one-time event. |
| Falcon-512 | Lattice-based signature, FIPS 206 draft, NIST Category 1. Compact signatures (~666 bytes). |
| Falcon-512 | Lattice-based signature, FIPS 206 draft, NIST Category 1. Compact signatures (~666 bytes). **Most provisional link:** FIPS 206 is still a draft, and Falcon's signing uses floating-point FFT, which is notoriously hard to make constant-time and deterministic across platforms — a concern for a scheme whose keys must be reproducibly derivable from a seed for decades. Implementations SHOULD publish cross-platform determinism test vectors for Falcon-512. |
| ML-KEM-768 | Lattice-based KEM, FIPS 203, NIST Category 3. For future PQ encryption (NIP-04/NIP-44 successors). Cannot sign; pubkey only. |
Multiple schemes are used simultaneously so that if one class is broken, the others remain. The community does not need to agree on a single algorithm; clients verify whichever subset they support.
@@ -298,7 +366,7 @@ This NIP builds on and is compatible with several existing community proposals:
> migration. It links PQ keys to an existing identity and anchors that link in Bitcoin; it does not
> yet define PQ authentication for routine events, key rotation/revocation, or PQ encryption.
A static, client-side-only web implementation exists at `https://laantungir.net/post-quantum/`.
A static, client-side-only web implementation exists at `https://laantungir.net/quantum-prep/`.
Cryptographic operations (BIP39, BIP32, PQ keygen/sign/verify, OTS submission and Merkle-path
verification) happen in the browser. Private keys are handled in the browser or via a NIP-07 signer
extension, which may be a remote signer (NIP-46); in that case the signing key does not touch the
+600
View File
@@ -0,0 +1,600 @@
{
"name": "nostr_quantum_preparation",
"version": "0.0.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nostr_quantum_preparation",
"version": "0.0.24",
"license": "ISC",
"dependencies": {
"@noble/curves": "^2.2.0",
"@noble/hashes": "^2.2.0",
"@noble/post-quantum": "^0.6.1",
"@scure/base": "^2.2.0",
"@scure/bip32": "^2.2.0",
"@scure/bip39": "^2.2.0"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@noble/ciphers": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz",
"integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
"integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.2.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/post-quantum": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.6.1.tgz",
"integrity": "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "~2.2.0",
"@noble/curves": "~2.2.0",
"@noble/hashes": "~2.2.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz",
"integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==",
"license": "MIT",
"dependencies": {
"@noble/curves": "2.2.0",
"@noble/hashes": "2.2.0",
"@scure/base": "2.2.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz",
"integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.2.0",
"@scure/base": "2.2.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "post_quantum_nostr",
"version": "0.0.19",
"name": "nostr_quantum_preparation",
"version": "0.0.32",
"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": {
+699 -5
View File
@@ -25,6 +25,8 @@ import { schnorr } from '@noble/curves/secp256k1.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const m = await import('../www/js/pq-crypto.mjs');
const policy = await import('../www/js/nip-qr-policy.mjs');
const vectorsDir = join(__dirname, 'vectors');
// ============================================================================
// BIP39 SEED PHRASE
@@ -217,6 +219,42 @@ describe('NIP-01 event id and verifyNostrEvent', () => {
const wrongIdEvent = { ...event, id: '0'.repeat(64), sig: '0'.repeat(128) };
assert.equal(m.verifyNostrEvent(wrongIdEvent), false, 'wrong id should fail verification');
});
// G56-19: missing event id must be rejected (id is now required, not optional)
test('G56-19: verifyNostrEvent rejects missing id', () => {
const mnemonic = m.generateSeedPhrase();
const seed = m.mnemonicToSeed(mnemonic);
const kp = m.deriveSecp256k1FromSeed(seed);
const pubkeyHex = m.bytesToHex(kp.publicKey);
const event = {
pubkey: pubkeyHex,
created_at: 1700000000,
kind: 1,
tags: [],
content: 'test event for missing id check'
};
// No id field at all — must be rejected
const noIdEvent = { ...event, sig: '0'.repeat(128) };
assert.equal(m.verifyNostrEvent(noIdEvent), false, 'missing id should fail verification');
});
// G56-19: malformed id (not 64 hex) must be rejected
test('G56-19: verifyNostrEvent rejects malformed id', () => {
const mnemonic = m.generateSeedPhrase();
const seed = m.mnemonicToSeed(mnemonic);
const kp = m.deriveSecp256k1FromSeed(seed);
const pubkeyHex = m.bytesToHex(kp.publicKey);
const event = {
id: 'short',
pubkey: pubkeyHex,
created_at: 1700000000,
kind: 1,
tags: [],
content: 'test event for malformed id check',
sig: '0'.repeat(128)
};
assert.equal(m.verifyNostrEvent(event), false, 'malformed id should fail verification');
});
});
// ============================================================================
@@ -311,6 +349,10 @@ describe('OTS verifier (verifyOtsProof) — F-C3 binding', () => {
assert.equal(result.verified, true, 'should verify against Bitcoin block 358391');
assert.equal(result.bitcoinAttestations.length, 1);
assert.equal(result.bitcoinAttestations[0].height, 358391);
// G56-04: trustMode must be present and indicate explorer-checked trust
assert.ok(result.trustMode, 'trustMode field must be present');
assert.ok(['multi-explorer-checked', 'single-explorer-checked'].includes(result.trustMode),
`trustMode should be explorer-checked, got: ${result.trustMode}`);
});
test('F-C3: wrong expected digest fails with "Target digest mismatch"', async () => {
@@ -334,6 +376,8 @@ describe('OTS verifier (verifyOtsProof) — F-C3 binding', () => {
const result = await m.verifyOtsProof(ots);
assert.equal(result.verified, false);
assert.ok(result.errors.some(e => e.includes('no Bitcoin attestations') || e.includes('pending')), 'should report no Bitcoin attestations');
// G56-04: trustMode should be 'structural-only' when no Bitcoin attestation is verified
assert.equal(result.trustMode, 'structural-only', 'pending proof should have structural-only trust mode');
});
});
@@ -366,8 +410,21 @@ describe('G56-01: strict PQ algorithm policy (verifyNIPQRContent)', () => {
return event;
}
// Helper: build wrapper tags for a given kind 1 event
// Helper: build wrapper tags for a given kind 1 event.
// F-D1: use the canonical digest and include the digest_version tag so the
// verifier treats the carrier as a v1 event (valid for migration). A helper
// wrapperTagsLegacy() below produces v0 tags for legacy-path tests.
function wrapperTags(kind1Event) {
return [
['e', kind1Event.id],
['sha256', m.canonicalEventDigest(kind1Event)],
['digest_version', String(m.CANONICAL_DIGEST_VERSION)]
];
}
// Helper: build legacy v0 wrapper tags (no digest_version, legacy hash) for
// testing the legacy inspection path.
function wrapperTagsLegacy(kind1Event) {
return [
['e', kind1Event.id],
['sha256', m.hashFullEvent(kind1Event)]
@@ -436,7 +493,7 @@ describe('G56-01: strict PQ algorithm policy (verifyNIPQRContent)', () => {
}
});
test('G56-01: unknown algorithm with no signature is NOT valid evidence', async () => {
test('F-D3: unknown algorithm is ignored (valid: null), not counted as evidence or failure', async () => {
const kind1 = await signedKind1('attestation with unknown alg', [
['algorithm', 'future-pq-scheme', m.bytesToBase64(new Uint8Array(64))],
]);
@@ -444,8 +501,13 @@ describe('G56-01: strict PQ algorithm policy (verifyNIPQRContent)', () => {
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
const unknownResult = result.results.find(r => r.algorithm === 'future-pq-scheme');
assert.ok(unknownResult, 'should have a result for the unknown algorithm');
assert.equal(unknownResult.valid, false, 'unknown algorithm must not be valid evidence');
assert.equal(result.policySufficient, false, 'policy must fail with only an unknown algorithm');
// F-D3: unknown algorithms are 'ignored' (valid: null) — neither valid
// evidence nor a failure. They do not flip the legacy `valid` flag.
assert.equal(unknownResult.valid, null, 'unknown algorithm must be ignored (valid: null), not false');
assert.ok(unknownResult.note.includes('ignored'), 'note should mention ignored');
// Policy must still fail because the mandatory algorithms are missing.
assert.equal(result.policySufficient, false, 'policy must fail with only an unknown algorithm (mandatory set missing)');
assert.equal(result.validForMigration, false, 'must not be valid for migration');
});
test('G56-01: duplicate algorithm tags fail', async () => {
@@ -519,9 +581,433 @@ describe('G56-01: strict PQ algorithm policy (verifyNIPQRContent)', () => {
});
// ============================================================================
// G56-02: NON-REPLACEABLE PROOF CARRIER (kind 9999) AND EARLIEST-ANCHOR SELECTION
// F-D1: CANONICAL OTS DIGEST (Fable5 H-1)
// ============================================================================
//
// The OTS target digest MUST be cross-implementation-reproducible. The legacy
// hashFullEvent() is key-order-dependent; canonicalEventDigest() hashes the
// NIP-01 serialization array extended with id and sig, which is canonical.
describe('F-D1: canonical OTS digest', () => {
const sk = new Uint8Array(32);
sk[31] = 1;
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
async function signedKind1() {
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = m.mnemonicToSeed(mnemonic);
const keys = m.derivePQKeysFromSeed(seed);
const event = m.buildKind1Announcement(pkHex, 958927, keys);
event.id = m.computeEventId(event);
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
return event;
}
test('canonicalEventDigest is deterministic for a fixed event', async () => {
const kind1 = await signedKind1();
const d1 = m.canonicalEventDigest(kind1);
const d2 = m.canonicalEventDigest(kind1);
assert.equal(d1, d2, 'same event must produce the same canonical digest');
assert.match(d1, /^[0-9a-f]{64}$/, 'digest must be 64 lowercase hex chars');
});
test('F-D1: canonical digest is invariant under object key reordering', async () => {
// A relay or second implementation may return the same event with different
// object key order. The canonical digest MUST be identical. The legacy
// hashFullEvent would NOT be (this is the H-1 regression test).
const kind1 = await signedKind1();
const canonicalOriginal = m.canonicalEventDigest(kind1);
// Reorder keys deliberately (sig first, id last, etc.)
const reordered = {
sig: kind1.sig,
content: kind1.content,
tags: kind1.tags,
kind: kind1.kind,
created_at: kind1.created_at,
pubkey: kind1.pubkey,
id: kind1.id
};
const canonicalReordered = m.canonicalEventDigest(reordered);
assert.equal(canonicalOriginal, canonicalReordered,
'canonical digest must NOT depend on object key order');
// And the legacy hash MUST differ (demonstrating why the fix is needed)
const legacyOriginal = m.hashFullEvent(kind1);
const legacyReordered = m.hashFullEvent(reordered);
assert.notEqual(legacyOriginal, legacyReordered,
'legacy hashFullEvent is key-order-dependent (this is the bug F-D1 fixes)');
});
test('F-D1: re-serialization invariance (JSON.parse then re-digest)', async () => {
const kind1 = await signedKind1();
const digestBefore = m.canonicalEventDigest(kind1);
const reparsed = JSON.parse(JSON.stringify(kind1));
const digestAfter = m.canonicalEventDigest(reparsed);
assert.equal(digestBefore, digestAfter,
'parse -> re-digest must yield the same canonical digest');
});
test('F-D1: buildProofCarrier emits digest_version tag and canonical sha256', async () => {
const kind1 = await signedKind1();
const otsProof = new Uint8Array([0x00]); // dummy proof bytes
const carrier = m.buildProofCarrier(kind1, otsProof);
const dvTag = carrier.tags.find(t => t[0] === 'digest_version');
const shaTag = carrier.tags.find(t => t[0] === 'sha256');
assert.ok(dvTag, 'proof carrier must have a digest_version tag');
assert.equal(dvTag[1], String(m.CANONICAL_DIGEST_VERSION),
`digest_version must be ${m.CANONICAL_DIGEST_VERSION}`);
assert.equal(shaTag[1], m.canonicalEventDigest(kind1),
'sha256 tag must be the canonical digest, not the legacy hash');
});
test('F-D1: v1 carrier with all PQ keys is validForMigration', async () => {
const kind1 = await signedKind1();
const tags = [
['e', kind1.id],
['sha256', m.canonicalEventDigest(kind1)],
['digest_version', String(m.CANONICAL_DIGEST_VERSION)]
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.digestVersionValid, true, 'digest_version should be valid');
assert.equal(result.digestIsLegacy, false, 'should not be legacy');
assert.equal(result.sha256Valid, true, 'sha256 should match canonical digest');
assert.equal(result.validForMigration, true, 'v1 carrier should be valid for migration');
});
test('F-D1: legacy v0 carrier (no digest_version) is NOT validForMigration', async () => {
const kind1 = await signedKind1();
// v0 tags: legacy hash, no digest_version
const tags = [
['e', kind1.id],
['sha256', m.hashFullEvent(kind1)]
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.digestVersionValid, false, 'v0 must not have a valid digest_version');
assert.equal(result.digestIsLegacy, true, 'should be flagged legacy');
assert.equal(result.sha256Valid, true, 'legacy sha256 should still match for inspection');
assert.equal(result.validForMigration, false, 'legacy v0 must NOT be valid for migration');
});
test('F-D1: unknown digest_version fails closed', async () => {
const kind1 = await signedKind1();
const tags = [
['e', kind1.id],
['sha256', m.canonicalEventDigest(kind1)],
['digest_version', '99'] // unsupported future version
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.digestVersionValid, false, 'unknown version must fail closed');
assert.equal(result.validForMigration, false, 'must not be valid for migration');
assert.ok(result.errors.some(e => e.includes('unsupported digest_version')),
'should report unsupported digest_version');
});
test('F-D1: missing digest_version is treated as legacy v0', async () => {
const kind1 = await signedKind1();
const tags = [
['e', kind1.id],
['sha256', m.hashFullEvent(kind1)]
// no digest_version tag
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.digestIsLegacy, true, 'missing tag => legacy v0');
assert.equal(result.digestVersionValid, false, 'legacy is not valid for migration');
});
test('F-D1: duplicate digest_version tags fail', async () => {
const kind1 = await signedKind1();
const tags = [
['e', kind1.id],
['sha256', m.canonicalEventDigest(kind1)],
['digest_version', String(m.CANONICAL_DIGEST_VERSION)],
['digest_version', String(m.CANONICAL_DIGEST_VERSION)]
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.digestVersionValid, false, 'duplicate digest_version must fail');
assert.equal(result.validForMigration, false, 'must not be valid for migration');
});
test('F-D1: v1 carrier with wrong sha256 (legacy hash instead of canonical) fails', async () => {
const kind1 = await signedKind1();
// Claim v1 but put the legacy hash in the sha256 tag
const tags = [
['e', kind1.id],
['sha256', m.hashFullEvent(kind1)],
['digest_version', String(m.CANONICAL_DIGEST_VERSION)]
];
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.digestVersionValid, true, 'version tag itself is valid');
assert.equal(result.sha256Valid, false, 'sha256 must match canonical, not legacy, for v1');
assert.equal(result.validForMigration, false, 'must not be valid for migration');
});
test('F-D1: canonicalEventDigest rejects missing/invalid fields', () => {
assert.throws(() => m.canonicalEventDigest(null), /expected an event object/);
assert.throws(() => m.canonicalEventDigest({}), /missing field/);
// created_at as a string => "must be numbers"
assert.throws(() => m.canonicalEventDigest({
pubkey: 'x', created_at: 'notanumber', kind: 1, tags: [], content: 'x', id: 'x', sig: 'x'
}), /must be numbers/);
// pubkey as a number => "must be strings"
assert.throws(() => m.canonicalEventDigest({
pubkey: 1, created_at: 1, kind: 1, tags: [], content: 'x', id: 'x', sig: 'x'
}), /must be strings/);
// tags as a string => "tags must be an array"
assert.throws(() => m.canonicalEventDigest({
pubkey: 'x', created_at: 1, kind: 1, tags: 'notarray', content: 'x', id: 'x', sig: 'x'
}), /tags must be an array/);
});
});
// ============================================================================
// F-D2: PROOF ARCHIVE (Fable5 H-2 — anchor availability / deletion resistance)
// ============================================================================
describe('F-D2: proof archive', () => {
const sk = new Uint8Array(32);
sk[31] = 1;
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
async function makeSignedKind1AndCarrier() {
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = m.mnemonicToSeed(mnemonic);
const keys = m.derivePQKeysFromSeed(seed);
const kind1 = m.buildKind1Announcement(pkHex, 958927, keys);
kind1.id = m.computeEventId(kind1);
kind1.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(kind1.id), sk));
const otsProof = new Uint8Array([0x00, 0x01, 0x02, 0x03]); // dummy proof
const carrier = m.buildProofCarrier(kind1, otsProof);
carrier.id = m.computeEventId(carrier);
carrier.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(carrier.id), sk));
return { kind1, carrier, otsProof };
}
test('F-D2: buildProofArchive produces a parseable archive with correct fields', async () => {
const { kind1, carrier, otsProof } = await makeSignedKind1AndCarrier();
const archiveJson = m.buildProofArchive(kind1, carrier, otsProof);
const parsed = m.parseProofArchive(archiveJson);
assert.equal(parsed.archiveType, 'nostr-pq-link-proof');
assert.equal(parsed.archiveVersion, 1);
// kind1 from buildKind1Announcement carries a non-serializable statementBytes
// Uint8Array; compare the JSON-serializable NIP-01 fields instead.
const kind1Serializable = { ...kind1 };
delete kind1Serializable.statementBytes;
assert.deepEqual(parsed.kind1Event, kind1Serializable);
assert.deepEqual(parsed.proofCarrierEvent, carrier);
assert.deepEqual(parsed.otsProof, otsProof);
assert.equal(parsed.canonicalDigest, m.canonicalEventDigest(kind1));
assert.equal(parsed.digestVersion, m.CANONICAL_DIGEST_VERSION);
});
test('F-D2: archive round-trips through JSON.parse and re-verification', async () => {
const { kind1, carrier, otsProof } = await makeSignedKind1AndCarrier();
const archiveJson = m.buildProofArchive(kind1, carrier, otsProof);
// Simulate offline storage and re-load
const reloaded = m.parseProofArchive(archiveJson);
// The extracted kind 1 must verify against the extracted carrier
const result = m.verifyNIPQRContent(
JSON.stringify(reloaded.kind1Event),
reloaded.proofCarrierEvent.tags
);
assert.equal(result.sha256Valid, true, 'extracted sha256 should match');
assert.equal(result.digestVersionValid, true, 'extracted digest_version should be valid');
assert.equal(result.validForMigration, true, 'archive should verify end-to-end without any relay');
});
test('F-D2: parseProofArchive rejects wrong archiveType', async () => {
const { kind1, carrier, otsProof } = await makeSignedKind1AndCarrier();
const archiveJson = m.buildProofArchive(kind1, carrier, otsProof);
const tampered = JSON.parse(archiveJson);
tampered.archiveType = 'not-the-right-type';
assert.throws(() => m.parseProofArchive(JSON.stringify(tampered)), /wrong archiveType/);
});
test('F-D2: parseProofArchive rejects unsupported archiveVersion', async () => {
const { kind1, carrier, otsProof } = await makeSignedKind1AndCarrier();
const archiveJson = m.buildProofArchive(kind1, carrier, otsProof);
const tampered = JSON.parse(archiveJson);
tampered.archiveVersion = 99;
assert.throws(() => m.parseProofArchive(JSON.stringify(tampered)), /unsupported archiveVersion/);
});
test('F-D2: parseProofArchive rejects malformed input', () => {
assert.throws(() => m.parseProofArchive(''), /non-empty JSON string/);
assert.throws(() => m.parseProofArchive('not json'), /invalid JSON/);
assert.throws(() => m.parseProofArchive('{}'), /wrong archiveType/);
assert.throws(() => m.parseProofArchive('{"archiveType":"nostr-pq-link-proof","archiveVersion":1}'), /missing or invalid kind1Event/);
});
test('F-D2: buildProofArchive rejects invalid inputs', () => {
assert.throws(() => m.buildProofArchive(null, {}, new Uint8Array()), /kind1Event must be an object/);
assert.throws(() => m.buildProofArchive({}, null, new Uint8Array()), /proofCarrierEvent must be an object/);
assert.throws(() => m.buildProofArchive({}, {}, 'notbytes'), /otsProof must be a Uint8Array/);
});
});
// ============================================================================
// F-D3: ALGORITHM POLICY RECONCILIATION (Fable5 M-1)
// ============================================================================
//
// The mandatory set is versioned and explicit. Unknown algorithms are
// 'ignored' (valid: null) — neither credit nor failure. A custom policy can
// drop a broken scheme (the NIP's revocation path).
describe('F-D3: algorithm policy reconciliation', () => {
const sk = new Uint8Array(32);
sk[31] = 1;
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
async function validKind1WithAllPQKeys() {
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = m.mnemonicToSeed(mnemonic);
const keys = m.derivePQKeysFromSeed(seed);
const event = m.buildKind1Announcement(pkHex, 958927, keys);
event.id = m.computeEventId(event);
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
return event;
}
function wrapperTags(kind1Event) {
return [
['e', kind1Event.id],
['sha256', m.canonicalEventDigest(kind1Event)],
['digest_version', String(m.CANONICAL_DIGEST_VERSION)]
];
}
test('F-D3: default policy is POLICY_V1 and result reports policyVersion', async () => {
const kind1 = await validKind1WithAllPQKeys();
const tags = wrapperTags(kind1);
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
assert.equal(result.policyVersion, 1, 'default policy version should be 1');
assert.equal(result.policySufficient, true, 'all mandatory algorithms present');
});
test('F-D3: extra unknown algorithm alongside valid mandatory set still passes', async () => {
const kind1 = await validKind1WithAllPQKeys();
// Add an unknown algorithm tag alongside the valid mandatory set
kind1.tags.push(['algorithm', 'future-pq-scheme', m.bytesToBase64(new Uint8Array(64))]);
kind1.id = m.computeEventId(kind1);
kind1.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(kind1.id), sk));
const tags = wrapperTags(kind1);
const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags);
const unknownResult = result.results.find(r => r.algorithm === 'future-pq-scheme');
assert.ok(unknownResult, 'should have a result for the unknown algorithm');
assert.equal(unknownResult.valid, null, 'unknown algorithm should be ignored (valid: null)');
// The mandatory set is still complete and valid, so policy passes.
assert.equal(result.policySufficient, true, 'policy should pass with valid mandatory set + ignored unknown');
assert.equal(result.validForMigration, true, 'should be valid for migration');
});
test('F-D3: custom policy dropping a scheme allows revocation-path event', async () => {
// Simulate a future POLICY_V2 that drops falcon-512 (because it was broken).
// An event that omits falcon-512 should pass under POLICY_V2 but fail under V1.
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = m.mnemonicToSeed(mnemonic);
const keys = m.derivePQKeysFromSeed(seed);
const event = m.buildKind1Announcement(pkHex, 958927, keys);
// Remove the falcon-512 tag (revocation scenario)
event.tags = event.tags.filter(t => !(Array.isArray(t) && t[1] === 'falcon-512'));
event.id = m.computeEventId(event);
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
const tags = wrapperTags(event);
// Under default V1 policy: falcon-512 is mandatory, so policy fails
const resultV1 = m.verifyNIPQRContent(JSON.stringify(event), tags);
assert.equal(resultV1.policySufficient, false, 'V1 policy must fail without falcon-512');
assert.ok(resultV1.errors.some(e => e.includes('missing mandatory algorithm: falcon-512')),
'should report missing falcon-512');
// Under a custom V2 policy that drops falcon-512: policy passes
const POLICY_V2 = {
version: 2,
mandatorySignatureAlgorithms: ['ml-dsa-44', 'ml-dsa-65', 'slh-dsa-128s'],
kemAlgorithms: ['ml-kem-768']
};
const resultV2 = m.verifyNIPQRContent(JSON.stringify(event), tags, { policy: POLICY_V2 });
assert.equal(resultV2.policyVersion, 2, 'should report custom policy version 2');
assert.equal(resultV2.policySufficient, true, 'V2 policy should pass without falcon-512');
assert.equal(resultV2.validForMigration, true, 'should be valid for migration under V2');
});
test('F-D3: policy module exports match the expected V1 set', () => {
assert.deepEqual([...policy.POLICY_V1.mandatorySignatureAlgorithms],
['ml-dsa-44', 'ml-dsa-65', 'slh-dsa-128s', 'falcon-512']);
assert.deepEqual([...policy.POLICY_V1.kemAlgorithms], ['ml-kem-768']);
assert.equal(policy.POLICY_V1.version, 1);
assert.equal(policy.isMandatorySignature('ml-dsa-44', policy.POLICY_V1), true);
assert.equal(policy.isMandatorySignature('ml-kem-768', policy.POLICY_V1), false);
assert.equal(policy.isKem('ml-kem-768', policy.POLICY_V1), true);
assert.equal(policy.isKem('ml-dsa-44', policy.POLICY_V1), false);
assert.ok(policy.knownAlgorithms(policy.POLICY_V1).has('falcon-512'));
assert.ok(!policy.knownAlgorithms(policy.POLICY_V1).has('future-scheme'));
});
});
// ============================================================================
// F-D5: STRICT BASE64 DECODING (Fable5 L-3)
// ============================================================================
describe('F-D5: strict base64 decoding', () => {
test('F-D5: valid base64 round-trips', () => {
const bytes = new Uint8Array([0x68, 0x65, 0x6c, 0x6c, 0x6f]); // "hello"
const b64 = m.bytesToBase64(bytes);
const decoded = m.base64ToBytes(b64);
assert.deepEqual(decoded, bytes, 'round-trip should preserve bytes');
});
test('F-D5: empty string throws', () => {
assert.throws(() => m.base64ToBytes(''), /non-empty base64 string/);
});
test('F-D5: missing padding throws', () => {
// "aGVsbG8" is "hello" without the trailing '=' padding
assert.throws(() => m.base64ToBytes('aGVsbG8'), /padding|base64|invalid/);
});
test('F-D5: whitespace in base64 throws', () => {
assert.throws(() => m.base64ToBytes('aGVsbG8=\n'), /padding|base64|invalid|whitespace/);
});
test('F-D5: non-base64 characters throw', () => {
assert.throws(() => m.base64ToBytes('not-base64!@#'), /base64|invalid/);
});
test('F-D5: non-string input throws', () => {
assert.throws(() => m.base64ToBytes(null), /non-empty base64 string/);
assert.throws(() => m.base64ToBytes(123), /non-empty base64 string/);
assert.throws(() => m.base64ToBytes(undefined), /non-empty base64 string/);
});
test('F-D5: whitespace-padded ots tag is rejected by base64ToBytes and verifyOtsProof', async () => {
const sk = new Uint8Array(32);
sk[31] = 1;
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = m.mnemonicToSeed(mnemonic);
const keys = m.derivePQKeysFromSeed(seed);
const kind1 = m.buildKind1Announcement(pkHex, 958927, keys);
kind1.id = m.computeEventId(kind1);
kind1.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(kind1.id), sk));
// Build a carrier, then tamper the ots tag to have trailing whitespace
const carrier = m.buildProofCarrier(kind1, new Uint8Array([0x00, 0x01]));
const otsTag = carrier.tags.find(t => t[0] === 'ots');
const tamperedOts = otsTag[1] + '\n'; // append whitespace
// F-D5: base64ToBytes must reject the whitespace-padded ots tag
assert.throws(() => m.base64ToBytes(tamperedOts), /padding|base64|invalid|whitespace/);
// F-D5: verifyOtsProof (which decodes the ots tag) must fail closed on the tampered tag
const sha256Tag = carrier.tags.find(t => t[0] === 'sha256');
const verifyResult = await m.verifyOtsProof(m.base64ToBytes(otsTag[1]), sha256Tag ? sha256Tag[1] : undefined).catch(() => null);
// Either it throws (caught) or returns verified:false — either way, not verified
if (verifyResult) {
assert.equal(verifyResult.verified, false, 'verifyOtsProof must not verify a tampered ots tag');
}
});
});
// ============================================================================
// Tests for the new non-replaceable proof carrier event kind, append-only
// upgrades, and the selectCanonicalProofCarrier function.
@@ -642,6 +1128,24 @@ describe('G56-02: non-replaceable proof carrier (kind 9999)', () => {
assert.equal(result.canonical, null);
assert.ok(result.errors.some(e => e.includes('expected author')), 'should report author mismatch');
});
test('F-D4/L-2: pending-only selection is labeled canonicalTrustMode=pending-only', async () => {
const kind1 = await validKind1WithAllPQKeys();
// A carrier with a dummy OTS proof (no Bitcoin attestation) => pending-only
const carrier = await signedProofCarrier(kind1, new Uint8Array(100));
const result = await m.selectCanonicalProofCarrier([carrier], kind1);
assert.ok(result.canonical, 'should select the pending candidate as canonical (best available)');
assert.equal(result.canonicalBitcoinHeight, null, 'no Bitcoin height for pending');
assert.equal(result.canonicalTrustMode, 'pending-only',
'pending-only selection MUST be labeled canonicalTrustMode=pending-only so callers do not treat it as a trust decision');
});
test('F-D4/L-2: no candidates yields canonicalTrustMode=none', async () => {
const kind1 = await validKind1WithAllPQKeys();
const result = await m.selectCanonicalProofCarrier([], kind1);
assert.equal(result.canonical, null);
assert.equal(result.canonicalTrustMode, 'none');
});
});
// ============================================================================
@@ -853,3 +1357,193 @@ describe('G56-07: isOtsConfirmed never throws on bad input', () => {
}
});
});
// ============================================================================
// G56-05: Signer output validation
// ============================================================================
describe('G56-05: validateSignerOutput', () => {
const sk = new Uint8Array(32);
sk[31] = 5;
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
const otherSk = new Uint8Array(32);
otherSk[31] = 9;
const otherPkHex = m.bytesToHex(schnorr.getPublicKey(otherSk));
// Helper: create a valid template + correctly signed event
function makeValidSignedEvent(content = 'test content', tags = [['t', 'x']]) {
const template = {
pubkey: pkHex,
created_at: 1700000000,
kind: 1,
tags,
content
};
const event = { ...template };
event.id = m.computeEventId(event);
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
return { template, event };
}
test('valid signer output passes validation', () => {
const { template, event } = makeValidSignedEvent();
const result = m.validateSignerOutput(event, template, pkHex);
assert.equal(result.id, event.id);
assert.equal(result.sig, event.sig);
});
test('signer changes content → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, content: 'tampered content' };
// Recompute id+sig so the tampered event is internally consistent
// (a real malicious signer would do this, but the content must still match the template)
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /content was changed/);
});
test('signer changes tags → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, tags: [['t', 'x'], ['extra', 'tag']] };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /tags length changed/);
});
test('signer changes created_at → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, created_at: 9999999999 };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /created_at changed/);
});
test('signer changes kind → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, kind: 9999 };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /kind changed/);
});
test('signer returns wrong pubkey → rejected', () => {
const { template, event } = makeValidSignedEvent();
// Sign with a different key, use that key's pubkey
const tampered = { ...template, pubkey: otherPkHex };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), otherSk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /pubkey mismatch/);
});
test('signer returns wrong ID → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, id: '0'.repeat(64) };
// sig is valid for the real id, not for '0'.repeat(64)
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /id mismatch/);
});
test('signer returns invalid signature → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, sig: 'f'.repeat(128) };
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /invalid Schnorr signature/);
});
test('signer returns extra field → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, extra: 'malicious' };
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /unexpected extra field/);
});
test('signer returns missing field → rejected', () => {
const { template, event } = makeValidSignedEvent();
const { sig, ...noSig } = event;
assert.throws(() => m.validateSignerOutput(noSig, template, pkHex), /missing field 'sig'/);
});
test('signer returns non-object → rejected', () => {
const { template } = makeValidSignedEvent();
assert.throws(() => m.validateSignerOutput(null, template, pkHex), /non-object/);
assert.throws(() => m.validateSignerOutput('string', template, pkHex), /non-object/);
});
test('signer returns malformed id (not 64 hex) → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, id: 'short' };
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /id must be 64/);
});
test('signer returns malformed sig (not 128 hex) → rejected', () => {
const { template, event } = makeValidSignedEvent();
const tampered = { ...event, sig: 'short' };
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /sig must be 128/);
});
test('no expectedPubkey → pubkey check skipped', () => {
const { template, event } = makeValidSignedEvent();
// Should pass without expectedPubkey (backward compatible)
const result = m.validateSignerOutput(event, template);
assert.equal(result.id, event.id);
});
test('tag element value changed → rejected', () => {
const { template, event } = makeValidSignedEvent('test', [['t', 'original']]);
const tampered = { ...event, tags: [['t', 'changed']] };
tampered.id = m.computeEventId(tampered);
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /tag 0 element 1 changed/);
});
});
// ============================================================================
// F-D6: CROSS-IMPLEMENTATION CONFORMANCE VECTORS (Fable5 F-6)
// ============================================================================
//
// Pinned test vectors that a second implementation (Rust/Python/Go) can be
// validated against without reading the JS source. The vector files live in
// test/vectors/ and are generated by test/vectors/generate-vectors.mjs.
describe('F-D6: cross-implementation conformance vectors', () => {
test('F-D6: seed-to-pubkeys vector is loadable and well-formed', () => {
const vecPath = join(vectorsDir, 'seed-to-pubkeys.v1.json');
let vec;
try {
vec = JSON.parse(readFileSync(vecPath, 'utf8'));
} catch (e) {
assert.fail(`Could not load ${vecPath}: ${e.message}`);
}
assert.equal(vec.vectorType, 'nostr-pq-link-seed-to-pubkeys');
assert.equal(vec.vectorVersion, 1);
assert.equal(typeof vec.mnemonic, 'string');
assert.equal(typeof vec.bip39SeedHex, 'string');
assert.ok(vec.derivedPublicKeys, 'should have derivedPublicKeys');
for (const alg of ['secp256k1', 'ml-dsa-44', 'ml-dsa-65', 'slh-dsa-128s', 'falcon-512', 'ml-kem-768']) {
assert.ok(vec.derivedPublicKeys[alg], `should have ${alg} entry`);
assert.equal(typeof vec.derivedPublicKeys[alg].publicKeyHex, 'string');
assert.ok(vec.derivedPublicKeys[alg].publicKeyHex.length > 0, `${alg} publicKeyHex should be non-empty`);
}
});
test('F-D6: implementation reproduces the pinned seed-to-pubkeys vector', () => {
const vecPath = join(vectorsDir, 'seed-to-pubkeys.v1.json');
const vec = JSON.parse(readFileSync(vecPath, 'utf8'));
const seed = m.mnemonicToSeed(vec.mnemonic);
// Verify the BIP39 seed matches
assert.equal(m.bytesToHex(seed), vec.bip39SeedHex, 'BIP39 seed should match the vector');
// Derive all keys and compare against the pinned hex values
const secpKp = m.deriveSecp256k1FromSeed(seed);
assert.equal(m.bytesToHex(secpKp.publicKey), vec.derivedPublicKeys.secp256k1.publicKeyHex,
'secp256k1 pubkey should match the pinned vector');
const pqKeys = m.derivePQKeysFromSeed(seed);
assert.equal(m.bytesToHex(pqKeys.mlDsa44.publicKey), vec.derivedPublicKeys['ml-dsa-44'].publicKeyHex,
'ml-dsa-44 pubkey should match the pinned vector');
assert.equal(m.bytesToHex(pqKeys.mlDsa65.publicKey), vec.derivedPublicKeys['ml-dsa-65'].publicKeyHex,
'ml-dsa-65 pubkey should match the pinned vector');
assert.equal(m.bytesToHex(pqKeys.slhDsa.publicKey), vec.derivedPublicKeys['slh-dsa-128s'].publicKeyHex,
'slh-dsa-128s pubkey should match the pinned vector');
assert.equal(m.bytesToHex(pqKeys.falcon512.publicKey), vec.derivedPublicKeys['falcon-512'].publicKeyHex,
'falcon-512 pubkey should match the pinned vector');
assert.equal(m.bytesToHex(pqKeys.mlKem.publicKey), vec.derivedPublicKeys['ml-kem-768'].publicKeyHex,
'ml-kem-768 pubkey should match the pinned vector');
});
});
+144
View File
@@ -0,0 +1,144 @@
/**
* Conformance vector generator (F-D6).
*
* Computes the pinned values for the cross-implementation conformance vectors
* from the fixed BIP39 test mnemonic, and writes them to test/vectors/.
*
* Run: node test/vectors/generate-vectors.mjs
*
* A second implementation (Rust/Python/Go) can be validated against the
* generated JSON files without reading the JS source.
*/
import { mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english.js';
import { HDKey } from '@scure/bip32';
import { schnorr } from '@noble/curves/secp256k1.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { ml_dsa44, ml_dsa65 } from '@noble/post-quantum/ml-dsa.js';
import { slh_dsa_sha2_128s } from '@noble/post-quantum/slh-dsa.js';
import { falcon512 } from '@noble/post-quantum/falcon.js';
import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
import { writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const VECTORS_DIR = __dirname;
const PQ_DERIVATION_PATHS = {
secp256k1: [0],
mlDsa44: [1],
mlDsa65: [2],
slhDsa: [3, 4],
falcon512: [5, 6],
mlKem: [7, 8],
};
const PQ_SEED_LENGTHS = {
mlDsa44: 32,
mlDsa65: 32,
slhDsa: 48,
falcon512: 48,
mlKem: 64,
};
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
function deriveBIP32Child(bip39Seed, childIndices) {
const hdKey = HDKey.fromMasterSeed(bip39Seed);
const path = `m/44'/1237'/0'/0/${childIndices.join('/')}`;
const child = hdKey.derive(path);
if (!child.privateKey) throw new Error(`Failed to derive at ${path}`);
return child.privateKey;
}
function derivePQSeedFromBIP32(bip39Seed, childIndices, requiredLength) {
if (childIndices.length === 1) {
const seed = deriveBIP32Child(bip39Seed, childIndices);
return seed;
} else {
let combined = new Uint8Array(0);
for (const idx of childIndices) {
const child = deriveBIP32Child(bip39Seed, [idx]);
const newCombined = new Uint8Array(combined.length + child.length);
newCombined.set(combined);
newCombined.set(child, combined.length);
combined = newCombined;
}
return combined.slice(0, requiredLength);
}
}
const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
if (!validateMnemonic(MNEMONIC, wordlist)) {
throw new Error('Test mnemonic failed validation');
}
const seed = mnemonicToSeedSync(MNEMONIC, '');
// secp256k1
const secpHd = HDKey.fromMasterSeed(seed).derive("m/44'/1237'/0'/0/0");
const secpPub = secpHd.publicKey;
// PQ keys
const mlDsa44Seed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.mlDsa44, PQ_SEED_LENGTHS.mlDsa44);
const mlDsa44Keys = ml_dsa44.keygen(mlDsa44Seed);
const mlDsa65Seed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.mlDsa65, PQ_SEED_LENGTHS.mlDsa65);
const mlDsa65Keys = ml_dsa65.keygen(mlDsa65Seed);
const slhDsaSeed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.slhDsa, PQ_SEED_LENGTHS.slhDsa);
const slhDsaKeys = slh_dsa_sha2_128s.keygen(slhDsaSeed);
const falconSeed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.falcon512, PQ_SEED_LENGTHS.falcon512);
const falconKeys = falcon512.keygen(falconSeed);
const mlKemSeed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.mlKem, PQ_SEED_LENGTHS.mlKem);
const mlKemKeys = ml_kem768.keygen(mlKemSeed);
const vector = {
vectorType: 'nostr-pq-link-seed-to-pubkeys',
vectorVersion: 1,
description: 'Pins the BIP32 truncation rule and PQ keygen determinism: a fixed BIP39 mnemonic must produce the same five PQ public keys on any conforming implementation.',
mnemonic: MNEMONIC,
bip39SeedHex: bytesToHex(seed),
derivationBasePath: "m/44'/1237'/0'/0/",
derivedPublicKeys: {
secp256k1: {
derivationPath: "m/44'/1237'/0'/0/0",
publicKeyHex: bytesToHex(secpPub)
},
'ml-dsa-44': {
derivationPath: "m/44'/1237'/0'/0/1",
publicKeyHex: bytesToHex(mlDsa44Keys.publicKey)
},
'ml-dsa-65': {
derivationPath: "m/44'/1237'/0'/0/2",
publicKeyHex: bytesToHex(mlDsa65Keys.publicKey)
},
'slh-dsa-128s': {
derivationPath: "m/44'/1237'/0'/0/3 + m/44'/1237'/0'/0/4 (concatenated, first 48 bytes used)",
publicKeyHex: bytesToHex(slhDsaKeys.publicKey)
},
'falcon-512': {
derivationPath: "m/44'/1237'/0'/0/5 + m/44'/1237'/0'/0/6 (concatenated, first 48 bytes used)",
publicKeyHex: bytesToHex(falconKeys.publicKey)
},
'ml-kem-768': {
derivationPath: "m/44'/1237'/0'/0/7 + m/44'/1237'/0'/0/8 (concatenated, all 64 bytes used)",
publicKeyHex: bytesToHex(mlKemKeys.publicKey)
}
},
notes: [
'The truncation rule is normative: for 48-byte seeds, two BIP32 children are concatenated (64 bytes) and the FIRST 48 bytes are used. For 64-byte seeds, all 64 bytes are used.',
'A future implementer who takes the last 48 bytes, or concatenates in the opposite order, will produce different keys and break seed-phrase recoverability.',
'A second implementation should run its own keygen from the same mnemonic and compare against the publicKeyHex values in this file.'
]
};
const outPath = join(VECTORS_DIR, 'seed-to-pubkeys.v1.json');
writeFileSync(outPath, JSON.stringify(vector, null, 2) + '\n');
console.log(`Wrote ${outPath}`);
console.log('secp256k1 pubkey:', bytesToHex(secpPub));
console.log('ml-dsa-44 pubkey:', bytesToHex(mlDsa44Keys.publicKey));
console.log('ml-dsa-65 pubkey:', bytesToHex(mlDsa65Keys.publicKey));
console.log('slh-dsa-128s pubkey:', bytesToHex(slhDsaKeys.publicKey));
console.log('falcon-512 pubkey:', bytesToHex(falconKeys.publicKey));
console.log('ml-kem-768 pubkey:', bytesToHex(mlKemKeys.publicKey));
+39
View File
@@ -0,0 +1,39 @@
{
"vectorType": "nostr-pq-link-seed-to-pubkeys",
"vectorVersion": 1,
"description": "Pins the BIP32 truncation rule and PQ keygen determinism: a fixed BIP39 mnemonic must produce the same five PQ public keys on any conforming implementation.",
"mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"bip39SeedHex": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
"derivationBasePath": "m/44'/1237'/0'/0/",
"derivedPublicKeys": {
"secp256k1": {
"derivationPath": "m/44'/1237'/0'/0/0",
"publicKeyHex": "02e8bcf3823669444d0b49ad45d65088635d9fd8500a75b5f20b59abefa56a144f"
},
"ml-dsa-44": {
"derivationPath": "m/44'/1237'/0'/0/1",
"publicKeyHex": "95d5552cf564847ce3e99994365996241c0b5a01a0cbe9084cadc507b1bb6c6a3fb45c5ce3180488ac9fe2603d7119905c9592c2b3fa29a3435207175d9d29e9717f0ed45c2e56941ed7b71521e0ea4fc1d6c66551e1cf7a787c14855090e5704482c28af8959f3353715c22848d5597c96f400307a13d6b5285a3e01beeb227d8ce1b7a438bfc77f285bafe1b0d68cfa17cf29555e9be756dc3ceb74c935de2c106308ec9cbe9eecc482948004aec738db4a2fc584c0228ae1828bcf9683134249b1316781acf52301aa01f917614056e6386431b587ccc209f85e6613acc1bde4e1d65bdb194a8a5771262edf54ef10eda5d9dac06f1d6dbd3baef173261893331fe4416d6c25c8fbe09cf85e8f3868b4ad96ee2c6bc203d96f7f4cee974388de27bdcb76439193b72a7a4e7a655c00f474d11e64002c8a03d45df7152b575d513d256b4d34fdaa42775fdc57286bb45281bea21f008a38e693360605d238c3730b396f2ca3a1ff4b5c785b3729dafa3e394b2b068a90dead1fa7db4aecd8b075246654156243e89281af50f073718c5bb59a903d90f850da56687d66447561ed766957d0da0d326d110a0113d55df852e6b71a29f0852e7df2f3f3257071e39e9ff26d2b5ef5301c138496d79c6e9601f9497f760798075839ee2603d19d6dae6d672d72c2d44e85f3c6470463cdc6a7956c27918a2510fb4a0a4608a8be1a07e638cc2edfa373be6256a8aea28024b13e8c492c283fdd49a0e7c124ab123d5e1a504534d8d2ef9acf8f9b0947f6d3cf6a927d8767509e240c03004eb2cdbde2306ba8ab9c76f2a9538f2fba3271a363adb74298c0b923e0bd4eb8ede471c979627d628e7925f6519a86eac7907f7ba278f9ecbced2e1f7ae85516172fa91c9d89504b91e883c13487cf8e6e27a3786642503f6c9b4116b81bb306166b8db6216b9fa76e70a038c5e0bfbf92811918b380f563209f5b9845b5790972f8454fb8a7366f3e3f9a656a5ab88bc2ad300ed45e93a442185f7dbcca30bdfa1c2d06599dcee5604b89f2291aee1d8f2382623f8592031417125e29fb5175726c37b6626c0aee04bfd4fee1b8fd550def8fbe3de400c885f09bf9b476da6d0534bd0642fad0955989b662c5e680a54159ed152f1eae6a4db21896cc5c1fb7c5b68553882fa2496a92bd16d2d5f98b493c19c1bc425e02236b52ce26b6a2994a5bdf297f0366b3eef0879c14da236b16b9e7921d271b1c99c449793d19e4a6ca374d376fba30d61118c18f5b8dc6a56995861957ea3bb526b3b3513a321a43663e60ce4904a8248713070dd59fd81667d6282f7a7a5b04dd18c59c2ca2bc6c1fcbf65095b14b1a2e2009342957113ccef8a5969b7c5d88837feeb1cfd9d8f3f4c8c7b587f34e73d354762127f2ec554280427a22bd029e4aa6d9dc151d9032d0d71dfb31fec6ffe2760e39f79e0b0cd47663ded101a29c40ec94fa777565383f7a767e2c86a7f54e7d2ad1e0c370dc51567c15e87c4f8394b08387c12d05e308634779a84f18080b852ba8e0c264d9d6f4bdd1c9d08fb36b9875d8d1742210861258b2895b0f8c48054d17a8cf5b92785ad8aa3b196deac8a9b467e08635679ab61f542cf3bb925350a5ec28c96524e23e1c8d1f5064a19f36072025dae73a8f8d7cd6b7498fd1606fb7f65bdc73c440c3c020ad159268fd371adba445378414c730fef743f275e8de85c6f1b1d7131bcf0ddbf77e9bb663ee50c548eb3ac9553a9ad65521f933ad4e0a3d5cc0f0467cec16f59c61bd16ac4a90a1e0a8440de037d5a03b05685d1f64738e966dfaedc671a9b65119d0151bebdde1be4e9adb692664e"
},
"ml-dsa-65": {
"derivationPath": "m/44'/1237'/0'/0/2",
"publicKeyHex": "d6a42c3b541fff98d2b4c13c07482507a12dc8b6ab5f4dee1f05d5269f5aa3ee337b42fffb3fc849239855cfe52ce0ccb0af9cbfa6d163b0847651e64af7a2a57048b8c04472004b9d18d86032c9f39c9e02c06f7f3a87611be4767e3de5b76eeda1fc84fbd3ce7889cc14b67bcf4c22881d46cad0c56ec835e5709d2c8c8ad223bed2860ccc260ed053ca6e7555f98e2d23dd4d8afb88d0d74f5009ef5ee26725a11d2c8d14ee9fdca7d217b84eeacbecca30adba09e76aa8c318c0cb435228d7937945fb14d40626d869b7a5c30107276c42617664084937c70550d2e850bb635e2af9afc3ddbb5dab9e9a4e1a9a1c0928abb83da1e1952fbd385fc15fbf4377ad19b86019bbf097f74fa51c17273c2283630a066737367a0feb3aae3ac600b7235601aca60c70f292df65e935294beff9c22a504733d1f2c5886f6b68018a4b737def82a90a84d30c7312c5a56f0a0c56aa83cec0499747c05c7ffa373dbf08e738732f9b986a95d3808c74b8952d2d34ef29c003a3ea732bfb1f4cc2f8a7f34769777a213ca379d687ad8a17cb35c43f98f97face0b41b7f343c1d55d78872ef8ac0f0e5b568ebf19074b2b54c57ea115c1c60976ba78c9765e476dc94b8c9eb0941840bdb2625b76a13332fe330d43e2032e8b1936c993d0a679fc637d62a1249d23823bd6c80aba45b32ddbcd2cf070b34f7b1869ef9c880dc401fe630b340458fd6937a80f4a909011a0e6ef75101abff6aba7f86e9763964438dee62dead9a16379139045570310069478952501e308fe9f199489f7655d61cee04f739d63d0b9cec72deb491a43a53f267d249873e362c0b3c31ee802897c5b74ad9bba89680c5cff5de5511afa3a89f4fd74d2063da77521a7019916abdcda32737abc8cc42e390df81d00d8d0a7ba003af8f651acded0540422215186ae0d52feb0272dde0664683af6d7052cfa1c557f15e36fd771837c737e77b14299c94e7bdc9eda403a9bda4dfd0a98013bd404bbd2c044d9a612638c6a8f5e4095e733f8c3cefafccd9a8ab21d666a35879e72a6972b85f9f5dc9aba3bdc0a8bf858a14fb91867390250ec2cc803adb9f6816c1b08f87db9039f00ef10a3e29b96a661721efcbaf906539de0d342d1619b5ceeac18df0a732c9015e25d8cfa7567dba914c21947f05628d90154ee005072694d1d4337246bc5a43b0e3231be54edd47b3c7d5d8247c236fa9848c49c09bcc9a46812f5d0948621962fdf6c60b91d0fbbb3ca4cd0a9d74d7cb82a4bf43342080b33a45d24c32d6c9c5a8372693e88898e2e6616fa5810300a1c18cd387b34b406439fdbd1723e78b25e4adf65972b8f3e759bc8b262747f791886f8195fde431fa3526b5fd4eedee5d47f76cd47c3162408e4f92722ad9b3181eee720bc73c0b4549a093c6a9a51798579c1b3c99d716a0277f75ba3cb2e5d73297d020ce4eb4ba73d2d660164423395cabe2d4a6da708707152e662c285ee6c5eecea6a276d016be9783eff66177be33cc76bccc6ffde99e14f6c1bfbd7d6b6c3edf2d738914272b2209e1930619cdcef75708d0711977a00c2e77effecd85515de0fc8cad517391c6d4146c2e4e3b5bd24676e359ea884731449dce002ae1f1e1e6803e78ed853555063ac4b21d714762c79f5a4c7e8fe6f02ba1e9d74516e7ef9e80a6524d5213451c814582c2f6be486902436fcc5a3298e338e110d6f89a89214b7e8807f0d28198006ea4f5380ad85c39a0e5f5d312fd489005ad64cb996538ee9086f11888cc8dbd9f70388b0b881bac100af9637a2a2f68cbee4336a375ad8855abd1f39ceb56229f074b8802e88d0c4d9f0ddbefd7b76ad7e3d0ae210b3ce5995e7caa454b74e0a9a1522e401f2982aecb93b57cc66cda3f04bc844377f52b6a045966a4a3659a36beed8cca4898301f93c11cdac0545d91ca03cd3515e9adc4cd0cfa14ec56f07e682fde3ee81107a8e1338effeb3ad951ce852c1b79d74d2889f5f621720419c0c1dd1f0261ba594e6666029976bf4e0fb1309df83812314beb6210cdc37b92c9d320480b87a2d84cce84128c7e07fc392c77d32fd6fbc18d757992da93b4c74c941ec063ae12c824a11cdf7263ce0e3a6563817dc92aa7598f6b27d859a32a16f8bb1c2bf51eec9981f1f5ac8227e1e76037126417c409ac968f80945252da64216d7939187ed263bc6ccd519822e2d527f98473d65ec1f4cbae91ba28e56999876625db6bff0b5aec238a590eeb60fffe755f9f15dd755797cda4b582f94810e36127df3a22b46b9068f10c02210cbd75322cdab5c4da5403b4a6fd9cb4c1486ca554c15faad8e769c6f63ba6c038346db54b8e77288aaec3aa7aeb200fe002db0d420fa7c895a4b71ea6c53226edbe7adc585741d92d8c6de4498b95b72ec97f012f30adfa4c4024759e582b03806cfab97fe4691a25101f57ab8cdeb6e17bab4031d1184de082ee657fdbc2e5e44239d83d6d03c9bb2f664c1ebe9eaa2d6853216fe48863d6b42df30ceabe3eed7d8f515c1a89ba86648259724eb8f2c3cfc65eb7d7ef97746faa2cf80ebc8e121341941008c2b5003603be2dd73c0ccac4a671c3ea4f950d78323bedbc6d79dac4207f30c1e51f757a7bfa4eda443fa39d650baa49e7f7ced0b3b8845280b0bd2f112b92a145fd7fd83d5ebf62cd0bc8de0768106c7baa5d9b977ce75d77525ffd029b5da486f2614e6c01f2ebc803744ddaf8a072c2a6fe772cbab77a"
},
"slh-dsa-128s": {
"derivationPath": "m/44'/1237'/0'/0/3 + m/44'/1237'/0'/0/4 (concatenated, first 48 bytes used)",
"publicKeyHex": "7bbbe598805d0d4ac114778c7663804515479ed5151870e16c4cc4e8a2be5e61"
},
"falcon-512": {
"derivationPath": "m/44'/1237'/0'/0/5 + m/44'/1237'/0'/0/6 (concatenated, first 48 bytes used)",
"publicKeyHex": "09270868041e5d6d864276f279eb8602a5d22a04a0a9841ad4654a2e5b7d7817bbcb88b475440580c48ad6a8e93cc68393008d15ed81e86f3da032e124a8a50e164eacf7c74ea41389f302642a983f628e06e7103b42c58ba40e5c25046cbae1502a883cb492951a46c437510317a92de5a2dc2b424c2fcaae9d3250a0116a9ec0d9120f435c1de849821212785e015a27d0dbed4a38b5f956a915b7b0c067d3d22b1dfca7471a9fa3548441724b56790545416b39a10d3c8ac2f92f14eb297162a7ad59183bdddf56c0dee61c908d2650d8b2a9e4ae71d7e5aeab0282e5c2c9b3ac263aa9e48c9c8502e5f5059726a7398ce275c6f59bf0d1a58b68e1532c0f97d1dd2710a154645027a0a3406236fec4392e26e0eb0649e499766724f3168a4188a0a9b4913c972429884d5786bcf11f694d6938ab0ad573545ebe026c1890e7a7358db4d3211a909809aece6b816c5d2ec4beb827642555ddcbe99049fca07efba4901ba2409eb23b185081b5a4f49650948a6822d7e56c27a222e841727e2a633d0cb2a96acbed0fb8a45844ec2b323efee71707d7a8543c9b93c4840f113a52aa2ec6784422870e53cf4be94c5a1040e0a4ae7a8624dc119fb045961d48b02186a5596ae3bc9101b153bc5da53e581eaab2662e13853d94c9c7a4add9d631015dae8f3ec0406b4e4f0ac56f62caedb3ad5e9ef681d47ea074a6d6c11db36215fabbb01d09360abfaa07e4922f7eb6c70624e9b7314113c4c11264dc10a65ecda3672ecb0888e5e47da8065a1c1e935f147061c75d6d9041c635a4242a6ef90073f3aa9142ce6f13b6541a28b8c9b614c86163b2ceb053a5d1a5f02816f20a06168ea284e2238d7fbd60da7c91af748857e0cfa0ea84b8e59b790adfbaa283c24aabcb4536cd064da42c2e3c622aae50f69504c8d20fd5882986af31255c79540442a6451007a73a3a42e0c50c68f808d897c3252058c818549abdad55abba175f6215e4be544be08734e9dee48a0a9485edb7abc343c4173a02110217a483110e80dd2e1c8e347beb03d6099edd57a38d1fb98e8d16583d2ffe906e75dbdca8af0655bb6a9c5055194ce8009d9d5b22c083e89fc035a5e4afe51aadf5980805ba190518b5de754bc5604e6c5de3b66fef52278d3ad2b265323a9150111b276d7a2c2fc05eacce47e2e35bb7c10c52b5713a6368ea33aa39e8aa5f8117dc85d431cf4cb4ec94d564c6a64171f035f9a15883dc7e98a44da0a53e82c8925a72"
},
"ml-kem-768": {
"derivationPath": "m/44'/1237'/0'/0/7 + m/44'/1237'/0'/0/8 (concatenated, all 64 bytes used)",
"publicKeyHex": "be1320e2e705f9c74b28d1c1ce52a80e564f06870365197cfb34789cf25b2ce9c620f952581c73c737b1929c9f7ea07b076b96027b874f4bbedde86d9f6c8d9ee855587348c46bbd67a4bab88c66eafc4d1df95094faa3c1e2134b962807f13459805d4f2080732a43796354bf7b23f1866ef7779035d379b178a2c2077c2f336be795a1f1f20250835df36026602226ae2a528b10c92345c7f4840946faaad5956b34d91dee1bcd5ed03da5367b5eac4c22b17c1548164ba98441b8766c4b7026220ef53a5b20a3b910104890ba2ef7bbbfcf9a8f48b2576841bcc9463d224424c9143236ca521ed037f835317933bd568ca54722cdcd4c16dec2aba0ba8a90e6b51ec088604b908eb957aaabbea7f73233b02cb1d6817b56afb5d7bd1724a87e5ac4abc29622fa59fdb59ad1eb1096091244cb984efa628944479e551bf4a2531c642e4f0618f9a0a743b546ace955bcd15112234045e891629b384fd4c083067df4e0a08409407cfbada8282be089ae1fb510a2904c07619c11c2b8d560855ee90c153a76c971151eba6aa46b8c717a4d7d0c061b76af8f27a8e1cc28cf0cd01e002717c33434357bae4a5ec067ca75e2160b82bbe529ae7c920f6a57168590619d49a0faec214e32947b56c0cce13d055b7a3acc9b1272a1e98533926171c8204cf15c1c51fb0c250407474c72dea991f7f9481ae469dc17938f710812c31014679fac8b59c2db5008857cc3b3b9e2695017a623d83248b49c41639b5daba0579b842d0e49174f08553d06383669c4d5babc81643737f7bb4af5b868f43f31c7ad4f3abeea0148feb9489b2c97eb8c6978dcc6a10c8eb1055671836236ab85acf975a85440fdf31124312506c6b34867afb3f41734819d329c5d6ed6303f137aa96747c8a922a4a4acca60a6a37b79bc9a29db0c3b0ba23894543489169cd1111244f49c72456a59c249a95985b2fbc4959217cc8a48008a6915474e9d1cc07d0a7b888168976c3b25e320a05a0e5159c7aeb915248a912e07820fd410f1873de91389f5640cf5663bd297643582515e5c6e81433892d707fcc4ae639672d011283c779e0de63907db1b157a85780a0688c72e66b38b1d803b7590b2fe4347631b4767b76e6bd1b784fb8d81cc6a338bc1e699a6b3e4570298cad35a6158024b8b86b5eadc6f04a99f61284e69b6217b86c48358411c4a421962cc304b19e2894f3e8042cc585de9bb40a3c086a505868cb407e380cdc3ec695dd36be72bcef394b6aeab144a5c62c4c79af0c9cf04782bddccbf99324e0fe5a43c88b9e584c83f304e85596238547e3ba64515325d7f75c20783b090730f7d64bc4f9cb500743ebf0a506d196764214c2efc1a0a8880b3fcb17fcb4e7cc022e1dc3fc0d624cc1239c7308813747a78c8ce1be9bf73e68cd62881f1926c3e4b6a96ec8463fb144c693b1c51bf3664c18ac07f71db57a570b49335c2fd9cabfd14b62eb585fdc34fb11ab5f46949d4768bf7960c70c92f2d3ca23c618d3bb05dfdaaad9f4b081c588f394cbe3fd169a66279079bbe51261a439aa9da48c23d9882de001ae5670a7a19096cd923e24055780abdabf36a4cb2c0e4c84d4855640381004959c82240d99f45028d98598597ea6d38468027258ea48458d4d1a83b9d0af8e7b2b4"
}
},
"notes": [
"The truncation rule is normative: for 48-byte seeds, two BIP32 children are concatenated (64 bytes) and the FIRST 48 bytes are used. For 64-byte seeds, all 64 bytes are used.",
"A future implementer who takes the last 48 bytes, or concatenates in the opposite order, will produce different keys and break seed-phrase recoverability.",
"A second implementation should run its own keygen from the same mnemonic and compare against the publicKeyHex values in this file."
]
}
+6 -6
View File
@@ -1,20 +1,20 @@
#!/bin/bash
#
# Upload the post-quantum Nostr web app to the server.
# Upload the Nostr Quantum Preparation web app to the server.
#
# Usage: ./upload.sh
#
# Uploads the contents of www/ to ubuntu@laantungir.net:html/post-quantum/
# The site will be accessible at https://laantungir.net/post-quantum
# Uploads the contents of www/ to ubuntu@laantungir.net:html/quantum-prep/
# The site will be accessible at https://laantungir.net/quantum-prep/
#
set -e
SERVER="ubuntu@laantungir.net"
REMOTE_DIR="html/post-quantum"
REMOTE_DIR="html/quantum-prep"
LOCAL_DIR="$(dirname "$0")/www"
echo "🔒 Post-Quantum Nostr — Upload"
echo "🔒 Nostr Quantum Preparation — Upload"
echo "=============================="
echo "Server: $SERVER"
echo "Remote: $REMOTE_DIR"
@@ -40,4 +40,4 @@ rsync -avz --delete \
echo ""
echo "✅ Upload complete!"
echo "🌐 Site available at: https://laantungir.net/post-quantum"
echo "🌐 Site available at: https://laantungir.net/quantum-prep"
+2 -2
View File
@@ -177,7 +177,7 @@ The BIP32 child private key (32 bytes) is used as the deterministic seed for eac
A static web page (no backend with secrets) that:
1. **Signs in** — uses nostr-login-lite (NIP-46 bunker, seed phrase, or browser extension) to authenticate with the user's existing Nostr identity
2. **Generates a seed phrase**12-word BIP39 mnemonic, generated client-side, never sent to any server
2. **Generates a seed phrase** — 24-word (256-bit) BIP39 mnemonic, generated client-side, never sent to any server
3. **Derives PQ keys** — uses `@noble/post-quantum` (pure JavaScript, no WASM) to generate ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, and ML-KEM-768 keypairs from the seed via BIP32 derivation paths
4. **Signs the events** — PQ signatures are created in the browser; the secp256k1 signature is requested from the user's signer (via `window.nostr.signEvent()`) for both the kind 1 announcement and the kind 9999 proof carrier
5. **Timestamps the kind 1 event** — submits the SHA-256 of the full signed kind 1 event JSON to OpenTimestamps and embeds the proof in the kind 9999 proof carrier
@@ -218,7 +218,7 @@ We use multiple schemes simultaneously because:
### The demo
A working demo is deployed at **https://laantungir.net/post-quantum** that implements the full flow: sign in, generate seed, derive PQ keys, sign the kind 1 and kind 9999 events, publish to relays, and timestamp via OpenTimestamps. It uses:
A working demo is deployed at **https://laantungir.net/quantum-prep** that implements the full flow: sign in, generate seed, derive PQ keys, sign the kind 1 and kind 9999 events, publish to relays, and timestamp via OpenTimestamps. It uses:
- `@noble/post-quantum` for PQ cryptography (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768)
- `@scure/bip39` for seed phrase generation
+79 -1287
View File
File diff suppressed because it is too large Load Diff
+1450
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
/**
* NIP-QR algorithm policy (F-D3).
*
* The set of mandatory PQ algorithms is versioned and explicit. This module
* defines the policy object that verifyNIPQRContent() uses to decide which
* algorithms are mandatory, which are KEMs (no signature), and which are
* unknown (ignored neither credit nor failure).
*
* Why a versioned, explicit policy:
* - The NIP's revocation path ("a user MAY publish a new key-link event
* omitting the broken scheme") only works if the mandatory set can change
* in a detectable way. A versioned policy makes that change explicit.
* - Unknown algorithms must NOT count as valid evidence (a malicious tag
* with a fake algorithm ID and a fake signature must not flip the
* verifier's result), but they also must NOT cause a policy failure
* (the NIP says clients verify whichever subset they support).
* - A future policy version can drop a broken scheme; verifiers that only
* know v1 reject v2 events fail-closed until upgraded.
*
* This is a pure data module with no imports, so it can be bundled and used
* both in the browser and in Node tests.
*/
/**
* Policy version 1 the current mandatory set.
*
* - mandatorySignatureAlgorithms: every one MUST be present exactly once with
* a valid signature. Missing or duplicate => policy failure.
* - kemAlgorithms: every one MUST be present exactly once (pubkey only, no
* signature). Missing or duplicate => policy failure.
* - Algorithms not in either set are 'ignored': reported as valid:null, neither
* credit nor failure, and do not flip the legacy `valid` flag.
*/
export const POLICY_V1 = Object.freeze({
version: 1,
mandatorySignatureAlgorithms: Object.freeze(['ml-dsa-44', 'ml-dsa-65', 'slh-dsa-128s', 'falcon-512']),
kemAlgorithms: Object.freeze(['ml-kem-768'])
});
/**
* The default policy used by verifyNIPQRContent() when no policy is supplied.
*/
export const DEFAULT_POLICY = POLICY_V1;
/**
* Known algorithms (signature + KEM) for a given policy.
* @param {object} policy - a policy object like POLICY_V1
* @returns {Set<string>}
*/
export function knownAlgorithms(policy = DEFAULT_POLICY) {
return new Set([...policy.mandatorySignatureAlgorithms, ...policy.kemAlgorithms]);
}
/**
* Whether an algorithm id is a mandatory signature algorithm under the policy.
*/
export function isMandatorySignature(algorithmId, policy = DEFAULT_POLICY) {
return policy.mandatorySignatureAlgorithms.includes(algorithmId);
}
/**
* Whether an algorithm id is a KEM algorithm under the policy.
*/
export function isKem(algorithmId, policy = DEFAULT_POLICY) {
return policy.kemAlgorithms.includes(algorithmId);
}
+650 -103
View File
File diff suppressed because it is too large Load Diff
+650
View File
@@ -0,0 +1,650 @@
import {
verifyNIPQRContent,
verifyNostrEvent,
validateSignerOutput,
NIP_QR_KIND,
buildUpgradedEvent,
buildProofCarrier,
selectCanonicalProofCarrier,
bytesToBase64,
base64ToBytes,
hexToBytes,
bytesToHex,
isOtsConfirmed,
verifyOtsProof,
upgradeOts,
isDetachedOtsFile,
parseProofArchive
} from '../pq-crypto.bundle.js';
/* ================================================================
TAB MANAGEMENT
================================================================ */
const tabRelay = document.getElementById('pqTabRelay');
const tabPaste = document.getElementById('pqTabPaste');
const panelRelay = document.getElementById('pqPanelRelay');
const panelPaste = document.getElementById('pqPanelPaste');
function switchTab(which) {
const isRelay = which === 'relay';
tabRelay.classList.toggle('pq-tab-active', isRelay);
tabPaste.classList.toggle('pq-tab-active', !isRelay);
panelRelay.classList.toggle('pq-tab-panel-active', isRelay);
panelPaste.classList.toggle('pq-tab-panel-active', !isRelay);
}
tabRelay.addEventListener('click', () => switchTab('relay'));
tabPaste.addEventListener('click', () => switchTab('paste'));
/* ================================================================
SHARED DOM + STATE
================================================================ */
const resultsWrap = document.getElementById('pqResultsWrap');
const resultList = document.getElementById('pqResultList');
const eventJsonWrap = document.getElementById('pqEventJsonWrap');
const downloadEventBtn = document.getElementById('pqDownloadEventBtn');
const eventJsonEl = document.getElementById('pqEventJson');
const otsBadge = document.getElementById('pqOtsBadge');
const otsUpgradeWrap = document.getElementById('pqOtsUpgradeWrap');
const otsInfo = document.getElementById('pqOtsInfo');
const otsUpgradeBtn = document.getElementById('pqOtsUpgradeBtn');
const otsRepublishBtn = document.getElementById('pqOtsRepublishBtn');
const otsUpgradeStatus = document.getElementById('pqOtsUpgradeStatus');
let currentEvent = null;
let currentOtsBytes = null;
let currentExpectedAuthor = null;
// F-H3: Build status DOM safely — type is internally controlled, message uses textContent
function setStatus(element, type, message) {
const div = document.createElement('div');
div.className = `pq-status pq-status-${type}`;
div.textContent = message;
element.innerHTML = '';
element.appendChild(div);
}
function addResult(algorithm, valid, detail) {
const item = document.createElement('div');
item.className = `pq-result-item ${valid ? 'pq-result-valid' : 'pq-result-invalid'}`;
item.textContent = `${valid ? 'PASS' : 'FAIL'}${algorithm}${detail ? ': ' + detail : ''}`;
resultList.appendChild(item);
}
// F-H3: Helper to set OTS badge (static HTML, safe) and info (textContent, safe)
function setOtsBadge(className, text) {
otsBadge.innerHTML = `<span class="pq-ots-badge ${className}"></span>`;
otsBadge.querySelector('span').textContent = text;
}
function setOtsInfo(text) {
otsInfo.textContent = text;
}
/* ================================================================
NPUB <-> HEX CONVERSION (NIP-19)
================================================================ */
function npubToHex(npub) {
try {
const decoded = window.NostrTools.nip19.decode(npub);
if (decoded.type === 'npub') return decoded.data;
} catch (e) { /* fall through */ }
return null;
}
function hexToNpub(hex) {
try {
return window.NostrTools.nip19.npubEncode(hex);
} catch (e) { return hex; }
}
function normalizePubkey(input) {
const trimmed = input.trim();
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return trimmed.toLowerCase();
if (trimmed.startsWith('npub1')) {
const hex = npubToHex(trimmed);
if (hex) return hex;
}
return null;
}
/* ================================================================
QUERY RELAY FOR PROOF CARRIER EVENTS (fetch ALL candidates)
================================================================ */
function queryRelayForEvents(pubkeyHex, relayUrl) {
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(relayUrl);
let resolved = false;
const events = [];
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
try { ws.close(); } catch (e) {}
resolve(events);
}
}, 15000);
ws.onopen = () => {
// REQ for all proof carrier events — no limit (G56-02 fix)
const subId = 'pq_verify_' + Math.random().toString(36).substring(7);
ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex] }]));
};
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'EVENT') {
if (data[2]) events.push(data[2]);
} else if (data[0] === 'EOSE') {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
try { ws.close(); } catch (e) {}
resolve(events);
}
}
} catch (e) {}
};
ws.onerror = () => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(new Error('connection error'));
}
};
} catch (e) {
reject(e);
}
});
}
/* ================================================================
VERIFY EVENT (shared by both tabs)
================================================================ */
async function verifyEvent(event) {
currentEvent = event;
resultList.innerHTML = '';
resultsWrap.style.display = 'none';
eventJsonWrap.style.display = 'none';
otsUpgradeWrap.style.display = 'none';
otsBadge.innerHTML = '';
// Display full proof carrier JSON
eventJsonEl.textContent = JSON.stringify(event, null, 2);
eventJsonWrap.style.display = 'block';
let allValid = true;
// 1. Verify proof carrier secp256k1 signature
let secpValid = false;
try {
secpValid = verifyNostrEvent(event);
} catch (e) {
secpValid = false;
addResult('secp256k1 (proof carrier)', false, `verification threw: ${e.message}`);
}
if (secpValid) {
addResult('secp256k1 (proof carrier)', true, 'valid');
} else if (!resultsWrap.querySelector('.pq-result-item')) {
addResult('secp256k1 (proof carrier)', false, 'INVALID');
}
if (!secpValid) allValid = false;
// 2. Verify kind 1 sig, PQ sigs, e tag, sha256 tag (strict policy)
// G56-03: pass outer pubkey and expected author for identity binding
let pqResults;
try {
pqResults = verifyNIPQRContent(event.content, event.tags, {
outerPubkey: event.pubkey,
expectedAuthor: currentExpectedAuthor || undefined
});
} catch (e) {
pqResults = {
valid: false,
results: [{ algorithm: 'verifyNIPQRContent', valid: false, note: `threw: ${e.message}` }],
kind1Event: null,
fullHash: null,
sha256Valid: false,
eTagValid: false,
pqProofsValid: false,
policySufficient: false,
validForMigration: false,
errors: [`verifyNIPQRContent threw: ${e.message}`]
};
}
for (const r of pqResults.results) {
addResult(r.algorithm, r.valid, r.note || (r.valid ? 'valid' : 'INVALID'));
if (!r.valid) allValid = false;
}
// 2b. Display policy sufficiency as a distinct result (G56-01)
if (pqResults.kind1Event) {
addResult(
'PQ algorithm policy',
pqResults.policySufficient,
pqResults.policySufficient
? 'all mandatory algorithms present and verified'
: (pqResults.errors.length > 0 ? pqResults.errors.join('; ') : 'insufficient PQ evidence')
);
if (!pqResults.policySufficient) allValid = false;
}
// 3. Inspect OTS proof tag and verify it matches the sha256 tag.
// First do a quick structural check for immediate UI feedback, then
// run full cryptographic verification (async — fetches Bitcoin block
// headers and validates the Merkle path).
const otsTag = event.tags.find(t => t[0] === 'ots');
const sha256Tag = event.tags.find(t => t[0] === 'sha256');
if (otsTag && otsTag[1]) {
try {
currentOtsBytes = base64ToBytes(otsTag[1]);
const hasBitcoinAttestation = isOtsConfirmed(currentOtsBytes);
const validFile = isDetachedOtsFile(currentOtsBytes);
const fullHash = pqResults.fullHash;
const hashMatchNote = (sha256Tag && fullHash)
? (sha256Tag[1].toLowerCase() === fullHash.toLowerCase()
? `Hash matches full kind 1 event: ${fullHash.substring(0, 16)}...`
: `WARNING: sha256 tag (${sha256Tag[1].substring(0, 16)}...) does not match computed hash (${fullHash.substring(0, 16)}...)`)
: 'No sha256 tag found';
// Quick structural display first
if (!validFile) {
setOtsBadge('pq-ots-badge-none', 'OTS: Invalid format');
setOtsInfo(`OTS tag present but does not appear to be a valid detached .ots file (${currentOtsBytes.length} bytes). ${hashMatchNote}.`);
otsUpgradeWrap.style.display = 'none';
} else if (hasBitcoinAttestation) {
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verifying...');
setOtsInfo(`OpenTimestamps proof contains a Bitcoin attestation. Performing full cryptographic verification (fetching block header)... Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
otsUpgradeWrap.style.display = 'block';
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'none';
// Run full async verification: parse the proof, bind the target
// digest to the sha256 tag, walk the Merkle path, and check the
// block header against a Bitcoin API.
verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined).then(result => {
if (result.verified && result.bitcoinAttestations.length > 0) {
const att = result.bitcoinAttestations[0];
const date = new Date(att.time * 1000).toISOString().substring(0, 19);
const trustLabel = result.trustMode === 'multi-explorer-checked'
? 'multi-explorer-checked (cross-verified)'
: result.trustMode === 'single-explorer-checked'
? 'single-explorer-checked (trusted API)'
: result.trustMode || 'unknown';
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
setOtsInfo(`OpenTimestamps proof verified. Bitcoin block ${att.height} (mined ${date} UTC). Merkle root matches. Trust mode: ${trustLabel} — block header is trusted from explorer API(s), not independently verified against PoW. Proof commits to the event hash. Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
otsUpgradeBtn.style.display = 'none';
} else {
setOtsBadge('pq-ots-badge-none', 'OTS: Verification failed');
const errDetail = result.errors.length > 0 ? ` Errors: ${result.errors.join('; ')}` : '';
setOtsInfo(`OpenTimestamps proof could NOT be cryptographically verified. ${hashMatchNote}.${errDetail}`);
otsUpgradeBtn.style.display = 'inline-block';
}
}).catch(err => {
setOtsBadge('pq-ots-badge-none', 'OTS: Verification error');
setOtsInfo(`OpenTimestamps verification error: ${err.message}. ${hashMatchNote}.`);
otsUpgradeBtn.style.display = 'inline-block';
});
} else {
setOtsBadge('pq-ots-badge-pending', 'OTS: Pending');
setOtsInfo(`OpenTimestamps proof is pending (no Bitcoin attestation yet). Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}. You can try upgrading it below.`);
otsUpgradeWrap.style.display = 'block';
otsUpgradeBtn.style.display = 'inline-block';
otsRepublishBtn.style.display = 'none';
}
} catch (e) {
setOtsBadge('pq-ots-badge-none', 'OTS: Error');
setOtsInfo(`Failed to parse OTS proof: ${e.message}`);
otsUpgradeWrap.style.display = 'none';
}
} else {
setOtsBadge('pq-ots-badge-none', 'OTS: None');
setOtsInfo('No OpenTimestamps proof tag found in this event.');
otsUpgradeWrap.style.display = 'none';
}
resultsWrap.style.display = 'block';
return allValid;
}
/* ================================================================
TAB 1: QUERY RELAY
================================================================ */
const queryBtn = document.getElementById('pqQueryBtn');
const queryStatus = document.getElementById('pqQueryStatus');
queryBtn.addEventListener('click', async () => {
queryBtn.disabled = true;
setStatus(queryStatus, 'info', 'Querying relays...');
const pubkeyInput = document.getElementById('pqPubkeyInput').value.trim();
const relayInput = document.getElementById('pqRelayInput').value.trim();
const pubkeyHex = normalizePubkey(pubkeyInput);
if (!pubkeyHex) {
setStatus(queryStatus, 'error', 'Invalid pubkey. Enter a 64-char hex pubkey or an npub.');
queryBtn.disabled = false;
return;
}
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
if (relayUrls.length === 0) {
setStatus(queryStatus, 'error', 'Enter at least one relay URL.');
queryBtn.disabled = false;
return;
}
let allCandidates = [];
let lastError = null;
let successCount = 0;
// G56-02: Query relays in parallel, collect ALL candidates (not limit: 1)
const promises = relayUrls.map(async (url) => {
try {
const events = await queryRelayForEvents(pubkeyHex, url);
if (events && events.length > 0) {
successCount++;
allCandidates.push(...events);
}
return { url, ok: true, count: events ? events.length : 0 };
} catch (e) {
lastError = e.message;
return { url, ok: false, error: e.message };
}
});
await Promise.all(promises);
// Deduplicate by event ID
const seenIds = new Set();
allCandidates = allCandidates.filter(e => {
if (!e || !e.id || seenIds.has(e.id)) return false;
seenIds.add(e.id);
return true;
});
if (allCandidates.length === 0) {
setStatus(queryStatus, 'error', `No proof carrier events found for this pubkey on any of the ${relayUrls.length} relay(s)${lastError ? ' (last error: ' + lastError + ')' : ''}.`);
queryBtn.disabled = false;
return;
}
const npub = hexToNpub(pubkeyHex);
setStatus(queryStatus, 'info', `Found ${allCandidates.length} proof carrier event(s) from ${npub.substring(0, 20)}... across ${successCount}/${relayUrls.length} relay(s). Selecting canonical (earliest Bitcoin anchor)...`);
// G56-02: Select the canonical proof carrier (earliest valid Bitcoin anchor)
// First, parse the kind 1 event from the first candidate to use for selection
let kind1Event = null;
try {
kind1Event = JSON.parse(allCandidates[0].content);
} catch (e) {
// Try other candidates
for (const c of allCandidates) {
try {
kind1Event = JSON.parse(c.content);
break;
} catch (e2) { /* keep trying */ }
}
}
if (!kind1Event) {
setStatus(queryStatus, 'error', 'Could not parse kind 1 announcement from any candidate.');
queryBtn.disabled = false;
return;
}
// Set the expected author for G56-03 identity binding
currentExpectedAuthor = pubkeyHex;
try {
const selection = await selectCanonicalProofCarrier(allCandidates, kind1Event, pubkeyHex);
if (selection.canonical) {
const heightNote = selection.canonicalBitcoinHeight ? `Bitcoin block ${selection.canonicalBitcoinHeight}` : 'pending (no Bitcoin anchor yet)';
const confirmedCount = selection.confirmedCandidates.length;
const pendingCount = selection.pendingCandidates.length;
// F-D4/L-2: a pending-only selection is NOT a trust decision. Surface
// canonicalTrustMode explicitly so the UI never presents a pending-only
// selection as a confirmed anchor.
const trustModeNote = selection.canonicalTrustMode === 'pending-only'
? ' ⚠ PENDING-ONLY — no Bitcoin attestation has been verified yet; this is not a trust decision.'
: (selection.canonicalTrustMode === 'none' ? ' ⚠ no trustable anchor found.' : '');
const statusClass = selection.canonicalTrustMode === 'pending-only' ? 'error' : 'success';
setStatus(queryStatus, statusClass, `Selected canonical proof carrier: ${selection.canonical.id.substring(0, 16)}... (${heightNote}). ${confirmedCount} confirmed, ${pendingCount} pending, ${allCandidates.length} total candidate(s).${trustModeNote}`);
await verifyEvent(selection.canonical);
} else {
setStatus(queryStatus, 'error', `No valid proof carrier found among ${allCandidates.length} candidate(s). Errors: ${selection.errors.join('; ')}`);
}
} catch (e) {
setStatus(queryStatus, 'error', `Canonical selection failed: ${e.message}`);
}
queryBtn.disabled = false;
});
/* ================================================================
TAB 2: PASTE EVENT JSON
================================================================ */
const verifyBtn = document.getElementById('pqVerifyBtn');
const eventInput = document.getElementById('pqEventInput');
const verifyStatus = document.getElementById('pqVerifyStatus');
verifyBtn.addEventListener('click', async () => {
verifyBtn.disabled = true;
setStatus(verifyStatus, 'info', 'Verifying...');
try {
const event = JSON.parse(eventInput.value.trim());
if (!event || !event.pubkey || !event.sig || !event.content || !event.tags) {
throw new Error('Invalid event: missing required fields (pubkey, sig, content, tags)');
}
if (event.kind !== NIP_QR_KIND) {
setStatus(verifyStatus, 'error', `Warning: event kind is ${event.kind}, expected ${NIP_QR_KIND}. Verifying anyway...`);
}
const allValid = await verifyEvent(event);
if (allValid) {
setStatus(verifyStatus, 'success', 'All signatures and PQ algorithm policy verified successfully!');
} else {
setStatus(verifyStatus, 'error', 'Some checks failed verification. See results below for details.');
}
} catch (error) {
console.error('[verify] Error:', error);
setStatus(verifyStatus, 'error', `Error: ${error.message}`);
} finally {
verifyBtn.disabled = false;
}
});
/* ================================================================
F-D2: IMPORT PROOF ARCHIVE (offline verification, no relay needed)
================================================================ */
const archiveFileInput = document.getElementById('pqArchiveFileInput');
if (archiveFileInput) {
archiveFileInput.addEventListener('change', async (ev) => {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
setStatus(verifyStatus, 'info', `Importing archive: ${file.name}...`);
try {
const text = await file.text();
const archive = parseProofArchive(text);
setStatus(verifyStatus, 'info', `Archive parsed (v${archive.archiveVersion}). Verifying proof carrier...`);
// Verify the extracted proof carrier end-to-end without any relay
const allValid = await verifyEvent(archive.proofCarrierEvent);
if (allValid) {
setStatus(verifyStatus, 'success',
`Archive verified successfully (offline, no relay contacted). ` +
`Kind 1 ID: ${archive.kind1Event.id.substring(0, 16)}... ` +
`Proof carrier ID: ${archive.proofCarrierEvent.id.substring(0, 16)}...`);
} else {
setStatus(verifyStatus, 'error', 'Archive proof carrier failed verification. See results below.');
}
} catch (error) {
console.error('[verify] Archive import error:', error);
setStatus(verifyStatus, 'error', `Archive import failed: ${error.message}`);
} finally {
// Allow re-selecting the same file
archiveFileInput.value = '';
}
});
}
/* ================================================================
DOWNLOAD EVENT JSON
================================================================ */
downloadEventBtn.addEventListener('click', () => {
if (!currentEvent) return;
const blob = new Blob([JSON.stringify(currentEvent, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (currentEvent.id ? currentEvent.id.substring(0, 16) : 'event') + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
/* ================================================================
OTS UPGRADE (from verify page)
================================================================ */
otsUpgradeBtn.addEventListener('click', async () => {
if (!currentOtsBytes) return;
otsUpgradeBtn.disabled = true;
setStatus(otsUpgradeStatus, 'info', 'Sending .ots proof to upgrade service...');
try {
const result = await upgradeOts(currentOtsBytes);
currentOtsBytes = result.proof;
// After upgrading, run full cryptographic verification to confirm
// the Bitcoin attestation is real (not just a byte-pattern match).
const sha256Tag = currentEvent && currentEvent.tags ? currentEvent.tags.find(t => t[0] === 'sha256') : null;
const verifyResult = await verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined);
const confirmed = verifyResult.verified;
if (confirmed) {
const att = verifyResult.bitcoinAttestations[0];
const date = att ? new Date(att.time * 1000).toISOString().substring(0, 19) : 'unknown';
const trustLabel = verifyResult.trustMode === 'multi-explorer-checked'
? 'multi-explorer-checked (cross-verified)'
: verifyResult.trustMode === 'single-explorer-checked'
? 'single-explorer-checked (trusted API)'
: verifyResult.trustMode || 'unknown';
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Trust mode: ${trustLabel}. Proof upgraded (${currentOtsBytes.length} bytes). You can publish a new kind 9999 event carrying the confirmed proof below; it will reference the original via an upgrade_of tag.`);
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'inline-block';
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
setOtsInfo(`OpenTimestamps proof verified. Bitcoin block ${att ? att.height : '?'}. Trust mode: ${trustLabel}. Proof size: ${currentOtsBytes.length} bytes.`);
} else {
setStatus(otsUpgradeStatus, 'info', `Proof upgraded but still pending (no Bitcoin attestation yet). ${result.detail ? 'Detail: ' + result.detail : ''} Try again later.`);
}
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Upgrade failed: ${error.message}`);
} finally {
otsUpgradeBtn.disabled = false;
}
});
otsRepublishBtn.addEventListener('click', async () => {
if (!currentEvent || !currentOtsBytes) return;
otsRepublishBtn.disabled = true;
setStatus(otsUpgradeStatus, 'info', 'Requesting secp256k1 signature for upgraded event...');
try {
if (!window.nostr || !window.nostr.signEvent) {
throw new Error('Nostr signer (window.nostr) not available. Use a NIP-07 signer extension to publish the upgraded event.');
}
const upgradedTemplate = buildUpgradedEvent(currentEvent, currentOtsBytes);
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
// G56-05: validate signer output before publishing
const validatedEvent = validateSignerOutput(signedEvent, upgradedTemplate, currentEvent.pubkey);
// Publish to the relays from the relay input field
const relayInput = document.getElementById('pqRelayInput').value.trim();
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
if (relayUrls.length === 0) {
throw new Error('No relay URLs specified in the relay input field.');
}
const eventJson = JSON.stringify(['EVENT', validatedEvent]);
const publishResults = await Promise.all(relayUrls.map(url => {
return new Promise((resolve) => {
try {
const ws = new WebSocket(url);
let done = false;
const t = setTimeout(() => { if (!done) { done = true; try { ws.close(); } catch(e){} resolve({ url, success: false, error: 'timeout' }); } }, 15000);
ws.onopen = () => ws.send(eventJson);
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'OK' && data[1] === validatedEvent.id) {
if (!done) { done = true; clearTimeout(t); try { ws.close(); } catch(e){} resolve({ url, success: true }); }
}
} catch (e) {}
};
ws.onclose = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'closed without OK' }); } };
ws.onerror = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'error' }); } };
} catch (e) { resolve({ url, success: false, error: e.message }); }
});
}));
const ok = publishResults.filter(r => r.success).length;
const fail = publishResults.filter(r => !r.success).length;
if (ok === 0) {
throw new Error(`No relay accepted the upgraded event (${fail} failed).`);
}
currentEvent = validatedEvent;
setStatus(otsUpgradeStatus, 'success', `Upgraded proof carrier published to ${ok} relay(s) (${fail} failed).`);
// Re-verify the new event
await verifyEvent(validatedEvent);
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Publish failed: ${error.message}`);
} finally {
otsRepublishBtn.disabled = false;
}
});
/* ================================================================
AUTO-DETECT SIGNED-IN USER
On page load, check for a NIP-07 signer / nostr-login-lite. If the
user is already signed in, pre-fill the pubkey field and auto-query
relays for their kind 9999 event.
================================================================ */
async function initAutoDetect() {
// Initialize nostr-login-lite if available (shares session with main page)
if (window.NOSTR_LOGIN_LITE) {
try {
if (!window.NOSTR_LOGIN_LITE._initialized) {
await window.NOSTR_LOGIN_LITE.init({
methods: { extension: true, local: true, nip46: true, seedphrase: true, nsigner: true }
});
}
} catch (e) {
console.log('[verify] nostr-login-lite init failed:', e.message);
}
}
if (!window.nostr || !window.nostr.getPublicKey) return;
let pubkeyHex = null;
try {
pubkeyHex = await window.nostr.getPublicKey();
} catch (e) {
console.log('[verify] Not signed in:', e.message);
return;
}
if (!pubkeyHex) return;
console.log('[verify] Auto-detected signed-in pubkey:', pubkeyHex);
const pubkeyInput = document.getElementById('pqPubkeyInput');
const npub = hexToNpub(pubkeyHex);
pubkeyInput.value = npub || pubkeyHex;
// Auto-trigger the query
queryBtn.click();
}
initAutoDetect();
+21
View File
@@ -0,0 +1,21 @@
/**
* Version display: fetch version.json and update title + header.
* Extracted from inline <script> for CSP compliance (G56-08).
*
* Auto-detects the page title prefix from the existing <title> tag.
*/
(function () {
// Derive the title prefix from the existing <title> element
// (e.g. "Post-Quantum Nostr" or "Verify NIP-QR Event")
var titlePrefix = document.title || '';
fetch('./js/version.json')
.then(function (r) { return r.json(); })
.then(function (v) {
var versionText = v.VERSION || '';
document.title = titlePrefix + ' ' + versionText;
var header = document.getElementById('divHeaderText');
if (header) header.textContent = titlePrefix + ' ' + versionText;
})
.catch(function () { /* version.json not found — keep default title */ });
})();
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.0.19",
"VERSION_NUMBER": "0.0.19",
"BUILD_DATE": "2026-07-24T13:25:51.884Z"
"VERSION": "v0.0.32",
"VERSION_NUMBER": "0.0.32",
"BUILD_DATE": "2026-07-26T15:52:35.987Z"
}
+473 -114
View File
File diff suppressed because it is too large Load Diff
+28 -630
View File
@@ -4,8 +4,9 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none';" />
<title>Verify NIP-QR Event</title>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none';" />
<meta name="referrer" content="no-referrer" />
<title>Verify Key-Link Event</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="shortcut icon" type="image/x-icon" href="data:image/x-icon;," />
@@ -242,7 +243,7 @@
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText" id="divHeaderText">Verify NIP-QR Event</div>
<div class="divHeaderText" id="divHeaderText">Verify Post-Quantum Key Link</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
@@ -252,25 +253,24 @@
<div class="pq-container">
<div class="pq-card">
<div class="pq-card-title">Verify NIP-QR Migration Event</div>
<div class="pq-card-title">Verify Post-Quantum Key Link</div>
<div class="pq-info-text" style="color: var(--accent-color); font-size: 13px;">
<strong>Research prototype.</strong> Verification confirms key linkage and signature
validity; it does not mean the identity is post-quantum secure. Bitcoin OTS confirmation
is API-assisted (trusts a public explorer), not a full light-client verification.
<strong>Note:</strong> Passing these checks confirms the keys are linked and the signatures
are valid it does not mean the identity is post-quantum secure. The Bitcoin timestamp check
relies on a public block explorer rather than running a full Bitcoin node, so a compromised
explorer could fool it.
</div>
<div class="pq-info-text">
Verify post-quantum migration events (kind 9999) by querying relays for a user's pubkey,
or by pasting event JSON directly. The kind 9999 event wraps a kind 1 announcement
(embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks,
reported as separate result lines:
the kind 9999 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), each PQ signature
individually (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512; ML-KEM-768 has no signature),
the PQ algorithm policy (all mandatory algorithms present and verified), identity binding
(outer/embedded/expected author match), and the OpenTimestamps proof.
You can check a post-quantum key-link event two ways: look it up on a relay by pubkey, or
paste the event JSON directly. The event you're checking (kind 9999) wraps a public
announcement (kind 1) and carries a Bitcoin timestamp proof. Verification checks each piece
separately and reports the results below: both Nostr signatures, the link between the two
events, each post-quantum signature (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512 —
ML-KEM-768 is for encryption, so it has no signature to check), that all required algorithms
are present, that the event really belongs to the claimed identity, and the Bitcoin timestamp.
</div>
<div class="pq-info-text">
<a href="./" class="pq-link">Back to migration page</a>
<a href="./" class="pq-link">Back to preparation page</a>
</div>
<div class="pq-tabs">
@@ -287,7 +287,7 @@
<input type="text" class="pq-input" id="pqPubkeyInput"
placeholder="Pubkey (hex or npub), e.g. 3bf0c63f869a8972... or npub1..." />
<input type="text" class="pq-input" id="pqRelayInput"
value="wss://laantungir.net/relay,wss://relay.damus.io,wss://nos.lol"
value="wss://laantungir.net/relay/,wss://sendit.nosflare.com/,wss://nos.lol"
placeholder="wss://relay1.com,wss://relay2.com" />
<button class="pq-button" id="pqQueryBtn">Query & Verify</button>
<div id="pqQueryStatus"></div>
@@ -313,6 +313,10 @@
}'></textarea>
<button class="pq-button" id="pqVerifyBtn">Verify Signatures</button>
<div id="pqVerifyStatus"></div>
<div class="pq-info-text" style="margin-top: 12px; font-size: 13px;">
Or import a proof archive file (offline verification without a relay):
</div>
<input type="file" id="pqArchiveFileInput" accept="application/json,.json" style="margin-top: 6px; font-size: 13px;" />
</div>
<!-- Shared results area -->
@@ -355,620 +359,14 @@
</div>
<!-- SCRIPTS -->
<script src="/nostr-login-lite/nostr.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script src="/nostr-login-lite/nostr.bundle.js" integrity="sha384-LNnPDD++DaWxljIhMLmfaoEoKB0B1HmACC6gNGLG+Qnmosprf/AX7u84pVY8xMpM" crossorigin="anonymous"></script>
<script src="/nostr-login-lite/nostr-lite.js" integrity="sha384-IQwa65eDC5trGjKn4cuEazmFiHTHD65gIqsjpzDtouc+j0MlyI927SZgHMWSi2aC" crossorigin="anonymous"></script>
<!-- Version display: fetch version.json and update title + header -->
<script>
fetch('./js/version.json')
.then(r => r.json())
.then(v => {
const versionText = v.VERSION || '';
document.title = 'Verify NIP-QR Event ' + versionText;
const header = document.getElementById('divHeaderText');
if (header) header.textContent = 'Verify NIP-QR Event ' + versionText;
})
.catch(() => { /* version.json not found — keep default title */ });
</script>
<!-- Version display (G56-08: extracted to external file, self-initializing) -->
<script src="./js/version-display.js"></script>
<script type="module">
import {
verifyNIPQRContent,
verifyNostrEvent,
NIP_QR_KIND,
buildUpgradedEvent,
buildProofCarrier,
selectCanonicalProofCarrier,
bytesToBase64,
base64ToBytes,
hexToBytes,
bytesToHex,
isOtsConfirmed,
verifyOtsProof,
upgradeOts,
isDetachedOtsFile
} from './pq-crypto.bundle.js';
/* ================================================================
TAB MANAGEMENT
================================================================ */
const tabRelay = document.getElementById('pqTabRelay');
const tabPaste = document.getElementById('pqTabPaste');
const panelRelay = document.getElementById('pqPanelRelay');
const panelPaste = document.getElementById('pqPanelPaste');
function switchTab(which) {
const isRelay = which === 'relay';
tabRelay.classList.toggle('pq-tab-active', isRelay);
tabPaste.classList.toggle('pq-tab-active', !isRelay);
panelRelay.classList.toggle('pq-tab-panel-active', isRelay);
panelPaste.classList.toggle('pq-tab-panel-active', !isRelay);
}
tabRelay.addEventListener('click', () => switchTab('relay'));
tabPaste.addEventListener('click', () => switchTab('paste'));
/* ================================================================
SHARED DOM + STATE
================================================================ */
const resultsWrap = document.getElementById('pqResultsWrap');
const resultList = document.getElementById('pqResultList');
const eventJsonWrap = document.getElementById('pqEventJsonWrap');
const downloadEventBtn = document.getElementById('pqDownloadEventBtn');
const eventJsonEl = document.getElementById('pqEventJson');
const otsBadge = document.getElementById('pqOtsBadge');
const otsUpgradeWrap = document.getElementById('pqOtsUpgradeWrap');
const otsInfo = document.getElementById('pqOtsInfo');
const otsUpgradeBtn = document.getElementById('pqOtsUpgradeBtn');
const otsRepublishBtn = document.getElementById('pqOtsRepublishBtn');
const otsUpgradeStatus = document.getElementById('pqOtsUpgradeStatus');
let currentEvent = null;
let currentOtsBytes = null;
let currentExpectedAuthor = null;
// F-H3: Build status DOM safely — type is internally controlled, message uses textContent
function setStatus(element, type, message) {
const div = document.createElement('div');
div.className = `pq-status pq-status-${type}`;
div.textContent = message;
element.innerHTML = '';
element.appendChild(div);
}
function addResult(algorithm, valid, detail) {
const item = document.createElement('div');
item.className = `pq-result-item ${valid ? 'pq-result-valid' : 'pq-result-invalid'}`;
item.textContent = `${valid ? 'PASS' : 'FAIL'} — ${algorithm}${detail ? ': ' + detail : ''}`;
resultList.appendChild(item);
}
// F-H3: Helper to set OTS badge (static HTML, safe) and info (textContent, safe)
function setOtsBadge(className, text) {
otsBadge.innerHTML = `<span class="pq-ots-badge ${className}"></span>`;
otsBadge.querySelector('span').textContent = text;
}
function setOtsInfo(text) {
otsInfo.textContent = text;
}
/* ================================================================
NPUB <-> HEX CONVERSION (NIP-19)
================================================================ */
function npubToHex(npub) {
try {
const decoded = window.NostrTools.nip19.decode(npub);
if (decoded.type === 'npub') return decoded.data;
} catch (e) { /* fall through */ }
return null;
}
function hexToNpub(hex) {
try {
return window.NostrTools.nip19.npubEncode(hex);
} catch (e) { return hex; }
}
function normalizePubkey(input) {
const trimmed = input.trim();
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return trimmed.toLowerCase();
if (trimmed.startsWith('npub1')) {
const hex = npubToHex(trimmed);
if (hex) return hex;
}
return null;
}
/* ================================================================
QUERY RELAY FOR PROOF CARRIER EVENTS (fetch ALL candidates)
================================================================ */
function queryRelayForEvents(pubkeyHex, relayUrl) {
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(relayUrl);
let resolved = false;
const events = [];
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
try { ws.close(); } catch (e) {}
resolve(events);
}
}, 15000);
ws.onopen = () => {
// REQ for all proof carrier events — no limit (G56-02 fix)
const subId = 'pq_verify_' + Math.random().toString(36).substring(7);
ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex] }]));
};
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'EVENT') {
if (data[2]) events.push(data[2]);
} else if (data[0] === 'EOSE') {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
try { ws.close(); } catch (e) {}
resolve(events);
}
}
} catch (e) {}
};
ws.onerror = () => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(new Error('connection error'));
}
};
} catch (e) {
reject(e);
}
});
}
/* ================================================================
VERIFY EVENT (shared by both tabs)
================================================================ */
async function verifyEvent(event) {
currentEvent = event;
resultList.innerHTML = '';
resultsWrap.style.display = 'none';
eventJsonWrap.style.display = 'none';
otsUpgradeWrap.style.display = 'none';
otsBadge.innerHTML = '';
// Display full proof carrier JSON
eventJsonEl.textContent = JSON.stringify(event, null, 2);
eventJsonWrap.style.display = 'block';
let allValid = true;
// 1. Verify proof carrier secp256k1 signature
let secpValid = false;
try {
secpValid = verifyNostrEvent(event);
} catch (e) {
secpValid = false;
addResult('secp256k1 (proof carrier)', false, `verification threw: ${e.message}`);
}
if (secpValid) {
addResult('secp256k1 (proof carrier)', true, 'valid');
} else if (!resultsWrap.querySelector('.pq-result-item')) {
addResult('secp256k1 (proof carrier)', false, 'INVALID');
}
if (!secpValid) allValid = false;
// 2. Verify kind 1 sig, PQ sigs, e tag, sha256 tag (strict policy)
// G56-03: pass outer pubkey and expected author for identity binding
let pqResults;
try {
pqResults = verifyNIPQRContent(event.content, event.tags, {
outerPubkey: event.pubkey,
expectedAuthor: currentExpectedAuthor || undefined
});
} catch (e) {
pqResults = {
valid: false,
results: [{ algorithm: 'verifyNIPQRContent', valid: false, note: `threw: ${e.message}` }],
kind1Event: null,
fullHash: null,
sha256Valid: false,
eTagValid: false,
pqProofsValid: false,
policySufficient: false,
validForMigration: false,
errors: [`verifyNIPQRContent threw: ${e.message}`]
};
}
for (const r of pqResults.results) {
addResult(r.algorithm, r.valid, r.note || (r.valid ? 'valid' : 'INVALID'));
if (!r.valid) allValid = false;
}
// 2b. Display policy sufficiency as a distinct result (G56-01)
if (pqResults.kind1Event) {
addResult(
'PQ algorithm policy',
pqResults.policySufficient,
pqResults.policySufficient
? 'all mandatory algorithms present and verified'
: (pqResults.errors.length > 0 ? pqResults.errors.join('; ') : 'insufficient PQ evidence')
);
if (!pqResults.policySufficient) allValid = false;
}
// 3. Inspect OTS proof tag and verify it matches the sha256 tag.
// First do a quick structural check for immediate UI feedback, then
// run full cryptographic verification (async — fetches Bitcoin block
// headers and validates the Merkle path).
const otsTag = event.tags.find(t => t[0] === 'ots');
const sha256Tag = event.tags.find(t => t[0] === 'sha256');
if (otsTag && otsTag[1]) {
try {
currentOtsBytes = base64ToBytes(otsTag[1]);
const hasBitcoinAttestation = isOtsConfirmed(currentOtsBytes);
const validFile = isDetachedOtsFile(currentOtsBytes);
const fullHash = pqResults.fullHash;
const hashMatchNote = (sha256Tag && fullHash)
? (sha256Tag[1].toLowerCase() === fullHash.toLowerCase()
? `Hash matches full kind 1 event: ${fullHash.substring(0, 16)}...`
: `WARNING: sha256 tag (${sha256Tag[1].substring(0, 16)}...) does not match computed hash (${fullHash.substring(0, 16)}...)`)
: 'No sha256 tag found';
// Quick structural display first
if (!validFile) {
setOtsBadge('pq-ots-badge-none', 'OTS: Invalid format');
setOtsInfo(`OTS tag present but does not appear to be a valid detached .ots file (${currentOtsBytes.length} bytes). ${hashMatchNote}.`);
otsUpgradeWrap.style.display = 'none';
} else if (hasBitcoinAttestation) {
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verifying...');
setOtsInfo(`OpenTimestamps proof contains a Bitcoin attestation. Performing full cryptographic verification (fetching block header)... Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
otsUpgradeWrap.style.display = 'block';
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'none';
// Run full async verification: parse the proof, bind the target
// digest to the sha256 tag, walk the Merkle path, and check the
// block header against a Bitcoin API.
verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined).then(result => {
if (result.verified && result.bitcoinAttestations.length > 0) {
const att = result.bitcoinAttestations[0];
const date = new Date(att.time * 1000).toISOString().substring(0, 19);
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
setOtsInfo(`OpenTimestamps proof cryptographically verified. Bitcoin block ${att.height} (mined ${date} UTC). Merkle root matches. Proof commits to the event hash. Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
otsUpgradeBtn.style.display = 'none';
} else {
setOtsBadge('pq-ots-badge-none', 'OTS: Verification failed');
const errDetail = result.errors.length > 0 ? ` Errors: ${result.errors.join('; ')}` : '';
setOtsInfo(`OpenTimestamps proof could NOT be cryptographically verified. ${hashMatchNote}.${errDetail}`);
otsUpgradeBtn.style.display = 'inline-block';
}
}).catch(err => {
setOtsBadge('pq-ots-badge-none', 'OTS: Verification error');
setOtsInfo(`OpenTimestamps verification error: ${err.message}. ${hashMatchNote}.`);
otsUpgradeBtn.style.display = 'inline-block';
});
} else {
setOtsBadge('pq-ots-badge-pending', 'OTS: Pending');
setOtsInfo(`OpenTimestamps proof is pending (no Bitcoin attestation yet). Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}. You can try upgrading it below.`);
otsUpgradeWrap.style.display = 'block';
otsUpgradeBtn.style.display = 'inline-block';
otsRepublishBtn.style.display = 'none';
}
} catch (e) {
setOtsBadge('pq-ots-badge-none', 'OTS: Error');
setOtsInfo(`Failed to parse OTS proof: ${e.message}`);
otsUpgradeWrap.style.display = 'none';
}
} else {
setOtsBadge('pq-ots-badge-none', 'OTS: None');
setOtsInfo('No OpenTimestamps proof tag found in this event.');
otsUpgradeWrap.style.display = 'none';
}
resultsWrap.style.display = 'block';
return allValid;
}
/* ================================================================
TAB 1: QUERY RELAY
================================================================ */
const queryBtn = document.getElementById('pqQueryBtn');
const queryStatus = document.getElementById('pqQueryStatus');
queryBtn.addEventListener('click', async () => {
queryBtn.disabled = true;
setStatus(queryStatus, 'info', 'Querying relays...');
const pubkeyInput = document.getElementById('pqPubkeyInput').value.trim();
const relayInput = document.getElementById('pqRelayInput').value.trim();
const pubkeyHex = normalizePubkey(pubkeyInput);
if (!pubkeyHex) {
setStatus(queryStatus, 'error', 'Invalid pubkey. Enter a 64-char hex pubkey or an npub.');
queryBtn.disabled = false;
return;
}
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
if (relayUrls.length === 0) {
setStatus(queryStatus, 'error', 'Enter at least one relay URL.');
queryBtn.disabled = false;
return;
}
let allCandidates = [];
let lastError = null;
let successCount = 0;
// G56-02: Query relays in parallel, collect ALL candidates (not limit: 1)
const promises = relayUrls.map(async (url) => {
try {
const events = await queryRelayForEvents(pubkeyHex, url);
if (events && events.length > 0) {
successCount++;
allCandidates.push(...events);
}
return { url, ok: true, count: events ? events.length : 0 };
} catch (e) {
lastError = e.message;
return { url, ok: false, error: e.message };
}
});
await Promise.all(promises);
// Deduplicate by event ID
const seenIds = new Set();
allCandidates = allCandidates.filter(e => {
if (!e || !e.id || seenIds.has(e.id)) return false;
seenIds.add(e.id);
return true;
});
if (allCandidates.length === 0) {
setStatus(queryStatus, 'error', `No proof carrier events found for this pubkey on any of the ${relayUrls.length} relay(s)${lastError ? ' (last error: ' + lastError + ')' : ''}.`);
queryBtn.disabled = false;
return;
}
const npub = hexToNpub(pubkeyHex);
setStatus(queryStatus, 'info', `Found ${allCandidates.length} proof carrier event(s) from ${npub.substring(0, 20)}... across ${successCount}/${relayUrls.length} relay(s). Selecting canonical (earliest Bitcoin anchor)...`);
// G56-02: Select the canonical proof carrier (earliest valid Bitcoin anchor)
// First, parse the kind 1 event from the first candidate to use for selection
let kind1Event = null;
try {
kind1Event = JSON.parse(allCandidates[0].content);
} catch (e) {
// Try other candidates
for (const c of allCandidates) {
try {
kind1Event = JSON.parse(c.content);
break;
} catch (e2) { /* keep trying */ }
}
}
if (!kind1Event) {
setStatus(queryStatus, 'error', 'Could not parse kind 1 announcement from any candidate.');
queryBtn.disabled = false;
return;
}
// Set the expected author for G56-03 identity binding
currentExpectedAuthor = pubkeyHex;
try {
const selection = await selectCanonicalProofCarrier(allCandidates, kind1Event, pubkeyHex);
if (selection.canonical) {
const heightNote = selection.canonicalBitcoinHeight ? `Bitcoin block ${selection.canonicalBitcoinHeight}` : 'pending (no Bitcoin anchor yet)';
const confirmedCount = selection.confirmedCandidates.length;
const pendingCount = selection.pendingCandidates.length;
setStatus(queryStatus, 'success', `Selected canonical proof carrier: ${selection.canonical.id.substring(0, 16)}... (${heightNote}). ${confirmedCount} confirmed, ${pendingCount} pending, ${allCandidates.length} total candidate(s).`);
await verifyEvent(selection.canonical);
} else {
setStatus(queryStatus, 'error', `No valid proof carrier found among ${allCandidates.length} candidate(s). Errors: ${selection.errors.join('; ')}`);
}
} catch (e) {
setStatus(queryStatus, 'error', `Canonical selection failed: ${e.message}`);
}
queryBtn.disabled = false;
});
/* ================================================================
TAB 2: PASTE EVENT JSON
================================================================ */
const verifyBtn = document.getElementById('pqVerifyBtn');
const eventInput = document.getElementById('pqEventInput');
const verifyStatus = document.getElementById('pqVerifyStatus');
verifyBtn.addEventListener('click', async () => {
verifyBtn.disabled = true;
setStatus(verifyStatus, 'info', 'Verifying...');
try {
const event = JSON.parse(eventInput.value.trim());
if (!event || !event.pubkey || !event.sig || !event.content || !event.tags) {
throw new Error('Invalid event: missing required fields (pubkey, sig, content, tags)');
}
if (event.kind !== NIP_QR_KIND) {
setStatus(verifyStatus, 'error', `Warning: event kind is ${event.kind}, expected ${NIP_QR_KIND}. Verifying anyway...`);
}
const allValid = await verifyEvent(event);
if (allValid) {
setStatus(verifyStatus, 'success', 'All signatures and PQ algorithm policy verified successfully!');
} else {
setStatus(verifyStatus, 'error', 'Some checks failed verification. See results below for details.');
}
} catch (error) {
console.error('[verify] Error:', error);
setStatus(verifyStatus, 'error', `Error: ${error.message}`);
} finally {
verifyBtn.disabled = false;
}
});
/* ================================================================
DOWNLOAD EVENT JSON
================================================================ */
downloadEventBtn.addEventListener('click', () => {
if (!currentEvent) return;
const blob = new Blob([JSON.stringify(currentEvent, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (currentEvent.id ? currentEvent.id.substring(0, 16) : 'event') + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
/* ================================================================
OTS UPGRADE (from verify page)
================================================================ */
otsUpgradeBtn.addEventListener('click', async () => {
if (!currentOtsBytes) return;
otsUpgradeBtn.disabled = true;
setStatus(otsUpgradeStatus, 'info', 'Sending .ots proof to upgrade service...');
try {
const result = await upgradeOts(currentOtsBytes);
currentOtsBytes = result.proof;
// After upgrading, run full cryptographic verification to confirm
// the Bitcoin attestation is real (not just a byte-pattern match).
const sha256Tag = currentEvent && currentEvent.tags ? currentEvent.tags.find(t => t[0] === 'sha256') : null;
const verifyResult = await verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined);
const confirmed = verifyResult.verified;
if (confirmed) {
const att = verifyResult.bitcoinAttestations[0];
const date = att ? new Date(att.time * 1000).toISOString().substring(0, 19) : 'unknown';
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Proof upgraded (${currentOtsBytes.length} bytes). You can publish a new kind 9999 event carrying the confirmed proof below; it will reference the original via an upgrade_of tag.`);
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'inline-block';
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
setOtsInfo(`OpenTimestamps proof cryptographically verified. Bitcoin block ${att ? att.height : '?'}. Proof size: ${currentOtsBytes.length} bytes.`);
} else {
setStatus(otsUpgradeStatus, 'info', `Proof upgraded but still pending (no Bitcoin attestation yet). ${result.detail ? 'Detail: ' + result.detail : ''} Try again later.`);
}
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Upgrade failed: ${error.message}`);
} finally {
otsUpgradeBtn.disabled = false;
}
});
otsRepublishBtn.addEventListener('click', async () => {
if (!currentEvent || !currentOtsBytes) return;
otsRepublishBtn.disabled = true;
setStatus(otsUpgradeStatus, 'info', 'Requesting secp256k1 signature for upgraded event...');
try {
if (!window.nostr || !window.nostr.signEvent) {
throw new Error('Nostr signer (window.nostr) not available. Use a NIP-07 signer extension to publish the upgraded event.');
}
const upgradedTemplate = buildUpgradedEvent(currentEvent, currentOtsBytes);
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
// Publish to the relays from the relay input field
const relayInput = document.getElementById('pqRelayInput').value.trim();
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
if (relayUrls.length === 0) {
throw new Error('No relay URLs specified in the relay input field.');
}
const eventJson = JSON.stringify(['EVENT', signedEvent]);
const publishResults = await Promise.all(relayUrls.map(url => {
return new Promise((resolve) => {
try {
const ws = new WebSocket(url);
let done = false;
const t = setTimeout(() => { if (!done) { done = true; try { ws.close(); } catch(e){} resolve({ url, success: false, error: 'timeout' }); } }, 15000);
ws.onopen = () => ws.send(eventJson);
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'OK' && data[1] === signedEvent.id) {
if (!done) { done = true; clearTimeout(t); try { ws.close(); } catch(e){} resolve({ url, success: true }); }
}
} catch (e) {}
};
ws.onclose = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'closed without OK' }); } };
ws.onerror = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'error' }); } };
} catch (e) { resolve({ url, success: false, error: e.message }); }
});
}));
const ok = publishResults.filter(r => r.success).length;
const fail = publishResults.filter(r => !r.success).length;
if (ok === 0) {
throw new Error(`No relay accepted the upgraded event (${fail} failed).`);
}
currentEvent = signedEvent;
setStatus(otsUpgradeStatus, 'success', `Upgraded proof carrier published to ${ok} relay(s) (${fail} failed).`);
// Re-verify the new event
await verifyEvent(signedEvent);
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Publish failed: ${error.message}`);
} finally {
otsRepublishBtn.disabled = false;
}
});
/* ================================================================
AUTO-DETECT SIGNED-IN USER
On page load, check for a NIP-07 signer / nostr-login-lite. If the
user is already signed in, pre-fill the pubkey field and auto-query
relays for their kind 9999 event.
================================================================ */
async function initAutoDetect() {
// Initialize nostr-login-lite if available (shares session with main page)
if (window.NOSTR_LOGIN_LITE) {
try {
if (!window.NOSTR_LOGIN_LITE._initialized) {
await window.NOSTR_LOGIN_LITE.init({
methods: { extension: true, local: true, nip46: true, seedphrase: true, nsigner: true }
});
}
} catch (e) {
console.log('[verify] nostr-login-lite init failed:', e.message);
}
}
if (!window.nostr || !window.nostr.getPublicKey) return;
let pubkeyHex = null;
try {
pubkeyHex = await window.nostr.getPublicKey();
} catch (e) {
console.log('[verify] Not signed in:', e.message);
return;
}
if (!pubkeyHex) return;
console.log('[verify] Auto-detected signed-in pubkey:', pubkeyHex);
const pubkeyInput = document.getElementById('pqPubkeyInput');
const npub = hexToNpub(pubkeyHex);
pubkeyInput.value = npub || pubkeyHex;
// Auto-trigger the query
queryBtn.click();
}
initAutoDetect();
</script>
<!-- Main application module (G56-08: extracted to external file for CSP compliance) -->
<script type="module" src="./js/verify-app.mjs"></script>
</body>
</html>