Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79185f9909 | ||
|
|
94f03f2999 | ||
|
|
200eb41f39 | ||
|
|
ed572e21e5 | ||
|
|
8039af473a | ||
|
|
2a1ddf3b17 | ||
|
|
68050fc8a3 | ||
|
|
14dc8a063c | ||
|
|
401c88511e | ||
|
|
cc66a5fa3e | ||
|
|
5ebc6d8d2a | ||
|
|
5d6dbc9ecd | ||
|
|
66babc5a7a | ||
|
|
87a8893785 | ||
|
|
5bc36c37e7 | ||
|
|
f29f63950b | ||
|
|
a1e1759704 | ||
|
|
113885eb96 | ||
|
|
cfa0ecc220 | ||
|
|
fee9d6c344 | ||
|
|
7a5701ff51 | ||
|
|
2eb465ed26 | ||
|
|
be5afe3c91 | ||
|
|
5a4a822c51 | ||
|
|
a728e125a8 | ||
|
|
1308ab7099 | ||
|
|
ea5e24cbb9 | ||
|
|
9a975fcbc5 | ||
|
|
cf6333917d | ||
|
|
122d51903d | ||
|
|
3d3d5d7dd7 | ||
|
|
ff54de18bb |
@@ -7,7 +7,6 @@ build/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Source maps
|
||||
*.bundle.js.map
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "resources/javascript-opentimestamps"]
|
||||
path = resources/javascript-opentimestamps
|
||||
url = https://github.com/opentimestamps/javascript-opentimestamps.git
|
||||
@@ -1,11 +1,57 @@
|
||||
# Post-Quantum Nostr
|
||||
# Nostr Quantum Preparation
|
||||
|
||||
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)
|
||||
@@ -13,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)
|
||||
|
||||
@@ -21,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -76,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]
|
||||
@@ -87,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
|
||||
|
||||
@@ -102,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, quantum-safe]
|
||||
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)
|
||||
|
||||
@@ -138,480 +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 | Borderline |
|
||||
| 24 words | 256 bits | ~128 bits | Adequate |
|
||||
|
||||
For users concerned about quantum attacks on the seed entropy itself, 24-word mnemonics are recommended. The bigger risk to seed phrases is classical (theft, phishing), not quantum. (Note: the current implementation defaults to 12-word mnemonics; 24-word support is recommended for production use.)
|
||||
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 0–9999 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 11112]
|
||||
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 --> KIND11112[Kind 11112 Wrapper Event - carries OTS proof]
|
||||
KIND11112 --> 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 KIND11112 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 11112 Wrapper (machine-verifiable, carries the OTS proof)
|
||||
|
||||
A replaceable event (kind 11112, in the 10000–19999 range) that wraps the kind 1 announcement and carries the OpenTimestamps proof. The content is the full kind 1 event as JSON (so verifiers don't need to fetch it from relays).
|
||||
|
||||
**Content:** `JSON.stringify(kind1Event)` — the full signed kind 1 event embedded as a JSON string.
|
||||
|
||||
**Tags:**
|
||||
- `['e', '<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 11112 events.
|
||||
- The event is anchored to a pre-quantum Bitcoin block via OpenTimestamps — the event with the earliest valid OTS proof wins (see Component 3).
|
||||
|
||||
**What the event does NOT prove:**
|
||||
- The PQ keys cannot *cryptographically* prove they share a common seed origin. They prove only that five PQ keypairs exist and each signed the attestation text. Common-origin is **asserted** in the attestation text and **authorized** by Account #1's signature, but it is not proven by a seed-derived signature.
|
||||
- The seed-derived secp256k1 key (Account #2, child 0) is derived but **not used to sign** in the current implementation. There is no "successor signature" binding the PQ keys to the seed.
|
||||
|
||||
This is a deliberate design choice. The security model relies on **OTS precedence** (the real event is anchored pre-quantum; a forged event cannot be backdated) rather than a seed-derived signature. After a quantum break, an attacker who forges Account #1 can publish a fraudulent migration event, but it will have a later OTS timestamp than the real event — so the real event is cryptographically distinguishable by its earlier Bitcoin anchor.
|
||||
|
||||
### ML-KEM Cannot Sign
|
||||
|
||||
ML-KEM (Kyber) is a KEM (Key Encapsulation Mechanism), not a signature scheme. It cannot sign the 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 11112 wrapper'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 11112 wrapper's `sha256` tag.
|
||||
3. The `.ots` proof is embedded in the kind 11112 wrapper's `ots` tag.
|
||||
> **Trust model.** The current OTS verification is **API-assisted**, not a full Bitcoin light-client
|
||||
> verification. Treat results accordingly.
|
||||
|
||||
The kind 11112 wrapper *carries* the OTS proof; the kind 1 event *is* what's timestamped. Verifiers re-compute the hash from the embedded kind 1 event and check it matches the `sha256` tag.
|
||||
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
|
||||
|
||||
The current implementation detects Bitcoin confirmation by searching the `.ots` proof bytes for the Bitcoin block-header attestation magic bytes, and delegates proof upgrading to a server-side helper. **Full client-side OTS verification** (parsing the proof, validating the Merkle path, checking the block header against the Bitcoin chain) is planned but not yet implemented. The vendored `javascript-opentimestamps` library in `resources/` provides the necessary primitives and will be integrated in a future release.
|
||||
|
||||
### What Should Be OpenTimestamped
|
||||
|
||||
Not every event needs OTS. Priority list:
|
||||
|
||||
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 1–8) under the hardened parent `m/44'/1237'/0'/0'`. Non-hardened derivation means anyone with the parent xpub (extended public key + chain code) at `m/44'/1237'/0'/0'` could derive the child *public* keys — but **not** the child *private* keys (which are what the PQ seeds are). So the PQ seeds remain secret unless the parent private key leaks. **Never publish the xpub at `m/44'/1237'/0'/0'`** — doing so would expose the child public keys (though not the PQ seeds themselves).
|
||||
|
||||
### The Critical Vulnerability: Chain Code Leakage
|
||||
|
||||
If an attacker obtains a chain code at any level AND breaks one child key at that level via Shor's, they can:
|
||||
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? |
|
||||
|---|---|
|
||||
@@ -622,132 +257,34 @@ 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 --> KIND11112[Build kind 11112 wrapper with OTS proof]
|
||||
KIND11112 --> SECP2[Account 1 signs kind 11112 via signer]
|
||||
SECP2 --> RELAY[Publish both events to relays]
|
||||
SEED --> BACKUP[User writes down 12-word phrase]
|
||||
end
|
||||
|
||||
subgraph CONFIRM["Bitcoin confirmation"]
|
||||
POLL[Poll OTS upgrade service]
|
||||
POLL --> UPGRADE[Upgrade .ots proof with Bitcoin attestation]
|
||||
UPGRADE --> REPUBLISH[Republish kind 11112 with confirmed proof]
|
||||
end
|
||||
|
||||
subgraph FUTURE["Post-quantum - when quantum computers arrive"]
|
||||
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 11112 wrapper
|
||||
6. The SHA-256 of the full signed kind 1 event is submitted to OpenTimestamps
|
||||
7. Both events are published to relays
|
||||
8. The client polls for Bitcoin confirmation and republishes the kind 11112 wrapper with the confirmed OTS proof
|
||||
9. After quantum migration: clients use PQ keys, old key retired
|
||||
**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 ~20–30 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` |
|
||||
| Kind 1 announcement event construction | Implemented | `www/js/pq-crypto.mjs` |
|
||||
| Kind 11112 wrapper event construction | Implemented | `www/js/pq-crypto.mjs` |
|
||||
| Kind 9999 proof carrier event construction | Implemented | `www/js/pq-crypto.mjs` |
|
||||
| NIP-01 event ID computation and Schnorr verification | Implemented | `www/js/pq-crypto.mjs` |
|
||||
| OpenTimestamps submission (pending proof) | Implemented | `www/js/pq-crypto.mjs` |
|
||||
| OTS upgrade polling (via server helper) | Implemented | `www/js/pq-crypto.mjs` |
|
||||
@@ -755,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 client-side OTS verification | Parse the .ots proof, validate the Merkle path, check the block header against the Bitcoin chain (the vendored `javascript-opentimestamps` library in `resources/` provides the primitives) |
|
||||
| OTS target-digest binding | Verify the OTS proof's target digest equals the event's `sha256` tag |
|
||||
| Raw-nsec migration (Component 4) | Encrypt old nsec, publish kind 30078, 36-word phrase encoding |
|
||||
| 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 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 |
|
||||
| Test suite | Known-answer tests for derivation, PQ sign/verify round-trips, event verification |
|
||||
| 24-word mnemonic default | Currently defaults to 12 words; 24 recommended for production |
|
||||
| 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 ~20–30 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
|
||||
|
||||
@@ -775,7 +314,7 @@ The current implementation is a static web app (`www/`) that performs the full m
|
||||
- [`@scure/bip32`](https://github.com/paulmillr/scure-bip32) — BIP32 HD wallet derivation
|
||||
- [`@scure/base`](https://github.com/paulmillr/scure-base) — bech32 (npub) encoding
|
||||
- [OpenTimestamps](https://opentimestamps.org/) — calendar servers for timestamping
|
||||
- [`javascript-opentimestamps`](resources/javascript-opentimestamps/) — vendored OTS library (present, not yet integrated for client-side verification)
|
||||
- [`javascript-opentimestamps`](resources/javascript-opentimestamps/) — vendored OTS library (used as a fixture source; full light-client integration pending)
|
||||
- [nostr-login-lite](https://github.com/nostrband/nostr-login-lite) — NIP-07 / NIP-46 authentication
|
||||
|
||||
---
|
||||
@@ -797,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.
|
||||
|
||||
@@ -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 0–9999 "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.
|
||||
@@ -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 (0–9999 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).
|
||||
@@ -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) |
|
||||
@@ -0,0 +1,671 @@
|
||||
# GPT-5.6 Comprehensive Security and Usability Audit
|
||||
|
||||
**Audit date:** 2026-07-20
|
||||
**Audited workspace:** `post_quantum_nostr` at root commit `9a975fcbc56e455a68ae16d734feeed76ba304b6`
|
||||
**Application version:** `0.0.11`
|
||||
**Auditor:** GPT-5.6
|
||||
**Scope:** first-party application code, cryptographic and Nostr/OTS protocol design, browser security, dependencies, tests, documentation claims, live public deployment, and the OpenTimestamps submodule boundary.
|
||||
|
||||
## Executive verdict
|
||||
|
||||
**Do not use this implementation to protect a real Nostr identity yet.** It is a useful research prototype with working PQ key generation/signature primitives and a promising OTS proof parser, but its verifier currently accepts events with **no PQ signatures**, treats missing signatures on declared signature algorithms as successful KEM-style entries, does not bind the outer wrapper identity to the embedded announcement identity, and does not implement its defining **earliest valid OTS anchor wins** selection rule. These are protocol-critical failures, not polish issues.
|
||||
|
||||
**Conceptual verdict:** the project is **conditionally sound as a pre-quantum key-commitment and identity-link protocol**, but it is **not, by itself, a complete post-quantum migration for Nostr**. Its central idea—have the current identity authorize PQ keys while secp256k1 is still trustworthy, anchor that authorization in Bitcoin, and later prefer the earliest valid anchor—is defensible if the protocol is redesigned to enforce immutable discovery, strict verification, key possession/continuity, and PQ-authorized rotation. Fixing only the implementation bugs would not be enough. Fixing the architectural requirements in the “Conceptual soundness assessment” below could yield a valid and useful project, but the result should initially be described as a **PQ key pre-commitment/link registry**, not as making the existing Nostr identity fully quantum-resistant.
|
||||
|
||||
The project did well in several areas:
|
||||
|
||||
- All 38 existing tests passed, including two live checks against Bitcoin API data.
|
||||
- A fresh browser bundle was byte-for-byte identical to the committed bundle.
|
||||
- `npm audit --omit=dev` reported no advisories for the currently installed root dependency graph.
|
||||
- The OTS target digest is bound to the expected event hash during full verification.
|
||||
- The live bundle exactly matched the audited local bundle.
|
||||
- A live calendar submission succeeded to all five configured calendars and parsed as a detached multi-calendar proof.
|
||||
|
||||
Those strengths do not compensate for fail-open acceptance in the key-link verifier. A user-facing statement such as “All signatures verified successfully” can currently be produced for a signed wrapper whose embedded announcement has zero PQ keys. The application also presents a latest replaceable event from relays, whereas the proposal says the canonical link is the event with the earliest verified Bitcoin anchor. That mismatch defeats the core post-quantum migration rule.
|
||||
|
||||
## Severity summary
|
||||
|
||||
| Severity | Count | IDs |
|
||||
|---|---:|---|
|
||||
| Critical | 2 | G56-01, G56-02 |
|
||||
| High | 6 | G56-03 through G56-08 |
|
||||
| Medium | 8 | G56-09 through G56-16 |
|
||||
| Low / informational | 4 | G56-17 through G56-20 |
|
||||
|
||||
## Findings at a glance
|
||||
|
||||
| ID | Severity | Finding | Confidence |
|
||||
|---|---|---|---|
|
||||
| G56-01 | Critical | Verifier accepts a migration with no PQ signatures and accepts missing signature fields as valid | Confirmed dynamically |
|
||||
| G56-02 | Critical | Earliest-valid-anchor rule is not implemented; relay discovery selects the latest replaceable event | Confirmed |
|
||||
| G56-03 | High | Outer wrapper identity is not bound to the embedded announcement identity | Confirmed dynamically |
|
||||
| G56-04 | High | OTS “verification” trusts public HTTPS APIs and does not validate Bitcoin headers or proof of work | Confirmed |
|
||||
| G56-05 | High | Signer output is reconstructed without validating returned identity, event ID, signature, or exact signed fields | Confirmed |
|
||||
| G56-06 | High | The system creates a new seed for any signed-in identity without proving seed continuity or a successor key relationship | Confirmed design limitation |
|
||||
| G56-07 | High | Verification is not robust against malformed untrusted relay/paste input and can throw | Confirmed dynamically |
|
||||
| G56-08 | High | Browser origin is the key-generation trust boundary, but deploy hardening and signer assets are unaudited/unpinned | Confirmed |
|
||||
| G56-09 | Medium | Twelve-word seed is the only generated option and its warning is removed in the audited workspace/live page | Confirmed |
|
||||
| G56-10 | Medium | Secret material is retained in ordinary JavaScript objects/DOM/clipboard and is not zeroized | Confirmed |
|
||||
| G56-11 | Medium | Publish workflow reports success and advances even when no relay accepts one or both events | Confirmed |
|
||||
| G56-12 | Medium | Resume/upgrade state can fetch an attacker-controlled latest wrapper and lacks robust event/workflow binding | Confirmed |
|
||||
| G56-13 | Medium | OTS parser lacks total input/operation budgets and uses unsafe 32-bit varuint arithmetic | Confirmed |
|
||||
| G56-14 | Medium | Dependency resolution is not reproducible from the repository; lockfile is ignored and inconsistent | Confirmed |
|
||||
| G56-15 | Medium | OpenTimestamps submodule is not clonable normally because `.gitmodules` is absent; historical dependency risk is unresolved | Confirmed |
|
||||
| G56-16 | Medium | Documentation and UI overclaim implementation status and security properties | Confirmed |
|
||||
| G56-17 | Low | Block-height statement is not validated and can be zero/untrusted | Confirmed |
|
||||
| G56-18 | Low | Relay URL policy permits insecure `ws://` and arbitrary endpoints | Confirmed |
|
||||
| G56-19 | Low | Missing event IDs may be accepted by the library verifier | Confirmed dynamically |
|
||||
| G56-20 | Informational | Large events may be rejected by relays; interoperability testing is absent | Confirmed size; relay behavior contextual |
|
||||
|
||||
---
|
||||
|
||||
## Conceptual soundness assessment
|
||||
|
||||
### Direct answer
|
||||
|
||||
**Is the idea flawed from the start? No—but its valid scope is narrower than the current product language suggests.**
|
||||
|
||||
The project starts from a real migration problem: after secp256k1 is broken, a fresh secp signature cannot distinguish the legitimate owner from an attacker. Creating and timestamping an authorization **before** that break is a legitimate way to establish historical precedence. A PQ key that signs the same canonical authorization provides durable proof that the corresponding PQ private key participated. Bitcoin/OpenTimestamps can provide an externally ordered existence proof without changing Nostr relays.
|
||||
|
||||
That gives a conceptually useful primitive:
|
||||
|
||||
> Before quantum compromise, identity A authorized PQ key set K; the authorization existed no later than Bitcoin block H; future clients can use a deterministic conflict rule to resolve later forged claims.
|
||||
|
||||
However, that primitive does **not** automatically make identity A quantum-resistant. A complete migration also needs a standardized way to authenticate future events with K, rotate/revoke keys after secp256k1 is distrusted, encrypt to PQ keys, discover all historical claims despite relay replacement/deletion, and recover from lost or compromised PQ keys. Those pieces are currently outside the implementation.
|
||||
|
||||
### What remains valid after remediation
|
||||
|
||||
If all audit recommendations and the architectural requirements in [`audit_mitigation.md`](audit_mitigation.md:1) are implemented, the project can validly provide:
|
||||
|
||||
1. **Pre-quantum commitment:** proof that a Nostr identity authorized a canonical PQ key set before a verified Bitcoin block.
|
||||
2. **PQ proof of possession:** proof that each linked signature key participated in the canonical authorization, and an explicit KEM possession mechanism if ML-KEM ownership is claimed.
|
||||
3. **Deterministic conflict resolution:** selection of the earliest valid immutable anchor rather than the newest relay event.
|
||||
4. **A migration root:** a trustworthy starting key set for a separate PQ-event, PQ-encryption, and key-rotation protocol.
|
||||
5. **Backward-compatible publication:** legacy relays can carry opaque events without understanding PQ cryptography, subject to size and retention limits.
|
||||
|
||||
### What remediation cannot make true without additional protocols
|
||||
|
||||
Even a perfectly implemented key-link protocol does not itself provide:
|
||||
|
||||
- PQ authentication for ordinary kind 0/1/3/etc. events;
|
||||
- PQ confidentiality for NIP-04 or NIP-44 messages;
|
||||
- automatic preservation of the social graph in clients that do not adopt identity-alias/successor semantics;
|
||||
- revocation or rotation after secp256k1 becomes untrustworthy;
|
||||
- availability if all relays and user archives lose the immutable announcement/proof;
|
||||
- protection from seed theft, malicious signers, compromised web origins, or broken PQ implementations;
|
||||
- consensus across Nostr clients about algorithm policy or canonical identity succession.
|
||||
|
||||
The project therefore needs companion specifications for PQ-signed events, key lifecycle, encryption, and client identity mapping before it can accurately claim a complete Nostr PQ migration.
|
||||
|
||||
### Architectural conditions for a sound protocol
|
||||
|
||||
The concept is sound only if all of these invariants are made normative and implemented:
|
||||
|
||||
1. **Canonical signed object:** PQ signatures and the current secp identity must cover one domain-separated, versioned, deterministic structure containing the old identity, complete ordered key set, algorithm identifiers, protocol version, creation context, and rotation policy. Human-readable prose may be displayed but must not be the normative signed representation.
|
||||
2. **Strict proof policy:** a verifier must distinguish structural validity, cryptographic validity, algorithm-policy sufficiency, OTS validity, and canonical-selection status. Missing signatures can never become successful KEM entries.
|
||||
3. **Identity equality:** queried author, wrapper author, embedded author, and canonical old-identity field must match.
|
||||
4. **Immutable candidates:** the initial authorization must not depend on a replaceable event that can hide prior history. An upgraded proof may reference the same immutable authorization digest, but cannot replace the candidate itself.
|
||||
5. **Complete discovery:** clients must collect candidates across relays/archives and apply a deterministic earliest-valid-anchor rule, including tie-breaks and confirmation-depth policy.
|
||||
6. **Possession and recoverability:** every signature key must prove possession. If the project promises seed recovery, the canonical statement must bind a seed-derived successor proof or the UI must explicitly avoid claiming common seed origin. KEM possession requires a defined challenge/decapsulation proof or must be described only as an authorized encryption key, not proven possession.
|
||||
7. **Post-break authority:** after the transition cutoff, secp-only updates must not supersede the canonical PQ root. Rotation/revocation must require a threshold or policy over already-authorized PQ keys.
|
||||
8. **Future event semantics:** a separate specification must define exactly how PQ keys authenticate future Nostr events and how those events bind to NIP-01 IDs, kinds, tags, replay domains, and old identities.
|
||||
9. **Availability:** users and clients must export, mirror, and validate the immutable authorization plus OTS proof independently of any one relay or web service.
|
||||
10. **Trust-explicit Bitcoin verification:** implementations must state whether they use a local node, a validated header chain, or trusted explorer APIs. Security labels must match that model.
|
||||
11. **Algorithm agility with policy:** multiple algorithms may hedge cryptanalytic risk, but clients need mandatory minimum sets, deprecation rules, downgrade resistance, and deterministic handling of unknown schemes.
|
||||
12. **Secure key boundary:** production derivation/signing should occur in a dedicated signer or hardware boundary, not an updateable general-purpose web origin holding a long-lived seed.
|
||||
|
||||
### Bottom line
|
||||
|
||||
With those conditions and the finding-specific fixes, this can become a valid **PQ migration foundation**. Without them, repairing individual code defects would leave a system that generates valid PQ signatures but lacks a trustworthy rule for what those signatures mean over time. The current project is therefore **salvageable and conceptually promising, but architecturally incomplete**.
|
||||
|
||||
## Scope, baseline, and trust boundaries
|
||||
|
||||
### First-party runtime surface
|
||||
|
||||
- Cryptographic module: [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1)
|
||||
- Generated browser bundle: [`pq-crypto.bundle.js`](../../www/pq-crypto.bundle.js)
|
||||
- Migration page and state machine: [`index.html`](../../www/index.html:1)
|
||||
- Verification page and relay query flow: [`verify.html`](../../www/verify.html:1)
|
||||
- Tests: [`pq-crypto.test.mjs`](../../test/pq-crypto.test.mjs:1)
|
||||
- Build script: [`build-pq-bundle.js`](../../build-pq-bundle.js:1)
|
||||
- Protocol proposal: [`nip_proposal.md`](../../nip_proposal.md:1)
|
||||
- User-facing claims: [`README.md`](../../README.md:1)
|
||||
|
||||
### External trust boundaries
|
||||
|
||||
1. **Web origin / deployment operator** controls the JavaScript that receives or generates a BIP39 mnemonic and PQ private keys.
|
||||
2. **nostr-login-lite / NIP-07/NIP-46 signer** supplies the current identity and signs events.
|
||||
3. **Nostr relays** receive, retain, filter, and return untrusted events.
|
||||
4. **OTS calendars** receive event digests and return pending timestamp fragments.
|
||||
5. **Same-origin `/ots-upgrade` helper** receives OTS proof bytes and returns upgraded bytes; its implementation is absent from the repository.
|
||||
6. **Blockstream and mempool.space APIs** supply the purported Bitcoin block hash, Merkle root, and timestamp.
|
||||
7. **npm dependencies and committed generated bundle** implement all cryptographic primitives in the browser.
|
||||
8. **OpenTimestamps submodule** supplies example proofs used by tests, but is not runtime-integrated.
|
||||
|
||||
### Audit limitations
|
||||
|
||||
- No browser automation framework or complete signer fixture is present, so the real NIP-07/NIP-46 modal flow was reviewed statically rather than driven end to end.
|
||||
- The `/ots-upgrade` server source and nginx/deployment configuration are not in the repository. Only black-box HTTP behavior could be checked.
|
||||
- No real Nostr identity was used and no migration event was published during the audit.
|
||||
- The workspace was already dirty: user changes existed in [`index.html`](../../www/index.html:1), [`verify.html`](../../www/verify.html:1), and [`nip_proposal.md`](../../nip_proposal.md:1), plus unrelated deleted audit files. The report evaluates the working tree, not a pristine commit.
|
||||
- Root advisory results describe the installed graph at audit time, not every graph permitted by semver ranges.
|
||||
|
||||
---
|
||||
|
||||
## Detailed findings
|
||||
|
||||
### G56-01 — Verifier accepts no PQ signatures and missing signatures as successful
|
||||
|
||||
**Severity:** Critical
|
||||
**Confidence:** Confirmed dynamically
|
||||
|
||||
The central verifier initializes results with only secp256k1, `e`, and SHA-256 checks, then loops over whatever `algorithm` tags happen to exist. It never requires any algorithm tag, never requires the four expected signature algorithms, and never validates uniqueness or policy. See [`verifyNIPQRContent()`](../../www/js/pq-crypto.mjs:606) and its unconditional aggregate at [`results.every()`](../../www/js/pq-crypto.mjs:706).
|
||||
|
||||
Worse, any algorithm tag lacking a fourth field is marked valid as a KEM, regardless of its algorithm identifier:
|
||||
|
||||
```js
|
||||
if (algorithm === 'ml-kem-768' || !sigBase64) {
|
||||
results.push({ algorithm, valid: true, note: 'KEM (no signature to verify)' });
|
||||
continue;
|
||||
}
|
||||
```
|
||||
|
||||
This logic is at [`verifyNIPQRContent()`](../../www/js/pq-crypto.mjs:683).
|
||||
|
||||
Dynamic probes established:
|
||||
|
||||
- A validly secp-signed embedded kind 1 event with `tags: []` returned `valid: true`.
|
||||
- `ml-dsa-44`, `ml-dsa-65`, `slh-dsa-128s`, and `falcon-512` tags containing only a public-key field each returned `valid: true` with note `KEM (no signature to verify)`.
|
||||
- An arbitrary unknown algorithm with no signature also returned valid.
|
||||
|
||||
The UI then presents `All signatures verified successfully!` when this incomplete result is true at [`verify.html`](../../www/verify.html:710).
|
||||
|
||||
**Impact:** The verifier can bless an event that provides no post-quantum authentication at all. Such an event does not establish the advertised migration and cannot support post-quantum continuity.
|
||||
|
||||
**Remediation:** Define and enforce a verification policy. At minimum:
|
||||
|
||||
1. Require exactly one tag for each mandatory supported algorithm.
|
||||
2. Require exact field counts and decoded byte lengths.
|
||||
3. Permit no-signature handling only for the exact `ml-kem-768` identifier.
|
||||
4. Reject duplicate known algorithms.
|
||||
5. Treat unknown algorithms as ignored—not valid evidence—and report them separately.
|
||||
6. Return distinct structural validity, cryptographic validity, policy sufficiency, and OTS validity fields.
|
||||
7. Add adversarial tests for empty tags, missing signatures, duplicate algorithms, unknown algorithms, and malformed encodings.
|
||||
|
||||
**Verification criterion:** No event can receive a migration-valid result unless the configured required PQ signature set verifies successfully.
|
||||
|
||||
### G56-02 — The defining earliest-valid-anchor rule is not implemented
|
||||
|
||||
**Severity:** Critical
|
||||
**Confidence:** Confirmed
|
||||
|
||||
The proposal makes earliest verified Bitcoin anchor selection the mechanism that defeats a later quantum forgery at [`nip_proposal.md`](../../nip_proposal.md:204). The implementation instead queries kind 11112 with `limit: 1` and describes it as “latest” at [`verify.html`](../../www/verify.html:487). Across relays, it chooses the largest `created_at` value at [`verify.html`](../../www/verify.html:660). The resume path also requests one event at [`index.html`](../../www/index.html:1201) and selects newest `created_at` across relays at [`index.html`](../../www/index.html:758).
|
||||
|
||||
Kind 11112 is replaceable, as documented in [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:425). A later forged or accidental replacement can therefore hide the historical event from ordinary relay queries. Even before a quantum break, a compromised current key can publish a newer wrapper. After a break, the attacker can do so trivially. The application does not gather all candidate announcements/proofs, verify them, compare Bitcoin heights, or preserve a stable immutable discovery record.
|
||||
|
||||
**Impact:** The implemented discovery behavior can select the exact later event that the OTS precedence design says must lose. This breaks the central migration security argument.
|
||||
|
||||
**Remediation:** Redesign discovery and storage before production use:
|
||||
|
||||
- Use a non-replaceable canonical announcement or an append-only indexed form for immutable candidates.
|
||||
- Fetch all candidates from multiple relays, not `limit: 1`.
|
||||
- Fully validate each candidate and select the earliest valid Bitcoin anchor according to a precise tie-break rule.
|
||||
- Define how upgraded OTS proofs refer to an immutable announcement without replacing or obscuring history.
|
||||
- Cache/export the canonical proof so relay retention does not determine identity continuity.
|
||||
- Implement PQ-authorized rotation separately.
|
||||
|
||||
**Verification criterion:** Given multiple candidates—including a later forged secp-valid candidate—the client deterministically chooses the candidate with the earliest valid Bitcoin anchor.
|
||||
|
||||
### G56-03 — Outer wrapper identity is not bound to embedded identity
|
||||
|
||||
**Severity:** High
|
||||
**Confidence:** Confirmed dynamically
|
||||
|
||||
[`verifyNIPQRContent()`](../../www/js/pq-crypto.mjs:606) validates the embedded kind 1 event and tags but receives no outer event object and therefore cannot require `outer.pubkey === embedded.pubkey`. [`verifyEvent()`](../../www/verify.html:530) separately validates the outer signature and calls the content verifier without performing this identity equality check.
|
||||
|
||||
A dynamic probe built a valid embedded event signed by key A, wrapped it in a valid kind 11112 event signed by key B, and observed:
|
||||
|
||||
- outer signature valid: `true`
|
||||
- embedded verification valid: `true`
|
||||
|
||||
**Impact:** A wrapper authored by one identity can carry another identity’s announcement and appear as a collection of passing checks. Relay query by author B can therefore display A’s embedded link under B’s result page. OTS upgrading/republishing may further re-sign mismatched content.
|
||||
|
||||
**Remediation:** Require equality among:
|
||||
|
||||
- requested relay-query author,
|
||||
- outer wrapper `pubkey`,
|
||||
- embedded kind 1 `pubkey`,
|
||||
- identity named in the human-readable content (or preferably a canonical structured identity field).
|
||||
|
||||
Also require correct outer kind and exact tag cardinality.
|
||||
|
||||
### G56-04 — Bitcoin verification trusts public API JSON and does not validate block headers or proof of work
|
||||
|
||||
**Severity:** High
|
||||
**Confidence:** Confirmed
|
||||
|
||||
[`fetchBitcoinBlockHeader()`](../../www/js/pq-crypto.mjs:1235) fetches a block hash by height and then a block JSON object from one of two public APIs. It extracts only a Merkle root and timestamp at [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1244). [`verifyOtsProof()`](../../www/js/pq-crypto.mjs:1323) compares the OTS commitment to that API-provided Merkle root.
|
||||
|
||||
Despite comments calling this “independently verifiable against the Bitcoin PoW chain” at [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1226), the implementation does not fetch raw 80-byte headers, hash a header, check its hash against target, verify chain linkage, validate accumulated work, or pin checkpoints. Both providers are contacted over HTTPS and are simply trusted to report honest data.
|
||||
|
||||
**Impact:** Compromise, interception at a trusted origin, coordinated false responses, or API semantic changes can cause a false “Bitcoin verified” result. Two fallback URLs are resilience, not trust minimization, because acceptance requires only one response.
|
||||
|
||||
**Remediation:** Accurately label the current mode as “verified against a trusted block explorer.” For stronger verification, use a local Bitcoin node or a real light client with headers, chainwork, checkpoints, and consensus validation. At minimum compare multiple independent providers and fail on disagreement, but do not call that trustless cryptographic Bitcoin verification.
|
||||
|
||||
### G56-05 — Signer output is trusted and reconstructed without validation
|
||||
|
||||
**Severity:** High
|
||||
**Confidence:** Confirmed
|
||||
|
||||
After `window.nostr.signEvent`, the migration flow copies `id`, `pubkey`, and `sig` from the signer but takes `created_at`, `kind`, `content`, and `tags` from the local template at [`index.html`](../../www/index.html:1078). It does not check:
|
||||
|
||||
- signer-returned pubkey equals the authenticated `currentPubkey`,
|
||||
- returned ID equals [`computeEventId()`](../../www/js/pq-crypto.mjs:510) for the final reconstructed event,
|
||||
- returned signature verifies,
|
||||
- signer did not alter fields,
|
||||
- the wrapper signer matches the announcement signer.
|
||||
|
||||
The same trust is repeated for the wrapper at [`index.html`](../../www/index.html:1118) and upgraded events at [`index.html`](../../www/index.html:1713). The verify page republishes signer output directly at [`verify.html`](../../www/verify.html:784), but also does not validate it before publishing.
|
||||
|
||||
**Impact:** A buggy, malicious, or incompatible signer can cause invalid or wrong-identity events to be displayed and published as successful. Reconstructing a hybrid object can pair a signer’s signature with different fields.
|
||||
|
||||
**Remediation:** Treat signer output as untrusted. Validate complete returned events, exact field equality against approved templates, pubkey equality, event ID, and Schnorr signature before any display, persistence, or publish.
|
||||
|
||||
### G56-06 — New seed continuity is asserted, not proven
|
||||
|
||||
**Severity:** High
|
||||
**Confidence:** Confirmed design limitation
|
||||
|
||||
The app generates or accepts an arbitrary seed after signing into an existing identity. It derives a seed-based secp key at [`deriveSecp256k1FromSeed()`](../../www/js/pq-crypto.mjs:198) but does not use that key to sign, and does not verify that an entered seed derives the currently signed-in Nostr identity. The documentation acknowledges no successor signature at [`README.md`](../../README.md:298).
|
||||
|
||||
For a raw-nsec identity, an old-identity signature plus pre-quantum OTS can establish that the old identity authorized listed PQ keys. It cannot prove those keys are recoverable from the mnemonic shown to the user or that all listed keys share one seed. For a mnemonic-based existing identity, failing to compare the derived secp public key wastes a strong continuity check.
|
||||
|
||||
**Impact:** A compromised browser can show one mnemonic while publishing attacker-chosen PQ keys; the old signer approves only a very large event whose key material is hard for a human to inspect. The user may believe the written mnemonic is a recoverable root when it is not cryptographically bound as such.
|
||||
|
||||
**Remediation:** Separate two modes:
|
||||
|
||||
1. **Existing mnemonic identity:** derive the NIP-06 secp key and require it to equal the signer pubkey before proceeding.
|
||||
2. **Raw-nsec migration:** define a canonical structured migration statement and add a proof-of-possession/successor signature from a seed-derived key, plus recovery self-test before publishing.
|
||||
|
||||
A hardware signer or dedicated PQ signer should ideally derive/sign without exposing the mnemonic to a web origin.
|
||||
|
||||
### G56-07 — Untrusted verifier input can throw instead of failing closed
|
||||
|
||||
**Severity:** High
|
||||
**Confidence:** Confirmed dynamically
|
||||
|
||||
The verification page accepts pasted JSON and relay events. [`verifyNIPQRContent()`](../../www/js/pq-crypto.mjs:606) assumes arrays and valid base64. Examples confirmed during testing:
|
||||
|
||||
- malformed base64 threw `InvalidCharacterError`,
|
||||
- non-array wrapper tags threw `TypeError: kind11112Tags.find is not a function`,
|
||||
- missing signatures caused [`verifyNostrEvent()`](../../www/js/pq-crypto.mjs:400) to throw,
|
||||
- `null` or wrong-shaped events threw.
|
||||
|
||||
Relay-driven verification at [`verify.html`](../../www/verify.html:679) invokes the verifier without the paste handler’s `try/catch`; malformed relay content can reject the click callback and leave inconsistent UI. PQ decoders can also receive attacker-selected large or malformed data without preflight size checks.
|
||||
|
||||
**Impact:** Remote relays can cause denial of service, misleading partial results, or expensive parsing/cryptographic work.
|
||||
|
||||
**Remediation:** Add strict schemas and byte-size limits before cryptography. Every exported verification function should return structured failure for untrusted input, never throw for ordinary malformed data. Add fuzz/property tests and maximum event/proof sizes.
|
||||
|
||||
### G56-08 — Browser origin security is insufficient for handling a root seed
|
||||
|
||||
**Severity:** High
|
||||
**Confidence:** Confirmed
|
||||
|
||||
The page states the seed is processed entirely in the browser at [`index.html`](../../www/index.html:364). That means the web origin—not merely the reviewed source—is trusted with the seed. The live response lacked HSTS, `X-Frame-Options`/CSP `frame-ancestors`, Referrer-Policy, Permissions-Policy, and other defense-in-depth headers. The meta CSP allows `'unsafe-inline'` scripts at [`index.html`](../../www/index.html:7) and [`verify.html`](../../www/verify.html:7), materially weakening XSS protection.
|
||||
|
||||
Two large same-origin signer scripts are loaded outside this repository at [`index.html`](../../www/index.html:577) and [`verify.html`](../../www/verify.html:350). They have no SRI and could not be source-audited here. Live sizes were 328,444 and 230,498 bytes. Any server compromise or update to these assets can exfiltrate the mnemonic and all PQ keys. The source for `/ots-upgrade` and server configuration is also absent.
|
||||
|
||||
**Impact:** A single origin compromise defeats the entire root-of-trust design. HTTPS alone does not make web-based seed entry equivalent to a hardware or native signer.
|
||||
|
||||
**Remediation:** Do not recommend entering a valuable existing mnemonic into this web app. Move derivation and PQ signing into a dedicated signer/hardware boundary. Also extract inline scripts, enforce nonce/hash CSP without `'unsafe-inline'`, add `frame-ancestors 'none'`, HSTS, Referrer-Policy, Permissions-Policy, pinned/vendored signer assets, reviewed deployment configuration, and release integrity documentation.
|
||||
|
||||
### G56-09 — Generated seed defaults to 12 words and the visible warning is absent
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
[`generateSeedPhrase()`](../../www/js/pq-crypto.mjs:76) and [`generateSeedPhraseWithEntropy()`](../../www/js/pq-crypto.mjs:91) always produce 128-bit entropy/12 words. The project itself estimates only about 64-bit security under an idealized Grover model at [`README.md`](../../README.md:172). The audited working tree removed the prior visible warning from the generation screen, and the live page matched this working tree.
|
||||
|
||||
**Impact:** Users receive “quantum-safe root of trust” messaging while the only generated option falls below the project’s own preferred PQ security level.
|
||||
|
||||
**Remediation:** Default to 24 words for a claimed long-term PQ root, retain a prominent explanation, and provide a deliberate entropy-strength selector. Be careful not to imply that BIP39’s low iteration count protects weak user-created phrases.
|
||||
|
||||
### G56-10 — Secret lifetime and exposure are not minimized
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
The mnemonic, BIP39 seed, derived secp private key, and five PQ secret keys are retained in global JavaScript variables at [`index.html`](../../www/index.html:623). The mnemonic is rendered in the DOM at [`index.html`](../../www/index.html:934) and can be copied to the system clipboard at [`index.html`](../../www/index.html:1373). Sign-out nulls references at [`index.html`](../../www/index.html:1477), but does not overwrite typed arrays or clear the seed display/input/clipboard. Ordinary JS garbage collection offers no reliable erasure.
|
||||
|
||||
**Impact:** Browser extensions, injected scripts, devtools, clipboard managers, page snapshots, and memory inspection can recover high-value long-lived secrets.
|
||||
|
||||
**Remediation:** Prefer signer-contained keys. If browser handling remains, minimize scope/lifetime, avoid deriving an unused secp private key, overwrite typed arrays where possible, clear all DOM/input references immediately after use, avoid clipboard encouragement, add mnemonic confirmation/recovery challenge, and document that zeroization cannot be guaranteed in JavaScript.
|
||||
|
||||
### G56-11 — Publication advances despite zero relay acceptance
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
After publishing, the code counts successes and failures but unconditionally displays `Published!`, disables the button, marks step 5 done, and exposes the next step at [`index.html`](../../www/index.html:1391). There is no requirement that either event reach any relay.
|
||||
|
||||
**Impact:** Users can believe migration succeeded when the announcement, wrapper, or both are unavailable. The two-event pair can also be split across relays.
|
||||
|
||||
**Remediation:** Require policy thresholds for both events, distinguish partial failure, retain retry controls, verify by querying accepted events back, and provide downloadable backups. Do not mark complete unless at least one configured durable relay stores both exact events.
|
||||
|
||||
### G56-12 — Resume workflow is insufficiently bound to the original event
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
Only OTS metadata/proof is persisted by [`savePendingOts()`](../../www/js/pq-crypto.mjs:1362), not the complete immutable original wrapper. On resume, the code queries relays for the latest kind 11112 and adopts it at [`index.html`](../../www/index.html:749). It does not require that the fetched event ID equal the persisted ID, that its SHA-256 target matches the persisted proof, or that it passes complete verification before becoming `pqEvent`.
|
||||
|
||||
There is also an ID transition bug: after an upgraded replacement is signed, [`savePendingOts()`](../../www/index.html:1726) stores the new wrapper event ID. The OTS proof, however, targets the embedded kind 1 hash, while comments and metadata call `eventId` “The NIP-QR event id” at [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1358). This overloaded identifier complicates safe recovery.
|
||||
|
||||
**Impact:** A resume can upgrade or republish the wrong wrapper/content, especially after replacements or hostile relay responses.
|
||||
|
||||
**Remediation:** Persist the complete signed announcement, wrapper, target digest, owner, and exact relay receipts under separate immutable IDs. On resume, validate every binding before signing or publishing. Never substitute a merely newer relay event.
|
||||
|
||||
### G56-13 — OTS parser resource and integer limits are incomplete
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
The parser allows recursion depth 512 at [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1126), but has no overall proof-size limit, total operation count, total branches, or total hash-work budget. [`readVaruint()`](../../www/js/pq-crypto.mjs:1032) uses JavaScript bitwise operations, which are signed 32-bit and wrap for sufficiently large encodings. The continuation loop can process many branches, and each operation allocates/hash-processes attacker-controlled data.
|
||||
|
||||
**Impact:** Pasted or relay-sourced proofs can consume excessive CPU/memory or create malformed length/height semantics.
|
||||
|
||||
**Remediation:** Reject proofs above a small protocol maximum; cap operations, branches, argument bytes, and total hashing; parse varints with safe-number checks or `BigInt`; reject non-canonical and overlong encodings; require complete input consumption; fuzz the parser.
|
||||
|
||||
### G56-14 — Root dependency graph is not reproducible from the repository
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
[`package.json`](../../package.json:13) uses caret ranges for all dependencies. [`package-lock.json`](../../package-lock.json) exists locally but is explicitly ignored by [`.gitignore`](../../.gitignore:8) and is not tracked. Its root metadata says version `1.0.0`, whereas the manifest says `0.0.11`, showing it is stale or generated under a different package state.
|
||||
|
||||
`npm audit --omit=dev` found zero vulnerabilities for the installed graph, and the bundle rebuilt identically. Those are positive observations for this machine only. A clean install later may resolve different code, and there is no committed integrity map connecting source dependencies to the released bundle.
|
||||
|
||||
**Remediation:** Commit an up-to-date lockfile, use `npm ci`, pin release dependencies, add CI audit/build/test jobs, produce an SBOM/provenance attestation, and document how the deployed bundle hash maps to a source revision.
|
||||
|
||||
### G56-15 — OpenTimestamps submodule and historical dependencies are not reproducible
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
The root tree records [`resources/javascript-opentimestamps/`](../../resources/javascript-opentimestamps/) as gitlink commit `c07ba8be0dae4e8721a84f32820abf7b2547a6ce`, but the repository has no `.gitmodules` mapping. `git submodule status` failed with “no submodule mapping found.” The already-present nested repository points to the upstream GitHub project, but a fresh clone cannot initialize it normally.
|
||||
|
||||
Its package metadata is version 0.4.9 and contains old ranges including `request`, `moment-timezone`, `minimatch`, and other historical packages. No lockfile is present, so its historical dependency graph could not be audited reproducibly. This code is not runtime-integrated, but its example proofs are required by root tests.
|
||||
|
||||
**Remediation:** Restore a valid `.gitmodules` entry or vendor a clearly licensed immutable snapshot. Pin and document the commit, remove claims that it is integrated, and either modernize/audit its dependency graph or retain only the test vectors with provenance and hashes.
|
||||
|
||||
### G56-16 — Documentation and UI overclaim status and guarantees
|
||||
|
||||
**Severity:** Medium
|
||||
**Confidence:** Confirmed
|
||||
|
||||
Examples:
|
||||
|
||||
- [`README.md`](../../README.md:372) says full client-side OTS verification is not implemented, but code now implements an API-trusting Merkle-root check.
|
||||
- [`README.md`](../../README.md:758) still lists full OTS verification, target binding, and the test suite as not implemented, although all exist in some form.
|
||||
- [`nip_proposal.md`](../../nip_proposal.md:294) says all OTS verification happens in the browser and “No private key ever touches a server,” but signer modes may be remote and the implementation relies on an unreviewed server helper and public APIs.
|
||||
- [`index.html`](../../www/index.html:323) says “Make your Nostr identity quantum-resistant,” while the project only links keys and does not define PQ signing for routine events or PQ messaging.
|
||||
- [`verify.html`](../../www/verify.html:255) says it verifies all signatures, despite G56-01.
|
||||
- The claim that a pending OTS proof establishes submission time at [`nip_proposal.md`](../../nip_proposal.md:208) is too strong: a pending calendar attestation is not a Bitcoin timestamp and depends on calendar behavior.
|
||||
|
||||
**Impact:** Users and reviewers may treat the prototype as production-ready or misunderstand its trust model.
|
||||
|
||||
**Remediation:** Maintain an explicit security model and implementation matrix generated from tests. Use “research prototype,” distinguish API-assisted verification from Bitcoin light-client verification, distinguish key linkage from identity becoming quantum-resistant, and label design-only research documents prominently.
|
||||
|
||||
### G56-17 — Claimed Bitcoin block height is unverified
|
||||
|
||||
**Severity:** Low
|
||||
**Confidence:** Confirmed
|
||||
|
||||
Block height comes from one mempool.space request at [`index.html`](../../www/index.html:808). Failure returns zero, yet signing continues at [`index.html`](../../www/index.html:1063). The verifier does not compare the `block_height` tag or content statement to the OTS attested block.
|
||||
|
||||
**Remediation:** Remove the claimed height from signed content or validate it against the eventual OTS result. Do not sign a “pre-quantum at height 0” statement.
|
||||
|
||||
### G56-18 — Insecure/arbitrary relay endpoints are allowed
|
||||
|
||||
**Severity:** Low
|
||||
**Confidence:** Confirmed
|
||||
|
||||
The add-relay validator explicitly accepts `ws://` at [`index.html`](../../www/index.html:1436). The verify page does not validate schemes before `new WebSocket()` at [`verify.html`](../../www/verify.html:474).
|
||||
|
||||
**Impact:** On an HTTP/local deployment, users can transmit public metadata to plaintext relays; arbitrary endpoints also increase privacy leakage and browser-network probing surface.
|
||||
|
||||
**Remediation:** Require `wss://` outside explicit localhost development, canonicalize URLs, cap count/length, and explain that relay queries reveal identity interest and client IP.
|
||||
|
||||
### G56-19 — Event ID is optional in library verification
|
||||
|
||||
**Severity:** Low
|
||||
**Confidence:** Confirmed dynamically
|
||||
|
||||
[`verifyNostrEvent()`](../../www/js/pq-crypto.mjs:400) checks ID equality only when `event.id` is truthy at [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:412). A dynamically created event with a valid signature but omitted `id` returned true.
|
||||
|
||||
The embedded verifier currently requires neither `id` explicitly in its structural condition at [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:624) nor at the core verifier. While the `e` tag later supplies some binding for wrappers, this exported primitive’s behavior is surprising and unsafe for callers.
|
||||
|
||||
**Remediation:** Require exact NIP-01 shape and a 64-hex ID by default. If ID-less template verification is needed, expose a separate clearly named function.
|
||||
|
||||
### G56-20 — Event-size interoperability is untested
|
||||
|
||||
**Severity:** Informational
|
||||
**Confidence:** Confirmed size; relay behavior contextual
|
||||
|
||||
A fixed-mnemonic unsigned kind 1 announcement was approximately 27,500 bytes before `id`/`sig`; the wrapper embeds that JSON and adds proof data. Relay limits vary, and the application’s default relays may reject large events. Existing tests do not publish/query a representative event or enforce a size budget.
|
||||
|
||||
**Remediation:** Test against target relay policies, report exact size before signer approval, select suitable relays, and consider a canonical compact binary or referenced commitment format while preserving independent availability.
|
||||
|
||||
---
|
||||
|
||||
## Cryptographic and protocol assessment
|
||||
|
||||
### Positive observations
|
||||
|
||||
- BIP39 generation uses the dependency’s CSPRNG-backed [`generateMnemonic()`](../../www/js/pq-crypto.mjs:76).
|
||||
- User entropy is mixed with fresh CSPRNG bytes through SHA-256 rather than replacing the CSPRNG at [`generateSeedPhraseWithEntropy()`](../../www/js/pq-crypto.mjs:91).
|
||||
- PQ derivation is deterministic and domain-separated by child paths at [`PQ_DERIVATION_PATHS`](../../www/js/pq-crypto.mjs:50).
|
||||
- The first-48-byte concatenation rule is explicit at [`derivePQSeedFromBIP32()`](../../www/js/pq-crypto.mjs:161).
|
||||
- Four PQ sign/verify round trips and tampering cases passed.
|
||||
- NIP-01 event serialization is conventional at [`computeEventId()`](../../www/js/pq-crypto.mjs:510).
|
||||
- Full-event hashing consistently uses `JSON.stringify` of the embedded signed object at [`hashFullEvent()`](../../www/js/pq-crypto.mjs:530), and the exact JSON string is embedded in the wrapper.
|
||||
- OTS target digest binding is enforced before Bitcoin-attestation processing at [`verifyOtsProof()`](../../www/js/pq-crypto.mjs:1300).
|
||||
- The known historical OTS proof verified against live API data, and pending proofs were rejected as Bitcoin-unconfirmed.
|
||||
|
||||
### Interoperability and design cautions
|
||||
|
||||
- There are no independent known-answer values for the BIP32 child private keys or resulting PQ public keys. Existing “determinism” tests compare the implementation with itself, not another implementation.
|
||||
- Deriving standardized PQ seeds directly from BIP32 secp scalars is bespoke and should receive cryptographic review. Rejection behavior in BIP32 means output is not simply an unconstrained PRF byte stream; HKDF with explicit labels may be easier to specify independently.
|
||||
- Falcon remains a draft/complex implementation choice. Multi-scheme hedging helps only if verification policy actually requires and distinguishes schemes.
|
||||
- The attestation signs human-readable content while keys live in tags. The secp signature binds tags, but each PQ signature does not bind its own algorithm identifier, public-key set, wrapper kind, or OTS digest. A canonical structured statement would make cross-client policy safer.
|
||||
- ML-KEM ownership is not proven by a KEM operation. The old secp event authorizes the public key at publication time, but no decapsulation proof establishes possession.
|
||||
- The security argument depends on long-term availability of an immutable announcement and proof. Replaceable events and relay retention are poor foundations without export/archive mechanisms.
|
||||
|
||||
---
|
||||
|
||||
## Browser and operational assessment
|
||||
|
||||
### Live deployment observations
|
||||
|
||||
At audit time:
|
||||
|
||||
- `https://laantungir.net/post-quantum/` and `verify.html` returned HTTP 200.
|
||||
- Live HTML content lengths matched local working-tree lengths.
|
||||
- Live bundle SHA-256 exactly matched local: `c14d40088d02e89f0d9aa8f1643ce31d687a4837a2eb0ab1b19406b69ecbfc32`.
|
||||
- The server exposed nginx `1.24.0 (Ubuntu)` and permissive `Access-Control-Allow-Origin: *`.
|
||||
- No HSTS, frame restriction, Referrer-Policy, Permissions-Policy, or other reviewed security headers appeared in HEAD responses.
|
||||
- `/ots-upgrade` rejected an empty POST with HTTP 400 and `{"error": "invalid body length"}`, which is a positive input check. Its implementation and broader limits remain unaudited.
|
||||
- Public mempool.space and Blockstream endpoints were reachable.
|
||||
|
||||
### State-machine concerns
|
||||
|
||||
- OTS polling can overlap if an upgrade request lasts longer than the 60-second interval; no in-flight lock exists at [`startOtsPolling()`](../../www/index.html:1636).
|
||||
- Sign-out calls `localStorage.clear()` and `sessionStorage.clear()` at [`index.html`](../../www/index.html:1490), which can delete unrelated same-origin application data.
|
||||
- The OTS helper response is accepted as arbitrary base64 proof bytes and then parsed/verified; this is acceptable only if parser budgets are fixed.
|
||||
- The verify page can offer republishing after OTS verification, but does not require the connected signer identity to equal the original wrapper identity before requesting a signature.
|
||||
- The relay query accepts the first `EVENT` without checking subscription ID or filter conformance at [`verify.html`](../../www/verify.html:493); a malicious relay can send arbitrary events, relying on later validation that is presently incomplete.
|
||||
|
||||
---
|
||||
|
||||
## Dependency and supply-chain assessment
|
||||
|
||||
### Root graph
|
||||
|
||||
Installed direct versions were:
|
||||
|
||||
- `@noble/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
|
||||
- `esbuild` 0.28.1
|
||||
|
||||
All reported MIT licenses. `esbuild` has an install script/native platform package, which is normal but relevant to build trust. No root runtime advisory was reported by npm for the installed graph.
|
||||
|
||||
### Conclusions
|
||||
|
||||
- The committed browser bundle reduces runtime npm resolution risk for the deployed static page, and it rebuilt identically on the audit machine.
|
||||
- However, ignored/stale lock metadata means future builds are not reproducible.
|
||||
- The same-origin signer scripts are a major additional supply-chain surface not represented in [`package.json`](../../package.json:1).
|
||||
- The nested OpenTimestamps repository is a broken gitlink boundary without root submodule metadata.
|
||||
- No SBOM, signed release, SRI, or CI provenance connects the live assets to a reviewed revision.
|
||||
|
||||
---
|
||||
|
||||
## Test and execution evidence
|
||||
|
||||
### Environment
|
||||
|
||||
- Node: `v24.14.1`
|
||||
- npm: `11.11.0`
|
||||
- Root commit: `9a975fcbc56e455a68ae16d734feeed76ba304b6`
|
||||
- Nested OTS commit: `c07ba8be0dae4e8721a84f32820abf7b2547a6ce`
|
||||
|
||||
### Existing tests
|
||||
|
||||
Command: `npm test`
|
||||
|
||||
Result: **38 passed, 0 failed**, duration about 22.7 seconds.
|
||||
|
||||
Coverage included:
|
||||
|
||||
- BIP39 generation and validation
|
||||
- deterministic PQ key generation
|
||||
- four PQ sign/verify/tamper suites
|
||||
- basic NIP-01 ID handling
|
||||
- hex input checks
|
||||
- OTS fixture parsing
|
||||
- OTS target digest mismatch rejection
|
||||
- live block-explorer checks for historical Bitcoin block 358391
|
||||
|
||||
Important gaps:
|
||||
|
||||
- no fully valid kind 1 + wrapper construction/verification round trip,
|
||||
- no required-algorithm policy tests,
|
||||
- no malformed/fuzz tests,
|
||||
- no wrapper/embedded identity mismatch test,
|
||||
- no signer-return validation test,
|
||||
- no earliest-anchor candidate selection test,
|
||||
- no real browser/signer/relay end-to-end test,
|
||||
- no independent PQ derivation known-answer vectors,
|
||||
- no OTS calendar-fragment compatibility test in the committed suite.
|
||||
|
||||
### Build reproducibility
|
||||
|
||||
Commands:
|
||||
|
||||
```text
|
||||
cp www/pq-crypto.bundle.js /tmp/post_quantum_nostr.bundle.before.js
|
||||
node build-pq-bundle.js
|
||||
cmp /tmp/post_quantum_nostr.bundle.before.js www/pq-crypto.bundle.js
|
||||
```
|
||||
|
||||
Result: **byte-for-byte identical**. Both SHA-256 values were:
|
||||
|
||||
`c14d40088d02e89f0d9aa8f1643ce31d687a4837a2eb0ab1b19406b69ecbfc32`
|
||||
|
||||
### Live OTS calendar submission
|
||||
|
||||
A synthetic 32-byte digest was submitted through [`timestampEvent()`](../../www/js/pq-crypto.mjs:862). All five configured calendars returned fragments. The merged detached proof was 1,078 bytes, parsed with the exact submitted target digest, and contained five pending branches. This validates current network reachability and basic merge/parser compatibility, not future Bitcoin confirmation or trustlessness.
|
||||
|
||||
### Adversarial probes
|
||||
|
||||
The following were executed against the module without modifying source:
|
||||
|
||||
- empty PQ algorithm set accepted as valid,
|
||||
- signature algorithms with omitted signatures accepted as KEM/no-signature entries,
|
||||
- unknown omitted-signature algorithm accepted,
|
||||
- outer/embedded identity mismatch accepted by separate checks,
|
||||
- missing event ID accepted if signature was valid,
|
||||
- malformed base64 and wrong-shaped tags caused exceptions.
|
||||
|
||||
These probes are the basis for G56-01, G56-03, G56-07, and G56-19.
|
||||
|
||||
### Audited file hashes
|
||||
|
||||
| File | SHA-256 |
|
||||
|---|---|
|
||||
| [`package.json`](../../package.json) | `63882474ff056e46bfcf9194f9a0eb8d56662f0042b3692b35c8e1b8d2ce62e3` |
|
||||
| local ignored [`package-lock.json`](../../package-lock.json) | `60cfaa1dce7de67763a8fae3695cc173f8910a3de7ca53890ae9269f4ead00b9` |
|
||||
| [`index.html`](../../www/index.html) | `0ae7436b621526662f8315b892ddc921c3e00b6dcac7e08c2d5dba1c7d75717b` |
|
||||
| [`verify.html`](../../www/verify.html) | `70069d0f3998864a411ce4a52269de9212b2121888bf40c93e3e71edd58494a3` |
|
||||
| [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs) | `241cb3fb2d4ef3b7b05c7aa1757c67361a4e0a1c809c3dbafe3153d84e3bd7e5` |
|
||||
| [`pq-crypto.bundle.js`](../../www/pq-crypto.bundle.js) | `c14d40088d02e89f0d9aa8f1643ce31d687a4837a2eb0ab1b19406b69ecbfc32` |
|
||||
| [`pq-crypto.test.mjs`](../../test/pq-crypto.test.mjs) | `107f9d04e4dd77e4f37ab203ace804c64bfa4d84e8334101586d322bdd311351` |
|
||||
|
||||
---
|
||||
|
||||
## Prioritized remediation roadmap
|
||||
|
||||
### Release blocker: protocol validity
|
||||
|
||||
1. Fix G56-01 with a strict schema and mandatory algorithm policy.
|
||||
2. Fix G56-03 by binding all identities and requested authors.
|
||||
3. Implement G56-02 with immutable candidate discovery and earliest-valid-anchor selection.
|
||||
4. Validate signer output and exact event fields (G56-05).
|
||||
5. Make every verifier fail closed under malformed input and add budgets (G56-07/G56-13).
|
||||
|
||||
### Release blocker: security model
|
||||
|
||||
6. Decide and formally specify mnemonic-existing-identity versus raw-nsec migration modes (G56-06).
|
||||
7. Replace API-trusting “Bitcoin cryptographic verification” claims or implement a real node/light-client trust model (G56-04).
|
||||
8. Move seed/PQ private-key operations into a signer boundary; until then, label the web origin risk prominently (G56-08/G56-10).
|
||||
|
||||
### Operational hardening
|
||||
|
||||
9. Require relay acceptance for both events and verify read-back (G56-11).
|
||||
10. Persist immutable complete workflow artifacts with exact bindings (G56-12).
|
||||
11. Default to 24 words and restore warning/confirmation UX (G56-09).
|
||||
12. Enforce WSS and privacy-aware endpoint validation (G56-18).
|
||||
13. Add deployment headers, strict CSP, asset pinning, reviewed `/ots-upgrade` source, and release integrity.
|
||||
|
||||
### Reproducibility and assurance
|
||||
|
||||
14. Commit a correct lockfile and repair the OpenTimestamps submodule (G56-14/G56-15).
|
||||
15. Add independent derivation vectors, adversarial tests, browser integration tests, relay tests, and fuzzing.
|
||||
16. Reconcile all documentation and UI claims with actual behavior (G56-16).
|
||||
17. Obtain independent cryptographic/protocol review before users anchor irreversible identity claims.
|
||||
|
||||
---
|
||||
|
||||
## Safe-use recommendation
|
||||
|
||||
At its current stage, the project is suitable for:
|
||||
|
||||
- local experimentation with throwaway identities,
|
||||
- generating test vectors,
|
||||
- researching Nostr/PQ migration formats,
|
||||
- exercising OpenTimestamps parsing and API-assisted checks.
|
||||
|
||||
It is **not** suitable for:
|
||||
|
||||
- entering an existing valuable BIP39 mnemonic,
|
||||
- making irreversible migration claims for a real identity,
|
||||
- relying on the verifier’s green/success result,
|
||||
- asserting that an identity is now quantum-resistant,
|
||||
- treating the OTS result as independently validated Bitcoin consensus,
|
||||
- relying on relay discovery to recover the canonical earliest migration.
|
||||
|
||||
A prudent user should wait until the critical/high findings are fixed, the architectural invariants above are implemented, a new adversarial test suite passes, deployment/source provenance is reproducible, and an independent human cryptography/protocol audit validates the revised design.
|
||||
@@ -0,0 +1,539 @@
|
||||
# GPT-5.6 Audit Mitigation and Redesign Plan
|
||||
|
||||
**Companion to:** [`audit.md`](audit.md:1)
|
||||
**Purpose:** turn the current research prototype into a defensible post-quantum key pre-commitment/link system and define the additional work required for a complete Nostr migration.
|
||||
|
||||
## 1. Target outcome
|
||||
|
||||
The immediate target should **not** be marketed as “Nostr is now quantum-resistant.” The achievable first production milestone is:
|
||||
|
||||
> A client can prove that, before a verified Bitcoin block, an existing Nostr identity authorized and demonstrated possession of a canonical set of PQ keys; all clients deterministically resolve competing claims to the same earliest valid authorization; future PQ protocols can use that key set as their migration root.
|
||||
|
||||
A complete migration is a later milestone requiring standardized PQ-signed events, PQ encryption, identity mapping, revocation, rotation, recovery, and client adoption.
|
||||
|
||||
## 2. Non-negotiable security invariants
|
||||
|
||||
Implement these before addressing UI polish. Every design and test should trace back to at least one invariant.
|
||||
|
||||
| Invariant | Required property |
|
||||
|---|---|
|
||||
| I-01 Canonical authorization | All authenticators sign one versioned, domain-separated deterministic byte representation—not free-form prose or partially overlapping objects. |
|
||||
| I-02 Strict policy | Missing, duplicated, malformed, downgraded, or unknown evidence cannot accidentally count as successful evidence. |
|
||||
| I-03 Identity binding | Query author, wrapper author, embedded author, and old-identity field are equal. |
|
||||
| I-04 Immutable history | An attacker cannot hide the initial candidate by publishing a newer replaceable event. |
|
||||
| I-05 Earliest-anchor resolution | Every conforming client chooses the same earliest fully valid Bitcoin anchor under defined tie-break rules. |
|
||||
| I-06 PQ possession | Each PQ signature key proves possession; KEM ownership is either proven by a specified mechanism or described only as authorization. |
|
||||
| I-07 Post-break authority | After migration, secp-only claims cannot replace or revoke the canonical PQ root. |
|
||||
| I-08 Availability | The canonical authorization and proof can be exported, mirrored, and verified without the original website or one relay. |
|
||||
| I-09 Trust transparency | “Bitcoin verified,” “API checked,” and “structurally present” are separate states. |
|
||||
| I-10 Secure key boundary | Production seed derivation and PQ signing occur in a dedicated signer/hardware boundary where feasible. |
|
||||
|
||||
## 3. Recommended protocol redesign
|
||||
|
||||
This redesign should precede patching the existing two-event format, because several critical flaws arise from format semantics.
|
||||
|
||||
### 3.1 Define a canonical authorization object
|
||||
|
||||
Add a protocol module—for example, a new `www/js/nip-qr-schema.mjs` file—rather than embedding schema logic in [`pq-crypto.mjs`](../../www/js/pq-crypto.mjs:1).
|
||||
|
||||
Use deterministic CBOR or an explicitly specified canonical JSON encoding. A conceptual structure is:
|
||||
|
||||
```text
|
||||
{
|
||||
protocol: "nostr-pq-link",
|
||||
version: 1,
|
||||
network: "nostr-main",
|
||||
old_pubkey: <32 bytes>,
|
||||
successor_pubkey: <optional 32-byte seed-derived Schnorr key>,
|
||||
created_at: <integer>,
|
||||
nonce: <32 random bytes>,
|
||||
keys: [
|
||||
{ algorithm: "ml-dsa-44", public_key: <bytes>, role: "sign" },
|
||||
{ algorithm: "ml-dsa-65", public_key: <bytes>, role: "sign" },
|
||||
{ algorithm: "slh-dsa-sha2-128s", public_key: <bytes>, role: "sign" },
|
||||
{ algorithm: "falcon-512", public_key: <bytes>, role: "sign" },
|
||||
{ algorithm: "ml-kem-768", public_key: <bytes>, role: "encrypt" }
|
||||
],
|
||||
policy: {
|
||||
required_signature_algorithms: [...],
|
||||
rotation_threshold: <defined rule>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Normative signing input:
|
||||
|
||||
```text
|
||||
UTF8("NOSTR-PQ-LINK\0") || protocol_version || canonical_encode(object)
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
- Sort key entries by registered algorithm ID.
|
||||
- Reject duplicate algorithms.
|
||||
- Pin exact public-key and signature lengths by version.
|
||||
- PQ signatures sign the complete object, including all sibling keys and policy.
|
||||
- The old secp identity signs a Nostr event committing to the exact canonical object digest.
|
||||
- If common seed origin/recoverability is claimed, a seed-derived successor key signs the same object.
|
||||
- Human-readable content is generated from the object and is explicitly non-normative.
|
||||
|
||||
### 3.2 Separate immutable authorization from mutable proof transport
|
||||
|
||||
Do not make the canonical authorization only a replaceable kind 11112 event.
|
||||
|
||||
Recommended structure:
|
||||
|
||||
1. **Immutable authorization event:** a regular, non-replaceable event containing or committing to the canonical authorization and all possession proofs.
|
||||
2. **OTS proof-carrier event:** may be parameterized/replaceable by authorization ID, but every version must reference the same immutable authorization event ID and target digest.
|
||||
3. **Archive package:** downloadable canonical object, signed authorization event, OTS proof, and verification metadata.
|
||||
|
||||
If kind allocation is not standardized, use an experimental range and clearly version it. Do not overload kind 1 as the sole normative migration record merely for feed visibility; a separate optional kind 1 announcement can reference the immutable authorization.
|
||||
|
||||
### 3.3 Define canonical conflict resolution
|
||||
|
||||
Specify the algorithm before coding it:
|
||||
|
||||
1. Gather all immutable authorization candidates for `old_pubkey` from all configured relays, local archives, and user-provided packages.
|
||||
2. Validate schema, old secp signature, identity equality, PQ proofs, algorithm policy, and OTS target binding.
|
||||
3. Require a Bitcoin attestation with configured confirmation depth.
|
||||
4. Sort valid candidates by:
|
||||
- lowest verified Bitcoin block height,
|
||||
- then lowest authorization digest lexicographically for deterministic same-block ties.
|
||||
5. The first candidate becomes the genesis PQ root.
|
||||
6. Later rotation is valid only if authorized under the canonical root’s PQ rotation policy.
|
||||
7. A later secp-only authorization never supersedes the genesis root after the defined transition policy activates.
|
||||
|
||||
Document the limitation that a previously unknown earlier proof can change resolution. Mitigate this with archive discovery, checkpointing, and proof gossip. Consider defining a stabilization period before treating a candidate as final.
|
||||
|
||||
### 3.4 Define PQ rotation and future event authentication
|
||||
|
||||
A linked key with no use protocol is only a commitment. Create separate specifications for:
|
||||
|
||||
- PQ authorization envelopes for future Nostr events;
|
||||
- replay/domain separation and binding to the NIP-01 event ID;
|
||||
- key rotation and revocation thresholds;
|
||||
- algorithm deprecation/emergency removal;
|
||||
- recovery after seed loss;
|
||||
- PQ encryption and KEM key rotation;
|
||||
- how clients map the old npub to a successor key without splitting the social graph.
|
||||
|
||||
A practical compatibility design is a normal NIP-01 event plus a standardized tag or companion event containing a PQ signature over a domain-separated NIP-01 event ID. Relays can remain unaware initially; PQ-aware clients enforce the companion proof. This needs independent protocol review before adoption.
|
||||
|
||||
## 4. Staged remediation plan
|
||||
|
||||
## Phase 0 — Freeze unsafe production claims
|
||||
|
||||
**Goal:** prevent real-user harm while redesign proceeds.
|
||||
|
||||
1. Add a prominent research-prototype warning to [`index.html`](../../www/index.html:320) and [`verify.html`](../../www/verify.html:254).
|
||||
2. Replace “Make your Nostr identity quantum-resistant” with “Create an experimental pre-quantum PQ key commitment.”
|
||||
3. Disable or gate real relay publication behind an explicit experimental acknowledgement.
|
||||
4. Warn users never to enter a valuable existing mnemonic into the web page.
|
||||
5. Restore the 12-word PQ-strength warning and default new generation to 24 words.
|
||||
6. Change every success state to distinguish:
|
||||
- event signatures valid,
|
||||
- PQ policy sufficient,
|
||||
- OTS pending,
|
||||
- OTS API-checked,
|
||||
- canonical earliest candidate selected.
|
||||
7. Mark [`README.md`](../../README.md:1), [`nip_proposal.md`](../../nip_proposal.md:1), and research documents with implementation-status banners.
|
||||
|
||||
**Release gate:** the public deployment cannot imply complete quantum resistance.
|
||||
|
||||
## Phase 1 — Replace the verifier with strict schema/policy validation
|
||||
|
||||
**Findings addressed:** G56-01, G56-03, G56-07, G56-17, G56-19, part of G56-20.
|
||||
|
||||
### Implementation instructions
|
||||
|
||||
1. Create a schema validator that checks before cryptography:
|
||||
- object type and exact required fields,
|
||||
- integer ranges,
|
||||
- exact kind values,
|
||||
- arrays and tag element types,
|
||||
- exact lowercase hex lengths,
|
||||
- strict canonical base64 with decoded byte limits,
|
||||
- maximum content/event/proof sizes,
|
||||
- exact tag cardinality,
|
||||
- no duplicate recognized tags/algorithms.
|
||||
2. Refactor [`verifyNostrEvent()`](../../www/js/pq-crypto.mjs:400) to require `id`, `sig`, `pubkey`, `created_at`, `kind`, `tags`, and `content` by default. Return `{ valid, errors }`; do not throw on untrusted malformed input.
|
||||
3. Replace the fail-open branch at [`verifyNIPQRContent()`](../../www/js/pq-crypto.mjs:683):
|
||||
- only `ml-kem-768` may omit a signature;
|
||||
- all signature algorithms require signatures;
|
||||
- unknown algorithms are `ignored` evidence, never successful evidence;
|
||||
- mandatory algorithms are evaluated after parsing all tags.
|
||||
4. Pass the full outer event and expected query author into the verifier. Require:
|
||||
- `outer.kind` is the expected wrapper kind,
|
||||
- `outer.pubkey === embedded.pubkey`,
|
||||
- `outer.pubkey === expectedAuthor` when supplied,
|
||||
- `e` and SHA-256 tags are unique and exact.
|
||||
5. Return a structured result:
|
||||
|
||||
```text
|
||||
{
|
||||
structurallyValid,
|
||||
secpValid,
|
||||
pqProofsValid,
|
||||
policySufficient,
|
||||
otsTargetBound,
|
||||
otsBitcoinStatus,
|
||||
canonicalSelectionStatus,
|
||||
validForMigration,
|
||||
errors,
|
||||
warnings
|
||||
}
|
||||
```
|
||||
|
||||
6. Make [`verify.html`](../../www/verify.html:530) display these separate states and never infer overall migration validity from `results.every(...)`.
|
||||
7. Validate the claimed block height against the verified OTS height or remove it from the normative authorization.
|
||||
|
||||
### Required tests
|
||||
|
||||
Add tests for:
|
||||
|
||||
- zero algorithm tags,
|
||||
- each missing mandatory algorithm,
|
||||
- missing signature on every signature algorithm,
|
||||
- arbitrary unknown no-signature algorithm,
|
||||
- duplicate algorithm tags,
|
||||
- duplicate `e`, SHA-256, and OTS tags in both orders,
|
||||
- wrong decoded key/signature lengths,
|
||||
- malformed base64/hex,
|
||||
- null/wrong-shaped objects,
|
||||
- missing event ID,
|
||||
- wrong wrapper kind,
|
||||
- wrapper/embedded/query-author mismatch,
|
||||
- oversized event and proof,
|
||||
- valid optional unknown algorithm ignored without policy credit,
|
||||
- exact valid fixture passing all layers.
|
||||
|
||||
**Acceptance criterion:** malformed input never throws, and no event without the full configured PQ policy can be labeled migration-valid.
|
||||
|
||||
## Phase 2 — Implement immutable candidate discovery and earliest-anchor selection
|
||||
|
||||
**Findings addressed:** G56-02, G56-12, G56-16.
|
||||
|
||||
### Implementation instructions
|
||||
|
||||
1. Replace `limit: 1` queries at [`verify.html`](../../www/verify.html:487) and [`index.html`](../../www/index.html:1201) with candidate collection until EOSE from each relay.
|
||||
2. Do not select by `created_at`.
|
||||
3. Verify every candidate independently and retain relay provenance.
|
||||
4. Introduce a pure function such as:
|
||||
|
||||
```text
|
||||
selectCanonicalAuthorization(candidates, policy)
|
||||
```
|
||||
|
||||
It must sort only fully valid confirmed candidates by verified block height and deterministic digest tie-break.
|
||||
5. Store immutable complete artifacts locally:
|
||||
- old identity,
|
||||
- authorization event JSON/bytes,
|
||||
- canonical authorization digest,
|
||||
- OTS target digest,
|
||||
- pending/confirmed proof,
|
||||
- proof-carrier event(s),
|
||||
- relay receipts.
|
||||
6. Replace the overloaded `eventId` metadata in [`savePendingOts()`](../../www/js/pq-crypto.mjs:1362) with explicit `authorizationEventId`, `wrapperEventId`, and `targetDigest`.
|
||||
7. On resume, require every persisted/fetched binding to match before signing or publishing.
|
||||
8. Add export/import of a self-contained archive package.
|
||||
|
||||
### Required tests
|
||||
|
||||
- later secp-valid forged candidate loses to earlier valid anchor,
|
||||
- earlier invalid PQ candidate is rejected,
|
||||
- earlier proof with wrong target is rejected,
|
||||
- same-block tie resolves identically regardless of relay order,
|
||||
- malicious relay sends events outside subscription/filter,
|
||||
- one relay hides the earlier candidate but another supplies it,
|
||||
- resume rejects a different event ID/digest/owner,
|
||||
- upgraded proof preserves immutable authorization target.
|
||||
|
||||
**Acceptance criterion:** candidate input ordering and relay response ordering cannot change the selected canonical root.
|
||||
|
||||
## Phase 3 — Validate signer output and bind seed continuity
|
||||
|
||||
**Findings addressed:** G56-05, G56-06, G56-09, G56-10.
|
||||
|
||||
### Implementation instructions
|
||||
|
||||
1. Add a single signer-validation helper used after every [`window.nostr.signEvent()`](../../www/index.html:1078) call:
|
||||
- verify returned object has exact fields,
|
||||
- verify no approved template field changed,
|
||||
- verify returned pubkey equals authenticated identity,
|
||||
- recompute ID,
|
||||
- verify Schnorr signature,
|
||||
- reject extra/missing semantic fields.
|
||||
2. Never reconstruct an event from a mixture of signer-returned and local fields. Validate and retain one exact signed object.
|
||||
3. Split onboarding into explicit modes:
|
||||
- **Existing mnemonic identity:** entered seed must derive the connected NIP-06 pubkey; otherwise stop.
|
||||
- **Raw-nsec identity:** generate a new 24-word seed and require a successor proof over the canonical authorization.
|
||||
4. Run a recovery self-test before publication: discard derived objects, rederive from mnemonic, and compare all public keys/deterministic identifiers.
|
||||
5. Prefer moving derivation/signing into a dedicated signer API. Define capabilities such as:
|
||||
- `pq_get_public_keys(protocolVersion, account)`,
|
||||
- `pq_sign_authorization(protocolVersion, digest, algorithms)`,
|
||||
- `pq_rotate_keys(...)`.
|
||||
6. Until signer support exists:
|
||||
- scope secrets locally rather than globals,
|
||||
- do not derive the unused secp key except for continuity/proof,
|
||||
- overwrite secret typed arrays where possible,
|
||||
- clear seed DOM/input immediately after confirmation,
|
||||
- avoid clipboard as the primary backup path,
|
||||
- warn about extensions/devtools/clipboard managers.
|
||||
|
||||
### Required tests
|
||||
|
||||
- signer changes content/tag/timestamp/pubkey,
|
||||
- signer returns wrong ID,
|
||||
- signer returns invalid signature,
|
||||
- connected pubkey changes between requests,
|
||||
- existing mnemonic derives wrong identity,
|
||||
- raw-nsec successor proof missing/invalid,
|
||||
- recovery rederivation mismatch,
|
||||
- cancellation at each signer prompt leaves no publishable partial state.
|
||||
|
||||
**Acceptance criterion:** nothing is persisted or published unless the exact signer-returned object is locally valid and bound to the intended identity/template.
|
||||
|
||||
## Phase 4 — Make OTS and Bitcoin verification trust-explicit and robust
|
||||
|
||||
**Findings addressed:** G56-04, G56-13, G56-17, part of G56-08.
|
||||
|
||||
### Parser hardening
|
||||
|
||||
In [`OtsReader`](../../www/js/pq-crypto.mjs:1014) and [`parseOtsFile()`](../../www/js/pq-crypto.mjs:1078):
|
||||
|
||||
1. Set maximum detached-proof size, for example 1 MiB unless evidence supports another bound.
|
||||
2. Track and cap:
|
||||
- recursion depth,
|
||||
- operation count,
|
||||
- branches,
|
||||
- cumulative append/prepend bytes,
|
||||
- cumulative hash input bytes,
|
||||
- attestations.
|
||||
3. Replace 32-bit bitwise varuint parsing at [`readVaruint()`](../../www/js/pq-crypto.mjs:1032) with safe arithmetic/`BigInt` plus maximum value and canonical-encoding checks.
|
||||
4. Require complete stream consumption.
|
||||
5. Reject unsupported operation/hash types explicitly.
|
||||
6. Fuzz with random, truncated, deeply branched, and overlong inputs.
|
||||
|
||||
### Bitcoin trust modes
|
||||
|
||||
Expose one of these explicit modes:
|
||||
|
||||
- `local-node-verified`: query a user-controlled Bitcoin node and validate block data;
|
||||
- `header-chain-verified`: validate raw headers, PoW, linkage, difficulty, chainwork, and checkpoints;
|
||||
- `multi-explorer-checked`: compare independent APIs and reject disagreement;
|
||||
- `single-explorer-checked`: current behavior, clearly labeled trusted API;
|
||||
- `structural-only`: no Bitcoin validity claim.
|
||||
|
||||
Do not use “cryptographically verified on Bitcoin” for explorer-only modes. Modify [`verifyOtsProof()`](../../www/js/pq-crypto.mjs:1284) to return the trust mode and evidence source.
|
||||
|
||||
### Upgrade helper
|
||||
|
||||
1. Add the `/ots-upgrade` server source and deployment config to the repository.
|
||||
2. Enforce body size, content type, timeouts, process isolation, rate limits, no arbitrary URI access, and sanitized output.
|
||||
3. The browser must still bind and validate returned proofs; never trust the helper’s `confirmed` boolean alone.
|
||||
4. Add server unit/integration tests and deployment documentation.
|
||||
|
||||
**Acceptance criterion:** the UI accurately states the Bitcoin trust model, malicious proofs stay within resource budgets, and a helper/API compromise cannot validate a proof for the wrong digest.
|
||||
|
||||
## Phase 5 — Fix publishing, relay, and recovery operations
|
||||
|
||||
**Findings addressed:** G56-11, G56-12, G56-18, G56-20.
|
||||
|
||||
### Implementation instructions
|
||||
|
||||
1. Validate relay URLs with `URL`; allow only `wss:` in production and `ws://localhost` under explicit development mode.
|
||||
2. Cap relay count, URL length, event size, and concurrent sockets.
|
||||
3. Require relay `OK` acceptance for both immutable authorization and proof carrier.
|
||||
4. Set a publication policy, for example:
|
||||
- at least two independent relays accept the authorization,
|
||||
- at least two accept the proof carrier,
|
||||
- at least one read-back query returns each exact event.
|
||||
5. Keep retry controls after partial failure; never disable publication as “complete” with zero acceptance.
|
||||
6. Bind subscription IDs and verify returned events match requested authors/kinds.
|
||||
7. Test representative 30–50 KiB events against target relays and document limits.
|
||||
8. Export the archive package before/after publish and encourage independent storage.
|
||||
9. Add multi-tab locking using `BroadcastChannel` or Web Locks so two tabs cannot race upgrades/publication.
|
||||
10. Prevent overlapping OTS polls with an in-flight guard or recursive timeout.
|
||||
11. Replace origin-wide `localStorage.clear()`/`sessionStorage.clear()` with deletion of application-owned keys only.
|
||||
|
||||
**Acceptance criterion:** the app cannot report completion without durable, read-back-confirmed publication and a user-exportable verification package.
|
||||
|
||||
## Phase 6 — Harden the browser deployment and supply chain
|
||||
|
||||
**Findings addressed:** G56-08, G56-10, G56-14, G56-15.
|
||||
|
||||
### Application/deployment instructions
|
||||
|
||||
1. Move inline JavaScript from [`index.html`](../../www/index.html:580) and [`verify.html`](../../www/verify.html:353) into reviewed module files.
|
||||
2. Enforce a CSP without script `'unsafe-inline'`; use hashes/nonces only where unavoidable.
|
||||
3. Add:
|
||||
- `frame-ancestors 'none'`,
|
||||
- HSTS with an appropriate rollout,
|
||||
- `X-Content-Type-Options: nosniff`,
|
||||
- strict Referrer-Policy,
|
||||
- restrictive Permissions-Policy,
|
||||
- appropriate COOP/CORP where compatible.
|
||||
4. Vendor and audit nostr-login-lite assets or pin reviewed immutable files with release hashes/SRI.
|
||||
5. Remove unnecessary permissive CORS headers from static pages/helper responses.
|
||||
6. Stop exposing avoidable server version information.
|
||||
7. Add deployment configuration and a security-header integration test.
|
||||
|
||||
### Dependency instructions
|
||||
|
||||
1. Remove [`package-lock.json`](../../package-lock.json) from [`.gitignore`](../../.gitignore:10), regenerate it so root version matches [`package.json`](../../package.json:3), and commit it.
|
||||
2. Use `npm ci` in CI/releases.
|
||||
3. Pin security-sensitive dependencies and review upgrades deliberately.
|
||||
4. Generate an SBOM and signed provenance with bundle SHA-256.
|
||||
5. Repair `.gitmodules` for [`resources/javascript-opentimestamps/`](../../resources/javascript-opentimestamps/) or replace the broken gitlink with a documented immutable test-vector snapshot.
|
||||
6. Run tests and advisories in CI; do not claim the old OTS package is integrated when it is only a fixture source.
|
||||
|
||||
**Acceptance criterion:** a clean clone can reproduce the bundle and tests from tracked metadata, and the deployed asset hashes can be tied to a source revision.
|
||||
|
||||
## Phase 7 — Documentation and protocol standardization
|
||||
|
||||
**Findings addressed:** G56-16 and conceptual completeness.
|
||||
|
||||
1. Rewrite [`nip_proposal.md`](../../nip_proposal.md:1) around the canonical authorization, immutable history, exact verification state machine, trust modes, and rotation semantics.
|
||||
2. Clearly separate:
|
||||
- implemented,
|
||||
- experimental,
|
||||
- design-only,
|
||||
- required companion standards.
|
||||
3. Replace “identity is quantum-resistant” with precise claims about linked keys and anchor status.
|
||||
4. Do not claim pending calendar proofs establish an external time.
|
||||
5. Publish test vectors:
|
||||
- canonical object bytes/digest,
|
||||
- BIP39/BIP32 child seeds,
|
||||
- PQ public keys/signatures,
|
||||
- valid Nostr authorization event,
|
||||
- pending and confirmed OTS proof,
|
||||
- candidate conflict-resolution examples.
|
||||
6. Obtain at least two independent implementations for canonical encoding and verification before standardization.
|
||||
7. Seek independent review from Nostr protocol experts, Bitcoin/OTS experts, and PQ cryptographers.
|
||||
|
||||
**Acceptance criterion:** two independent implementations produce identical vectors and canonical candidate selection.
|
||||
|
||||
## 5. Finding-to-work mapping
|
||||
|
||||
| Finding | Primary mitigation phase | Release-blocking? |
|
||||
|---|---|---|
|
||||
| G56-01 | Phase 1 | Yes |
|
||||
| G56-02 | Phase 2 / protocol redesign | Yes |
|
||||
| G56-03 | Phase 1 | Yes |
|
||||
| G56-04 | Phase 4 | Yes for strong Bitcoin claims |
|
||||
| G56-05 | Phase 3 | Yes |
|
||||
| G56-06 | Protocol redesign / Phase 3 | Yes |
|
||||
| G56-07 | Phase 1 | Yes |
|
||||
| G56-08 | Phase 0 and Phase 6 | Yes for real seeds |
|
||||
| G56-09 | Phase 0 / Phase 3 | Yes for PQ-strength claim |
|
||||
| G56-10 | Phase 3 / Phase 6 | Yes for production web custody |
|
||||
| G56-11 | Phase 5 | Yes |
|
||||
| G56-12 | Phase 2 / Phase 5 | Yes |
|
||||
| G56-13 | Phase 4 | Yes for untrusted proofs |
|
||||
| G56-14 | Phase 6 | Yes for reproducible release |
|
||||
| G56-15 | Phase 6 | Required for clean clone/tests |
|
||||
| G56-16 | Phase 0 / Phase 7 | Yes for public claims |
|
||||
| G56-17 | Phase 1 / Phase 4 | No if removed from claims |
|
||||
| G56-18 | Phase 5 | No for local research; yes for production |
|
||||
| G56-19 | Phase 1 | Yes |
|
||||
| G56-20 | Phase 5 / protocol redesign | Yes if target relays reject events |
|
||||
|
||||
## 6. Test strategy
|
||||
|
||||
### Unit tests
|
||||
|
||||
- canonical encoding and rejection of non-canonical forms,
|
||||
- strict event/schema validation,
|
||||
- exact algorithm policy,
|
||||
- signer-return validation,
|
||||
- candidate sorting/tie-breaks,
|
||||
- persistence binding,
|
||||
- OTS parser limits and varints,
|
||||
- trust-mode labeling.
|
||||
|
||||
### Property and fuzz tests
|
||||
|
||||
- arbitrary JSON/tag shapes never throw,
|
||||
- arbitrary OTS bytes stay within CPU/memory/time budgets,
|
||||
- candidate ordering does not alter selection,
|
||||
- encode/decode round trips preserve canonical bytes,
|
||||
- mutation of any signed canonical field invalidates all applicable proofs.
|
||||
|
||||
### Integration tests
|
||||
|
||||
- mock signer approves/cancels/mutates events,
|
||||
- mock relays reorder, hide, duplicate, reject, and inject events,
|
||||
- mock calendars partially fail and return malformed fragments,
|
||||
- mock upgrade helper returns wrong-target or oversized proofs,
|
||||
- explorer disagreement and outage behavior,
|
||||
- resume after every workflow checkpoint,
|
||||
- multiple browser tabs.
|
||||
|
||||
### End-to-end tests
|
||||
|
||||
Use throwaway identities against controlled relays:
|
||||
|
||||
1. create 24-word seed,
|
||||
2. derive and recover keys,
|
||||
3. sign canonical authorization,
|
||||
4. publish and read back,
|
||||
5. timestamp and upgrade,
|
||||
6. verify through each trust mode,
|
||||
7. inject a later forged candidate,
|
||||
8. confirm earliest valid anchor remains canonical,
|
||||
9. rotate using PQ authorization,
|
||||
10. verify archive import with the website offline.
|
||||
|
||||
### Independent vectors
|
||||
|
||||
At least one second implementation in another language must reproduce the canonical encoding, derivation, signatures where deterministic, event IDs, and selection result. Self-comparison is insufficient.
|
||||
|
||||
## 7. Release gates
|
||||
|
||||
### Gate A — Research demo
|
||||
|
||||
- Phase 0 warnings complete.
|
||||
- No real-security claims.
|
||||
- Existing and adversarial tests pass.
|
||||
- Throwaway identities only.
|
||||
|
||||
### Gate B — Experimental key commitment
|
||||
|
||||
- Protocol redesign, Phases 1–5 complete.
|
||||
- Strict verification and earliest-anchor selection independently tested.
|
||||
- Archive export/import works.
|
||||
- Explorer trust is labeled accurately.
|
||||
- No valuable mnemonic entry into the web origin.
|
||||
|
||||
### Gate C — Production pre-commitment registry
|
||||
|
||||
- Dedicated signer/hardware integration.
|
||||
- Phase 6 reproducibility/deployment hardening.
|
||||
- Independent implementation and security audit.
|
||||
- Kind/format coordination with Nostr community.
|
||||
- Durable relay/archive strategy.
|
||||
|
||||
### Gate D — Complete PQ migration
|
||||
|
||||
All Gate C requirements plus standardized and deployed:
|
||||
|
||||
- PQ authentication for future events,
|
||||
- PQ key rotation/revocation/recovery,
|
||||
- client identity mapping/social-graph behavior,
|
||||
- PQ encryption protocol,
|
||||
- algorithm deprecation governance,
|
||||
- broad client adoption.
|
||||
|
||||
Only at Gate D should the project claim to make ongoing Nostr identity use post-quantum secure.
|
||||
|
||||
## 8. Conceptual conclusion
|
||||
|
||||
The project is **not doomed by its premise**. Pre-quantum authorization plus independently ordered timestamping is a reasonable bootstrap for migration away from a signature algorithm that may later become forgeable. Multi-algorithm commitments can provide useful hedging.
|
||||
|
||||
The original implementation does, however, conflate three different achievements:
|
||||
|
||||
1. generating PQ keys,
|
||||
2. historically linking those keys to an old identity,
|
||||
3. making the identity’s future Nostr activity post-quantum secure.
|
||||
|
||||
Only the first is currently strong; the second is incomplete; the third requires companion protocols. Implementing this document would make the second defensible and create a valid foundation for the third. It would not eliminate the need to standardize and deploy future PQ event, rotation, encryption, availability, and client identity semantics.
|
||||
@@ -0,0 +1,159 @@
|
||||
# GPT-5.6 Audit Remediation Progress
|
||||
|
||||
**Audit:** [`audits/GPT5.6/audit.md`](audit.md:1)
|
||||
**Mitigation plan:** [`audits/GPT5.6/audit_mitigation.md`](audit_mitigation.md:1)
|
||||
**Last updated:** 2026-07-24
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Total | Fixed | In Progress | Not Started |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Critical | 2 | 2 | 0 | 0 |
|
||||
| High | 6 | 6 | 0 | 0 |
|
||||
| Medium | 8 | 7 | 0 | 0 |
|
||||
| Low / Info | 4 | 3 | 0 | 0 |
|
||||
| **Total** | **20** | **18** | **0** | **0** |
|
||||
|
||||
## Findings status
|
||||
|
||||
| ID | Severity | Finding | Status | Version | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| 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.14–v0.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 | ✅ 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 | ✅ 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 | ✅ 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.14–v0.0.18 | Kind 9999 docs (v0.0.14–v0.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 | ✅ 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
|
||||
|
||||
### v0.0.12 — G56-01 fix
|
||||
- Rewrote `verifyNIPQRContent()` with strict mandatory algorithm policy
|
||||
- Missing signatures on signature algorithms now fail (not treated as KEM successes)
|
||||
- Unknown algorithms reported as ignored, not valid evidence
|
||||
- Duplicate algorithm detection
|
||||
- Public key length validation
|
||||
- All base64/verification wrapped in try/catch (fail closed)
|
||||
- Added `pqProofsValid`, `policySufficient`, `validForMigration`, `errors` fields to result
|
||||
- Updated `verify.html` to display "PQ algorithm policy" result line
|
||||
- Added 11 adversarial tests
|
||||
|
||||
### v0.0.13 — Checkbox UI fix
|
||||
- Fixed checkbox fill (background red when checked, not just border)
|
||||
|
||||
### v0.0.14 — G56-02 + G56-03 fix
|
||||
- Changed `NIP_QR_KIND` from 11112 (replaceable) to 9999 (non-replaceable)
|
||||
- Added `buildProofCarrier()` with `ots_status` and `upgrade_of` tags
|
||||
- `buildUpgradedEvent()` now creates append-only events (not replacements)
|
||||
- Added `selectCanonicalProofCarrier()` for earliest-valid-anchor selection
|
||||
- Removed `limit: 1` from relay queries in verify.html and index.html
|
||||
- Added identity binding (`outerPubkey`, `expectedAuthor`) to `verifyNIPQRContent()`
|
||||
- Added `identityBound` field to verification result
|
||||
- Updated resume workflow to fetch all candidates
|
||||
- Added 12 new tests (8 for G56-02, 4 for G56-03)
|
||||
|
||||
### v0.0.15 — Documentation update
|
||||
- Updated nip_proposal.md and README.md for kind 9999
|
||||
|
||||
### v0.0.16 — Eliminate old references
|
||||
- Eliminated all remaining "kind 11112" references from user-facing files and docs
|
||||
- Updated explanation.md, laans_explanation.md, why_what_how.md
|
||||
- Cleaned up button IDs and import statements in index.html
|
||||
|
||||
### v0.0.17 — G56-09 fix
|
||||
- `generateSeedPhrase()` and `generateSeedPhraseWithEntropy()` now default to 24 words (256-bit entropy)
|
||||
- Both accept an `entropyBits` argument (128 or 256) and validate it
|
||||
- Added 12/24-word radio selector to the seed-generation panel in `index.html`; toggling regenerates the seed
|
||||
- Restored entropy-strength warning (12 words = ~64-bit under Grover, testing only; 24 words recommended)
|
||||
- Removed inaccurate claims: "Make your Nostr identity quantum-resistant" → "Create an experimental pre-quantum PQ key commitment"; "quantum-safe root of trust" → "recovery root"; "quantum-safe seed phrase" → "seed phrase"
|
||||
- Added research-prototype warning to the unauthenticated landing card
|
||||
- Updated README.md entropy table and nip_proposal.md mermaid diagram
|
||||
- Added 4 new tests (24-word default, 12-word option, invalid entropyBits rejection for both functions)
|
||||
|
||||
### v0.0.18 — G56-16 fix (status reconciliation)
|
||||
- README.md: rewrote OTS verification status section to distinguish API-assisted verification (current, trusts explorer) from full light-client verification (not implemented); added research-prototype banner
|
||||
- README.md: removed stale "not implemented" items (OTS target-digest binding, test suite, 24-word default); added accurate not-implemented items (light-client verification, multi-explorer cross-check, PQ event auth/rotation, independent implementation)
|
||||
- nip_proposal.md: softened "No private key ever touches a server" → NIP-07/remote-signer caveat + API-assisted OTS trust model; pending-proof "establishes submission time" → "evidence of submission, not an independent timestamp"; fixed "republished" → "new proof carrier published with upgrade_of tag"
|
||||
- verify.html: added research-prototype note; rewrote card description to list each verification check as a separate result line (PQ signatures individually, policy sufficiency, identity binding)
|
||||
- Added research-prototype / design-only banners to explanation.md, laans_explanation.md, why_what_how.md, and all 5 research/ docs
|
||||
- No new tests (documentation-only change)
|
||||
|
||||
### v0.0.19 — G56-07 fix (fuzz testing + fail-closed verifiers)
|
||||
- `verifyNostrEvent()`: added structural validation (type-checks all required fields) + try/catch; returns `false` on any malformed input instead of throwing
|
||||
- `parseOtsFile()`: wrapped parser body in try/catch; returns `null` on any malformed/truncated input
|
||||
- Added `MAX_OTS_PROOF_SIZE` (1 MiB) and `MAX_EVENT_JSON_SIZE` (64 KiB) size guards; oversized inputs rejected before any parsing/decoding work
|
||||
- `verifyNIPQRContent()`: added content-size guard at entry
|
||||
- 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
|
||||
- **After G56-01:** 49 tests
|
||||
- **After G56-02+03:** 61 tests
|
||||
- **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:** ✅
|
||||
@@ -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>.
|
||||
+10
-5
@@ -1,5 +1,10 @@
|
||||
# How the NIP-QR Event Is Constructed: Step by Step
|
||||
|
||||
> **⚠️ Research prototype.** This document describes an experimental pre-quantum key-commitment
|
||||
> system. It links PQ keys to an existing Nostr identity and anchors that link in Bitcoin; it does
|
||||
> not, by itself, make the identity post-quantum secure. Complete migration requires companion
|
||||
> protocols not yet specified or implemented.
|
||||
|
||||
## The Misconception
|
||||
|
||||
The seed phrase is NOT used directly as the private key for PQ signing. The seed phrase is a source of entropy that is used to **derive** separate keys for each PQ algorithm via **BIP32 HD wallet derivation paths** — the same standard used by NIP-06 for secp256k1 keys.
|
||||
@@ -175,13 +180,13 @@ full_hash → OpenTimestamps calendar → pending .ots proof
|
||||
|
||||
This hash is what gets anchored to the Bitcoin blockchain.
|
||||
|
||||
## Step 11: Build and sign the kind 11112 wrapper event
|
||||
## Step 11: Build and sign the kind 9999 proof carrier event
|
||||
|
||||
A kind 11112 wrapper event is built that embeds the full kind 1 event as JSON content and carries the OTS proof:
|
||||
A kind 9999 proof carrier event is built that embeds the full kind 1 event as JSON content and carries the OTS proof:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 11112,
|
||||
"kind": 9999,
|
||||
"pubkey": "<Account #1 secp256k1 pubkey (hex)>",
|
||||
"created_at": 1720780000,
|
||||
"content": "<JSON string of the full signed kind 1 event>",
|
||||
@@ -197,7 +202,7 @@ This wrapper is sent to the user's signer (`window.nostr.signEvent()`), which si
|
||||
|
||||
## Step 12: Publish both events
|
||||
|
||||
Both the kind 1 announcement and the kind 11112 wrapper are published to Nostr relays. The client then polls the OpenTimestamps upgrade service for Bitcoin confirmation and republishes the kind 11112 wrapper with the confirmed OTS proof once the Bitcoin attestation is received.
|
||||
Both the kind 1 announcement and the kind 9999 proof carrier are published to Nostr relays. The client then polls the OpenTimestamps upgrade service for Bitcoin confirmation and republishes the kind 9999 proof carrier with the confirmed OTS proof once the Bitcoin attestation is received.
|
||||
|
||||
---
|
||||
|
||||
@@ -210,7 +215,7 @@ Both the kind 1 announcement and the kind 11112 wrapper are published to Nostr r
|
||||
| SLH-DSA-128s private key | The attestation text (content) | PQ signature (hash-based, Cat 1) | Conservative fallback if lattice schemes break |
|
||||
| Falcon-512 private key | The attestation text (content) | PQ signature (lattice, Cat 1) | Compact signatures, compatible with trbouma's PR |
|
||||
| Account #1 secp256k1 key | The kind 1 announcement event | Schnorr signature | Existing identity authorizes the migration |
|
||||
| Account #1 secp256k1 key | The kind 11112 wrapper event | Schnorr signature | Existing identity authorizes the wrapper + OTS proof |
|
||||
| Account #1 secp256k1 key | The kind 9999 proof carrier event | Schnorr signature | Existing identity authorizes the wrapper + OTS proof |
|
||||
| ML-KEM-768 | (nothing — KEM, not signature) | N/A | Ownership asserted by attestation text; authorized by Account #1's signature covering the tags |
|
||||
|
||||
## Summary: BIP32 Key Derivation Tree
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Laan's explanation
|
||||
|
||||
> **⚠️ Research prototype.** This is a short summary of an experimental pre-quantum key-commitment
|
||||
> system. It does not, by itself, make a Nostr identity post-quantum secure.
|
||||
|
||||
A short summary of how the post-quantum Nostr migration works, matching the implementation.
|
||||
|
||||
1. **Create a new seed phrase.** This is your post-quantum (PQ) Nostr backup. It's a 12-word BIP39 mnemonic — pure entropy, not tied to any algorithm.
|
||||
1. **Create a new seed phrase.** This is your post-quantum (PQ) Nostr backup. It's a BIP39 mnemonic (24 words by default; 12 words available for testing) — pure entropy, not tied to any algorithm.
|
||||
|
||||
2. **Derive 5 PQ keypairs from the seed.** Using BIP32 hierarchical deterministic derivation (the same standard as NIP-06), each PQ algorithm gets its own child key under the path `m/44'/1237'/0'/0/`:
|
||||
- child 1 → ML-DSA-44
|
||||
@@ -17,10 +20,10 @@ A short summary of how the post-quantum Nostr migration works, matching the impl
|
||||
|
||||
4. **Each PQ key signs the attestation.** Each PQ signature scheme (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512) signs the attestation text. ML-KEM-768 does NOT sign — it's a KEM (Key Encapsulation Mechanism), not a signature scheme. Its public key is included in a tag without a signature.
|
||||
|
||||
5. **Your existing identity signs the events.** The kind 1 announcement (with PQ signatures in its tags) and a kind 11112 wrapper (which embeds the kind 1 event as JSON and carries the OpenTimestamps proof) are both signed by your **existing** Nostr identity via `window.nostr.signEvent()`. This authorizes the migration.
|
||||
5. **Your existing identity signs the events.** The kind 1 announcement (with PQ signatures in its tags) and a kind 9999 proof carrier (which embeds the kind 1 event as JSON and carries the OpenTimestamps proof) are both signed by your **existing** Nostr identity via `window.nostr.signEvent()`. This authorizes the migration.
|
||||
|
||||
6. **Timestamp the kind 1 event.** The SHA-256 of the full signed kind 1 event JSON is submitted to OpenTimestamps, anchoring it to the Bitcoin blockchain. This proves the event existed at this point in time, pre-quantum.
|
||||
|
||||
7. **Publish both events to relays.** The kind 1 announcement and the kind 11112 wrapper are published. The client then polls for Bitcoin confirmation and republishes the kind 11112 wrapper with the confirmed OTS proof.
|
||||
7. **Publish both events to relays.** The kind 1 announcement and the kind 9999 proof carrier are published. The client then polls for Bitcoin confirmation and republishes the kind 9999 proof carrier with the confirmed OTS proof.
|
||||
|
||||
**Note:** The seed-derived secp256k1 key (at `m/44'/1237'/0'/0/0`) is derived but not used to sign. There is no "successor signature." The security model relies on OTS precedence — the real event is anchored pre-quantum, and a forged event cannot be backdated to before that anchor.
|
||||
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
# NIP-QR: Post-Quantum Key-Link Attestations
|
||||
|
||||
`draft` `optional`
|
||||
|
||||
This NIP defines a way for a Nostr user to **link** their existing secp256k1 identity to one or more post-quantum (PQ) public keys, **without** changing how events are signed, **without** requiring the network to agree on a single PQ algorithm, and **without** users abandoning their identity or social graph.
|
||||
|
||||
It is intentionally additive. It does not modify NIP-01's event format, signature scheme, or relay behavior. Legacy clients continue to work unchanged; PQ-aware clients gain the ability to verify a pre-quantum, Bitcoin-anchored link between an identity and a set of PQ keys.
|
||||
|
||||
## Motivation
|
||||
|
||||
### The threat
|
||||
|
||||
Nostr's identity, authentication, and encryption (NIP-04, NIP-44) all rest on secp256k1. Shor's algorithm breaks secp256k1 in polynomial time on a sufficiently large fault-tolerant quantum computer. An attacker who has recorded any Nostr event can recover the private key, forge signatures, impersonate the user, and decrypt every NIP-04/NIP-44 message ever sent to them. NIP-44 itself states: "No post-quantum security."
|
||||
|
||||
This includes a **collect-now-decrypt-later** threat: encrypted messages on public relays can be stored today and decrypted once a quantum computer exists. The symmetric ciphers are quantum-safe; the secp256k1 ECDH key agreement is not.
|
||||
|
||||
### Why Bitcoin's hash-the-pubkey trick does not help Nostr
|
||||
|
||||
Bitcoin has partial quantum resistance because addresses are hashes of pubkeys and pubkeys are only revealed at spend time. Nostr cannot do this: the pubkey *is* the identity, it must be revealed on every event for Schnorr verification, and it is reused across thousands of events over years. Hashing the pubkey would give zero seconds of quantum resistance — the pubkey is revealed the moment the first event is published.
|
||||
|
||||
The fix is post-quantum signatures and post-quantum key agreement, not hashing the pubkey. This NIP describes how to *link* PQ keys to an existing identity so that the link is established **now**, while secp256k1 can still be trusted, and is later verifiable even after secp256k1 is broken.
|
||||
|
||||
### Why a link, not a swap
|
||||
|
||||
Replacing secp256k1 event signatures with PQ signatures (as proposed elsewhere) requires the whole network to agree on an algorithm, change relay validation, and re-onboard every user. This NIP takes a different approach: the user publishes a **one-time attestation** that links their secp256k1 identity to a set of PQ keys. The attestation is signed by the existing identity (valid now) and by each PQ signature scheme (valid forever). It is anchored to the Bitcoin blockchain via OpenTimestamps (NIP-03) so it cannot be backdated.
|
||||
|
||||
After a quantum break, an attacker who forges the secp256k1 key can publish a *fraudulent* link event, but they cannot produce an OpenTimestamps proof anchoring it to a Bitcoin block earlier than the real one. The real link is cryptographically distinguishable by its earlier Bitcoin anchor.
|
||||
|
||||
### Why multiple schemes
|
||||
|
||||
The PQ standardization landscape is still young. SIKE was a NIST finalist broken in 2022 by a classical algorithm that ran in an hour on a laptop. Picking a single PQ algorithm now is risky. This NIP allows a user to link to **multiple** PQ schemes simultaneously. If one is later broken, the others remain valid. Clients verify whichever subset they support. No community-wide algorithm consensus is required to proceed.
|
||||
|
||||
## Design summary
|
||||
|
||||
A user with an existing Nostr identity (the **attesting identity**) publishes two events:
|
||||
|
||||
1. A **kind 1 announcement** — a human-readable attestation whose `content` is signed by each PQ signature scheme, with the PQ public keys and signatures carried in `algorithm` tags.
|
||||
2. A **kind 9999 proof carrier** — a non-replaceable event that embeds the full signed kind 1 event as JSON content and carries an OpenTimestamps proof anchoring the kind 1 event to a Bitcoin block. Upgrades (pending → confirmed) publish a new kind 9999 event with an `upgrade_of` tag referencing the previous one; both remain on relays permanently.
|
||||
|
||||
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]
|
||||
SEED --> PQ[PQ keypairs - children 1..8]
|
||||
SECP --> K1[Kind 1 announcement - attesting identity signs]
|
||||
PQ --> K1
|
||||
K1 --> OTS[OpenTimestamps anchor to Bitcoin block]
|
||||
OTS --> K9999[Kind 9999 proof carrier - carries OTS proof]
|
||||
K9999 --> RELAY[Published to relays]
|
||||
```
|
||||
|
||||
## PQ key derivation from a BIP39 seed
|
||||
|
||||
All PQ keys are derived from a BIP39 seed via **BIP32 hierarchical deterministic derivation**, the same standard NIP-06 uses for secp256k1 keys. PQ keys live at fixed child indices under the NIP-06 base path.
|
||||
|
||||
**Base path:** `m/44'/1237'/0'/0/` (NIP-06 account 0, change 0)
|
||||
|
||||
| Child index/indices | Algorithm | Seed length needed | BIP32 path | Derivation |
|
||||
|---|---|---|---|---|
|
||||
| 0 | secp256k1 (NIP-06) | 32 bytes | `m/44'/1237'/0'/0/0` | Standard BIP32; private key used directly |
|
||||
| 1 | ML-DSA-44 | 32 bytes | `m/44'/1237'/0'/0/1` | Single child; 32-byte private key is the PQ seed |
|
||||
| 2 | ML-DSA-65 | 32 bytes | `m/44'/1237'/0'/0/2` | Single child; 32-byte private key is the PQ seed |
|
||||
| 3 + 4 | SLH-DSA-128s | 48 bytes | `m/44'/1237'/0'/0/3` + `m/44'/1237'/0'/0/4` | Two children concatenated (64 bytes), first 48 used |
|
||||
| 5 + 6 | Falcon-512 | 48 bytes | `m/44'/1237'/0'/0/5` + `m/44'/1237'/0'/0/6` | Two children concatenated (64 bytes), first 48 used |
|
||||
| 7 + 8 | ML-KEM-768 | 64 bytes | `m/44'/1237'/0'/0/7` + `m/44'/1237'/0'/0/8` | Two children concatenated (64 bytes) |
|
||||
|
||||
### Concatenation and truncation rule (normative)
|
||||
|
||||
BIP32 child derivation produces exactly 32 bytes per child. Some PQ algorithms need more than 32 bytes for their `keygen()` seed:
|
||||
|
||||
- For a 48-byte seed, derive **two** children, concatenate them in **child-index order** (lower index first) to produce 64 bytes, and use the **first 48 bytes**.
|
||||
- For a 64-byte seed, derive **two** children, concatenate them in **child-index order**, and use all 64 bytes.
|
||||
|
||||
Implementations MUST use the first N bytes after concatenation in ascending child-index order. A future implementer who takes the *last* 48 bytes, or concatenates in the opposite order, will produce different keys and break seed-phrase recoverability. This rule is pinned here precisely so that two independent implementations produce the same PQ keys from the same seed.
|
||||
|
||||
### Why BIP32 and not HKDF
|
||||
|
||||
BIP32 is the HD-wallet standard already used by NIP-06. Using BIP32 paths for PQ keys means:
|
||||
|
||||
- Consistency with NIP-06 — PQ keys are derived the same way as secp256k1 keys, at different child indices.
|
||||
- Wallet compatibility — HD wallet infrastructure (hardware wallets, Amber-style signers, bunkers) can derive these same paths.
|
||||
- Compartmentalization — hardened derivation at the account level means breaking one leaf key does not compromise siblings; the parent chain code is a secret symmetric value that a quantum computer cannot recover from a leaf public key.
|
||||
|
||||
### Seed phrase entropy
|
||||
|
||||
A 12-word BIP39 mnemonic carries 128 bits of entropy (~64 bits of post-quantum security under Grover's quadratic speedup). A 24-word mnemonic carries 256 bits (~128 bits PQ). Implementations SHOULD support 24-word mnemonics for users concerned about quantum attacks on the seed entropy itself. The dominant risk to seed phrases remains classical (theft, phishing), not quantum.
|
||||
|
||||
## The kind 1 announcement event
|
||||
|
||||
A regular Nostr text note (kind 1) that serves as a public, human-readable attestation. It is signed with the user's **existing** secp256k1 identity (the attesting identity), via NIP-07 (`window.nostr.signEvent`) or any conforming signer.
|
||||
|
||||
### Content
|
||||
|
||||
The `content` is a human-readable attestation statement. The exact wording is not normative, but it MUST include the attesting identity's npub and hex pubkey, the Bitcoin block height at signing time, and a list of the PQ algorithms whose keys appear in the tags. Each PQ signature scheme signs `TextEncoder.encode(content)` — the UTF-8 bytes of the content string.
|
||||
|
||||
Example content:
|
||||
|
||||
```
|
||||
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, as a decimal string.
|
||||
- `["algorithm", "<algorithm-id>", "<base64 pubkey>", "<base64 signature>"]` — one tag per PQ **signature** scheme. The signature is over `TextEncoder.encode(content)`.
|
||||
- `["algorithm", "ml-kem-768", "<base64 pubkey>"]` — for ML-KEM-768. ML-KEM is a KEM, not a signature scheme, so it has no signature field. Its ownership is asserted by the attestation text and authorized by the attesting identity's secp256k1 signature over the kind 1 event (which covers the tags, including the ML-KEM pubkey).
|
||||
|
||||
### Algorithm identifiers
|
||||
|
||||
The following `algorithm-id` strings are defined by this NIP:
|
||||
|
||||
| `algorithm-id` | FIPS | Type | Public key size | Signature size |
|
||||
|---|---|---|---|---|
|
||||
| `ml-dsa-44` | 204 | Signature | 1312 bytes | 2420 bytes |
|
||||
| `ml-dsa-65` | 204 | Signature | 1952 bytes | 3309 bytes |
|
||||
| `slh-dsa-128s` | 205 | Signature | 32 bytes | 7856 bytes |
|
||||
| `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 — 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
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1,
|
||||
"pubkey": "<attesting identity secp256k1 pubkey, hex>",
|
||||
"content": "I am signaling that the post-quantum public keys ...",
|
||||
"tags": [
|
||||
["block_height", "840000"],
|
||||
["algorithm", "ml-dsa-44", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "ml-dsa-65", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "slh-dsa-128s", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "falcon-512", "<base64 pubkey>", "<base64 signature>"],
|
||||
["algorithm", "ml-kem-768", "<base64 pubkey>"]
|
||||
],
|
||||
"sig": "<attesting identity Schnorr signature>"
|
||||
}
|
||||
```
|
||||
|
||||
## The kind 9999 proof carrier event
|
||||
|
||||
A **non-replaceable** event (kind 9999, in the 0–9999 range) that wraps the kind 1 announcement and carries the OpenTimestamps proof. It is signed with the attesting identity's secp256k1 key.
|
||||
|
||||
Kind 9999 is non-replaceable: each publication is permanent on relays. Upgrades (e.g. pending → confirmed OTS proof) publish a **new** kind 9999 event with an `upgrade_of` tag referencing the previous one. Both events remain on relays. Clients fetch all kind 9999 events for a pubkey and select the one with the earliest valid Bitcoin anchor.
|
||||
|
||||
The kind number is provisional and subject to maintainer allocation.
|
||||
|
||||
### Kind allocation is load-bearing
|
||||
|
||||
The non-replaceable semantics of the 0–9999 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. 10000–19999 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.
|
||||
|
||||
### Tags
|
||||
|
||||
- `["e", "<kind 1 event id>"]` — reference to the kind 1 announcement.
|
||||
- `["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
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 9999,
|
||||
"pubkey": "<attesting identity secp256k1 pubkey, hex>",
|
||||
"content": "<JSON string of the full signed kind 1 event>",
|
||||
"tags": [
|
||||
["e", "<kind 1 event id>"],
|
||||
["sha256", "<hex canonical digest of the signed kind 1 event>"],
|
||||
["digest_version", "1"],
|
||||
["ots", "<base64 .ots proof>"]
|
||||
],
|
||||
"sig": "<attesting identity Schnorr signature>"
|
||||
}
|
||||
```
|
||||
|
||||
## OpenTimestamps anchoring
|
||||
|
||||
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.
|
||||
|
||||
With an OTS anchor, the real event is committed to a specific Bitcoin block before quantum computers exist. A fraudulent event published later cannot produce an OTS proof for that block or any earlier block — doing so would require re-mining historical Bitcoin blocks (infeasible even with a quantum computer, due to cumulative proof-of-work) or finding a SHA-256 preimage (still infeasible under Grover's quadratic speedup on 256-bit hashes).
|
||||
|
||||
### Verification rule: earliest valid anchor wins
|
||||
|
||||
When a client finds multiple kind 9999 proof carriers for the same attesting identity, it verifies the OTS proof in each and selects the one whose Bitcoin attestation has the **earliest block height**. That proof carrier's embedded kind 1 event is the canonical key-link for that identity. Later proof carriers, even if validly signed, are treated as superseding only if they are signed by a PQ key already linked by the earliest proof carrier.
|
||||
|
||||
### OTS proof states
|
||||
|
||||
An OTS proof may be **pending** (not yet anchored in a Bitcoin block) or **confirmed** (anchored). A pending proof is evidence that the digest was submitted to calendar servers; it is **not** an independent timestamp and depends on calendar behavior. A confirmed proof asserts a Bitcoin block commitment. Clients SHOULD treat a confirmed proof as authoritative. A pending proof can be upgraded to a confirmed proof later by re-querying the calendars; when that happens, a **new** proof carrier event is published carrying the upgraded proof and referencing the original via an `upgrade_of` tag. The original event remains on relays permanently.
|
||||
|
||||
### Client-side verification
|
||||
|
||||
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
|
||||
|
||||
A PQ-aware client, when it encounters a kind 9999 proof carrier for a pubkey it cares about:
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
### Legacy clients
|
||||
|
||||
Legacy clients see the kind 1 announcement as an ordinary text note and the kind 9999 proof carrier as an unknown event. They ignore both. Nothing breaks.
|
||||
|
||||
## What this NIP proves and does not prove
|
||||
|
||||
**It proves:**
|
||||
|
||||
- Each PQ signature scheme listed in the tags signed the attestation text. Verified by checking each PQ signature against its tag's pubkey.
|
||||
- The attesting identity authorized the link. Verified by the secp256k1 Schnorr signature on the kind 1 and kind 9999 events, valid pre-quantum.
|
||||
- The link existed at a specific pre-quantum Bitcoin block. Verified by the OTS proof.
|
||||
|
||||
**It does not prove:**
|
||||
|
||||
- The PQ keys share a common seed origin. Common-origin is *asserted* in the attestation text and *authorized* by the attesting identity's signature, but it is not proven by a seed-derived signature. The security model relies on OTS precedence, not on a cryptographic proof of common origin.
|
||||
- The attesting identity still controls the PQ private keys. The link only proves the keys existed and were authorized at anchor time.
|
||||
|
||||
This is a deliberate design choice. The threat model is backdating of fraudulent links, which OTS defeats. Forging a link *after* a quantum break produces a later anchor than the real link, so the real link is cryptographically distinguishable.
|
||||
|
||||
## Revocation and broken schemes
|
||||
|
||||
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 (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. 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
|
||||
|
||||
| Algorithm | Why included |
|
||||
|---|---|
|
||||
| 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). **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.
|
||||
|
||||
## Out of scope
|
||||
|
||||
The following are explicitly **not** part of this NIP. They may be addressed by separate future NIPs:
|
||||
|
||||
- **PQ signatures on routine events.** This NIP links keys; it does not change how events are signed. PQ-signed events are a separate problem.
|
||||
- **PQ encryption of messages.** ML-KEM-768 pubkeys are linked here so future encryption NIPs can use them, but this NIP does not define a NIP-04/NIP-44 replacement.
|
||||
- **Migration of users with a raw nsec and no seed phrase.** That is a key-management problem orthogonal to the link format defined here.
|
||||
- **Quantum-safe self-storage** (e.g. one-time pad or seed-derived symmetric encryption of kind 30078 application data). Orthogonal to identity linking.
|
||||
- **HD wallet compartmentalization.** A property of BIP32/NIP-06, not something this NIP re-specifies.
|
||||
|
||||
## Prior art
|
||||
|
||||
This NIP builds on and is compatible with several existing community proposals:
|
||||
|
||||
- **PR #391** (eznix86, "NIP-101 Algorithm Transition") introduced the cross-signed transition event concept. This NIP extends it with OpenTimestamps anchoring, multi-scheme hedging, and seed-phrase-derived keys.
|
||||
- **PR #2185** (trbouma, "Add NIP for PQ") proposes swapping event signatures to ML-DSA-44. This NIP is compatible with that work — a future PQ-signature NIP can use the PQ keys linked here.
|
||||
- **Issue #1971** (paulmillr, "NIP-44: post-quantum security") is the main community discussion. The "whole architecture" problem identified there (breaking nsec breaks everything derived from it) is why this NIP uses a BIP39 seed phrase as the root of trust rather than deriving PQ keys from nsec.
|
||||
- **PR #1647** (fiatjaf, "nip4e: Decoupling encryption from identity") separates encryption keys from identity keys. This NIP's ML-KEM-768 link is compatible with that pattern.
|
||||
- **NIP-03** (OpenTimestamps Attestations for Events) provides the anchoring primitive this NIP relies on.
|
||||
- **NIP-06** (Basic key derivation from seed) defines the BIP32 base path under which this NIP allocates PQ child indices.
|
||||
|
||||
## Reference implementation
|
||||
|
||||
> **⚠️ Research prototype.** The implementation is experimental and is not a complete post-quantum
|
||||
> 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/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
|
||||
page origin. OTS proof *upgrading* uses a server-side helper, and Bitcoin block-header confirmation
|
||||
relies on a public explorer API (mempool.space) — this is **API-assisted verification**, not a full
|
||||
Bitcoin light-client verification. Source: [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs:1) in this
|
||||
repository.
|
||||
Generated
+600
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "post_quantum_nostr",
|
||||
"version": "0.0.6",
|
||||
"name": "nostr_quantum_preparation",
|
||||
"version": "0.0.36",
|
||||
"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": {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# G56-02 Implementation Plan: Non-Replaceable Proof Carrier with Append-Only Upgrades
|
||||
|
||||
**Date:** 2026-07-24
|
||||
**Finding:** G56-02 — Earliest-valid-anchor rule is not implemented; relay discovery selects the latest replaceable event
|
||||
**Approach:** Switch the proof carrier from replaceable kind 11112 to non-replaceable kind 9999, with append-only upgrade events and earliest-anchor selection
|
||||
**No migration needed:** the project has not been publicly released
|
||||
|
||||
## Problem summary
|
||||
|
||||
Kind 11112 is in the 10000–19999 range, which Nostr relays treat as **replaceable** — they keep only the latest event per pubkey. This means:
|
||||
|
||||
1. An attacker who breaks secp256k1 can publish a new kind 11112 that replaces the original on relays
|
||||
2. The code queries with `limit: 1` and selects the newest `created_at`, which is the opposite of the proposal's "earliest valid Bitcoin anchor wins" rule
|
||||
3. The original event — with its earlier Bitcoin anchor — can be hidden
|
||||
|
||||
## Design
|
||||
|
||||
### Two-event structure (unchanged conceptually, changed kind)
|
||||
|
||||
**Event 1: Kind 1 Announcement** (non-replaceable, already correct)
|
||||
- Contains the human-readable attestation text and PQ keys/signatures in `algorithm` tags
|
||||
- Signed by the user's existing secp256k1 identity
|
||||
- Its full signed JSON hash is what gets submitted to OpenTimestamps
|
||||
- No changes needed — kind 1 is already in the 0–9999 non-replaceable range
|
||||
|
||||
**Event 2: Kind 9999 Proof Carrier** (non-replaceable, changed from 11112)
|
||||
- References Event 1 by `e` tag
|
||||
- Carries the `sha256` tag (hash of Event 1's full signed JSON)
|
||||
- Carries the `ots` tag (base64 .ots proof)
|
||||
- New `ots_status` tag: `"pending"` or `"confirmed"`
|
||||
- New `upgrade_of` tag: references the previous proof carrier event ID (only on upgrade events)
|
||||
- Published as append-only — upgrades publish a NEW event, never replace
|
||||
|
||||
### Append-only upgrade flow
|
||||
|
||||
```
|
||||
1. User signs in → generates seed → derives PQ keys → signs kind 1 announcement
|
||||
2. Submit kind 1 hash to OTS calendars → get pending .ots proof
|
||||
3. Publish kind 1 announcement (permanent, non-replaceable)
|
||||
4. Publish kind 9999 proof carrier with ots_status="pending" (permanent, non-replaceable)
|
||||
5. Wait 10-30 min for Bitcoin confirmation
|
||||
6. Upgrade the .ots proof via /ots-upgrade helper
|
||||
7. Publish a NEW kind 9999 proof carrier with ots_status="confirmed" and upgrade_of="<pending event id>"
|
||||
8. Both proof carriers remain on relays permanently — client picks the one with the earliest valid Bitcoin anchor
|
||||
```
|
||||
|
||||
### Re-migration flow (user lost seed, runs process again years later)
|
||||
|
||||
```
|
||||
1. User signs in with same Nostr identity → generates new seed → derives new PQ keys
|
||||
2. Publishes a new kind 1 announcement (new PQ keys, new block height)
|
||||
3. Publishes new kind 9999 proof carriers (pending, then confirmed)
|
||||
4. Client sees multiple kind 1 announcements + multiple proof carriers
|
||||
5. Earliest valid Bitcoin anchor across all of them is the canonical root
|
||||
6. Later migrations are valid if published while secp256k1 is still trustworthy
|
||||
```
|
||||
|
||||
### Earliest-anchor selection logic
|
||||
|
||||
A new pure function `selectCanonicalProofCarrier(candidates, kind1Event)`:
|
||||
|
||||
1. Input: array of kind 9999 events for a pubkey, plus the kind 1 announcement they reference
|
||||
2. Filter: only candidates whose `e` tag matches the kind 1 event ID
|
||||
3. Filter: only candidates whose `sha256` tag matches `hashFullEvent(kind1Event)`
|
||||
4. Filter: only candidates with a valid secp256k1 signature
|
||||
5. For each remaining candidate, parse and verify its OTS proof:
|
||||
- If `ots_status` is "confirmed" and the OTS proof has a valid Bitcoin attestation → record the Bitcoin block height
|
||||
- If `ots_status` is "pending" or the OTS proof has no Bitcoin attestation → skip (not yet anchored)
|
||||
6. Sort valid candidates by Bitcoin block height (ascending)
|
||||
7. Return the one with the earliest block height
|
||||
8. If tie, sort by event ID lexicographically (deterministic tie-break)
|
||||
9. If no candidate has a confirmed Bitcoin anchor, return the best pending candidate with a note
|
||||
|
||||
## Files to change
|
||||
|
||||
### 1. `www/js/pq-crypto.mjs`
|
||||
|
||||
**Change `NIP_QR_KIND`:**
|
||||
- Line 430: change `11112` to `9999`
|
||||
- Update comment: "Kind 9999 is a non-replaceable event (0–9999 range). Each publication is permanent. Upgrades publish a new event with `upgrade_of` referencing the previous one."
|
||||
|
||||
**Update `buildKind11112Wrapper()` → rename to `buildProofCarrier()`:**
|
||||
- Add `['ots_status', 'pending']` tag (or 'confirmed' when applicable)
|
||||
- Add optional `['upgrade_of', previousEventId]` tag when upgrading
|
||||
- Keep existing `e`, `sha256`, `ots` tags
|
||||
|
||||
**Update `buildUpgradedEvent()`:**
|
||||
- Instead of copying tags and replacing the `ots` tag, build a completely new proof carrier event
|
||||
- Include `ots_status: 'confirmed'`
|
||||
- Include `upgrade_of: <original proof carrier event id>`
|
||||
- Keep the same `e` and `sha256` tags (they reference the same kind 1 event)
|
||||
|
||||
**Add `selectCanonicalProofCarrier(candidates, kind1Event)`:**
|
||||
- Pure function, no network calls
|
||||
- Takes array of proof carrier events and the kind 1 announcement
|
||||
- Returns `{ canonical, allCandidates, errors }` where `canonical` is the event with the earliest valid Bitcoin anchor
|
||||
- OTS verification is async, so this function should be async and call `verifyOtsProof()` for each candidate's OTS proof
|
||||
|
||||
### 2. `www/verify.html`
|
||||
|
||||
**Update `queryRelayForEvent()`:**
|
||||
- Change kind from `NIP_QR_KIND` (which will be 9999) — already uses the constant, so this is automatic
|
||||
- Remove `limit: 1` — fetch all events
|
||||
- Return an array of events instead of a single event
|
||||
|
||||
**Update `verifyEvent()`:**
|
||||
- Accept an array of proof carrier candidates
|
||||
- Call `selectCanonicalProofCarrier()` to pick the canonical one
|
||||
- Display all candidates in the UI with their OTS status and Bitcoin anchor height
|
||||
- Highlight the canonical (earliest-anchor) one
|
||||
- Still verify the kind 1 announcement and PQ signatures as before
|
||||
|
||||
**Update the relay query tab:**
|
||||
- Query for both kind 1 and kind 9999 events for the pubkey
|
||||
- Match proof carriers to announcements by `e` tag
|
||||
- Display the full migration history
|
||||
|
||||
### 3. `www/index.html`
|
||||
|
||||
**Update `queryRelayForKind11112()` → rename to `queryRelayForProofCarriers()`:**
|
||||
- Remove `limit: 1`
|
||||
- Return an array
|
||||
|
||||
**Update the sign/publish flow:**
|
||||
- After signing the kind 1 announcement and getting the pending OTS proof, publish the kind 9999 proof carrier with `ots_status: 'pending'`
|
||||
|
||||
**Update `publishUpgradedEvent()`:**
|
||||
- Build a NEW proof carrier event (not a replacement)
|
||||
- Include `ots_status: 'confirmed'` and `upgrade_of: <pending event id>`
|
||||
- Publish it as a new event
|
||||
- Do NOT try to replace the old event
|
||||
|
||||
**Update the resume workflow:**
|
||||
- Fetch all kind 9999 proof carriers for the pubkey
|
||||
- Find the one that matches the persisted kind 1 event hash
|
||||
- If a confirmed proof carrier already exists, the workflow is complete
|
||||
- If only a pending proof carrier exists, continue the upgrade polling
|
||||
|
||||
**Update `savePendingOts()`:**
|
||||
- Store both the pending proof carrier event ID and the kind 1 event ID
|
||||
- Store the `sha256` target digest explicitly
|
||||
- On resume, validate that fetched events match persisted IDs
|
||||
|
||||
### 4. `test/pq-crypto.test.mjs`
|
||||
|
||||
**Update existing tests:**
|
||||
- Any test referencing kind 11112 → change to 9999
|
||||
|
||||
**Add new tests:**
|
||||
- `buildProofCarrier` includes `ots_status` tag
|
||||
- `buildUpgradedEvent` includes `ots_status: 'confirmed'` and `upgrade_of` tag
|
||||
- `selectCanonicalProofCarrier` returns the event with the earliest Bitcoin anchor
|
||||
- `selectCanonicalProofCarrier` handles ties deterministically
|
||||
- `selectCanonicalProofCarrier` skips pending-only candidates when confirmed ones exist
|
||||
- `selectCanonicalProofCarrier` rejects candidates with wrong `e` tag or `sha256` tag
|
||||
- `selectCanonicalProofCarrier` rejects candidates with invalid secp signatures
|
||||
- Multiple migration attempts (re-migration) — earliest anchor wins across all
|
||||
|
||||
### 5. `nip_proposal.md`
|
||||
|
||||
- Change kind 11112 to kind 9999
|
||||
- Update the description: "non-replaceable event (0–9999 range)"
|
||||
- Add `ots_status` and `upgrade_of` tags to the tag specification
|
||||
- Update the verification rule: "fetch all kind 9999 events, select earliest valid Bitcoin anchor"
|
||||
- Update the proof states section: upgrades publish new events, not replacements
|
||||
|
||||
### 6. `README.md`
|
||||
|
||||
- Update any reference to kind 11112 → kind 9999
|
||||
- Update the migration flow description
|
||||
- Update the implementation status table
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. No event in the system is replaceable — both kind 1 and kind 9999 are in the 0–9999 range
|
||||
2. Upgrades publish new events with `upgrade_of` referencing the previous one — no event is ever replaced
|
||||
3. Relay queries fetch all candidates, not `limit: 1`
|
||||
4. `selectCanonicalProofCarrier` deterministically picks the earliest valid Bitcoin anchor
|
||||
5. A later forged proof carrier cannot hide or supersede an earlier valid one
|
||||
6. Re-migration (publishing a second migration attempt) works naturally — all events are permanent
|
||||
7. All existing tests pass (updated for kind 9999)
|
||||
8. New tests for append-only upgrades and earliest-anchor selection pass
|
||||
9. The verify page displays the full candidate history and highlights the canonical one
|
||||
@@ -1,5 +1,9 @@
|
||||
# Amber Integration Analysis for PQ Nostr Web App
|
||||
|
||||
> **⚠️ Design-only research document.** This is an analysis of a possible integration, not an
|
||||
> implemented feature. The current system is a research prototype and does not make Nostr
|
||||
> identities post-quantum secure by itself.
|
||||
|
||||
## Amber Capabilities (from source code analysis)
|
||||
|
||||
[Amber](https://github.com/greenart7c3/Amber) is an Android Nostr event signer that keeps the nsec segregated in a dedicated app. Based on source code inspection:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Amber Integration Plan: End-User Experience
|
||||
|
||||
> **⚠️ Design-only research document.** This describes a planned user experience, not a shipped
|
||||
> feature. The current system is a research prototype; it links PQ keys to an identity and anchors
|
||||
> that link in Bitcoin, but does not by itself make the identity post-quantum secure.
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the complete end-to-end user experience for migrating a Nostr identity to post-quantum security using the PQ web app and Amber signer. There are **two paths** depending on how the user's account was created in Amber:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Existing Post-Quantum Proposals in the Nostr Ecosystem
|
||||
|
||||
> **⚠️ Research document.** A survey of community discussion; not an implementation spec.
|
||||
|
||||
A survey of pull requests and issues in `nostr-protocol/nips` related to post-quantum cryptography, key migration, and key rotation — and how they compare to our plan in [`README.md`](../README.md).
|
||||
|
||||
Research date: 2026-07-12
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# NIP-QR: Two-Account Flow Architecture
|
||||
|
||||
> **⚠️ Design-only research document.** This describes the proposed architecture; the current
|
||||
> implementation is a research prototype and does not by itself make Nostr identities
|
||||
> post-quantum secure.
|
||||
|
||||
## The Flow (as proposed)
|
||||
|
||||
```
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Post-Quantum Cryptography: JavaScript Package Survey
|
||||
|
||||
> **⚠️ Research document.** A library survey; not an implementation spec.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**We do NOT need to write our own implementations.** There are mature, well-maintained JavaScript packages for all NIST-standardized post-quantum algorithms. The standout recommendation is **[`@noble/post-quantum`](https://www.npmjs.com/package/@noble/post-quantum)** — a pure JavaScript implementation of all FIPS 203/204/205 algorithms, from the highly respected noble cryptography family (335 GitHub stars, MIT licensed, self-audited, actively maintained).
|
||||
|
||||
+1239
-4
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
+15
-10
@@ -1,5 +1,10 @@
|
||||
# Post-Quantum Nostr: Why, What, How
|
||||
|
||||
> **⚠️ Research prototype.** This document describes an experimental pre-quantum key-commitment
|
||||
> and identity-link system. 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.
|
||||
|
||||
## Why: The Problem
|
||||
|
||||
### Quantum computers will break Nostr's cryptography
|
||||
@@ -57,7 +62,7 @@ A migration strategy that brings post-quantum security to Nostr:
|
||||
|
||||
1. **Seed phrase as algorithm-agnostic root of trust** — A BIP39 mnemonic is pure entropy (128/256 bits). It's not tied to any cryptographic algorithm. PQ keys are derived from it deterministically using BIP32 hierarchical deterministic derivation (the same standard used by NIP-06 for secp256k1 keys), with each PQ algorithm at a different child index under the NIP-06 base path. Same seed always produces the same PQ keys. The seed is quantum-safe (symmetric KDF, Grover's quadratic speedup is negligible).
|
||||
|
||||
2. **Multi-scheme cross-signed key-link events** — Two Nostr events (a kind 1 announcement and a kind 11112 wrapper) that link a user's secp256k1 identity to multiple PQ keys simultaneously: ML-DSA-44 and ML-DSA-65 (lattice-based signatures), SLH-DSA-128s (hash-based signatures), Falcon-512 (lattice-based signatures), and ML-KEM-768 (lattice-based KEM for encryption). Each PQ signature scheme independently signs the attestation text. If one scheme is later broken, the others remain valid.
|
||||
2. **Multi-scheme cross-signed key-link events** — Two Nostr events (a kind 1 announcement and a kind 9999 proof carrier) that link a user's secp256k1 identity to multiple PQ keys simultaneously: ML-DSA-44 and ML-DSA-65 (lattice-based signatures), SLH-DSA-128s (hash-based signatures), Falcon-512 (lattice-based signatures), and ML-KEM-768 (lattice-based KEM for encryption). Each PQ signature scheme independently signs the attestation text. If one scheme is later broken, the others remain valid.
|
||||
|
||||
3. **OpenTimestamps for pre-quantum anchoring** — The key-link event is timestamped via NIP-03 (OpenTimestamps), anchoring it to a Bitcoin block. This prevents a future quantum attacker from backdating a fraudulent key-link event, because they cannot produce a valid OTS proof for a past Bitcoin block (re-mining historical blocks is infeasible even with a quantum computer).
|
||||
|
||||
@@ -84,7 +89,7 @@ A migration strategy that brings post-quantum security to Nostr:
|
||||
Account #1 (existing identity, social graph knows this npub)
|
||||
|
|
||||
| signs: "I authorize this migration to these PQ keys"
|
||||
| (secp256k1 Schnorr signature on the kind 1 and kind 11112 events,
|
||||
| (secp256k1 Schnorr signature on the kind 1 and kind 9999 events,
|
||||
| valid pre-quantum)
|
||||
v
|
||||
Kind 1 announcement event (published under Account #1's pubkey)
|
||||
@@ -97,7 +102,7 @@ Kind 1 announcement event (published under Account #1's pubkey)
|
||||
| OpenTimestamps anchor of the kind 1 event hash to Bitcoin block
|
||||
| (proves the event existed at this time, pre-quantum)
|
||||
v
|
||||
Kind 11112 wrapper event (carries the OTS proof, embeds the kind 1 event)
|
||||
Kind 9999 proof carrier event (carries the OTS proof, embeds the kind 1 event)
|
||||
|
|
||||
v
|
||||
Published to Nostr relays
|
||||
@@ -130,11 +135,11 @@ The migration uses **two Nostr events**:
|
||||
|
||||
The content is a human-readable attestation statement. Each PQ signature scheme signs `TextEncoder.encode(content)`. The PQ public keys and signatures go in the `algorithm` tags. ML-KEM-768 has only a pubkey tag (no signature — it's a KEM).
|
||||
|
||||
#### Kind 11112 wrapper (machine-verifiable, carries the OTS proof)
|
||||
#### Kind 9999 proof carrier (machine-verifiable, carries the OTS proof)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 11112,
|
||||
"kind": 9999,
|
||||
"pubkey": "<Account #1 secp256k1 pubkey>",
|
||||
"content": "<JSON string of the full signed kind 1 event>",
|
||||
"tags": [
|
||||
@@ -172,12 +177,12 @@ 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 11112 wrapper
|
||||
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 11112 wrapper
|
||||
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
|
||||
6. **Publishes to relays** — sends both events to Nostr relays via direct WebSocket
|
||||
7. **Polls for Bitcoin confirmation** — upgrades the OTS proof and republishes the kind 11112 wrapper with the confirmed proof
|
||||
7. **Polls for Bitcoin confirmation** — upgrades the OTS proof and republishes the kind 9999 proof carrier with the confirmed proof
|
||||
8. **Shows the result** — displays the complete event JSON and event ID
|
||||
|
||||
All cryptographic operations happen either in the browser (JavaScript/WASM) or in the user's signer (Amber, browser extension, or bunker). No private key ever touches a server.
|
||||
@@ -213,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 11112 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
|
||||
|
||||
+135
-1227
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
+1336
-213
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,788 @@
|
||||
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 tabSignIn = document.getElementById('pqTabSignIn');
|
||||
const panelRelay = document.getElementById('pqPanelRelay');
|
||||
const panelPaste = document.getElementById('pqPanelPaste');
|
||||
const panelSignIn = document.getElementById('pqPanelSignIn');
|
||||
|
||||
function switchTab(which) {
|
||||
const isRelay = which === 'relay';
|
||||
const isPaste = which === 'paste';
|
||||
const isSignIn = which === 'signin';
|
||||
tabRelay.classList.toggle('pq-tab-active', isRelay);
|
||||
tabPaste.classList.toggle('pq-tab-active', isPaste);
|
||||
tabSignIn.classList.toggle('pq-tab-active', isSignIn);
|
||||
panelRelay.classList.toggle('pq-tab-panel-active', isRelay);
|
||||
panelPaste.classList.toggle('pq-tab-panel-active', isPaste);
|
||||
panelSignIn.classList.toggle('pq-tab-panel-active', isSignIn);
|
||||
}
|
||||
|
||||
tabRelay.addEventListener('click', () => switchTab('relay'));
|
||||
tabPaste.addEventListener('click', () => switchTab('paste'));
|
||||
tabSignIn.addEventListener('click', () => switchTab('signin'));
|
||||
|
||||
/* ================================================================
|
||||
SHARED DOM + STATE
|
||||
================================================================ */
|
||||
const resultsWrap = document.getElementById('pqResultsWrap');
|
||||
const resultList = document.getElementById('pqResultList');
|
||||
const eventJsonWrap = document.getElementById('pqEventJsonWrap');
|
||||
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 = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
/* ================================================================
|
||||
AUTHENTICATION (nostr-login-lite / NIP-07)
|
||||
================================================================ */
|
||||
async function initNostrLoginLite() {
|
||||
if (!window.NOSTR_LOGIN_LITE) return false;
|
||||
if (!window.NOSTR_LOGIN_LITE._initialized) {
|
||||
await window.NOSTR_LOGIN_LITE.init({
|
||||
methods: { extension: true, local: true, nip46: true, seedphrase: true, nsigner: true }
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fill the pubkey field and auto-query relays for the signed-in user's
|
||||
// kind 9999 event. Switches to the Query Relay tab so the user sees the
|
||||
// results. Returns the pubkey hex on success, null otherwise.
|
||||
async function queryAsSignedInUser() {
|
||||
if (!window.nostr || !window.nostr.getPublicKey) {
|
||||
throw new Error('No Nostr signer available. Install a NIP-07 browser extension (nos2x, Alby) or use nostr-login-lite.');
|
||||
}
|
||||
const pubkeyHex = await window.nostr.getPublicKey();
|
||||
if (!pubkeyHex) throw new Error('No pubkey returned by signer.');
|
||||
|
||||
console.log('[verify] Signed-in pubkey:', pubkeyHex);
|
||||
const pubkeyInput = document.getElementById('pqPubkeyInput');
|
||||
const npub = hexToNpub(pubkeyHex);
|
||||
pubkeyInput.value = npub || pubkeyHex;
|
||||
|
||||
// Show the query tab so the user sees the results
|
||||
switchTab('relay');
|
||||
queryBtn.click();
|
||||
return pubkeyHex;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
TAB 3: SIGN IN
|
||||
================================================================ */
|
||||
const signInBtn = document.getElementById('pqSignInBtn');
|
||||
const signInError = document.getElementById('pqSignInError');
|
||||
const signInStatus = document.getElementById('pqSignInStatus');
|
||||
|
||||
signInBtn.addEventListener('click', async () => {
|
||||
signInBtn.disabled = true;
|
||||
signInError.innerHTML = '';
|
||||
setStatus(signInStatus, 'info', 'Signing in...');
|
||||
try {
|
||||
if (window.NOSTR_LOGIN_LITE) {
|
||||
await initNostrLoginLite();
|
||||
// Try a restored session first
|
||||
if (window.nostr) {
|
||||
try {
|
||||
const pk = await window.nostr.getPublicKey();
|
||||
if (pk) {
|
||||
await queryAsSignedInUser();
|
||||
setStatus(signInStatus, 'success', 'Signed in. Querying your event...');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[verify] Not yet authenticated, launching login modal...');
|
||||
}
|
||||
}
|
||||
// Launch the login modal and wait for auth events
|
||||
const pubkeyHex = await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) { settled = true; cleanup(); reject(new Error('Login timed out')); }
|
||||
}, 120000);
|
||||
|
||||
async function tryGetPubkey() {
|
||||
if (settled) return;
|
||||
if (window.nostr) {
|
||||
try {
|
||||
const pk = await window.nostr.getPublicKey();
|
||||
if (pk && !settled) { settled = true; cleanup(); resolve(pk); }
|
||||
} catch (e) { console.log('[verify] getPublicKey not ready:', e.message); }
|
||||
}
|
||||
}
|
||||
|
||||
function authHandler(e) {
|
||||
if (e.type === 'nlMethodSelected' && e.detail && e.detail.pubkey) {
|
||||
if (!settled) { settled = true; cleanup(); resolve(e.detail.pubkey); }
|
||||
return;
|
||||
}
|
||||
tryGetPubkey();
|
||||
setTimeout(tryGetPubkey, 200);
|
||||
setTimeout(tryGetPubkey, 500);
|
||||
setTimeout(tryGetPubkey, 1000);
|
||||
setTimeout(tryGetPubkey, 2000);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
window.removeEventListener('nlAuth', authHandler);
|
||||
window.removeEventListener('nlAuthRestored', authHandler);
|
||||
window.removeEventListener('nlMethodSelected', authHandler);
|
||||
window.removeEventListener('nlAuthComplete', authHandler);
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
window.addEventListener('nlAuth', authHandler);
|
||||
window.addEventListener('nlAuthRestored', authHandler);
|
||||
window.addEventListener('nlMethodSelected', authHandler);
|
||||
window.addEventListener('nlAuthComplete', authHandler);
|
||||
|
||||
try {
|
||||
const result = window.NOSTR_LOGIN_LITE.launch();
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(() => {
|
||||
tryGetPubkey(); setTimeout(tryGetPubkey, 200); setTimeout(tryGetPubkey, 500);
|
||||
}).catch(e => { if (!settled) { settled = true; cleanup(); reject(e); } });
|
||||
}
|
||||
} catch (e) {
|
||||
if (!settled) { settled = true; cleanup(); reject(e); }
|
||||
}
|
||||
});
|
||||
// Fill the pubkey field with the launched-session pubkey and query
|
||||
const pubkeyInput = document.getElementById('pqPubkeyInput');
|
||||
const npub = hexToNpub(pubkeyHex);
|
||||
pubkeyInput.value = npub || pubkeyHex;
|
||||
switchTab('relay');
|
||||
queryBtn.click();
|
||||
setStatus(signInStatus, 'success', 'Signed in. Querying your event...');
|
||||
} else {
|
||||
// No nostr-login-lite — fall back to a plain NIP-07 extension
|
||||
await queryAsSignedInUser();
|
||||
setStatus(signInStatus, 'success', 'Signed in. Querying your event...');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[verify] Sign-in failed:', error);
|
||||
setStatus(signInError, 'error', error.message);
|
||||
setStatus(signInStatus, 'error', 'Sign-in failed.');
|
||||
} finally {
|
||||
signInBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
/* ================================================================
|
||||
URL ?npub= PARAMETER + AUTO-DETECT SIGNED-IN USER
|
||||
On page load, first check for a ?npub= query parameter (used by the
|
||||
"Verify this attestation" link in the kind 1 event). If present,
|
||||
pre-fill the pubkey field and auto-query relays for that pubkey's
|
||||
kind 9999 event. Otherwise, fall back to detecting a NIP-07 signer /
|
||||
nostr-login-lite session and auto-query for the signed-in user.
|
||||
================================================================ */
|
||||
async function initAutoDetect() {
|
||||
// 1. Check for ?npub= (or ?pubkey=) URL parameter first
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const npubParam = params.get('npub') || params.get('pubkey');
|
||||
if (npubParam) {
|
||||
const pubkeyHex = normalizePubkey(npubParam);
|
||||
if (pubkeyHex) {
|
||||
console.log('[verify] URL parameter pubkey:', pubkeyHex);
|
||||
const pubkeyInput = document.getElementById('pqPubkeyInput');
|
||||
const npub = hexToNpub(pubkeyHex);
|
||||
pubkeyInput.value = npub || pubkeyHex;
|
||||
queryBtn.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to signed-in user detection
|
||||
if (window.NOSTR_LOGIN_LITE) {
|
||||
try {
|
||||
await initNostrLoginLite();
|
||||
} 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();
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.0.6",
|
||||
"VERSION_NUMBER": "0.0.6",
|
||||
"BUILD_DATE": "2026-07-19T11:24:35.730Z"
|
||||
"VERSION": "v0.0.36",
|
||||
"VERSION_NUMBER": "0.0.36",
|
||||
"BUILD_DATE": "2026-07-27T20:31:02.763Z"
|
||||
}
|
||||
|
||||
+925
-204
File diff suppressed because it is too large
Load Diff
+54
-505
@@ -4,13 +4,17 @@
|
||||
<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;," />
|
||||
|
||||
<style>
|
||||
#divHeaderText {
|
||||
font-size: calc(max(var(--header-height), var(--header-min-height)) * 0.5) !important;
|
||||
}
|
||||
#divBody {
|
||||
flex-direction: column !important;
|
||||
flex-wrap: nowrap !important;
|
||||
@@ -145,8 +149,9 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 15px;
|
||||
background: var(--primary-color);
|
||||
color: var(--secondary-color);
|
||||
background: var(--secondary-color);
|
||||
color: var(--primary-color);
|
||||
border: var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -160,8 +165,9 @@
|
||||
}
|
||||
|
||||
.pq-event-preview {
|
||||
background: var(--primary-color);
|
||||
color: var(--secondary-color);
|
||||
background: var(--secondary-color);
|
||||
color: var(--primary-color);
|
||||
border: var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 15px;
|
||||
font-size: 11px;
|
||||
@@ -237,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>
|
||||
@@ -247,34 +253,25 @@
|
||||
<div class="pq-container">
|
||||
|
||||
<div class="pq-card">
|
||||
<div class="pq-card-title">Verify NIP-QR Migration Event</div>
|
||||
<div class="pq-info-text">
|
||||
Verify post-quantum migration events (kind 11112) by querying relays for a user's pubkey,
|
||||
or by pasting event JSON directly. The kind 11112 event wraps a kind 1 announcement
|
||||
(embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks:
|
||||
the kind 11112 secp256k1 signature, the embedded kind 1 event's secp256k1 signature,
|
||||
the e tag (kind 1 event ID), the sha256 tag (full kind 1 event hash), all PQ signatures
|
||||
(ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768), and the OpenTimestamps proof.
|
||||
</div>
|
||||
<div class="pq-info-text">
|
||||
<a href="./" class="pq-link">Back to migration page</a>
|
||||
</div>
|
||||
<div class="pq-card-title">Verify Post-Quantum Key Link</div>
|
||||
|
||||
<div class="pq-tabs">
|
||||
<div class="pq-tab pq-tab-active" id="pqTabRelay">Query Relay</div>
|
||||
<div class="pq-tab" id="pqTabPaste">Paste Event JSON</div>
|
||||
<div class="pq-tab" id="pqTabSignIn">Sign In</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 1: Query Relay -->
|
||||
<div class="pq-tab-panel pq-tab-panel-active" id="pqPanelRelay">
|
||||
<div class="pq-info-text">
|
||||
Enter a Nostr pubkey (hex or npub) and one or more relay URLs. The page will query the
|
||||
relay(s) for the latest kind 11112 event from that pubkey and verify it.
|
||||
relay(s) for the latest kind 9999 event from that pubkey and verify the signatures are
|
||||
valid, and whether it has been submitted to Open Time Stamps.
|
||||
</div>
|
||||
<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>
|
||||
@@ -283,13 +280,13 @@
|
||||
<!-- TAB 2: Paste Event JSON -->
|
||||
<div class="pq-tab-panel" id="pqPanelPaste">
|
||||
<div class="pq-info-text">
|
||||
Paste a kind 11112 event JSON below to verify all signatures.
|
||||
Paste a kind 9999 event JSON below to verify all signatures.
|
||||
</div>
|
||||
<textarea class="pq-textarea" id="pqEventInput" placeholder='Paste kind 11112 event JSON here, e.g.:
|
||||
<textarea class="pq-textarea" id="pqEventInput" placeholder='Paste kind 9999 event JSON here, e.g.:
|
||||
{
|
||||
"id": "...",
|
||||
"pubkey": "...",
|
||||
"kind": 11112,
|
||||
"kind": 9999,
|
||||
"content": "{\"id\":\"...\",\"pubkey\":\"...\",\"kind\":1,\"content\":\"I am signaling...\",\"tags\":[...],\"sig\":\"...\"}",
|
||||
"tags": [
|
||||
["e", "<kind 1 event id>"],
|
||||
@@ -300,6 +297,21 @@
|
||||
}'></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>
|
||||
|
||||
<!-- TAB 3: SIGN IN -->
|
||||
<div class="pq-tab-panel" id="pqPanelSignIn">
|
||||
<div class="pq-info-text">
|
||||
Sign in with your Nostr identity (NIP-07 extension or nostr-login-lite) to look up your
|
||||
own kind 9999 event automatically.
|
||||
</div>
|
||||
<div id="pqSignInError"></div>
|
||||
<button class="pq-button" id="pqSignInBtn">Sign In</button>
|
||||
<div id="pqSignInStatus"></div>
|
||||
</div>
|
||||
|
||||
<!-- Shared results area -->
|
||||
@@ -311,11 +323,6 @@
|
||||
<div class="pq-result-list" id="pqResultList"></div>
|
||||
</div>
|
||||
|
||||
<div id="pqEventDisplayWrap" style="display: none; margin-top: 15px;">
|
||||
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Event content:</strong></div>
|
||||
<div class="pq-event-preview" id="pqEventContent"></div>
|
||||
</div>
|
||||
|
||||
<div id="pqEventJsonWrap" style="display: none; margin-top: 15px;">
|
||||
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Full event JSON:</strong></div>
|
||||
<div class="pq-event-preview" id="pqEventJson"></div>
|
||||
@@ -327,10 +334,21 @@
|
||||
<div id="pqOtsInfo" class="pq-status pq-status-info"></div>
|
||||
<div class="pq-button-row">
|
||||
<button class="pq-button" id="pqOtsUpgradeBtn">Upgrade OTS Proof</button>
|
||||
<button class="pq-button" id="pqOtsRepublishBtn" style="display:none;">Republish with Confirmed Proof</button>
|
||||
<button class="pq-button" id="pqOtsRepublishBtn" style="display:none;">Publish Upgraded Event</button>
|
||||
</div>
|
||||
<div id="pqOtsUpgradeStatus"></div>
|
||||
</div>
|
||||
|
||||
<hr style="border: var(--border); margin: 25px 0 15px;" />
|
||||
|
||||
<div class="pq-info-text" style="color: var(--accent-color); font-size: 13px;">
|
||||
<strong>Note:</strong> Passing every check does not mean that your current nostr identity
|
||||
is now post-quantum secure. This tool is the preparation step. Full quantum safety for
|
||||
day-to-day Nostr use still requires companion protocols that are not yet built.
|
||||
</div>
|
||||
<div class="pq-info-text">
|
||||
<a href="./" class="pq-link">Back to preparation page</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -344,483 +362,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,
|
||||
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 eventDisplayWrap = document.getElementById('pqEventDisplayWrap');
|
||||
const eventContent = document.getElementById('pqEventContent');
|
||||
const eventJsonWrap = document.getElementById('pqEventJsonWrap');
|
||||
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;
|
||||
|
||||
// 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 KIND 11112 EVENT
|
||||
================================================================ */
|
||||
function queryRelayForEvent(pubkeyHex, relayUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const ws = new WebSocket(relayUrl);
|
||||
let resolved = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
try { ws.close(); } catch (e) {}
|
||||
reject(new Error('timeout'));
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
ws.onopen = () => {
|
||||
// REQ for kind 11112, limit 1 (latest)
|
||||
const subId = 'pq_verify_' + Math.random().toString(36).substring(7);
|
||||
ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex], limit: 1 }]));
|
||||
};
|
||||
|
||||
ws.onmessage = (msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (data[0] === 'EVENT') {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
try { ws.close(); } catch (e) {}
|
||||
resolve(data[2]);
|
||||
}
|
||||
} else if (data[0] === 'EOSE') {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
try { ws.close(); } catch (e) {}
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
} 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';
|
||||
eventDisplayWrap.style.display = 'none';
|
||||
eventJsonWrap.style.display = 'none';
|
||||
otsUpgradeWrap.style.display = 'none';
|
||||
otsBadge.innerHTML = '';
|
||||
|
||||
// Display content (the embedded kind 1 event, pretty-printed) and full kind 11112 JSON
|
||||
try {
|
||||
const parsed = JSON.parse(event.content);
|
||||
eventContent.textContent = JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
eventContent.textContent = event.content;
|
||||
}
|
||||
eventDisplayWrap.style.display = 'block';
|
||||
eventJsonEl.textContent = JSON.stringify(event, null, 2);
|
||||
eventJsonWrap.style.display = 'block';
|
||||
|
||||
let allValid = true;
|
||||
|
||||
// 1. Verify kind 11112 secp256k1 signature
|
||||
const secpValid = verifyNostrEvent(event);
|
||||
addResult('secp256k1 (kind 11112 wrapper)', secpValid, secpValid ? 'valid' : 'INVALID');
|
||||
if (!secpValid) allValid = false;
|
||||
|
||||
// 2. Verify kind 1 sig, PQ sigs, e tag, sha256 tag
|
||||
const pqResults = verifyNIPQRContent(event.content, event.tags);
|
||||
for (const r of pqResults.results) {
|
||||
addResult(r.algorithm, r.valid, r.note || (r.valid ? 'valid' : 'INVALID'));
|
||||
if (!r.valid) 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 foundEvent = null;
|
||||
let lastError = null;
|
||||
let successCount = 0;
|
||||
|
||||
// Query relays in parallel, take the first event returned
|
||||
const promises = relayUrls.map(async (url) => {
|
||||
try {
|
||||
const event = await queryRelayForEvent(pubkeyHex, url);
|
||||
if (event) {
|
||||
successCount++;
|
||||
if (!foundEvent || (event.created_at > foundEvent.created_at)) {
|
||||
foundEvent = event;
|
||||
}
|
||||
}
|
||||
return { url, ok: true, event };
|
||||
} catch (e) {
|
||||
lastError = e.message;
|
||||
return { url, ok: false, error: e.message };
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
if (foundEvent) {
|
||||
const npub = hexToNpub(pubkeyHex);
|
||||
setStatus(queryStatus, 'success', `Found kind 11112 event from ${npub.substring(0, 20)}... (created_at: ${foundEvent.created_at}, queried ${successCount}/${relayUrls.length} relays).`);
|
||||
await verifyEvent(foundEvent);
|
||||
} else {
|
||||
setStatus(queryStatus, 'error', `No kind 11112 event found for this pubkey on any of the ${relayUrls.length} relay(s)${lastError ? ' (last error: ' + lastError + ')' : ''}.`);
|
||||
}
|
||||
|
||||
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 verified successfully!');
|
||||
} else {
|
||||
setStatus(verifyStatus, 'error', 'Some signatures failed verification.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[verify] Error:', error);
|
||||
setStatus(verifyStatus, 'error', `Error: ${error.message}`);
|
||||
} finally {
|
||||
verifyBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
/* ================================================================
|
||||
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 republish the event with the confirmed proof below.`);
|
||||
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 republish.');
|
||||
}
|
||||
|
||||
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 kind 11112 event published to ${ok} relay(s) (${fail} failed).`);
|
||||
// Re-verify the new event
|
||||
await verifyEvent(signedEvent);
|
||||
} catch (error) {
|
||||
setStatus(otsUpgradeStatus, 'error', `Republish failed: ${error.message}`);
|
||||
} finally {
|
||||
otsRepublishBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<!-- Main application module (G56-08: extracted to external file for CSP compliance) -->
|
||||
<script type="module" src="./js/verify-app.mjs"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user