Compare commits

...
2 Commits
14 changed files with 295 additions and 722 deletions
+124 -651
View File
@@ -1,18 +1,57 @@
# Post-Quantum Nostr
# Nostr Quantum Preparation
> **⚠️ Research prototype.** This project is an experimental pre-quantum key-commitment and
> identity-link system. It does **not**, by itself, make a Nostr identity post-quantum secure:
> it links PQ keys to an existing identity and anchors that link in Bitcoin. Complete migration
> requires companion protocols (PQ event authentication, rotation, encryption, client adoption)
> that are not yet specified or implemented. Do not enter a valuable existing mnemonic into the
> web app. See the [implementation status](#implementation-status) section for what exists today.
A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.
## TLDR
### Problem
Quantum computers will break secp256k1 which nostr relies on for its public private key pair. This means that given an npub, a quantum computer will be able to derive your nsec, read all your encrypted data and sign events as you.
We have new cryptography that we think is secure in a post quantum world that we can use in nostr, but the field is still evolving, and we have no idea of how much time we have left to migrate nostr. It could be we have 1 year or 100 years until quantum computers arrive. This makes settling on new cryptography difficult.
### Solution
This project is an interim solution to the problem for those who are concerned about keeping their identities secure through the transition, and also to give developers breathing room.
This project takes what I have found as the likely post quantum algorithms, and will generate for you a new public private key pair for all of them.
I have currently used the following post quantum algorithms:
- ML-DSA-44
- ML-DSA-65
- SLH-DSA-128s
- Falcon-512
- ML-KEM-768
This app will generate public-private keypairs for each of these, publish on nostr your public key for each, and sign it with your current Nostr identity via your favorite signer (the nsec never leaves the signer).
You are making a statement to the world: "Here are post quantum pubkeys. If quantum computers hit, you know that the person signing using these can only be me."
This entire kind 1 event is then hashed and stamped into a block on Bitcoin using OpenTimestamps.
The OpenTimestamps pending proof is published to nostr as a (new) kind 9999 event. If the app is left open, it will watch for the confirmation that your event has been timestamped on Bitcoin, and then publish a second kind 9999 with the upgraded timestamp.
You can then store the new seed phrase that was generated, along with the kind 9999 event(s), and if quantum computers suddenly appear, you have an identity that could only have been created by you.
It's interesting to note that even if a nostr consensus decides not to use any of these algorithms, as long as any of them are resistant to quantum attacks, you can use it to prove your transition from your current nostr identity to your new nostr identity.
I invite you to try it out, and give me any feedback. Run through it with a test identity before you use it on your real identity.
Note that I consider using this app low stakes, in that you are not revealing to the app your current private key. You are using a nostr signer. And also, you don't HAVE to use these public keys in the future. They are just sitting out there in case you ever need them. And if you screw up, or lose your seed phrase, just run the whole thing again.
https://laantungir.net/quantum-prep/
> **⚠️ Warning.** Do not enter a valuable existing mnemonic into the web app. This project links
> post-quantum keys to an existing Nostr identity and anchors that link in Bitcoin. It does **not**,
> by itself, make a Nostr identity post-quantum secure: complete migration requires companion
> protocols (PQ event authentication, rotation, encryption, client adoption) that are not yet
> specified or implemented. See the [implementation status](#implementation-status) section for what
> exists today.
A preparation strategy for the eventual post-quantum migration of Nostr — without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.
## Table of Contents
- [The Problem](#the-problem)
- [Why Bitcoin's Hash-the-Pubkey Trick Doesn't Work for Nostr](#why-bitcoins-hash-the-pubkey-trick-doesnt-work-for-nostr)
- [The Strategy: Overview](#the-strategy-overview)
- [Component 1: Seed Phrase as Algorithm-Agnostic Root of Trust](#component-1-seed-phrase-as-algorithm-agnostic-root-of-trust)
- [Component 2: Multi-Scheme Cross-Signed Key-Link Events](#component-2-multi-scheme-cross-signed-key-link-events)
@@ -20,7 +59,6 @@ A migration strategy for bringing post-quantum security to Nostr without breakin
- [Component 4: Migrating Existing Users with Raw nsec (Design Only)](#component-4-migrating-existing-users-with-raw-nsec-design-only)
- [Component 5: Quantum-Safe Self-Storage (Design Only)](#component-5-quantum-safe-self-storage-design-only)
- [Component 6: Deterministic Wallet Compartmentalization](#component-6-deterministic-wallet-compartmentalization)
- [The Complete Migration Flow](#the-complete-migration-flow)
- [What Remains Unsolved](#what-remains-unsolved)
- [Implementation Status](#implementation-status)
@@ -28,52 +66,9 @@ A migration strategy for bringing post-quantum security to Nostr without breakin
## The Problem
Nostr's cryptography is built entirely on secp256k1:
Nostr's identity, authentication, and encryption (NIP-04 and NIP-44) all rely on secp256k1. **Shor's algorithm** breaks secp256k1 in polynomial time on a sufficiently large quantum computer, so an attacker who has recorded any of your events can recover your private key, forge signatures, decrypt your messages, and impersonate you.
- **Identity**: Your pubkey IS your identity — it's in every event, it's your npub, it's what filters use, it's what NIP-05 verifies, it's what the social graph is built on.
- **Authentication**: Every event is signed with a Schnorr signature over secp256k1. Signature verification requires the public key, which is published in plaintext on every event.
- **Encryption (NIP-04)**: Uses secp256k1 ECDH to derive a shared secret, then AES-256-CBC. The shared X coordinate is used directly as the AES key. The IV is published in plaintext.
- **Encryption (NIP-44)**: Uses secp256k1 ECDH to derive a shared secret, then HKDF → ChaCha20 + HMAC-SHA256. The nonce is published in plaintext inside the payload.
**Shor's algorithm** breaks the elliptic curve discrete logarithm problem on secp256k1 in polynomial time. With a sufficiently large fault-tolerant quantum computer, an attacker who has recorded any Nostr event can:
1. Extract the public key from the event.
2. Run Shor's algorithm to recover the private key.
3. Forge signatures, decrypt all NIP-04/NIP-44 messages, and impersonate the user.
### NIP-04 vs NIP-44: No Quantum Advantage
NIP-44 is a substantial classical-security upgrade over NIP-04 (authenticated encryption, key separation, per-message keys, better cipher, length-hiding padding), but it offers **zero** post-quantum security advantage. Both derive their shared secret from secp256k1 ECDH. Everything NIP-44 adds (HKDF, per-message nonces, ChaCha20, HMAC-SHA256, custom padding) sits downstream of the broken ECDH step.
The NIP-44 spec itself acknowledges this:
> No post-quantum security: a powerful quantum computer would be able to decrypt the messages
The symmetric primitives in both NIPs (AES-256, ChaCha20, HMAC-SHA256, HKDF-SHA256) are adequately post-quantum (~128-bit PQ security via Grover's quadratic speedup). The weakness is exclusively the secp256k1 ECDH key agreement, which has zero post-quantum security in both NIPs.
---
## Why Bitcoin's Hash-the-Pubkey Trick Doesn't Work for Nostr
Bitcoin has limited quantum resistance because addresses are hashes of pubkeys (RIPEMD160(SHA256(pubkey))), and pubkeys are only revealed at spend time. This works because of three properties that Nostr does not have:
| Property | Bitcoin | Nostr |
|---|---|---|
| Identifier | Address (hash of pubkey) | Pubkey directly |
| Pubkey revealed | At spend time only | On every event |
| After reveal | UTXO consumed, key discarded | Identity persists, key reused |
In Nostr, the pubkey is load-bearing in ways it isn't in Bitcoin:
1. **The pubkey IS the identity.** It's in every event, it's your npub, it's what filters use, it's what NIP-05 verifies, it's what the social graph is built on.
2. **The pubkey must be revealed for every event.** Schnorr signature verification requires the public key. You can't verify a signature with a hash of the pubkey.
3. **Identity is never consumed.** You publish thousands of events over years with the same pubkey. There's no "consume and rotate" moment.
If Nostr published hash(pubkey) instead, you'd get quantum resistance for exactly **zero seconds** — the time between key generation and your first event publication, at which point the pubkey must be revealed for signature verification.
The only ways to avoid revealing the pubkey (ZK-SNARKs, giving up public verifiability, one-time keys per event) are impractical or fundamentally change what Nostr is.
**The real fix is post-quantum signatures and post-quantum key agreement, not hashing the pubkey.** This document describes how to get there without breaking Nostr.
NIP-44 is a real classical-security upgrade over NIP-04, but it offers **zero** post-quantum advantage: both derive their shared secret from secp256k1 ECDH, and everything NIP-44 adds (HKDF, ChaCha20, HMAC, padding) sits downstream of that broken step. The symmetric primitives themselves (AES-256, ChaCha20, HMAC-SHA256) are adequately post-quantum; the weakness is exclusively the ECDH key agreement.
---
@@ -83,7 +78,7 @@ The only ways to avoid revealing the pubkey (ZK-SNARKs, giving up public verifia
flowchart TD
subgraph SECURITY["Security Layers - All Quantum-Resistant"]
L1[Layer 1: Seed phrase - PQ-safe root of trust]
L2[Layer 2: Key-link event + OTS - PQ-safe identity migration]
L2[Layer 2: Key-link event + OTS - PQ-safe identity preparation]
L3[Layer 3: PQ signatures - PQ-safe future authentication]
L4[Layer 4: OTS on important events - PQ-safe historical provenance]
L5[Layer 5: OTP or PQ encryption - PQ-safe confidentiality]
@@ -94,7 +89,7 @@ flowchart TD
L2 --> L4
```
The strategy uses existing Nostr infrastructure (NIP-06, NIP-03) plus one new event type (a key-link NIP). No new cryptographic primitives are needed for the migration itself — PQ algorithms are used additively, and the community doesn't need to agree on which one to use.
The strategy uses existing Nostr infrastructure (NIP-06, NIP-03) plus one new event type (a key-link NIP). No new cryptographic primitives are needed for the preparation itself — PQ algorithms are used additively, and the community doesn't need to agree on which one to use.
### Design Principles
@@ -109,48 +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.
> **⚠️ 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 — and any in-browser signature
> (secp or PQ) could be faked by the same compromised browser. No in-browser cryptographic trick
> can prove the seed and the published keys are bound, because the browser is the potential adversary.
>
> **The only real proof is out-of-band verification:** after the migration, the user should take
> their seed phrase to a *different* device or browser, derive the keys independently, and confirm
> the public keys match the published event. This is the one step a compromised browser cannot fake.
>
> **Why not verify the mnemonic against the current identity?** Deriving the NIP-06 secp key from
> an entered mnemonic and requiring it to match the signed-in pubkey would provide a strong
> continuity proof. However, no current Nostr signers (NIP-07 extensions) accept seed phrases, so
> this would require the user to type their valuable existing mnemonic into the web page — an
> unacceptable security risk. This mode is deferred until hardware PQ signers or seed-accepting
> signers exist.
**The seed is algorithm-agnostic.** It's just entropy. You can derive any key type from it:
```mermaid
flowchart TD
SEED[BIP39 Seed - 64 bytes, recovery root]
SEED --> BIP32[BIP32 derivation - secp256k1 keypair]
SEED --> PQKEM[BIP32 derivation - ML-KEM keypair]
SEED --> PQDSA[BIP32 derivation - ML-DSA keypair]
SEED --> SLHDSA[BIP32 derivation - SLH-DSA keypair]
SEED --> FALCON[BIP32 derivation - Falcon keypair]
style SEED fill:#cfc
style BIP32 fill:#fcc
style PQKEM fill:#cfc
style PQDSA fill:#cfc
style SLHDSA fill:#cfc
style FALCON fill:#cfc
```
### PQ Key Derivation from BIP39 Seeds
All keys — both secp256k1 and PQ — are derived from the BIP39 seed via **BIP32 hierarchical deterministic derivation**, the same standard used by NIP-06 for secp256k1 keys. PQ keys are derived at different child indices under the NIP-06 base path, using the BIP32 child private key (32 bytes) as the deterministic seed for each PQ algorithm's `keygen()` function.
All keys — both secp256k1 and PQ — are derived from the BIP39 seed via **BIP32 hierarchical deterministic derivation**, the same standard NIP-06 uses for secp256k1. PQ keys are derived at different child indices under the NIP-06 base path, using the BIP32 child private key (32 bytes) as the deterministic seed for each PQ algorithm's `keygen()`.
**Base path:** `m/44'/1237'/0'/0/` (NIP-06 account 0, change 0)
@@ -163,519 +119,134 @@ All keys — both secp256k1 and PQ — are derived from the BIP39 seed via **BIP
| 5 + 6 | Falcon-512 | 48 bytes | `m/44'/1237'/0'/0/5` + `m/44'/1237'/0'/0/6` | Two children concatenated (64 bytes), first 48 used |
| 7 + 8 | ML-KEM-768 | 64 bytes | `m/44'/1237'/0'/0/7` + `m/44'/1237'/0'/0/8` | Two children concatenated (64 bytes) |
### Why some algorithms need two children
**Truncation rule (pinned for compatibility):** when a PQ seed needs more than 32 bytes, two BIP32 children are derived and concatenated (64 bytes total). For 48-byte seeds, the **first 48 bytes** are used; for 64-byte seeds, all 64 bytes are used. Implementations must use the first N bytes — taking the last 48 would produce different keys and break compatibility.
BIP32 child derivation produces exactly 32 bytes (the private key) per child. Some PQ algorithms need more than 32 bytes for their `keygen()` seed:
> **⚠️ Continuity is asserted, not proven (G56-06).** The old Nostr identity *authorizes* the PQ
> keys by signing an event that contains them. This is an authorization, not a cryptographic proof
> that the PQ keys were derived from the seed phrase shown to the user. A compromised browser could
> display one mnemonic while publishing attacker-controlled PQ keys. **The only real proof is
> out-of-band verification:** after the key link is published, take the seed phrase to a *different* device,
> derive the keys independently, and confirm the public keys match the published event. This is the
> one step a compromised browser cannot fake.
- **ML-DSA-44** and **ML-DSA-65**: need 32 bytes — one child is enough
- **SLH-DSA-128s**: needs 48 bytes — derive two children (64 bytes), use the first 48
- **Falcon-512**: needs 48 bytes — derive two children (64 bytes), use the first 48
- **ML-KEM-768**: needs 64 bytes — derive two children and concatenate (64 bytes)
### Seed entropy
**Truncation rule (must be pinned for compatibility):** when a PQ seed requires more than 32 bytes, two BIP32 children are derived and concatenated (64 bytes total). For 48-byte seeds, the **first 48 bytes** of the concatenation are used. For 64-byte seeds, all 64 bytes are used. A future implementer who takes the *last* 48 bytes would produce different keys and break compatibility — implementations must use the first N bytes.
The implementation defaults to 24-word (256-bit) mnemonics, with a 12-word option for testing. Under Grover's quadratic speedup, 12 words leaves only ~64 bits of PQ security; 24 words gives ~128 bits. Use 24 words for a real PQ recovery root. The bigger risk to seed phrases is classical (theft, phishing), not quantum — treat the phrase as a high-value secret and store it offline.
### Why BIP32 and not HKDF?
### Secret material lifetime
BIP32 is the standard for HD wallets. Using BIP32 paths gives us:
1. **Consistency with NIP-06** — PQ keys are derived the same way as secp256k1 keys, just at different child indices
2. **Compartmentalization** — hardened derivation at the account level means breaking one key doesn't compromise siblings
3. **Wallet compatibility** — HD wallet infrastructure (Amber, hardware wallets, etc.) can derive these same paths
4. **Tree structure** — the derivation path documents exactly which key is where
5. **Security** — BIP32 uses HMAC-SHA512 (the same HMAC used everywhere in HD wallets), not a custom construction
### What is HMAC?
HMAC (Hash-based Message Authentication Code) is the cryptographic primitive that BIP32 is built on. Every BIP32 child key derivation is:
```
HMAC-SHA512(parent_chain_code, data) → 32-byte private key + 32-byte chain code
```
This is not a separate or weaker construction — it IS BIP32. Using BIP32 paths means we're using the same HMAC-based derivation that all HD wallets use.
### Seed Phrase Entropy Considerations
| Mnemonic length | Entropy | Post-quantum security (Grover's) | Assessment |
|---|---|---|---|
| 12 words | 128 bits | ~64 bits | Testing only — weaker under a quantum brute-force model |
| 24 words | 256 bits | ~128 bits | Recommended for a PQ recovery root |
The implementation defaults to 24-word (256-bit) mnemonics, with a 12-word option available for
testing. Users concerned about quantum attacks on the seed entropy itself should use 24 words. The
bigger risk to seed phrases is classical (theft, phishing), not quantum. Note that BIP39's PBKDF2
iteration count is low; the seed phrase should be treated as a high-value secret and stored offline.
### Secret Material Lifetime and Zeroization (G56-10)
The web app derives the BIP39 seed, secp256k1 key, and five PQ secret keys in browser memory.
On sign-out, the app **zeroizes all `Uint8Array` secret buffers** (fills with zeros before
nulling references), **clears the DOM** (seed display, seed input, entropy hint), and **clears
the clipboard** (in case the user copied the seed phrase).
**Important limitation:** JavaScript garbage collection does **not** guarantee erasure. The
browser runtime may copy buffers internally, and zeroizing a `Uint8Array` only overwrites the
JS-visible buffer — the engine may retain copies in its own memory management. Browser
extensions, devtools memory inspectors, and page snapshots may still recover secrets during
the session. The real fix is moving key derivation and signing into a hardware or native signer
boundary (see G56-06 and G56-08). The zeroization implemented here is a best-effort reduction
of the exposure window, not a guarantee.
On sign-out, the app zeroizes all `Uint8Array` secret buffers (BIP39 seed, secp private key, five PQ secret keys), clears the DOM, and clears the clipboard. **JavaScript garbage collection does not guarantee erasure** — the browser runtime may retain copies internally. The real fix is moving key derivation and signing into a hardware or native signer boundary; the in-browser zeroization is a best-effort reduction of the exposure window, not a guarantee.
---
## Component 2: Multi-Scheme Cross-Signed Key-Link Events
This is the core innovation. Instead of choosing one PQ algorithm, **cross-sign with all viable PQ schemes now.** Let consensus emerge later.
This is the core innovation. Instead of choosing one PQ algorithm, **cross-sign with all viable PQ schemes now** and let consensus emerge later. The PQ standardization landscape is still evolving (ML-DSA and ML-KEM are finalized, Falcon is still draft, SIKE was a NIST finalist broken in 2022 on a laptop), so hedging across schemes hedges against any single scheme falling.
### The Standardization Uncertainty Problem
The key link uses **two Nostr events**:
The PQ standardization landscape is still evolving:
### 1. Kind 1 Announcement (human-readable, visible in Nostr feeds)
| Scheme | NIST Status | Concern |
|---|---|---|
| ML-DSA (Dilithium) | FIPS 204, finalized 2024 | Lattice-based — could have undiscovered structural weaknesses |
| SLH-DSA (SPHINCS+) | FIPS 205, finalized 2024 | Hash-based, very conservative, but huge signatures (8-50 KB) |
| FN-DSA (Falcon) | FIPS 206, still draft | Compact but complex FFT, implementation concerns |
| ML-KEM (Kyber) | FIPS 203, finalized 2024 | Lattice-based — same class as Dilithium |
A regular Nostr text note whose `content` is a human-readable attestation naming the user's npub and listing the PQ algorithms. The PQ public keys and signatures go in `algorithm` tags. Each PQ signature scheme signs `TextEncoder.encode(content)`; the event is signed with the user's **existing** Nostr identity via `window.nostr.signEvent` (NIP-07).
History gives reason to be cautious: SIKE was a NIST PQ finalist that was broken in 2022 using a classical algorithm that ran in an hour on a laptop. If the Nostr community had picked SIKE early, we'd be back to square one.
### 2. Kind 9999 Proof Carrier (machine-verifiable, carries the OTS proof)
### The Multi-Scheme Solution
A non-replaceable event (kind 9999, in the 09999 range) whose `content` is the full signed kind 1 event as JSON (so verifiers don't need to fetch it from relays) and whose tags carry the OpenTimestamps proof. Each publication is permanent — upgrades publish a *new* kind 9999 with an `upgrade_of` tag referencing the previous one; both remain on relays. The non-replaceable range is load-bearing for this append-only design.
The implementation includes **five** PQ schemes — four signature schemes and one KEM:
**Tags:** `['e', '<kind1 id>']`, `['sha256', '<hex canonical digest of the signed kind 1 event>']`, `['digest_version', '<version>']`, `['ots', '<base64 .ots proof>']`, `['ots_status', 'pending'|'confirmed']`, and optionally `['upgrade_of', '<previous proof carrier id>']`. The wrapper is signed with the user's existing identity.
```mermaid
flowchart TD
SEED[BIP39 Seed] --> SECP[secp256k1 keypair - child 0]
SEED --> MLDSA44[ML-DSA-44 keypair - child 1]
SEED --> MLDSA65[ML-DSA-65 keypair - child 2]
SEED --> SLHDSA[SLH-DSA-128s keypair - children 3+4]
SEED --> FALCON[Falcon-512 keypair - children 5+6]
SEED --> MLKEM[ML-KEM-768 keypair - children 7+8]
### What this proves and what it does not
MLDSA44 --> SIGN1[ML-DSA-44 sign attestation]
MLDSA65 --> SIGN2[ML-DSA-65 sign attestation]
SLHDSA --> SIGN3[SLH-DSA-128s sign attestation]
FALCON --> SIGN4[Falcon-512 sign attestation]
**Proves:**
- Each PQ signature scheme (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512) signed the attestation text — verified against the pubkey in its tag.
- The user's existing Nostr identity authorized the key link — verified by the secp256k1 Schnorr signature on both events.
- The event is anchored to a pre-quantum Bitcoin block via OpenTimestamps — the event with the earliest valid OTS proof wins.
SIGN1 --> KIND1[Kind 1 Announcement Event]
SIGN2 --> KIND1
SIGN3 --> KIND1
SIGN4 --> KIND1
MLKEM --> KIND1
**Does NOT prove:**
- The PQ keys cannot *cryptographically* prove they share a common seed origin. They prove only that five PQ keypairs exist and each signed the attestation. Common-origin is **asserted** in the attestation and **authorized** by the existing identity's signature, not proven by a seed-derived signature.
- The seed-derived secp256k1 key (child 0) is derived but **not used to sign**. There is no "successor signature."
SECP --> ACCT1[Account 1 - existing identity signs kind 1 and kind 9999]
This is deliberate. The security model relies on **OTS precedence** (the real event is anchored pre-quantum; a forged event cannot be backdated) rather than a seed-derived signature.
KIND1 --> OTS[OpenTimestamp the kind 1 event hash]
OTS --> KIND9999[Kind 9999 Proof Carrier - carries OTS proof]
KIND9999 --> RELAY[Published to relays NOW]
### ML-KEM cannot sign
RELAY --> CLIENT1[PQ-aware client - picks ML-DSA]
RELAY --> CLIENT2[PQ-aware client - picks SLH-DSA]
RELAY --> CLIENT3[Legacy client - ignores all PQ keys]
ML-KEM (Kyber) is a KEM (Key Encapsulation Mechanism), not a signature scheme, so it cannot sign the attestation. Its public key is included in an `algorithm` tag without a signature field; its ownership is asserted by the attestation text and authorized by the existing identity's signature over the kind 1 event (which covers the tags). This is weaker than a cross-signature but sufficient: the secp256k1 signature is valid now (pre-quantum), and the ML-KEM key is for encryption, not authentication — a false ownership claim is self-correcting (the claimant couldn't decrypt messages sent to that key).
style KIND1 fill:#ffa
style KIND9999 fill:#ffa
style OTS fill:#cfc
```
### Revocation
### The Two-Event Structure
The migration uses **two Nostr events**:
#### 1. Kind 1 Announcement (human-readable, visible in Nostr feeds)
A regular Nostr text note (kind 1) that serves as a public announcement of the post-quantum key migration. The content is human-readable text with newlines. The PQ public keys and signatures go in the tags.
**Content:** A human-readable attestation statement, e.g.:
```
I am signaling that the post-quantum public keys listed in the tags of this event
were generated by me and I hold the private keys. I may use these keys in the future
as successors to my current Nostr identity.
My current identity:
npub: <npub>
hex: <hex pubkey>
This attestation is established pre-quantum at Bitcoin block height <height>.
Post-quantum public keys in tags:
ML-DSA-44 (Dilithium, FIPS 204, NIST Level 2)
ML-DSA-65 (Dilithium, FIPS 204, NIST Level 3)
SLH-DSA-128s (SPHINCS+, FIPS 205, NIST Level 1)
Falcon-512 (FIPS 206 draft, NIST Level 1)
ML-KEM-768 (Kyber, FIPS 203, NIST Level 3)
Each post-quantum key has cryptographically signed this attestation. This event is
pending timestamp on the Bitcoin blockchain via OpenTimestamps.
```
**Tags:**
- `['block_height', '<height>']` — the Bitcoin block height at signing time
- `['algorithm', '<algorithm>', '<base64 pubkey>', '<base64 signature>']` — one per PQ signature scheme
- `['algorithm', 'ml-kem-768', '<base64 pubkey>']` — for ML-KEM (KEM, no signature)
Each PQ signature scheme signs `TextEncoder.encode(content)` — the human-readable attestation text. The event is signed with the user's **existing** Nostr identity (Account #1) via `window.nostr.signEvent` (NIP-07).
#### 2. Kind 9999 Proof Carrier (machine-verifiable, carries the OTS proof)
A non-replaceable event (kind 9999, in the 09999 range) that wraps the kind 1 announcement and carries the OpenTimestamps proof. The content is the full kind 1 event as JSON (so verifiers don't need to fetch it from relays). Each publication is permanent — upgrades publish a new event with an `upgrade_of` tag referencing the previous one.
**Content:** `JSON.stringify(kind1Event)` — the full signed kind 1 event embedded as a JSON string.
**Tags:**
- `['e', '<kind1 event id>']` — reference to the kind 1 announcement
- `['sha256', '<hex SHA-256 of the full signed kind 1 event JSON>']` — what was timestamped
- `['ots', '<base64 .ots proof>']` — the OpenTimestamps proof (pending or confirmed)
The wrapper is signed with the user's existing identity (Account #1) via `window.nostr.signEvent`.
### What This Proves and What It Does Not
**What the event proves:**
- Each PQ signature scheme (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512) signed the attestation text — verified by checking each PQ signature against the pubkey in its tag.
- The user's existing Nostr identity (Account #1) authorized the migration — verified by the secp256k1 Schnorr signature on the kind 1 and kind 9999 events.
- The event is anchored to a pre-quantum Bitcoin block via OpenTimestamps — the event with the earliest valid OTS proof wins (see Component 3).
**What the event does NOT prove:**
- The PQ keys cannot *cryptographically* prove they share a common seed origin. They prove only that five PQ keypairs exist and each signed the attestation text. Common-origin is **asserted** in the attestation text and **authorized** by Account #1's signature, but it is not proven by a seed-derived signature.
- The seed-derived secp256k1 key (Account #2, child 0) is derived but **not used to sign** in the current implementation. There is no "successor signature" binding the PQ keys to the seed.
This is a deliberate design choice. The security model relies on **OTS precedence** (the real event is anchored pre-quantum; a forged event cannot be backdated) rather than a seed-derived signature. After a quantum break, an attacker who forges Account #1 can publish a fraudulent migration event, but it will have a later OTS timestamp than the real event — so the real event is cryptographically distinguishable by its earlier Bitcoin anchor.
### ML-KEM Cannot Sign
ML-KEM (Kyber) is a KEM (Key Encapsulation Mechanism), not a signature scheme. It cannot sign the attestation. Instead, its public key is included in an `algorithm` tag (without a signature field), and its ownership is asserted by the attestation text and authorized by Account #1's signature over the kind 1 event (which covers the tags, including the ML-KEM pubkey).
This is weaker than a cross-signature but sufficient because:
- The secp256k1 signature is valid NOW (pre-quantum), establishing the link while we can trust secp256k1
- The ML-KEM key is used for encryption, not authentication — a false claim of ownership would just mean someone can't decrypt messages sent to you, which is self-correcting
### Why This Is Better Than Picking One Scheme
| Concern | Pick-one approach | Multi-scheme approach |
|---|---|---|
| Scheme gets broken later | Migration fails, need new link | Other links still valid |
| Standardization changes | May need to re-link | Already covered |
| Consensus not reached | Can't proceed | Proceed immediately |
| Event size | Small | Larger (~20-30 KB), but one-time cost |
| Client complexity | Support one scheme | Support any subset |
| Race condition risk | Must publish before quantum | Published now, all schemes |
Event size is a non-issue because this is a **one-time publication**, not per-event overhead.
### Revocation: If a Scheme Is Broken Later
If a specific PQ scheme is later found to be weak (like SIKE was):
1. **Clients simply stop trusting the broken scheme** — no revocation event needed; clients just ignore links to algorithms on a known-broken list
2. The remaining links are still valid
3. Optionally, publish a new key-link event without the broken scheme, signed by the remaining valid PQ keys
If a PQ scheme is later found to be weak (like SIKE), clients simply stop trusting it — no revocation event needed. The remaining links stay valid. A user may optionally publish a new key-link event omitting the broken scheme, signed by the still-valid PQ keys.
---
## Component 3: OpenTimestamps for Pre-Quantum Anchoring
NIP-03 (OpenTimestamps Attestations for Events) anchors Nostr event hashes to the Bitcoin blockchain via OpenTimestamps. The implementation submits the **SHA-256 of the full signed kind 1 event JSON** (including its `id` and `sig`) to the OpenTimestamps calendar servers, and embeds the resulting `.ots` proof in the kind 9999 proof carrier's `ots` tag.
NIP-03 (OpenTimestamps Attestations for Events) anchors Nostr event hashes to the Bitcoin blockchain. The implementation submits the **canonical digest** of the fully signed kind 1 event to OpenTimestamps calendar servers and embeds the resulting `.ots` proof in the kind 9999 proof carrier's `ots` tag. The proof carrier *carries* the proof; the kind 1 event *is* what's timestamped.
### Why Bitcoin Timestamping Is Quantum-Resistant
The canonical digest (version 1) is `sha256(JSON.stringify([0, pubkey, created_at, kind, tags, content, id, sig]))` — the NIP-01 event-ID serialization array extended with the `id` and `sig` fields. This is reproducible by any conforming implementation (including non-JS ones and verifiers that re-fetch the event from a relay with different key order), unlike `sha256(JSON.stringify(event))` which is key-order-dependent. The proof carrier records the digest version in a `digest_version` tag; verifiers fail closed on unknown versions. Legacy v0 proof carriers (pre-canonicalization) committed to `sha256(JSON.stringify(event))` and are accepted only for historical inspection, not as valid for the key link.
The Bitcoin proof-of-work chain is quantum-resistant — not because Bitcoin's signatures are PQ-safe (they're not for revealed pubkeys), but because **re-mining historical blocks is infeasible even with a quantum computer.** Grover's algorithm gives only a quadratic speedup on mining, which doesn't help retroactively alter past blocks.
### Why Bitcoin timestamping is quantum-resistant
An attacker who breaks your secp256k1 key via Shor's can forge new events but **cannot backdate an event to before a specific Bitcoin block** — they'd need to produce a valid OTS proof anchoring it to a past block, which requires either:
- Re-mining that Bitcoin block (impossible, even with a quantum computer, due to cumulative proof-of-work)
- Finding a SHA256 preimage for the event hash (still infeasible with Grover's quadratic speedup on 256-bit hashes)
Bitcoin's proof-of-work chain is quantum-resistant not because Bitcoin's signatures are PQ-safe (they're not, for revealed pubkeys) but because **re-mining historical blocks is infeasible even with a quantum computer.** Grover's gives only a quadratic speedup on mining, which doesn't help retroactively alter past blocks. An attacker who breaks your secp256k1 key via Shor's can forge new events but **cannot backdate an event to before a specific Bitcoin block** — they'd need to re-mine that block (impossible) or find a SHA-256 preimage (still infeasible).
### The Critical Refinement: OpenTimestamp the Key-Link Event
The kind 1 announcement event MUST be timestamped via OpenTimestamps. This prevents a race condition attack:
### Why the key-link event must be anchored
**Without OTS on the key-link event:**
- Attacker breaks secp256k1 key via Shor's
- Attacker publishes a fraudulent key-link event linking your secp256k1 key to their PQ key
- Attacker publishes a fraudulent key-link event linking your key to their PQ key
- Both real and fraudulent events have valid secp256k1 signatures (key is compromised)
- Clients can't distinguish them — `created_at` is forgeable
**With OTS on the key-link event:**
- Your real key-link event is anchored to Bitcoin block N (before quantum computers)
- Attacker's fraudulent event is published later and cannot produce an OTS proof for block N or earlier
- Your real event is anchored to Bitcoin block N (before quantum computers)
- The attacker's fraudulent event is published later and cannot produce an OTS proof for block N or earlier
- Clients verify: "the key-link event with the earliest valid OTS proof wins"
- **The real key-link is cryptographically distinguishable from the fraudulent one**
### What Is Timestamped
### Current OTS verification status
The implementation timestamps the **SHA-256 of the full signed kind 1 event JSON** (computed by hashing `JSON.stringify(kind1Event)`, which includes the `id` and `sig` fields). This hash is:
1. Submitted to OpenTimestamps calendar servers to obtain a pending `.ots` proof.
2. Recorded in the kind 9999 proof carrier's `sha256` tag.
3. The `.ots` proof is embedded in the kind 9999 proof carrier's `ots` tag.
> **Trust model.** The current OTS verification is **API-assisted**, not a full Bitcoin light-client
> verification. Treat results accordingly.
The kind 9999 proof carrier *carries* the OTS proof; the kind 1 event *is* what's timestamped. Verifiers re-compute the hash from the embedded kind 1 event and check it matches the `sha256` tag.
The current implementation parses the `.ots` proof, validates the Merkle path against a block header from a public Bitcoin explorer API (mempool.space), and detects confirmation by searching the proof bytes for the block-header attestation magic. Proof *upgrading* (asking calendars for a confirmed proof) is delegated to a server-side helper.
### Current OTS Verification Status
> **Research prototype — trust model.** The current OTS verification is **API-assisted**, not a
> full Bitcoin light-client verification. Treat results accordingly.
The current implementation:
- Parses the `.ots` proof and validates the Merkle path against a block header obtained from a
public Bitcoin explorer API (mempool.space).
- Detects Bitcoin confirmation by searching the proof bytes for the block-header attestation magic
bytes and confirming the attested block height via the same API.
- Delegates proof *upgrading* (asking calendars for a confirmed proof) to a server-side helper.
**What is NOT done:**
- **Full light-client verification** — validating the block header's proof-of-work, difficulty, and
chain linkage independently, without trusting an explorer API. The vendored
`javascript-opentimestamps` library in `resources/` provides primitives for this and will be
integrated in a future release.
- **Multi-explorer cross-checking** — comparing independent APIs and rejecting disagreement.
Because the block header is trusted from a single API, a compromised or malicious explorer could
falsify a confirmation. The UI labels this as "API-checked," not "cryptographically verified on
Bitcoin."
### What Should Be OpenTimestamped
Not every event needs OTS. Priority list:
1. **Key-link events** — absolutely critical, foundation of PQ migration
2. **Identity-defining events** — NIP-05 verification, profile events (kind 0)
3. **Important content** — contracts, announcements, evidence
4. **High-value events** — financial events (NIP-60 Cashu wallets, zaps)
Routine events (regular posts, reactions) probably don't need OTS — the impact of retroactive forgery is low.
**Not yet done:** full light-client verification (validating block headers, proof-of-work, difficulty, and chain linkage independently, without trusting an explorer API) and multi-explorer cross-checking. Because the block header is trusted from a single API, a compromised explorer could falsify a confirmation — the UI labels this as "API-checked," not "cryptographically verified on Bitcoin." The vendored `javascript-opentimestamps` library in `resources/` provides the primitives for full light-client verification, which is planned for a future release.
---
## Component 4: Migrating Existing Users with Raw nsec (Design Only)
> **Status: Design only — not yet implemented.** The current implementation supports users who already have a Nostr identity (via NIP-07 signer or nostr-login-lite). The raw-nsec migration described below is future work.
> **Status: Design only — not yet implemented.** The current implementation supports users who already have a Nostr identity via a NIP-07 signer. The raw-nsec preparation below is future work.
Most Nostr users today have a raw nsec (random private key) with no seed phrase. Asking them to abandon their identity and social graph to adopt a seed phrase is a non-starter. This component addresses how to migrate them.
Most Nostr users today have a raw nsec (random private key) with no seed phrase. Asking them to abandon their identity and social graph to adopt a seed phrase is a non-starter. The challenge is that a raw nsec is 32 random bytes — it can't be "derived" from a seed (that's a preimage problem), so it must be **encoded** (directly or encrypted) alongside a new seed phrase.
### The Challenge
Three approaches have been designed:
A secp256k1 private key is 32 bytes (256 bits). BIP39 encodes 11 bits per word. Encoding the old nsec requires 24 extra words. The old nsec was generated randomly — it cannot be "derived" from a seed (that's a preimage problem). It must be **encoded** (directly or encrypted).
- **Approach A — 36-word phrase:** Generate a 12-word BIP39 seed, derive an encryption key from it, encrypt the old nsec with ChaCha20, and encode the 32-byte ciphertext as 24 more BIP39 words. The full 36-word phrase recovers both keys; the first 12 alone recover the PQ keys after the eventual migration.
- **Approach B — 12 words + relay backup (recommended):** Generate a 12-word phrase, derive the new secp key, PQ keys, and a symmetric encryption key, encrypt the old nsec, and publish it as a kind 30078 event signed with the new key. Recovery needs only the 12 words plus relay access to fetch and decrypt the event.
- **Approach C — Hybrid:** Primary backup is 12 words (relay recovery); optional fallback is also writing the 24 extra encrypted-nsec words in case relays are unavailable.
### Approach A: Extended Phrase (36 words)
```mermaid
flowchart LR
subgraph CREATION["One-time migration"]
OLD[Old nsec - 32 bytes]
NEW[Generate 12-word BIP39 phrase]
NEW --> SEED[BIP39 seed]
SEED --> ENCKEY[Derive encryption key via HKDF]
ENCKEY --> ENC[Encrypt old nsec: ChaCha20]
OLD --> ENC
ENC --> CIPHER[32 bytes encrypted nsec]
CIPHER --> WORDS[Encode as 24 BIP39 words + checksum]
NEW --> PHRASE[Full phrase: 12 + 24 = 36 words]
WORDS --> PHRASE
end
subgraph RECOVERY["Recovery with full phrase"]
FULL[36 words entered]
FULL --> FIRST[First 12 words]
FULL --> LAST[Last 24 words]
FIRST --> SEED2[BIP39 seed]
SEED2 --> ENCKEY2[Derive encryption key]
LAST --> CIPHER2[Decode to 32 bytes]
ENCKEY2 --> DEC[Decrypt]
CIPHER2 --> DEC
DEC --> OLDRECOV[Old nsec recovered]
SEED2 --> PQKEYS[PQ keys derived]
end
subgraph FUTURE["After migration - drop old key"]
FIRST12[First 12 words only]
FIRST12 --> SEED3[BIP39 seed]
SEED3 --> PQKEYS2[PQ keys only]
end
```
**Design:**
1. Generate a standard 12-word BIP39 phrase (128 bits entropy)
2. Derive a 32-byte encryption key from the BIP39 seed: `enc_key = HKDF-Extract(salt="nostr-legacy-key", ikm=bip39_seed)`
3. Encrypt the old nsec: `encrypted_nsec = ChaCha20(enc_key, nonce=0, old_nsec)` → 32 bytes
4. Encode the 32-byte encrypted nsec as 24 BIP39 words (with its own checksum)
5. Full phrase: 12 words (new seed) + 24 words (encrypted old nsec) = 36 words
**Recovery with 36 words:** First 12 → BIP39 seed → PQ keys + encryption key. Last 24 → decode → decrypt → old nsec. Both keys recovered.
**Recovery with 12 words (after migration):** First 12 → PQ keys only. Old nsec no longer needed.
**Why encrypt the old nsec?** The extra 24 words are meaningless without the first 12 (partial compromise protection). If someone steals only the last 24 words: useless. If someone steals only the first 12 words: they get PQ keys but not the old nsec.
### Approach B: 12 Words + Relay Backup (Recommended)
```mermaid
flowchart TD
subgraph SETUP["One-time migration"]
OLD[Old nsec]
NEW[Generate 12-word BIP39 phrase]
NEW --> SEED[BIP39 seed]
SEED --> NEWSECP[New secp256k1 key via NIP-06]
SEED --> PQKEYS[PQ keys via BIP32]
SEED --> ENCKEY[Symmetric encryption key via HKDF]
ENCKEY --> ENC[Encrypt old nsec]
OLD --> ENC
ENC --> EVENT30078[kind 30078: encrypted old nsec]
NEWSECP --> SIGN1[Sign event with new secp256k1 key]
SIGN1 --> EVENT30078
EVENT30078 --> RELAY[Publish to relays]
OLD --> KEYLINK[Key-link event: cross-signed]
NEWSECP --> KEYLINK
PQKEYS --> KEYLINK
KEYLINK --> OTS[OpenTimestamp via NIP-03]
OTS --> BTC[Anchored in Bitcoin]
KEYLINK --> RELAY
end
subgraph RECOVER["Recovery from 12 words only"]
WORDS[12 words entered]
WORDS --> SEED2[BIP39 seed]
SEED2 --> NEWSECP2[New secp256k1 key]
SEED2 --> PQKEYS2[PQ keys]
SEED2 --> ENCKEY2[Encryption key]
NEWSECP2 --> FETCH[Fetch kind 30078 from relays]
FETCH --> CIPHER[Encrypted old nsec]
ENCKEY2 --> DEC[Decrypt]
CIPHER --> DEC
DEC --> OLDRECOV[Old nsec recovered]
end
```
**Design:**
1. Generate a 12-word BIP39 phrase
2. Derive from seed: new secp256k1 key (NIP-06 path), PQ keys (BIP32), symmetric encryption key (HKDF)
3. Encrypt old nsec with the symmetric key: `encrypted_nsec = ChaCha20-Poly1305(enc_key, old_nsec)`
4. Publish as kind 30078 event, signed with the **new** secp256k1 key
5. Publish key-link event (cross-signed, OpenTimestamped via NIP-03)
6. User backs up only 12 words
**Recovery:** 12 words → derive new key + encryption key → fetch kind 30078 from relays → decrypt → old nsec. Both keys recovered from 12 words + relay access.
**No circular dependency:** The kind 30078 event is found by the new pubkey (derived from the 12-word seed). The new secp256k1 key is the "storage identity." The old nsec is just data inside the event.
### Approach C: Hybrid (Best of Both)
- Primary backup: 12-word phrase (PQ keys + relay recovery of old nsec)
- Optional fallback: also write down 24 extra words (encrypted old nsec) in case relays are unavailable
- If relays available: 12 words suffice
- If relays down: 12 + 24 = 36 words recover everything
### Comparison
| Property | 36-word phrase | 12-word + relay backup | Hybrid |
|---|---|---|---|
| Backup size | 36 words | 12 words | 12 or 36 words |
| Relay dependency | None | Yes | Optional |
| Custom format | Yes | No | Optional |
| Self-contained | Yes | No | Yes (with 36) |
| Quantum-safe | Yes | Yes | Yes |
All three are quantum-safe. Approach B is recommended because it minimizes what the user must back up.
---
## Component 5: Quantum-Safe Self-Storage (Design Only)
> **Status: Design only — not yet implemented.** The current implementation does not include encrypted self-storage. The approaches below are design proposals for future work.
> **Status: Design only — not yet implemented.**
For self-storage use cases (e.g., kind 30078 application data), NIP-04 and NIP-44 are unnecessary and quantum-vulnerable. Two quantum-safe alternatives:
For self-storage use cases (e.g., kind 30078 application data), NIP-04 and NIP-44 are unnecessary and quantum-vulnerable. Two quantum-safe alternatives are proposed:
### Option A: One-Time Pad (Information-Theoretic Security)
- **One-time pad (OTP):** Information-theoretic security — the strongest guarantee in cryptography, stronger than any computational security including post-quantum. The pad must be truly random, as long as the data, never reused, and stored securely (e.g., USB drive). Self-storage sidesteps OTP's usual key-distribution problem since you're encrypting for yourself. Pad reuse is fatal: reusing any section breaks the encryption.
- **Symmetric key from seed:** Derive a 256-bit key from the BIP39 seed (independent of secp256k1) via HKDF and encrypt with ChaCha20-Poly1305 or AES-256-GCM. Simpler than OTP (no pad management), computationally PQ-secure (~128-bit), and adequate for most use cases.
A one-time pad provides **information-theoretic security** — the strongest security guarantee in cryptography, stronger than any computational security (including post-quantum). It's a mathematical proof of perfect secrecy: no computer that could ever exist, in any universe governed by the laws of physics, can break it.
**Requirements:**
- Pad must be truly random (hardware RNG recommended, e.g., TrueRNG)
- Pad must be as long as the data
- Pad must never be reused
- Pad must be stored securely (e.g., USB drive)
**For self-storage, OTP is a natural fit** because you sidestep OTP's biggest limitation (key distribution between parties) — you're encrypting for yourself, so the pad lives on your device.
**What a quantum attacker can and cannot do with OTP-encrypted kind 30078:**
- ❌ Read your stored data — OTP is information-theoretically secure
- ✅ Forge your signature — Shor's breaks secp256k1 (identity compromised)
- ✅ Impersonate you, delete/replace your events
- ❌ Decrypt your stored data — even with your private key, the data is protected by the pad
This gives **clean separation**: quantum breakage of your secp256k1 key compromises your identity/authentication but NOT your stored data's confidentiality.
**Critical warning: pad reuse is fatal.** If any pad section is used twice, XOR of the two ciphertexts equals XOR of the two plaintexts — the encryption breaks. Multi-device sync requires an atomic reservation protocol for pad sections.
### Option B: Symmetric Key from Seed (Computational PQ Security)
Derive a 256-bit symmetric key from the BIP39 seed (independent of secp256k1) and encrypt with ChaCha20-Poly1305 or AES-256-GCM:
```
storage_key = HKDF-Extract(salt="nostr-storage-v1", ikm=bip39_seed)
ciphertext = ChaCha20-Poly1305(storage_key, nonce, plaintext)
```
This is post-quantum because:
- HKDF-SHA256 is symmetric (~128-bit PQ security)
- ChaCha20 / AES-256 are symmetric (~128-bit PQ security)
- The secp256k1 key is never part of the encryption path
**Tradeoff vs OTP:** Simpler (no pad management, no USB drive), but computationally secure rather than information-theoretically secure. Adequate for most use cases.
### Comparison
| Property | OTP | Symmetric key from seed |
|---|---|---|
| Security level | Information-theoretic (perfect secrecy) | Computational, ~128-bit PQ |
| Key material | Pad as long as all data | Single 256-bit key |
| Storage overhead | Pad on USB drive | Just the seed phrase |
| Quantum resistance | Unconditional | Conditional (Grover's is optimal) |
| Future-proofing | Permanent | Could theoretically fall to new quantum algorithms |
| Multi-device | Requires pad sync + offset coordination | Just share the seed |
| Practicality | More complex | Simpler |
OTP gives clean separation: a quantum break of your secp256k1 key compromises identity/authentication but **not** stored-data confidentiality.
---
## Component 6: Deterministic Wallet Compartmentalization
BIP32/BIP39 deterministic wallets (NIP-06) provide meaningful quantum compartmentalization through their tree structure.
BIP32/BIP39 deterministic wallets (NIP-06) provide meaningful quantum compartmentalization through their tree structure. The key insight is that **chain codes are secret** — when Shor's recovers a leaf private key from its published public key, the attacker gets only that key. To climb the tree they need the parent chain code, which is a symmetric HMAC-SHA512 primitive with no quantum advantage. Without chain codes, the attacker is stuck at the leaf level.
### The Core Protection: Chain Codes Are Secret
When Shor's algorithm recovers a leaf private key from its published public key, the attacker gets only that key. To climb the tree (recover the parent private key), they need the parent chain code:
```
parent_private_key = child_private_key - IL mod n
where IL = HMAC-SHA512(parent_chain_code, parent_public_key || index)[:32]
```
The parent chain code is NOT published in normal Nostr usage. HMAC-SHA512 is a symmetric primitive — a quantum computer provides no advantage. **Without chain codes, the attacker is stuck at the leaf level.**
### The Firewall Effect of Hardened Derivation
The NIP-06 path `m/44'/1237'/account'/0/0` has hardened derivation at three levels. This means:
- Breaking all keys in Account 0 does NOT compromise Account 1 (hardened at `account'` level)
- The seed/master key remains secure even if all account keys are broken
- Hardened derivation requires the parent **private** key, not just the public key
### Note on the PQ Derivation Paths
The PQ keys in this implementation are derived at **non-hardened** children (indices 18) under the hardened parent `m/44'/1237'/0'/0'`. Non-hardened derivation means anyone with the parent xpub (extended public key + chain code) at `m/44'/1237'/0'/0'` could derive the child *public* keys — but **not** the child *private* keys (which are what the PQ seeds are). So the PQ seeds remain secret unless the parent private key leaks. **Never publish the xpub at `m/44'/1237'/0'/0'`** — doing so would expose the child public keys (though not the PQ seeds themselves).
### The Critical Vulnerability: Chain Code Leakage
If an attacker obtains a chain code at any level AND breaks one child key at that level via Shor's, they can:
1. Compute `IL` using the chain code + child public key + index
2. Recover the parent private key
3. Derive ALL children at that level
4. Continue climbing if parent chain codes are also known
**Where chain codes leak:** published xpubs (extended public keys), wallet software that exports xpubs, backup exposure. In standard Nostr usage, chain codes are not published.
### Summary
The NIP-06 path `m/44'/1237'/account'/0/0` has hardened derivation at three levels, so breaking all keys in Account 0 does **not** compromise Account 1, and the seed/master key remains secure even if all account keys are broken.
| Scenario | Compromised? |
|---|---|
@@ -686,123 +257,25 @@ 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 24-word BIP39 phrase]
SEED --> DERIVE[Derive 5 PQ keypairs from seed via BIP32]
DERIVE --> SIGN[Each PQ key signs the attestation text]
SIGN --> KIND1[Build kind 1 announcement with PQ sigs in tags]
KIND1 --> SECP1[Account 1 signs kind 1 via signer]
SECP1 --> HASH[SHA-256 of full signed kind 1 event JSON]
HASH --> OTS[Submit hash to OpenTimestamps]
OTS --> KIND9999[Build kind 9999 proof carrier with OTS proof]
KIND9999 --> SECP2[Account 1 signs kind 9999 via signer]
SECP2 --> RELAY[Publish both events to relays]
SEED --> BACKUP[User writes down 24-word phrase]
end
subgraph CONFIRM["Bitcoin confirmation"]
POLL[Poll OTS upgrade service]
POLL --> UPGRADE[Upgrade .ots proof with Bitcoin attestation]
UPGRADE --> REPUBLISH[Publish new kind 9999 with confirmed proof]
end
subgraph FUTURE["Post-quantum - when quantum computers arrive"]
ATTACK[Attacker breaks secp256k1 via Shors]
ATTACK --> FORGE[Can forge secp256k1 signatures]
ATTACK --> CANNOT1[Cannot forge PQ signatures]
ATTACK --> CANNOT2[Cannot backdate events - OTS proofs anchor history]
VERIFY[Verify key-link event via OTS precedence]
VERIFY --> USEPQ[Switch to PQ signatures]
USEPQ --> REJECT[Reject forged secp256k1-only events]
end
NOW --> CONFIRM --> FUTURE
style NOW fill:#cfc
style CONFIRM fill:#ffa
style FUTURE fill:#fcc
```
### User Experience
1. User signs in with their existing Nostr identity (NIP-07 signer or nostr-login-lite)
2. Client generates a 24-word (256-bit) phrase, shows it to the user to write down
3. Client derives 5 PQ keypairs from the seed via BIP32 paths
4. Each PQ signature scheme signs the human-readable attestation text
5. The user's signer signs the kind 1 announcement and the kind 9999 proof carrier
6. The SHA-256 of the full signed kind 1 event is submitted to OpenTimestamps
7. Both events are published to relays
8. The client polls for Bitcoin confirmation and publishes a new kind 9999 proof carrier with the confirmed OTS proof (append-only — the original remains on relays)
9. After quantum migration: clients use PQ keys, old key retired
**Recommendations:** use separate accounts for separate identities; never publish xpubs (chain codes are the keys to the kingdom); use 24-word mnemonics; don't reuse keys across services; assume published keys *will* be broken and plan for containment, not prevention.
---
## What Remains Unsolved
### Historical Event Authenticity (Partially Solved)
Past secp256k1-signed events that were NOT OpenTimestamped remain forgeable after quantum break. An attacker can create fake old events that appear to be from you.
**Mitigation:** OpenTimestamp important events now (NIP-03). Events with valid OTS proofs are cryptographically anchored to a pre-quantum timestamp and cannot be forged retroactively. Events without OTS proofs become untrustworthy after quantum break.
**Not fully solved:** Routine events (regular posts, reactions) that aren't worth OpenTimestamping will become forgeable. The impact is low for most content, but high for events used as evidence, contracts, or historical records.
### Relay Trust for Recovery
Approach B (12 words + relay backup, Component 4 — design only) depends on relays retaining the kind 30078 event containing the encrypted old nsec. If all relays delete it, the old nsec is lost.
**Mitigations:**
- Publish to multiple relays
- Use relays you control or trust
- Use the hybrid approach (Approach C) with 36-word fallback
- The event is small and signed, so it's easy to preserve
### PQ Signature Size
PQ signatures are much larger than secp256k1 Schnorr signatures:
| Scheme | Signature size |
|---|---|
| secp256k1 Schnorr | 64 bytes |
| ML-DSA-44 | ~2.4 KB |
| ML-DSA-65 | ~3.3 KB |
| SLH-DSA-128s | ~8 KB |
| Falcon-512 | ~0.7 KB |
This increases the kind 1 announcement event size (~20-30 KB total for all PQ signatures). This is a one-time cost for the migration event, not per-event overhead.
### PQ Algorithm Risk
Any PQ algorithm could theoretically be broken by a new classical or quantum algorithm (as SIKE was). The multi-scheme approach hedges against this — if one scheme falls, the others remain linked. But if ALL lattice-based schemes are broken simultaneously, only SLH-DSA (hash-based, very conservative) remains as a fallback.
### Common-Origin Not Cryptographically Proven
The current implementation does not include a seed-derived secp256k1 signature binding the PQ keys to the seed (see Component 2, "What This Proves and What It Does Not"). The PQ keys self-attest by signing the attestation text, and the user's existing identity authorizes the migration. Common-origin is asserted, not proven. This is acceptable because OTS precedence (not a seed-derived signature) is the mechanism that distinguishes real from forged events after a quantum break.
- **Historical event authenticity (partially solved):** Past secp256k1-signed events that were *not* OpenTimestamped remain forgeable after a quantum break. OpenTimestamping important events now (NIP-03) anchors them cryptographically; routine events that aren't worth timestamping will become forgeable, but the impact is low for most content.
- **Relay trust for recovery:** Approach B (Component 4, design only) depends on relays retaining the kind 30078 event containing the encrypted old nsec. Mitigations: publish to multiple relays, use relays you control, or use the hybrid 36-word fallback.
- **PQ signature size:** PQ signatures are much larger than secp256k1 Schnorr (64 bytes) — ML-DSA-44 ~2.4 KB, ML-DSA-65 ~3.3 KB, SLH-DSA-128s ~8 KB, Falcon-512 ~0.7 KB. The kind 1 announcement is ~2030 KB total, a one-time cost for the key-link event.
- **PQ algorithm risk:** Any PQ algorithm could theoretically be broken (as SIKE was). The multi-scheme approach hedges; if all lattice-based schemes fall simultaneously, only SLH-DSA (hash-based, very conservative) remains.
- **Common-origin not cryptographically proven:** See Component 2. OTS precedence, not a seed-derived signature, is the mechanism that distinguishes real from forged events after a quantum break.
---
## Implementation Status
### What Is Implemented
### What is implemented
The current implementation is a static web app (`www/`) that performs the full migration flow for users who already have a Nostr identity:
The current implementation is a static web app (`www/`) that performs the full preparation flow for users who already have a Nostr identity:
| Component | Status | Location |
|---|---|---|
@@ -819,13 +292,13 @@ The current implementation is a static web app (`www/`) that performs the full m
| Verification page (query relay or paste event JSON) | Implemented | `www/verify.html` |
| NIP-07 signer integration (nostr-login-lite) | Implemented | `www/index.html` |
### What Is Not Yet Implemented
### What is not yet implemented
| Component | Description |
|---|---|
| Full light-client Bitcoin verification | Validate block headers, proof-of-work, difficulty, and chain linkage independently of an explorer API (the vendored `javascript-opentimestamps` library in `resources/` provides primitives). Current path trusts a single explorer API for the header. |
| Multi-explorer cross-checking | Compare independent Bitcoin APIs and reject disagreement |
| Raw-nsec migration (Component 4) | Encrypt old nsec, publish kind 30078, 36-word phrase encoding |
| Raw-nsec preparation (Component 4) | Encrypt old nsec, publish kind 30078, 36-word phrase encoding |
| Quantum-safe self-storage (Component 5) | OTP or symmetric-key encryption for kind 30078 data |
| PQ event authentication / rotation / revocation | Companion protocols for signing future events with PQ keys, rotating/revoking keys, PQ encryption |
| Independent implementation / test vectors | A second implementation reproducing canonical encoding, derivation, and selection |
@@ -863,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.
+2 -2
View File
@@ -1,6 +1,6 @@
# Nginx security headers for post-quantum-nostr (G56-08)
# Nginx security headers for nostr-quantum-preparation (G56-08)
#
# Add these to the nginx server block for laantungir.net/post-quantum.
# 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.
+1 -1
View File
@@ -59,7 +59,7 @@ done
# Get the latest git tag (authoritative version)
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo "🔒 Post-Quantum Nostr — Version Increment"
echo "🔒 Nostr Quantum Preparation — Version Increment"
echo "=========================================="
echo "Latest tag: $LATEST_TAG"
echo "Bump type: $BUMP_TYPE"
+1 -1
View File
@@ -366,7 +366,7 @@ This NIP builds on and is compatible with several existing community proposals:
> migration. It links PQ keys to an existing identity and anchors that link in Bitcoin; it does not
> yet define PQ authentication for routine events, key rotation/revocation, or PQ encryption.
A static, client-side-only web implementation exists at `https://laantungir.net/post-quantum/`.
A static, client-side-only web implementation exists at `https://laantungir.net/quantum-prep/`.
Cryptographic operations (BIP39, BIP32, PQ keygen/sign/verify, OTS submission and Merkle-path
verification) happen in the browser. Private keys are handled in the browser or via a NIP-07 signer
extension, which may be a remote signer (NIP-46); in that case the signing key does not touch the
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "post_quantum_nostr",
"name": "nostr_quantum_preparation",
"version": "0.0.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "post_quantum_nostr",
"name": "nostr_quantum_preparation",
"version": "0.0.24",
"license": "ISC",
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "post_quantum_nostr",
"version": "0.0.30",
"name": "nostr_quantum_preparation",
"version": "0.0.32",
"description": "A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.",
"main": "index.js",
"scripts": {
+6 -6
View File
@@ -1,20 +1,20 @@
#!/bin/bash
#
# Upload the post-quantum Nostr web app to the server.
# Upload the Nostr Quantum Preparation web app to the server.
#
# Usage: ./upload.sh
#
# Uploads the contents of www/ to ubuntu@laantungir.net:html/post-quantum/
# The site will be accessible at https://laantungir.net/post-quantum
# Uploads the contents of www/ to ubuntu@laantungir.net:html/quantum-prep/
# The site will be accessible at https://laantungir.net/quantum-prep/
#
set -e
SERVER="ubuntu@laantungir.net"
REMOTE_DIR="html/post-quantum"
REMOTE_DIR="html/quantum-prep"
LOCAL_DIR="$(dirname "$0")/www"
echo "🔒 Post-Quantum Nostr — Upload"
echo "🔒 Nostr Quantum Preparation — Upload"
echo "=============================="
echo "Server: $SERVER"
echo "Remote: $REMOTE_DIR"
@@ -40,4 +40,4 @@ rsync -avz --delete \
echo ""
echo "✅ Upload complete!"
echo "🌐 Site available at: https://laantungir.net/post-quantum"
echo "🌐 Site available at: https://laantungir.net/quantum-prep"
+1 -1
View File
@@ -218,7 +218,7 @@ We use multiple schemes simultaneously because:
### The demo
A working demo is deployed at **https://laantungir.net/post-quantum** that implements the full flow: sign in, generate seed, derive PQ keys, sign the kind 1 and kind 9999 events, publish to relays, and timestamp via OpenTimestamps. It uses:
A working demo is deployed at **https://laantungir.net/quantum-prep** that implements the full flow: sign in, generate seed, derive PQ keys, sign the kind 1 and kind 9999 events, publish to relays, and timestamp via OpenTimestamps. It uses:
- `@noble/post-quantum` for PQ cryptography (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768)
- `@scure/bip39` for seed phrase generation
+64 -27
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>Post-Quantum Nostr</title>
<title>Nostr Quantum Preparation</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="shortcut icon" type="image/x-icon" href="data:image/x-icon;," />
@@ -94,6 +94,27 @@
border-color: var(--accent-color);
}
/* Braille spinner shown in the checklist box while a step is working
(e.g. step 6 waiting for Bitcoin confirmation). The frames are
advanced by JS (setStepSpinner); this class just restyles the box. */
.pq-checklist-box.pq-spinner {
border: none;
color: var(--accent-color);
font-size: 18px;
line-height: 20px;
}
/* Inline braille spinner shown next to the "Wait for Bitcoin
confirmation" label. Frames advanced by JS (startInlineSpinner). */
.pq-inline-spinner {
display: inline-block;
color: var(--accent-color);
font-size: 16px;
line-height: 1;
margin-right: 6px;
vertical-align: -2px;
}
.pq-checklist-link {
color: var(--accent-color);
text-decoration: underline;
@@ -272,7 +293,7 @@
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText" id="divHeaderText">Post-Quantum Nostr</div>
<div class="divHeaderText" id="divHeaderText">Nostr Quantum Preparation</div>
</div>
<div id="divHeaderFlexRight">
<button class="pq-button pq-button-secondary pq-hidden" id="pqSignOutBtn" style="width: auto; padding: 6px 16px; font-size: 13px;">Sign Out</button>
@@ -286,7 +307,7 @@
PERSISTENT CHECKLIST (always visible at top)
================================================================ -->
<div class="pq-checklist">
<div class="pq-checklist-title">Migration Steps</div>
<div class="pq-checklist-title">Preparation Steps</div>
<div class="pq-checklist-item" id="step-1">
<span class="pq-checklist-box"></span>
<span>1. Sign in with your Nostr identity</span>
@@ -301,7 +322,7 @@
</div>
<div class="pq-checklist-item" id="step-4">
<span class="pq-checklist-box"></span>
<span>4. Sign the NIP-QR migration event</span>
<span>4. Sign the key-link event</span>
</div>
<div class="pq-checklist-item" id="step-5">
<span class="pq-checklist-box"></span>
@@ -323,19 +344,35 @@
<!-- STEP 0: NOT AUTHENTICATED -->
<div id="pqNotAuthed" class="pq-card">
<div class="pq-card-title">Post-Quantum Nostr</div>
<div class="pq-card-title">Nostr Quantum Preparation</div>
<div class="pq-info-text" style="font-size: 16px; color: var(--primary-color);">
Create an experimental pre-quantum PQ key commitment
Link your Nostr identity to post-quantum keys — before quantum computers arrive
</div>
<div class="pq-info-text">
Sign in with your Nostr signer to begin. Your npub won't change, your followers stay,
and your social graph is preserved. We'll generate a seed phrase and link
it to your current identity.
Quantum computers will eventually break the cryptography Nostr uses today. When that
happens, anyone who recorded your public key could forge your signature, impersonate you,
and read your encrypted messages. This tool lets you prepare now: it generates a new seed
phrase, creates post-quantum keys from it, and publishes a signed statement linking those
keys to your current Nostr identity. The statement is then anchored to a Bitcoin block, so
no one can backdate a fake one later.
</div>
<div class="pq-info-text">
Your current nsec, npub, your followers, and your social graph stay exactly as they are.
This program doesn't change anything about your current nostr setup.
</div>
<div class="pq-info-text">
Upon completing this process, you should have saved 2 things:
<ol style="margin: 8px 0 0 0; padding-left: 22px;">
<li>A new seed phrase. This should be kept secret and private.</li>
<li>A proof archive. This information is published to relays, but keeping a copy yourself is useful in case the relays don't keep the event.</li>
</ol>
In case quantum computers break nostr, you can use your seed phrase to create a new nostr
identity, and prove that your old nostr identity created this new one.
</div>
<div class="pq-info-text" style="font-size: 13px; color: var(--accent-color);">
<strong>Research prototype.</strong> Do not enter a valuable existing mnemonic. This tool
does not make your Nostr identity post-quantum secure on its own; it creates a commitment
that future PQ protocols can build on.
<strong>Warning:</strong> Don't enter a valuable existing seed phrase. This tool doesn't make
your Nostr identity post-quantum secure on its own it creates a public link your future self
can prove is yours. Run through it once with a test identity before you use it on your real one.
</div>
<div class="pq-info-text" id="noSignerWarning" style="color: var(--accent-color); display: none;">
No Nostr signer detected. Install a NIP-07 browser extension (like nos2x or Alby) and reload.
@@ -346,7 +383,7 @@
<!-- STEP 1: AUTHENTICATED - INTRO -->
<div id="pqAuthed" class="pq-card pq-hidden">
<div class="pq-card-title">Post-Quantum Migration</div>
<div class="pq-card-title">Post-Quantum Preparation</div>
<div class="pq-info-text">
<strong>Connected as:</strong>
<div class="pq-npub-display" id="pqNpub"></div>
@@ -360,11 +397,11 @@
<ol>
<li>Generate a new <strong>seed phrase</strong> (your recovery backup)</li>
<li>Derive <strong>post-quantum keys</strong> from that seed via BIP32 paths (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768)</li>
<li>Sign a <strong>NIP-QR migration event</strong> linking your identity to the PQ keys</li>
<li>Sign a <strong>key-link event</strong> linking your identity to the post-quantum keys</li>
<li>Publish the event to relays</li>
</ol>
</div>
<button class="pq-button" id="pqStartBtn">Start Migration</button>
<button class="pq-button" id="pqStartBtn">Start Preparation</button>
</div>
<!-- STEP 2: SEED PHRASE GENERATION -->
@@ -397,9 +434,9 @@
</div>
<div class="pq-seed-display" id="pqSeedDisplay"></div>
<div class="pq-info-text" style="margin-top: 12px; font-size: 13px; color: var(--accent-color);">
<strong>Verify out-of-band after migration:</strong> This seed phrase is your backup — but
<strong>Verify out-of-band after linking:</strong> This seed phrase is your backup — but
this browser cannot cryptographically prove the published PQ keys came from it. After completing
the migration, take your seed phrase to a <em>different</em> device or browser, derive the keys
the key link, take your seed phrase to a <em>different</em> device or browser, derive the keys
independently, and confirm the public keys match the published event. This is the only way to
catch a compromised browser that might have substituted attacker keys.
</div>
@@ -485,12 +522,12 @@
<!-- STEP 4: SIGNING -->
<div id="pqSignStep" class="pq-card pq-hidden">
<div class="pq-card-title">Sign Migration Event</div>
<div class="pq-card-title">Sign Key-Link Event</div>
<div class="pq-info-text">
We'll now create two events: a kind 1 announcement (public, visible in Nostr feeds) and
a kind 9999 proof carrier (carries the OpenTimestamps proof). Each PQ key signs the attestation
statement, and your signer signs both events with your secp256k1 key. The SHA-256 of the
full kind 1 event (including its secp256k1 signature) is submitted to OpenTimestamps.
We'll now create two events: a public announcement (kind 1, visible in Nostr feeds) and
a proof carrier (kind 9999, which holds the Bitcoin timestamp). Each post-quantum key signs
the announcement text, and your signer signs both events with your current Nostr key. The
hash of the signed announcement is then sent to OpenTimestamps to be anchored in Bitcoin.
</div>
<div id="pqSignStatus"></div>
<button class="pq-button" id="pqSignBtn">Sign Event</button>
@@ -545,10 +582,10 @@
<div id="pqOtsStep" class="pq-card pq-hidden">
<div class="pq-card-title">OpenTimestamps Attestation - Leave This Page Open</div>
<div class="pq-info-text">
Your event hash has already been submitted to OpenTimestamps in the previous step, and a
<strong>pending</strong> timestamp proof is embedded in the kind 9999 event you just published.
OpenTimestamps anchors that hash into the Bitcoin blockchain, proving your event existed at this
point in time — preventing a future quantum attacker from backdating a fraudulent event.
Your event's hash was already sent to OpenTimestamps in the previous step, and a
<strong>pending</strong> timestamp proof is embedded in the event you just published.
OpenTimestamps anchors that hash into the Bitcoin blockchain, proving your event existed at
this moment — so a future quantum attacker can't fake an older event and claim it came first.
</div>
<div class="pq-info-text">
Bitcoin confirmation typically takes <strong>10-30 minutes</strong>. You can
@@ -575,7 +612,7 @@
</div>
<div class="pq-checklist-item pq-active" id="pqOtsConfirm">
<span class="pq-checklist-box"></span>
<span>Wait for Bitcoin confirmation
<span><span class="pq-inline-spinner" id="pqOtsConfirmSpinner"></span>Wait for Bitcoin confirmation
<div class="pq-key-meta" id="pqOtsConfirmDetail" style="margin-top: 2px;">Polling every 60 seconds...</div>
</span>
</div>
+69 -5
View File
@@ -85,7 +85,7 @@
if (el) {
el.classList.add('pq-done');
el.classList.remove('pq-active');
el.querySelector('.pq-checklist-box').textContent = '';
clearStepSpinner(stepNum);
}
}
@@ -101,10 +101,65 @@
const el = stepElements[stepNum];
if (el) {
el.classList.remove('pq-active', 'pq-done');
el.querySelector('.pq-checklist-box').textContent = '';
clearStepSpinner(stepNum);
}
}
/* Braille spinner: shown in a step's checklist box while that step is
actively working (e.g. step 6 waiting for Bitcoin confirmation).
Advances through the standard braille spinner frames every 80ms. */
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const stepSpinnerIntervals = {};
function setStepSpinner(stepNum) {
const el = stepElements[stepNum];
if (!el) return;
const box = el.querySelector('.pq-checklist-box');
if (!box) return;
clearStepSpinner(stepNum);
el.classList.add('pq-active');
el.classList.remove('pq-done');
box.classList.add('pq-spinner');
let frame = 0;
box.textContent = SPINNER_FRAMES[0];
stepSpinnerIntervals[stepNum] = setInterval(() => {
frame = (frame + 1) % SPINNER_FRAMES.length;
box.textContent = SPINNER_FRAMES[frame];
}, 80);
}
function clearStepSpinner(stepNum) {
const el = stepElements[stepNum];
if (el) {
const box = el.querySelector('.pq-checklist-box');
if (box) { box.classList.remove('pq-spinner'); box.textContent = ''; }
}
if (stepSpinnerIntervals[stepNum]) {
clearInterval(stepSpinnerIntervals[stepNum]);
delete stepSpinnerIntervals[stepNum];
}
}
/* Inline braille spinner next to the "Wait for Bitcoin confirmation"
label. Same frames as the checklist-box spinner. */
let inlineSpinnerInterval = null;
function startInlineSpinner(elementId) {
const el = document.getElementById(elementId);
if (!el) return;
stopInlineSpinner(elementId);
let frame = 0;
el.textContent = SPINNER_FRAMES[0];
inlineSpinnerInterval = setInterval(() => {
frame = (frame + 1) % SPINNER_FRAMES.length;
el.textContent = SPINNER_FRAMES[frame];
}, 80);
}
function stopInlineSpinner() {
if (inlineSpinnerInterval) { clearInterval(inlineSpinnerInterval); inlineSpinnerInterval = null; }
const el = document.getElementById('pqOtsConfirmSpinner');
if (el) el.textContent = '';
}
/* ================================================================
VIEW MANAGEMENT
================================================================ */
@@ -719,8 +774,8 @@
RELAY LIST MANAGEMENT
================================================================ */
const DEFAULT_RELAYS = [
'wss://laantungir.net/relay',
'wss://relay.damus.io',
'wss://laantungir.net/relay/',
'wss://sendit.nosflare.com/',
'wss://nos.lol',
'wss://relay.primal.net'
];
@@ -1010,6 +1065,8 @@
console.log('[post-quantum] Signing out...');
// Stop OTS polling, but preserve its persisted workflow for the next login.
if (otsPollInterval) { clearInterval(otsPollInterval); otsPollInterval = null; }
clearStepSpinner(6);
stopInlineSpinner();
pendingOtsBytes = null;
const persistedOts = localStorage.getItem('pq-pending-ots');
// Logout from nostr-login-lite
@@ -1106,6 +1163,7 @@
if (otsLogEl) otsLogEl.innerHTML = '';
setKeyIcon('pqOtsConfirm', '');
setKeyIcon('pqOtsConfirmedPublish', '');
stopInlineSpinner();
// Check for a saved workflow from a previous session (same event ID)
let existing = loadPendingOts();
@@ -1213,6 +1271,11 @@
const confirmDetail = document.getElementById('pqOtsConfirmDetail');
let pollCount = 0;
if (otsPollInterval) clearInterval(otsPollInterval);
// Show a braille spinner in the step-6 checklist box and inline next
// to the "Wait for Bitcoin confirmation" label so the user can see
// the page is still actively working while waiting for Bitcoin.
setStepSpinner(6);
startInlineSpinner('pqOtsConfirmSpinner');
async function poll() {
pollCount++;
@@ -1245,6 +1308,7 @@
: verifyResult.trustMode || 'unknown';
otsLog(`Poll ${pollCount}: Bitcoin attestation VERIFIED (block ${att ? att.height : '?'}, mined ${date} UTC, trust: ${trustLabel}). Proof: ${pendingOtsBytes.length} bytes.`);
setKeyIcon('pqOtsConfirm', 'Done');
stopInlineSpinner();
confirmDetail.textContent = `Verified on Bitcoin (block ${att ? att.height : '?'}, ${trustLabel})`;
if (otsPollInterval) { clearInterval(otsPollInterval); otsPollInterval = null; }
@@ -1288,7 +1352,7 @@
try {
otsLog('Building upgraded proof carrier with confirmed OTS proof...');
const savedWorkflow = loadPendingOts();
const configuredRelays = getRelayUrlsString() || (savedWorkflow && savedWorkflow.relayUrls) || 'wss://laantungir.net/relay';
const configuredRelays = getRelayUrlsString() || (savedWorkflow && savedWorkflow.relayUrls) || 'wss://laantungir.net/relay/';
// Build the upgraded event: copy all tags from the original, replace the ots tag
const upgradedTemplate = buildUpgradedEvent(pqEvent, upgradedOtsBytes);
+2 -2
View File
@@ -638,8 +638,8 @@ Post-quantum public keys in tags:
Each post-quantum key has cryptographically signed this attestation. This event is pending timestamp on the Bitcoin blockchain via OpenTimestamps.
Verify this attestation: https://laantungir.net/post-quantum/verify.html
Created at: https://laantungir.net/post-quantum/`;
Verify this attestation: https://laantungir.net/quantum-prep/verify.html
Created at: https://laantungir.net/quantum-prep/`;
const statementBytes = new TextEncoder().encode(content);
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.0.30",
"VERSION_NUMBER": "0.0.30",
"BUILD_DATE": "2026-07-25T17:00:12.091Z"
"VERSION": "v0.0.32",
"VERSION_NUMBER": "0.0.32",
"BUILD_DATE": "2026-07-26T15:52:35.987Z"
}
+2 -2
View File
@@ -9929,8 +9929,8 @@ Post-quantum public keys in tags:
Each post-quantum key has cryptographically signed this attestation. This event is pending timestamp on the Bitcoin blockchain via OpenTimestamps.
Verify this attestation: https://laantungir.net/post-quantum/verify.html
Created at: https://laantungir.net/post-quantum/`;
Verify this attestation: https://laantungir.net/quantum-prep/verify.html
Created at: https://laantungir.net/quantum-prep/`;
const statementBytes = new TextEncoder().encode(content);
const mlDsa44Sig = signWithMLDSA44(statementBytes, pqKeys.mlDsa44.secretKey);
const mlDsa65Sig = signWithMLDSA65(statementBytes, pqKeys.mlDsa65.secretKey);
+16 -17
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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 NIP-QR Event</title>
<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;," />
@@ -243,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>
@@ -253,25 +253,24 @@
<div class="pq-container">
<div class="pq-card">
<div class="pq-card-title">Verify NIP-QR Migration Event</div>
<div class="pq-card-title">Verify Post-Quantum Key Link</div>
<div class="pq-info-text" style="color: var(--accent-color); font-size: 13px;">
<strong>Research prototype.</strong> Verification confirms key linkage and signature
validity; it does not mean the identity is post-quantum secure. Bitcoin OTS confirmation
is API-assisted (trusts a public explorer), not a full light-client verification.
<strong>Note:</strong> Passing these checks confirms the keys are linked and the signatures
are valid it does not mean the identity is post-quantum secure. The Bitcoin timestamp check
relies on a public block explorer rather than running a full Bitcoin node, so a compromised
explorer could fool it.
</div>
<div class="pq-info-text">
Verify post-quantum migration events (kind 9999) by querying relays for a user's pubkey,
or by pasting event JSON directly. The kind 9999 event wraps a kind 1 announcement
(embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks,
reported as separate result lines:
the kind 9999 secp256k1 signature, the embedded kind 1 event's secp256k1 signature,
the e tag (kind 1 event ID), the sha256 tag (full kind 1 event hash), each PQ signature
individually (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512; ML-KEM-768 has no signature),
the PQ algorithm policy (all mandatory algorithms present and verified), identity binding
(outer/embedded/expected author match), and the OpenTimestamps proof.
You can check a post-quantum key-link event two ways: look it up on a relay by pubkey, or
paste the event JSON directly. The event you're checking (kind 9999) wraps a public
announcement (kind 1) and carries a Bitcoin timestamp proof. Verification checks each piece
separately and reports the results below: both Nostr signatures, the link between the two
events, each post-quantum signature (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512 —
ML-KEM-768 is for encryption, so it has no signature to check), that all required algorithms
are present, that the event really belongs to the claimed identity, and the Bitcoin timestamp.
</div>
<div class="pq-info-text">
<a href="./" class="pq-link">Back to migration page</a>
<a href="./" class="pq-link">Back to preparation page</a>
</div>
<div class="pq-tabs">
@@ -288,7 +287,7 @@
<input type="text" class="pq-input" id="pqPubkeyInput"
placeholder="Pubkey (hex or npub), e.g. 3bf0c63f869a8972... or npub1..." />
<input type="text" class="pq-input" id="pqRelayInput"
value="wss://laantungir.net/relay,wss://relay.damus.io,wss://nos.lol"
value="wss://laantungir.net/relay/,wss://sendit.nosflare.com/,wss://nos.lol"
placeholder="wss://relay1.com,wss://relay2.com" />
<button class="pq-button" id="pqQueryBtn">Query & Verify</button>
<div id="pqQueryStatus"></div>