first
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
||||
# Build artifacts
|
||||
build/
|
||||
*.o
|
||||
*.so
|
||||
*.a
|
||||
*.dylib
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Source maps
|
||||
*.bundle.js.map
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
@@ -0,0 +1,702 @@
|
||||
# Post-Quantum Nostr
|
||||
|
||||
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.
|
||||
|
||||
## 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)
|
||||
- [Component 3: OpenTimestamps for Pre-Quantum Anchoring](#component-3-opentimestamps-for-pre-quantum-anchoring)
|
||||
- [Component 4: Migrating Existing Users with Raw nsec](#component-4-migrating-existing-users-with-raw-nsec)
|
||||
- [Component 5: Quantum-Safe Self-Storage](#component-5-quantum-safe-self-storage)
|
||||
- [Component 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)
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
Nostr's cryptography is built entirely on secp256k1:
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## The Strategy: Overview
|
||||
|
||||
```mermaid
|
||||
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]
|
||||
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]
|
||||
end
|
||||
|
||||
L1 --> L2 --> L3
|
||||
L1 --> L5
|
||||
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.
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **No social graph loss** — existing follows and identity carry forward
|
||||
2. **No user re-onboarding** — the seed phrase becomes the new root of trust
|
||||
3. **Backward compatible** — legacy clients continue working; PQ is additive
|
||||
4. **No algorithm consensus required** — link to all viable PQ schemes now, converge later
|
||||
5. **Pre-quantum timestamping** — establish links now, before quantum computers exist
|
||||
6. **Transparent to users** — the client handles everything at seed-phrase login
|
||||
|
||||
---
|
||||
|
||||
## 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 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[HKDF derivation - ML-KEM keypair]
|
||||
SEED --> PQDSA[HKDF derivation - ML-DSA keypair]
|
||||
SEED --> SLHDSA[HKDF derivation - SLH-DSA keypair]
|
||||
SEED --> SYMKEY[HKDF derivation - symmetric encryption key]
|
||||
|
||||
style SEED fill:#cfc
|
||||
style BIP32 fill:#fcc
|
||||
style PQKEM fill:#cfc
|
||||
style PQDSA fill:#cfc
|
||||
style SLHDSA fill:#cfc
|
||||
style SYMKEY fill:#cfc
|
||||
```
|
||||
|
||||
### PQ Key Derivation from BIP39 Seeds
|
||||
|
||||
BIP32 is specific to secp256k1. For PQ keys, use HKDF-SHA256 (quantum-resistant) to derive key material from the BIP39 seed:
|
||||
|
||||
```
|
||||
For each PQ algorithm A:
|
||||
pq_seed_A = HKDF-Extract(salt="nostr-pq-v1", ikm=bip39_seed)
|
||||
pq_key_material_A = HKDF-Expand(pq_seed_A, info=A.identifier, L=A.key_size)
|
||||
pq_keypair_A = A.keygen(pq_key_material_A)
|
||||
```
|
||||
|
||||
The identifier string (`"ml-dsa-65"`, `"slh-dsa-128s"`, `"ml-kem-768"`, etc.) is the only algorithm-specific part. New algorithms can be added later without changing the derivation scheme.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
### The Standardization Uncertainty Problem
|
||||
|
||||
The PQ standardization landscape is still evolving:
|
||||
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
### The Multi-Scheme Solution
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
SEED[BIP39 Seed] --> SECP[secp256k1 keypair]
|
||||
SEED --> MLDSA[ML-DSA keypair]
|
||||
SEED --> SLHDSA[SLH-DSA keypair]
|
||||
SEED --> FALCON[FN-DSA keypair]
|
||||
SEED --> MLKEM[ML-KEM keypair]
|
||||
|
||||
SECP --> SIGN[Schnorr sign]
|
||||
MLDSA --> SIGN2[ML-DSA sign]
|
||||
SLHDSA --> SIGN3[SLH-DSA sign]
|
||||
FALCON --> SIGN4[FN-DSA sign]
|
||||
|
||||
SIGN --> EVENT[Key-Link Event]
|
||||
SIGN2 --> EVENT
|
||||
SIGN3 --> EVENT
|
||||
SIGN4 --> EVENT
|
||||
|
||||
EVENT --> OTS[OpenTimestamp via NIP-03]
|
||||
OTS --> RELAY[Published to relays NOW]
|
||||
|
||||
RELAY --> CLIENT1[PQ-aware client - picks ML-DSA]
|
||||
RELAY --> CLIENT2[PQ-aware client - picks SLH-DSA]
|
||||
RELAY --> CLIENT3[Legacy client - ignores all PQ keys]
|
||||
|
||||
style EVENT fill:#ffa
|
||||
style OTS fill:#cfc
|
||||
```
|
||||
|
||||
### Key-Link Event Format
|
||||
|
||||
A new event kind (to be assigned) containing multiple cross-signed links:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": <new key-link kind>,
|
||||
"pubkey": "<secp256k1 npub>",
|
||||
"content": {
|
||||
"links": [
|
||||
{
|
||||
"algorithm": "ml-dsa-65",
|
||||
"public_key": "<ML-DSA-65 public key, base64>",
|
||||
"signature": "<ML-DSA signature over the link statement, base64>"
|
||||
},
|
||||
{
|
||||
"algorithm": "slh-dsa-128s",
|
||||
"public_key": "<SLH-DSA-128s public key, base64>",
|
||||
"signature": "<SLH-DSA signature over the link statement, base64>"
|
||||
},
|
||||
{
|
||||
"algorithm": "fn-dsa-512",
|
||||
"public_key": "<FN-DSA-512 public key, base64>",
|
||||
"signature": "<FN-DSA signature over the link statement, base64>"
|
||||
},
|
||||
{
|
||||
"algorithm": "ml-kem-768",
|
||||
"public_key": "<ML-KEM-768 public key, base64>",
|
||||
"note": "KEM key for encryption; ownership asserted by secp256k1 signature over this content"
|
||||
}
|
||||
],
|
||||
"statement": "All keys in this event are derived from the same seed and controlled by the same entity as secp256k1 key <npub>. This link is established pre-quantum.",
|
||||
"secp256k1_signature": "<Schnorr signature over the entire content>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each PQ signature scheme independently signs the same link statement. The secp256k1 Schnorr signature covers the entire content (including all PQ public keys and signatures), binding them together.
|
||||
|
||||
### ML-KEM Cannot Sign
|
||||
|
||||
ML-KEM (Kyber) is a KEM (Key Encapsulation Mechanism), not a signature scheme. It cannot sign the link statement. Instead, its binding is proven indirectly: the secp256k1 signature covers the ML-KEM public key in the content, and the ML-KEM key is derived from the same seed. The proof is: "the entity that controls the secp256k1 key (proven by Schnorr signature) asserts that this ML-KEM key is also theirs."
|
||||
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
## Component 3: OpenTimestamps for Pre-Quantum Anchoring
|
||||
|
||||
NIP-03 (OpenTimestamps Attestations for Events) already exists and is implemented in nostr_core_lib. It anchors Nostr event hashes to the Bitcoin blockchain via OpenTimestamps.
|
||||
|
||||
### Why Bitcoin Timestamping Is Quantum-Resistant
|
||||
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
### The Critical Refinement: OpenTimestamp the Key-Link Event
|
||||
|
||||
The key-link event itself MUST be timestamped via NIP-03. This prevents a race condition attack:
|
||||
|
||||
**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
|
||||
- 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
|
||||
- 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 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.
|
||||
|
||||
---
|
||||
|
||||
## Component 4: Migrating Existing Users with Raw nsec
|
||||
|
||||
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.
|
||||
|
||||
### The Challenge
|
||||
|
||||
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: 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 HKDF]
|
||||
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 (HKDF), 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 |
|
||||
|
||||
---
|
||||
|
||||
## Component 5: Quantum-Safe Self-Storage
|
||||
|
||||
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:
|
||||
|
||||
### Option A: One-Time Pad (Information-Theoretic Security)
|
||||
|
||||
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 |
|
||||
|
||||
---
|
||||
|
||||
## Component 6: Deterministic Wallet Compartmentalization
|
||||
|
||||
BIP32/BIP39 deterministic wallets (NIP-06) provide meaningful quantum compartmentalization through their tree structure.
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
| Scenario | Compromised? |
|
||||
|---|---|
|
||||
| One leaf key broken via Shor's | Only that key |
|
||||
| Multiple leaf keys from same account broken | Only those keys |
|
||||
| Leaf key broken + chain code at that level known | Parent + all siblings |
|
||||
| Account key broken + chain code at account level known | Parent + all accounts |
|
||||
| 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 raw nsec OR seed phrase]
|
||||
USER --> MIGRATE[Client: Enable PQ migration]
|
||||
MIGRATE --> SEED[Generate or use 12-word BIP39 phrase]
|
||||
SEED --> DERIVE[Derive: new secp256k1 + all PQ keys + storage key]
|
||||
DERIVE --> ENCRYPT[Encrypt old nsec if migrating from raw nsec]
|
||||
ENCRYPT --> STORE30078[Publish kind 30078: encrypted old nsec]
|
||||
DERIVE --> KEYLINK[Publish key-link event: cross-signed by all PQ schemes]
|
||||
KEYLINK --> OTS[OpenTimestamp the key-link event via NIP-03]
|
||||
OTS --> BTC[Anchored in Bitcoin block]
|
||||
KEYLINK --> RELAY[Published to relays]
|
||||
STORE30078 --> RELAY
|
||||
SEED --> BACKUP[User writes down 12-word phrase]
|
||||
end
|
||||
|
||||
subgraph TRANSITION["Transition period - dual signing"]
|
||||
DUAL[Client signs events with both secp256k1 and PQ keys]
|
||||
DUAL --> LEGACY[Legacy clients verify secp256k1 signature]
|
||||
DUAL --> PQAWARE[PQ-aware clients verify PQ signature]
|
||||
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]
|
||||
ATTACK --> CANNOT3[Cannot break OTP-encrypted storage]
|
||||
|
||||
PQAWARE --> VERIFY[Verify key-link event via OTS]
|
||||
VERIFY --> USEPQ[Switch to PQ signatures]
|
||||
USEPQ --> REJECT[Reject forged secp256k1-only events]
|
||||
end
|
||||
|
||||
subgraph RETIRE["Old key retirement"]
|
||||
DROP[User drops extra backup, keeps 12 words only]
|
||||
DROP --> PQONLY[Client uses PQ keys only]
|
||||
PQONLY --> DONE[Migration complete]
|
||||
end
|
||||
|
||||
NOW --> TRANSITION --> FUTURE --> RETIRE
|
||||
|
||||
style NOW fill:#cfc
|
||||
style TRANSITION fill:#ffa
|
||||
style FUTURE fill:#fcc
|
||||
style RETIRE fill:#cfc
|
||||
```
|
||||
|
||||
### User Experience
|
||||
|
||||
1. User clicks "Enable quantum-safe migration" in their client
|
||||
2. Client generates a 12-word phrase, shows it to the user to write down
|
||||
3. Client handles everything else automatically:
|
||||
- Derives new secp256k1 key + all PQ keys from seed
|
||||
- Encrypts old nsec and publishes as kind 30078 (if migrating from raw nsec)
|
||||
- Publishes cross-signed key-link event
|
||||
- OpenTimestamps the key-link event via NIP-03
|
||||
4. During transition: client signs with both old and new keys
|
||||
5. After quantum migration: client uses PQ keys only, old key retired
|
||||
6. User drops extra backup, keeps 12 words
|
||||
|
||||
---
|
||||
|
||||
## 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) 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-65 | ~3.3 KB |
|
||||
| SLH-DSA-128s | ~8 KB |
|
||||
| FN-DSA-512 | ~0.7 KB |
|
||||
|
||||
This increases event sizes during the transition period (dual signing). After migration, events can be signed with PQ only. FN-DSA (Falcon) offers the most compact PQ signatures.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Status
|
||||
|
||||
This is a design document. The following building blocks already exist:
|
||||
|
||||
### Existing Infrastructure
|
||||
|
||||
| Component | Status | Location |
|
||||
|---|---|---|
|
||||
| NIP-06 (key derivation from mnemonic) | Implemented | nostr_core_lib: `nip006.c` |
|
||||
| NIP-03 (OpenTimestamps) | Implemented | nostr_core_lib: `nip003.c` |
|
||||
| NIP-44 (encrypted payloads) | Implemented | nostr_core_lib: `nip044.c` |
|
||||
| NIP-04 (encrypted DMs, deprecated) | Implemented | nostr_core_lib: `nip004.c` |
|
||||
| OTP cipher (information-theoretic encryption) | Implemented | ~/lt/otp |
|
||||
|
||||
### To Be Built
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| Key-link NIP | New NIP defining the cross-signed key-link event format |
|
||||
| PQ key derivation from BIP39 seeds | HKDF-based derivation of ML-DSA, SLH-DSA, FN-DSA, ML-KEM keys |
|
||||
| PQ signature support | Integration of liboqs or similar for PQ signature verification |
|
||||
| Migration tooling | Client-side tooling for migrating raw nsec users to seed phrases |
|
||||
| Kind 30078 encrypted storage | Symmetric-key or OTP encryption for self-storage |
|
||||
| Extended phrase encoding | 36-word phrase format (Approach A/C) |
|
||||
|
||||
### Dependencies
|
||||
|
||||
- [liboqs](https://github.com/open-quantum-safe/liboqs) — Open Quantum Safe library for PQ algorithms
|
||||
- [OpenTimestamps](https://opentimestamps.org/) — already integrated via NIP-03
|
||||
- BIP39/BIP32 — already integrated via NIP-06
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [NIP-03: OpenTimestamps Attestations for Events](https://github.com/nostr-protocol/nips/blob/master/03.md)
|
||||
- [NIP-06: Basic key derivation from mnemonic seed phrase](https://github.com/nostr-protocol/nips/blob/master/06.md)
|
||||
- [NIP-44: Encrypted Payloads (Versioned)](https://github.com/nostr-protocol/nips/blob/master/44.md)
|
||||
- [BIP39: Mnemonic code for generating deterministic keys](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki)
|
||||
- [BIP32: Hierarchical Deterministic Wallets](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki)
|
||||
- [NIST FIPS 203: ML-KEM (Kyber)](https://csrc.nist.gov/pubs/fips/203/final)
|
||||
- [NIST FIPS 204: ML-DSA (Dilithium)](https://csrc.nist.gov/pubs/fips/204/final)
|
||||
- [NIST FIPS 205: SLH-DSA (SPHINCS+)](https://csrc.nist.gov/pubs/fips/205/final)
|
||||
- [OpenTimestamps](https://opentimestamps.org/)
|
||||
- [Open Quantum Safe](https://openquantumsafe.org/)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This document is released into the public domain. The migration strategy described herein is intended as a community resource for the Nostr ecosystem.
|
||||
Binary file not shown.
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Build script for the post-quantum crypto bundle.
|
||||
* Bundles pq-crypto.mjs and all its dependencies into a single
|
||||
* ESM file that can be loaded directly in the browser.
|
||||
*
|
||||
* Usage: node build-pq-bundle.js
|
||||
*/
|
||||
|
||||
const esbuild = require('esbuild');
|
||||
const path = require('path');
|
||||
|
||||
async function build() {
|
||||
console.log('🔧 Building PQ crypto bundle...');
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ['www/js/pq-crypto.mjs'],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
target: ['es2020'],
|
||||
outfile: 'www/pq-crypto.bundle.js',
|
||||
sourcemap: true,
|
||||
minify: false, // keep readable for demo
|
||||
logLevel: 'info',
|
||||
// Pure JS — no WASM files to handle
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ PQ crypto bundle built: www/pq-crypto.bundle.js');
|
||||
}
|
||||
|
||||
build().catch(err => {
|
||||
console.error('❌ Build failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
# How the NIP-QR Event Is Constructed: Step by Step
|
||||
|
||||
## 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.
|
||||
|
||||
Here's the exact flow:
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Generate a new seed phrase
|
||||
|
||||
```
|
||||
random entropy (128 bits) → BIP39 mnemonic (12 words)
|
||||
```
|
||||
|
||||
Example: `apple garden forest ocean desert meadow river bridge mountain sunset valley horizon`
|
||||
|
||||
This is just 128 bits of random entropy encoded as 12 words. It contains no cryptographic keys yet. It's algorithm-agnostic — it's just randomness.
|
||||
|
||||
## Step 2: Convert the mnemonic to a BIP39 seed
|
||||
|
||||
```
|
||||
mnemonic → PBKDF2-HMAC-SHA512(mnemonic, "mnemonic", 2048 iterations) → 64-byte seed
|
||||
```
|
||||
|
||||
This is the standard BIP39 derivation. The 12 words become a 64-byte (512-bit) seed. This seed is still just entropy — it's not a key for any specific algorithm.
|
||||
|
||||
## Step 3: Create the BIP32 master key
|
||||
|
||||
```
|
||||
64-byte BIP39 seed → BIP32 master key (HMAC-SHA512(seed, "Bitcoin seed"))
|
||||
```
|
||||
|
||||
This is the root of the HD wallet tree. From here, we derive all keys — both secp256k1 and PQ — down standard BIP32 paths.
|
||||
|
||||
## Step 4: Derive all keys from BIP32 paths
|
||||
|
||||
All keys are derived under the NIP-06 base path `m/44'/1237'/0'/0/`, using different child indices:
|
||||
|
||||
| Child index | Algorithm | Seed length | BIP32 path | How derived |
|
||||
|---|---|---|---|---|
|
||||
| 0 | secp256k1 (NIP-06) | 32 bytes | `m/44'/1237'/0'/0/0` | Standard BIP32 — private key used directly |
|
||||
| 1 | ML-DSA-44 | 32 bytes | `m/44'/1237'/0'/0/1` | Single child — 32-byte private key is the PQ seed |
|
||||
| 2 | ML-DSA-65 | 32 bytes | `m/44'/1237'/0'/0/2` | Single child — 32-byte private key is the PQ seed |
|
||||
| 3+4 | SLH-DSA-128s | 48 bytes | `m/44'/1237'/0'/0/3` + `m/44'/1237'/0'/0/4` | Two children concatenated (64 bytes), first 48 used |
|
||||
| 5+6 | Falcon-512 | 48 bytes | `m/44'/1237'/0'/0/5` + `m/44'/1237'/0'/0/6` | Two children concatenated (64 bytes), first 48 used |
|
||||
| 7+8 | ML-KEM-768 | 64 bytes | `m/44'/1237'/0'/0/7` + `m/44'/1237'/0'/0/8` | Two children concatenated (64 bytes) |
|
||||
|
||||
### Why some algorithms need two children
|
||||
|
||||
BIP32 child derivation produces exactly 32 bytes (the private key) per child. Some PQ algorithms need more than 32 bytes for their `keygen()` seed:
|
||||
|
||||
- **ML-DSA-44** and **ML-DSA-65**: need 32 bytes — one child is enough
|
||||
- **SLH-DSA-128s**: needs 48 bytes — derive two children (64 bytes), use the first 48
|
||||
- **Falcon-512**: needs 48 bytes — derive two children (64 bytes), use the first 48
|
||||
- **ML-KEM-768**: needs 64 bytes — derive two children and concatenate (64 bytes)
|
||||
|
||||
### Why BIP32 and not HKDF?
|
||||
|
||||
BIP32 is the standard for HD wallets. Using BIP32 paths gives us:
|
||||
|
||||
1. **Consistency with NIP-06** — PQ keys are derived the same way as secp256k1 keys, just at different child indices
|
||||
2. **Compartmentalization** — hardened derivation at the account level means breaking one key doesn't compromise siblings
|
||||
3. **Wallet compatibility** — HD wallet infrastructure (Amber, hardware wallets, etc.) can derive these same paths
|
||||
4. **Tree structure** — the derivation path documents exactly which key is where
|
||||
5. **Security** — BIP32 uses HMAC-SHA512 (the same HMAC used everywhere in HD wallets), not a custom construction
|
||||
|
||||
### What is HMAC?
|
||||
|
||||
HMAC (Hash-based Message Authentication Code) is the cryptographic primitive that BIP32 is built on. Every BIP32 child key derivation is:
|
||||
|
||||
```
|
||||
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.
|
||||
|
||||
## Step 5: Generate PQ keypairs from the BIP32-derived seeds
|
||||
|
||||
Each PQ algorithm's `keygen()` function takes the BIP32-derived seed and deterministically produces a keypair:
|
||||
|
||||
```
|
||||
child 1 (32 bytes) → ML-DSA-44 keygen() → {publicKey: 1312 bytes, secretKey: 2560 bytes}
|
||||
child 2 (32 bytes) → ML-DSA-65 keygen() → {publicKey: 1952 bytes, secretKey: 4032 bytes}
|
||||
child 3+4 (48 bytes) → SLH-DSA-128s keygen() → {publicKey: 32 bytes, secretKey: 64 bytes}
|
||||
child 5+6 (48 bytes) → Falcon-512 keygen() → {publicKey: 897 bytes, secretKey: 1281 bytes}
|
||||
child 7+8 (64 bytes) → ML-KEM-768 keygen() → {publicKey: 1184 bytes, secretKey: 2400 bytes}
|
||||
```
|
||||
|
||||
The `keygen(seed)` functions in `@noble/post-quantum` use the seed as a deterministic RNG to drive key generation. This means:
|
||||
|
||||
- **Same seed phrase → same BIP32 master key → same child keys → same PQ keypairs**
|
||||
- The PQ key generation is deterministic and reproducible
|
||||
- If a user loses their PQ keys, they can re-derive them from the seed phrase
|
||||
|
||||
## Step 6: Construct the link statement
|
||||
|
||||
A text statement is created that binds all the keys together:
|
||||
|
||||
```
|
||||
"Identity <Account #1 pubkey> is migrating to successor <Account #2 pubkey>.
|
||||
All PQ keys listed below are derived from the same BIP39 seed as <Account #2 pubkey>.
|
||||
This link is established pre-quantum."
|
||||
```
|
||||
|
||||
This statement is what gets signed by all the keys.
|
||||
|
||||
## Step 7: Sign the statement with each PQ private key
|
||||
|
||||
The statement (as bytes) is signed independently by each PQ signature scheme:
|
||||
|
||||
```
|
||||
statement_bytes → ML-DSA-44 sign(statement_bytes, ml_dsa44_secretKey) → signature (2420 bytes)
|
||||
statement_bytes → ML-DSA-65 sign(statement_bytes, ml_dsa65_secretKey) → signature (3309 bytes)
|
||||
statement_bytes → SLH-DSA-128s sign(statement_bytes, slh_dsa_secretKey) → signature (7856 bytes)
|
||||
statement_bytes → Falcon-512 sign(statement_bytes, falcon_secretKey) → signature (~666 bytes)
|
||||
```
|
||||
|
||||
ML-KEM-768 does NOT sign — it's a KEM (Key Encapsulation Mechanism), not a signature scheme. Its public key is included in the event, and its ownership is proven by the secp256k1 signature covering it.
|
||||
|
||||
## Step 8: Sign the statement with Account #2's secp256k1 key
|
||||
|
||||
The successor's secp256k1 private key (derived at `m/44'/1237'/0'/0/0`) signs the statement + all PQ public keys:
|
||||
|
||||
```
|
||||
statement + pq_public_keys → Schnorr sign(secp256k1_private_key) → successor_signature
|
||||
```
|
||||
|
||||
This proves that the seed-derived secp256k1 key (Account #2) endorses the PQ keys. Since both Account #2's secp256k1 key and the PQ keys come from the same BIP32 tree (same seed), this transitively proves PQ key ownership.
|
||||
|
||||
## Step 9: Build the NIP-QR event content
|
||||
|
||||
All the pieces are assembled into a JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"statement": "Identity <pubkey1> is migrating to successor <pubkey2>...",
|
||||
"successor_pubkey": "<Account #2 secp256k1 pubkey (hex)>",
|
||||
"successor_signature": "<Account #2 Schnorr signature over statement+pq_keys>",
|
||||
"pq_keys": [
|
||||
{
|
||||
"algorithm": "ml-dsa-44",
|
||||
"public_key": "<ML-DSA-44 public key (base64)>",
|
||||
"signature": "<ML-DSA-44 signature over statement (base64)>"
|
||||
},
|
||||
{
|
||||
"algorithm": "ml-dsa-65",
|
||||
"public_key": "<ML-DSA-65 public key (base64)>",
|
||||
"signature": "<ML-DSA-65 signature over statement (base64)>"
|
||||
},
|
||||
{
|
||||
"algorithm": "slh-dsa-128s",
|
||||
"public_key": "<SLH-DSA-128s public key (base64)>",
|
||||
"signature": "<SLH-DSA-128s signature over statement (base64)>"
|
||||
},
|
||||
{
|
||||
"algorithm": "falcon-512",
|
||||
"public_key": "<Falcon-512 public key (base64)>",
|
||||
"signature": "<Falcon-512 signature over statement (base64)>"
|
||||
},
|
||||
{
|
||||
"algorithm": "ml-kem-768",
|
||||
"public_key": "<ML-KEM-768 public key (base64)>",
|
||||
"note": "KEM key for encryption; ownership asserted by secp256k1 signature over this content"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Step 10: Build the Nostr event and sign with Account #1
|
||||
|
||||
The JSON content is placed into a standard Nostr event:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30078,
|
||||
"pubkey": "<Account #1 secp256k1 pubkey (hex)>",
|
||||
"created_at": 1720780000,
|
||||
"tags": [
|
||||
["d", "nip-qr-migration"],
|
||||
["successor", "<Account #2 secp256k1 pubkey (hex)>"],
|
||||
["algorithm", "ml-dsa-44"],
|
||||
["algorithm", "ml-dsa-65"],
|
||||
["algorithm", "slh-dsa-128s"],
|
||||
["algorithm", "falcon-512"],
|
||||
["algorithm", "ml-kem-768"]
|
||||
],
|
||||
"content": "<JSON string from Step 9>"
|
||||
}
|
||||
```
|
||||
|
||||
This unsigned event is sent to the user's signer (via `window.nostr.signEvent()`), which signs it with Account #1's secp256k1 private key — the user's existing Nostr identity. The signer returns the `id` and `sig` fields.
|
||||
|
||||
## Step 11: Publish the complete event
|
||||
|
||||
The fully signed event is published to Nostr relays.
|
||||
|
||||
---
|
||||
|
||||
## Summary: Who Signs What
|
||||
|
||||
| Signer | What they sign | Signature type | Purpose |
|
||||
|---|---|---|---|
|
||||
| ML-DSA-44 private key | The link statement | PQ signature (lattice, Cat 2) | Proves PQ key ownership post-quantum |
|
||||
| ML-DSA-65 private key | The link statement | PQ signature (lattice, Cat 3) | Higher security level PQ signature |
|
||||
| SLH-DSA-128s private key | The link statement | PQ signature (hash-based, Cat 1) | Conservative fallback if lattice schemes break |
|
||||
| Falcon-512 private key | The link statement | PQ signature (lattice, Cat 1) | Compact signatures, compatible with trbouma's PR |
|
||||
| Account #2 secp256k1 key | Statement + all PQ public keys | Schnorr signature | Proves seed-derived key endorses PQ keys |
|
||||
| Account #1 secp256k1 key | The entire Nostr event | Schnorr signature | Old identity authorizes the migration |
|
||||
| ML-KEM-768 | (nothing — KEM, not signature) | N/A | Ownership proven by Account #2's signature covering its public key |
|
||||
|
||||
## Summary: BIP32 Key Derivation Tree
|
||||
|
||||
```
|
||||
12-word mnemonic (128 bits entropy)
|
||||
|
|
||||
| PBKDF2-HMAC-SHA512 (2048 iterations)
|
||||
v
|
||||
64-byte BIP39 seed
|
||||
|
|
||||
| HMAC-SHA512 (BIP32 master key derivation)
|
||||
v
|
||||
BIP32 master key
|
||||
|
|
||||
| derive m/44'/1237'/0'/0/ (NIP-06 base path)
|
||||
v
|
||||
Account 0, change 0
|
||||
|
|
||||
+--→ child 0 → secp256k1 keypair (Account #2, NIP-06)
|
||||
| |
|
||||
| v
|
||||
| signs the link statement (Schnorr)
|
||||
|
|
||||
+--→ child 1 → 32-byte seed → ML-DSA-44 keypair
|
||||
| |
|
||||
| v
|
||||
| signs the link statement (ML-DSA-44)
|
||||
|
|
||||
+--→ child 2 → 32-byte seed → ML-DSA-65 keypair
|
||||
| |
|
||||
| v
|
||||
| signs the link statement (ML-DSA-65)
|
||||
|
|
||||
+--→ child 3+4 → 48-byte seed → SLH-DSA-128s keypair
|
||||
| |
|
||||
| v
|
||||
| signs the link statement (SLH-DSA-128s)
|
||||
|
|
||||
+--→ child 5+6 → 48-byte seed → Falcon-512 keypair
|
||||
| |
|
||||
| v
|
||||
| signs the link statement (Falcon-512)
|
||||
|
|
||||
+--→ child 7+8 → 64-byte seed → ML-KEM-768 keypair
|
||||
|
|
||||
v
|
||||
(no signature — KEM, used for encryption)
|
||||
```
|
||||
|
||||
The key insight: **the seed phrase is not a key — it's the root of a BIP32 HD wallet tree.** Each PQ algorithm gets its own child key at a specific derivation path, just like secp256k1 keys are derived at `m/44'/1237'/0'/0/0`. The same seed always produces the same tree, making all keys recoverable.
|
||||
|
||||
## The 5 PQ Algorithms
|
||||
|
||||
| Algorithm | FIPS | Type | Security level | Key size | Sig size | Why included |
|
||||
|---|---|---|---|---|---|---|
|
||||
| ML-DSA-44 | 204 | Signature | Cat 2 (~AES-128) | 1312 B | 2420 B | Used by trbouma's PR #2185 — compatibility |
|
||||
| ML-DSA-65 | 204 | Signature | Cat 3 (~AES-192) | 1952 B | 3309 B | NIST-recommended minimum for new deployments |
|
||||
| SLH-DSA-128s | 205 | Signature | Cat 1 (hash-based) | 32 B | 7856 B | Conservative fallback — if all lattice schemes break |
|
||||
| Falcon-512 | 206 (draft) | Signature | Cat 1 (~AES-128) | 897 B | ~666 B | Used by trbouma's PR — compact signatures |
|
||||
| ML-KEM-768 | 203 | KEM | Cat 3 (~AES-192) | 1184 B | N/A | PQ encryption — replaces ECDH in NIP-04/NIP-44 |
|
||||
|
||||
We include all 5 because the multi-scheme design means we don't have to pick one. If any single algorithm is later broken, the others remain valid. This is the key advantage over approaches that pick a single algorithm.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Laan's explanation
|
||||
|
||||
1. Create a new fresh seed phrase. This will be your post quantum (pq) nostr account.
|
||||
2. With this new seed phrase, we are going to create 5 different nsec npub key pairs. Each one using a different post quantum algorythm.
|
||||
3. Construct the link statement
|
||||
|
||||
A text statement is created that binds all the keys together:
|
||||
|
||||
```
|
||||
"Identity <Account #1 pubkey> is migrating to successor <Account #2 pubkey>.
|
||||
All PQ keys listed below are derived from the same BIP39 seed as <Account #2 pubkey>.
|
||||
This link is established pre-quantum."
|
||||
```
|
||||
|
||||
This statement is what gets signed by all the keys.
|
||||
2. With your old account, create an event.
|
||||
3.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "post_quantum_nostr",
|
||||
"version": "1.0.0",
|
||||
"description": "A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.1"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
# OpenTimestamps Integration Plan
|
||||
|
||||
## Why OpenTimestamps?
|
||||
|
||||
The NIP-QR migration event establishes a link between a Nostr identity and post-quantum keys. This link must be provably created **before** quantum computers exist. If an attacker later breaks secp256k1 via Shor's algorithm, they could forge a fraudulent NIP-QR event with a different set of PQ keys. Without timestamping, clients cannot distinguish the real event from a forged one.
|
||||
|
||||
OpenTimestamps anchors the event to a Bitcoin block, proving it existed at a specific time. Re-mining historical Bitcoin 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).
|
||||
|
||||
## How OpenTimestamps Works
|
||||
|
||||
1. **Submit a hash** to an OpenTimestamps calendar server (e.g., `https://alice.btc.calendar.opentimestamps.org`)
|
||||
2. The calendar server aggregates hashes from multiple users into a Merkle tree
|
||||
3. The calendar server commits the Merkle root to a Bitcoin transaction (OP_RETURN)
|
||||
4. The server returns a **pending .ots file** — a proof file that contains the Merkle path but no Bitcoin confirmation yet
|
||||
5. **Wait for the Bitcoin transaction to confirm** (typically 1-3 blocks, ~10-30 minutes)
|
||||
6. The .ots file can then be **upgraded** to include the Bitcoin confirmation proof
|
||||
7. The final .ots file can be **verified** against the Bitcoin blockchain
|
||||
|
||||
## NIP-03: OpenTimestamps Attestations for Events
|
||||
|
||||
NIP-03 defines `kind: 1040` — an event that contains an OTS proof for another event:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1040,
|
||||
"tags": [
|
||||
["e", "<target-event-id>", "<relay-url>"],
|
||||
["k", "<target-event-kind>"]
|
||||
],
|
||||
"content": "<base64-encoded OTS file data>"
|
||||
}
|
||||
```
|
||||
|
||||
- The OTS proof MUST prove the referenced event's `id` (which is `SHA-256(serialized event)`)
|
||||
- The content MUST be the full content of an `.ots` file with at least one Bitcoin attestation
|
||||
- NIP-03 is marked `unrecommended` but is the only standard for OTS in Nostr
|
||||
|
||||
## The Plan
|
||||
|
||||
### Step 1: Submit the NIP-QR event hash to OpenTimestamps
|
||||
|
||||
After the NIP-QR event is signed (step 4 of our flow), before or after publishing to relays:
|
||||
|
||||
```
|
||||
POST https://alice.btc.calendar.opentimestamps.org/timestamp
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Body: <raw 32-byte SHA-256 hash of the event id>
|
||||
```
|
||||
|
||||
The event `id` IS the SHA-256 hash (it's `SHA-256(serialized event)` in hex). We need to:
|
||||
1. Convert the event `id` (hex string) to raw 32 bytes
|
||||
2. POST those bytes to the calendar server
|
||||
3. Receive the .ots file (binary) in the response
|
||||
|
||||
### Step 2: Save the pending .ots file
|
||||
|
||||
The response from the calendar server is a binary .ots file (pending proof). Store this:
|
||||
- In browser memory (for immediate use)
|
||||
- Offer the user a download link (so they can keep it)
|
||||
|
||||
### Step 3: Wait for Bitcoin confirmation
|
||||
|
||||
The .ots proof is initially "pending" — it contains the Merkle path but no Bitcoin block confirmation. The user needs to wait for the calendar server to commit to Bitcoin (typically 1-3 blocks).
|
||||
|
||||
Options:
|
||||
- **Option A**: Show the user a "pending" status and a link to upgrade later
|
||||
- **Option B**: Poll the calendar server's upgrade endpoint periodically
|
||||
- **Option C**: Use a WebSocket or long-poll to wait for confirmation
|
||||
|
||||
For the demo, **Option A** is simplest: show the pending .ots file, explain it needs time to confirm, and provide instructions for upgrading later.
|
||||
|
||||
### Step 4: Upgrade the .ots file (later)
|
||||
|
||||
The pending .ots file can be upgraded by submitting it to the calendar server:
|
||||
|
||||
```
|
||||
POST https://alice.btc.calendar.opentimestamps.org
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Body: <raw .ots file bytes>
|
||||
```
|
||||
|
||||
The server returns the upgraded .ots file with Bitcoin confirmation proof.
|
||||
|
||||
For the demo, this can be a manual step the user does later (or we can poll automatically).
|
||||
|
||||
### Step 5: Publish the NIP-03 event (kind 1040)
|
||||
|
||||
Once the .ots file is confirmed (has a Bitcoin attestation), publish a `kind: 1040` event:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1040,
|
||||
"pubkey": "<user's pubkey>",
|
||||
"tags": [
|
||||
["e", "<NIP-QR event id>", "<relay-url>"],
|
||||
["k", "30078"]
|
||||
],
|
||||
"content": "<base64-encoded .ots file>"
|
||||
}
|
||||
```
|
||||
|
||||
This event is signed by the user's secp256k1 key (via `window.nostr.signEvent()`) and published to relays.
|
||||
|
||||
### Step 6: Verify the OTS proof
|
||||
|
||||
The verification page (`verify.html`) can also verify the OTS proof:
|
||||
1. Find the kind 1040 event referencing the NIP-QR event
|
||||
2. Extract the .ots file from the content (base64-decode)
|
||||
3. Verify the .ots proof against the Bitcoin blockchain
|
||||
4. This proves the NIP-QR event existed at the time of the Bitcoin block
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Purpose | URL | Method |
|
||||
|---|---|---|
|
||||
| Submit hash for timestamping | `https://alice.btc.calendar.opentimestamps.org/timestamp` | POST (raw bytes) |
|
||||
| Upgrade pending .ots | `https://alice.btc.calendar.opentimestamps.org` | POST (raw .ots bytes) |
|
||||
| Verify .ots proof | Client-side verification (no server needed) | — |
|
||||
|
||||
Multiple calendar servers can be used for redundancy:
|
||||
- `https://alice.btc.calendar.opentimestamps.org`
|
||||
- `https://bob.btc.calendar.opentimestamps.org`
|
||||
- `https://btc.calendar.catallaxy.com`
|
||||
|
||||
### Browser Implementation
|
||||
|
||||
The OpenTimestamps API uses raw binary POST requests, which can be done with `fetch()`:
|
||||
|
||||
```javascript
|
||||
// Submit event hash for timestamping
|
||||
async function timestampEvent(eventIdHex) {
|
||||
const hashBytes = hexToBytes(eventIdHex); // 32 bytes
|
||||
const response = await fetch('https://alice.btc.calendar.opentimestamps.org/timestamp', {
|
||||
method: 'POST',
|
||||
body: hashBytes
|
||||
});
|
||||
const otsFile = await response.arrayBuffer(); // binary .ots file
|
||||
return new Uint8Array(otsFile);
|
||||
}
|
||||
```
|
||||
|
||||
### UI Changes
|
||||
|
||||
Add a new step to the checklist:
|
||||
1. Sign in with your Nostr identity
|
||||
2. Generate a quantum-safe seed phrase
|
||||
3. Derive post-quantum keys from seed
|
||||
4. Sign the NIP-QR migration event
|
||||
5. Publish event to relays
|
||||
6. **Timestamp the event with OpenTimestamps** (new)
|
||||
7. Verify the event signatures (link to verify.html)
|
||||
|
||||
After publishing to relays (step 5), the page automatically:
|
||||
1. Submits the event hash to the OpenTimestamps calendar
|
||||
2. Receives the pending .ots file
|
||||
3. Shows the .ots file (base64) and a download link
|
||||
4. Explains that the proof needs Bitcoin confirmation (10-30 minutes)
|
||||
5. Offers to check/upgrade the proof later
|
||||
|
||||
### OTS Verification
|
||||
|
||||
For verifying .ots files in the browser, we can use the `javascript-opentimestamps` library:
|
||||
- npm: `javascript-opentimestamps`
|
||||
- GitHub: https://github.com/opentimestamps/javascript-opentimestamps
|
||||
|
||||
This library can verify .ots files against a Bitcoin blockchain API (e.g., Blockstream's esplora API at `https://blockstream.info/api`).
|
||||
|
||||
## Updated Checklist
|
||||
|
||||
1. Sign in with your Nostr identity
|
||||
2. Generate a quantum-safe seed phrase
|
||||
3. Derive post-quantum keys from seed
|
||||
4. Sign the NIP-QR migration event
|
||||
5. Publish event to relays
|
||||
6. Timestamp the event with OpenTimestamps
|
||||
7. Verify the event signatures (link to verify.html)
|
||||
|
||||
## Updated Flow
|
||||
|
||||
```
|
||||
Sign in → Generate seed → Derive PQ keys → Sign event → Publish to relays
|
||||
→ Submit hash to OpenTimestamps → Receive pending .ots
|
||||
→ (wait for Bitcoin confirmation)
|
||||
→ Upgrade .ots → Publish kind 1040 event → Done
|
||||
```
|
||||
|
||||
## What to Build
|
||||
|
||||
1. **`timestampEvent(eventIdHex)`** function — submits hash to OTS calendar, returns .ots file
|
||||
2. **`upgradeOts(otsBytes)`** function — upgrades pending .ots to confirmed
|
||||
3. **UI for step 6** — shows OTS status, pending .ots file, download link, upgrade button
|
||||
4. **Publish kind 1040 event** — after .ots is confirmed, sign and publish the NIP-03 event
|
||||
5. **OTS verification in verify.html** — verify .ots proof against Bitcoin blockchain
|
||||
6. **Install `javascript-opentimestamps`** — for client-side .ots verification
|
||||
|
||||
## Considerations
|
||||
|
||||
- **CORS**: The OpenTimestamps calendar servers support CORS, so browser requests should work
|
||||
- **Time**: Bitcoin confirmation takes 10-30 minutes — the user may need to come back later
|
||||
- **Multiple calendars**: For redundancy, submit to multiple calendar servers
|
||||
- **NIP-03 unrecommended status**: We use it anyway because it's the only standard, and the concept is sound
|
||||
- **The event id IS the hash**: Nostr event ids are SHA-256 hashes, so we timestamp the id directly
|
||||
@@ -0,0 +1,186 @@
|
||||
# Amber Integration Analysis for PQ Nostr Web App
|
||||
|
||||
## 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:
|
||||
|
||||
### Seed Phrase Support ✅
|
||||
- **NIP-06 key derivation**: Uses `com.vitorpamplona.quartz.nip06KeyDerivation.Bip39Mnemonics` and `Nip06` for deriving secp256k1 keys from mnemonics
|
||||
- **Stores seed words**: `DataStoreAccess.SEED_WORDS` persists the mnemonic in Android encrypted storage ([`DataStoreAccess.kt`](../Amber/app/src/main/java/com/greenart7c3/nostrsigner/DataStoreAccess.kt:41))
|
||||
- **Generates new seeds**: Login screen generates random entropy → `Bip39Mnemonics.toMnemonics(entropy)` ([`LoginScreen.kt`](../Amber/app/src/main/java/com/greenart7c3/nostrsigner/ui/LoginScreen.kt:428))
|
||||
- **Mnemonic login**: UI supports 12 or 24 word entry with account index selection ([`MnemonicLoginInput.kt`](../Amber/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/MnemonicLoginInput.kt:48))
|
||||
- **Seed backup**: Account export includes encrypted seed words ([`AccountExportService.kt`](../Amber/app/src/main/java/com/greenart7c3/nostrsigner/service/AccountExportService.kt:193))
|
||||
|
||||
### Signing Operations ✅
|
||||
Amber supports these signer types ([`SignerType.kt`](../Amber/app/src/main/java/com/greenart7c3/nostrsigner/models/SignerType.kt:5)):
|
||||
- `SIGN_EVENT` — sign any Nostr event
|
||||
- `GET_PUBLIC_KEY` — return npub
|
||||
- `NIP04_ENCRYPT` / `NIP04_DECRYPT`
|
||||
- `NIP44_ENCRYPT` / `NIP44_DECRYPT`
|
||||
- `NIP44_V3_ENCRYPT` / `NIP44_V3_DECRYPT` (new v3 cipher with kind+scope context)
|
||||
- `DECRYPT_ZAP_EVENT`
|
||||
|
||||
### NIP-46 Remote Signing ✅
|
||||
- Amber acts as a NIP-46 signing device — "your smartphone act as a NIP-46 signing device without any need for servers or additional hardware"
|
||||
- Web apps can connect to Amber via NIP-46 (nostrconnect:// URI)
|
||||
- Supports permission management per-app
|
||||
|
||||
### Multiple Accounts ✅
|
||||
- Supports multiple accounts, each with its own nsec/seed words
|
||||
|
||||
---
|
||||
|
||||
## How Amber Fits the PQ Web App Vision
|
||||
|
||||
### The Key Insight
|
||||
|
||||
Amber already stores the **seed phrase** (BIP39 mnemonic) and uses it to derive secp256k1 keys via NIP-06. The seed phrase is algorithm-agnostic — it's just 128/256 bits of entropy. Our plan's **Component 1** (seed phrase as root of trust) means PQ keys can be derived from the *same seed* using different derivation paths.
|
||||
|
||||
### Three Architecture Options
|
||||
|
||||
#### Option A: Web App + Amber Signing (Minimal Amber Changes)
|
||||
|
||||
```
|
||||
┌─────────────────┐ NIP-46 ┌──────────────────┐
|
||||
│ Web App │ ◄──────────────► │ Amber │
|
||||
│ (browser) │ │ (Android) │
|
||||
│ │ │ │
|
||||
│ 1. User enters │ getPublicKey │ Returns npub │
|
||||
│ seed phrase │ ──────────────► │ │
|
||||
│ (client-side │ │ │
|
||||
│ only, WASM) │ signEvent │ Signs key-link │
|
||||
│ │ (key-link) │ event with │
|
||||
│ 2. Derives PQ │ ──────────────► │ secp256k1 │
|
||||
│ keys from │ │ │
|
||||
│ seed (WASM) │ │ │
|
||||
│ │ │ │
|
||||
│ 3. PQ-signs │ │ │
|
||||
│ key-link │ │ │
|
||||
│ client-side │ │ │
|
||||
│ │ │ │
|
||||
│ 4. Assembles & │ │ │
|
||||
│ publishes │ │ │
|
||||
│ key-link │ │ │
|
||||
│ event │ │ │
|
||||
└─────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
**Flow**:
|
||||
1. User connects to Amber via NIP-46 for secp256k1 signing
|
||||
2. User enters their seed phrase in the web app (client-side only, WASM, never sent to server)
|
||||
3. Web app derives PQ keys (ML-DSA, ML-KEM, SLH-DSA) from seed using liboqs-WASM
|
||||
4. Web app requests Amber to sign the key-link event content with secp256k1
|
||||
5. Web app PQ-signs the key-link event content client-side
|
||||
6. Web app assembles the complete key-link event (secp256k1 sig + PQ sigs) and publishes to relays
|
||||
7. Web app requests OpenTimestamps attestation via NIP-03
|
||||
|
||||
**Pros**: No Amber modifications needed. Full PQ key derivation in browser.
|
||||
**Cons**: User must enter seed phrase in browser (even though client-side only). Trust model requires user to verify the web app isn't exfiltrating the seed.
|
||||
|
||||
#### Option B: Extend Amber for PQ Key Derivation (Best Security)
|
||||
|
||||
```
|
||||
┌─────────────────┐ NIP-46 ┌──────────────────┐
|
||||
│ Web App │ ◄──────────────► │ Amber │
|
||||
│ (browser) │ │ (Android) │
|
||||
│ │ │ │
|
||||
│ 1. Connect to │ new method: │ Derives PQ keys │
|
||||
│ Amber via │ pq_derive_keys │ from stored seed │
|
||||
│ NIP-46 │ ──────────────► │ (liboqs native) │
|
||||
│ │ │ │
|
||||
│ 2. Requests PQ │ Returns PQ │ Returns PQ │
|
||||
│ pubkey list │ ◄────────────── │ pubkeys only │
|
||||
│ │ │ (privkeys stay │
|
||||
│ 3. Requests │ new method: │ in Amber) │
|
||||
│ key-link │ pq_sign │ │
|
||||
│ signing │ ──────────────► │ PQ-signs event │
|
||||
│ │ │ with PQ privkeys │
|
||||
│ 4. Requests │ signEvent │ │
|
||||
│ secp256k1 │ (existing) │ secp256k1 signs │
|
||||
│ signing │ ──────────────► │ │
|
||||
│ │ │ │
|
||||
│ 5. Assembles & │ │ │
|
||||
│ publishes │ │ │
|
||||
└─────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
**Flow**:
|
||||
1. User connects to Amber via NIP-46
|
||||
2. Web app calls new NIP-46 method `pq_derive_keys` — Amber derives PQ keys from its stored seed phrase using liboqs (native Android)
|
||||
3. Amber returns only the PQ *public* keys (private keys never leave Amber)
|
||||
4. Web app constructs the key-link event content with all pubkeys
|
||||
5. Web app calls `pq_sign` — Amber PQ-signs the event content with each PQ private key
|
||||
6. Web app calls `signEvent` — Amber secp256k1-signs the event
|
||||
7. Web app assembles and publishes the complete key-link event
|
||||
|
||||
**Pros**: Seed phrase never leaves Amber. PQ private keys never leave Amber. Best security model — consistent with Amber's philosophy of "private keys should be exposed to as few systems as possible."
|
||||
**Cons**: Requires Amber modifications (new NIP-46 methods, liboqs integration for Android).
|
||||
|
||||
#### Option C: Hybrid (Pragmatic)
|
||||
|
||||
```
|
||||
┌─────────────────┐ NIP-46 ┌──────────────────┐
|
||||
│ Web App │ ◄──────────────► │ Amber │
|
||||
│ (browser) │ │ (Android) │
|
||||
│ │ │ │
|
||||
│ User chooses: │ │ │
|
||||
│ │ │ │
|
||||
│ Path 1 (Amber │ signEvent │ Signs with │
|
||||
│ has seed): │ ──────────────► │ secp256k1 │
|
||||
│ Request secp │ │ │
|
||||
│ sig from Amber │ │ │
|
||||
│ Enter seed in │ │ │
|
||||
│ web app for PQ │ │ │
|
||||
│ key derivation │ │ │
|
||||
│ (client-side) │ │ │
|
||||
│ │ │ │
|
||||
│ Path 2 (raw │ signEvent │ Signs with │
|
||||
│ nsec, no seed │ ──────────────► │ secp256k1 │
|
||||
│ in Amber): │ │ │
|
||||
│ Generate new │ │ │
|
||||
│ seed in web app │ │ │
|
||||
│ (client-side), │ │ │
|
||||
│ derive PQ keys, │ │ │
|
||||
│ request secp │ │ │
|
||||
│ sig from Amber │ │ │
|
||||
└─────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
**Pros**: Works for both existing seed-phrase users and raw-nsec users. No Amber changes required for initial launch. Can upgrade to Option B later.
|
||||
**Cons**: Seed phrase still enters browser for PQ derivation. Two code paths.
|
||||
|
||||
---
|
||||
|
||||
## Recommendation: Option B (Extend Amber)
|
||||
|
||||
This aligns with:
|
||||
1. **Amber's security philosophy**: "Private keys should be exposed to as few systems as possible"
|
||||
2. **Our plan's Component 1**: Seed phrase as algorithm-agnostic root of trust — Amber already stores it
|
||||
3. **Our plan's Component 2**: Cross-signed key-link events — Amber can sign with both secp256k1 and PQ keys
|
||||
4. **Community discussion** (Issue #1971): trbouma and mikedilger both advocate for HSM/enclave-based PQ key storage
|
||||
|
||||
### Required Amber Changes
|
||||
|
||||
1. **Add liboqs dependency**: Native library for PQ algorithms (ML-DSA, ML-KEM, SLH-DSA)
|
||||
2. **PQ key derivation from seed**: New function that takes the stored BIP39 seed and derives PQ keys using a deterministic scheme (e.g., `HKDF(seed, "nostr-pq-ml-dsa-44")` → PQ private key)
|
||||
3. **New NIP-46 methods**:
|
||||
- `pq_get_public_keys` — return array of PQ public keys with algorithm identifiers
|
||||
- `pq_sign` — sign a message with a specified PQ algorithm's private key
|
||||
4. **Permission management**: New permission types for PQ signing operations
|
||||
5. **UI updates**: Show PQ key information, approval screens for PQ signing requests
|
||||
|
||||
### Required Web App Components
|
||||
|
||||
1. **NIP-46 client**: Connect to Amber, send signing requests
|
||||
2. **Key-link event builder**: Construct the event content with all pubkeys, request signatures, assemble final event
|
||||
3. **OpenTimestamps client**: Request OTS attestation for the key-link event (NIP-03)
|
||||
4. **Relay publisher**: Publish the key-link event to relays
|
||||
5. **Verification UI**: Show the user what was created, verify the key-link event is valid
|
||||
6. **No server-side secrets**: The web app is a static site — no backend that could intercept keys
|
||||
|
||||
### For Users Without Amber (or Without Seed Phrase in Amber)
|
||||
|
||||
The web app should also support:
|
||||
- **NIP-07 browser extension signing** (nos2x, etc.) for secp256k1 signatures
|
||||
- **Client-side seed phrase entry** (WASM, never transmitted) for users who want to derive PQ keys in-browser
|
||||
- **New seed generation** for users who don't have a seed phrase yet (Component 4: migrating existing users with raw nsec)
|
||||
@@ -0,0 +1,813 @@
|
||||
# Amber Integration Plan: End-User Experience
|
||||
|
||||
## 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:
|
||||
|
||||
- **Path A (Simple)**: User logged into Amber with a seed phrase (mnemonic). Amber already has the seed. PQ keys are derived from that same seed. No second account needed.
|
||||
- **Path B (Migration)**: User logged into Amber with a raw nsec. No seed phrase exists. A new seed must be generated and linked to the old identity via a two-account migration event.
|
||||
|
||||
The web app automatically detects which path applies by asking Amber for the account's seed words (or checking if they're empty).
|
||||
|
||||
---
|
||||
|
||||
## Critical Fact: Amber Does Not Tie Raw nsec to Seed Phrases
|
||||
|
||||
When a user enters a raw nsec in Amber, the seed words field is set to `null` ([`AccountStateViewModel.kt`](../Amber/app/src/main/java/com/greenart7c3/nostrsigner/ui/AccountStateViewModel.kt:144)):
|
||||
|
||||
```kotlin
|
||||
LocalPreferences.updatePrefsForLogin(
|
||||
Amber.instance, account,
|
||||
keyPair.pubKey.toHexKey(),
|
||||
keyPair.privKey!!.toHexKey(),
|
||||
null // ← seedWords is null for raw nsec login
|
||||
)
|
||||
```
|
||||
|
||||
| Login method | nsec stored | Seed words stored | Relationship |
|
||||
|---|---|---|---|
|
||||
| Raw nsec (`nsec1...`) | ✅ | `null` (empty) | None — nsec is random, no seed |
|
||||
| Mnemonic (12/24 words) | ✅ (derived from seed via NIP-06) | ✅ | nsec IS derived from seed |
|
||||
|
||||
A raw nsec is 32 random bytes. You cannot derive a seed phrase from it (preimage problem — impossible). You cannot derive the existing nsec from a new seed (different key). The only bridge between them is a **signed migration event**.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Amber** installed on Android phone (from F-Droid, GitHub Releases, or ZapStore)
|
||||
- **Existing Nostr account** already in Amber
|
||||
- **Desktop or mobile browser** to access the PQ web app
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Connect to the PQ Web App (Both Paths)
|
||||
|
||||
### Screen 1: Landing Page
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🔒 Post-Quantum Nostr Migration │
|
||||
│ │
|
||||
│ Make your Nostr identity quantum-resistant. │
|
||||
│ Your followers stay. Your npub doesn't change. │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Connect with Amber │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Connect with browser extension │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ How it works | FAQ | Source code │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User action**: Taps "Connect with Amber"
|
||||
|
||||
**Behind the scenes**:
|
||||
- Web app generates a random NIP-46 connection keypair
|
||||
- Web app displays a `nostrconnect://` URI or QR code
|
||||
- Web app opens a WebSocket to a relay, listening for Amber's response
|
||||
|
||||
### Screen 2: Connect to Amber
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Connect Amber │
|
||||
│ │
|
||||
│ Scan this QR code with Amber, or open this │
|
||||
│ link on your phone: │
|
||||
│ │
|
||||
│ ┌───────────────────┐ │
|
||||
│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │
|
||||
│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │
|
||||
│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │
|
||||
│ └───────────────────┘ │
|
||||
│ │
|
||||
│ nostrconnect://... │
|
||||
│ │
|
||||
│ Waiting for Amber... │
|
||||
│ ●●○○○ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User action**: Opens the link on their phone (or scans QR code)
|
||||
|
||||
### On Amber (Phone): Connection Request
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ pq-nostr.app wants to connect │
|
||||
│ │
|
||||
│ This app is requesting to: │
|
||||
│ • Read your public key │
|
||||
│ • Sign events │
|
||||
│ • Encrypt and decrypt messages │
|
||||
│ │
|
||||
│ Account: npub1abc... (Account #1) │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Approve │ │ Reject │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User action**: Taps "Approve"
|
||||
|
||||
**Behind the scenes**:
|
||||
- Amber sends a NIP-46 `connect` response to the web app via the relay
|
||||
- Web app now has a NIP-46 session with Amber
|
||||
- Web app requests `get_public_key` → receives the user's npub
|
||||
|
||||
### Screen 3: Account Detected — Path Selection
|
||||
|
||||
The web app asks Amber if the account has seed words. This determines which path to show.
|
||||
|
||||
**Behind the scenes**:
|
||||
- Web app requests seed words from Amber (via a new NIP-46 method, or by asking the user to check Amber's backup screen)
|
||||
- If seed words exist → **Path A (Simple)**
|
||||
- If seed words are empty → **Path B (Migration)**
|
||||
|
||||
---
|
||||
|
||||
## Path A: Simple Path (User Has Seed Phrase in Amber)
|
||||
|
||||
*This path applies to users who logged into Amber with a 12/24-word mnemonic. Amber already stores their seed phrase. PQ keys are derived from that same seed. No second account needed.*
|
||||
|
||||
### Screen A1: Seed Phrase Retrieval
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ✅ Connected to Amber │
|
||||
│ │
|
||||
│ Your identity: npub1abc...xyz │
|
||||
│ Account name: Alice │
|
||||
│ Seed phrase: ✅ Found in Amber │
|
||||
│ │
|
||||
│ Great! Your account already has a seed phrase. │
|
||||
│ We can derive quantum-resistant keys from it │
|
||||
│ without creating a new account. │
|
||||
│ │
|
||||
│ To proceed, we need your seed phrase to │
|
||||
│ derive the post-quantum keys in your browser. │
|
||||
│ The seed phrase will NOT be sent to any server. │
|
||||
│ │
|
||||
│ Option 1: Export from Amber │
|
||||
│ Open Amber → Account → Backup → Show Seed Words │
|
||||
│ Then type or paste them below. │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Enter your 12/24 seed words here... │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Continue │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User action**: Opens Amber's backup screen, copies seed words, pastes into web app
|
||||
|
||||
**Behind the scenes**:
|
||||
- Seed phrase is handled entirely client-side (WASM), never transmitted
|
||||
- Web app validates the mnemonic (BIP39 checksum)
|
||||
- Web app verifies the derived secp256k1 pubkey matches the connected Amber account
|
||||
|
||||
### Screen A2: Generating Quantum Keys
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Generating Quantum Keys... │
|
||||
│ │
|
||||
│ Deriving post-quantum keys from your seed... │
|
||||
│ │
|
||||
│ ✅ ML-DSA-65 (Dilithium) signature key │
|
||||
│ Public key: 1952 bytes │
|
||||
│ │
|
||||
│ ✅ SLH-DSA-128s (SPHINCS+) signature key │
|
||||
│ Public key: 32 bytes │
|
||||
│ │
|
||||
│ ✅ ML-KEM-768 (Kyber) encryption key │
|
||||
│ Public key: 1184 bytes │
|
||||
│ │
|
||||
│ All keys derived from your seed phrase. │
|
||||
│ Same seed → same keys, every time. │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Continue │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Behind the scenes**:
|
||||
- Web app uses liboqs-wasm to generate PQ keypairs deterministically from the BIP39 seed
|
||||
- PQ private keys exist only in browser memory (WASM), never sent anywhere
|
||||
|
||||
### Screen A3: Signing the NIP-QR Event
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Signing the Migration Event │
|
||||
│ │
|
||||
│ PQ signatures (done in browser): │
|
||||
│ ✅ ML-DSA-65 signature │
|
||||
│ ✅ SLH-DSA-128s signature │
|
||||
│ │
|
||||
│ Amber signature (need your approval): │
|
||||
│ ⏳ Signing the NIP-QR event... │
|
||||
│ │
|
||||
│ Please approve the signing request in Amber. │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Behind the scenes**:
|
||||
- Web app constructs the link statement:
|
||||
```
|
||||
"Identity <npub> is linked to the following PQ keys,
|
||||
all derived from the same BIP39 seed. This link is
|
||||
established pre-quantum."
|
||||
```
|
||||
- Web app PQ-signs the statement with each PQ private key (client-side WASM)
|
||||
- Web app sends a `signEvent` request to Amber to sign the NIP-QR event
|
||||
|
||||
### On Amber (Phone): Sign NIP-QR Event
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ pq-nostr.app wants to sign │
|
||||
│ │
|
||||
│ Account: npub1abc...xyz │
|
||||
│ │
|
||||
│ Event kind: <NIP-QR kind> │
|
||||
│ │
|
||||
│ This is your quantum migration event. │
|
||||
│ It links your identity to quantum-resistant │
|
||||
│ keys derived from your seed phrase. │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Approve │ │ Reject │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User action**: Taps "Approve"
|
||||
|
||||
**Behind the scenes**:
|
||||
- Amber signs the NIP-QR event with the secp256k1 private key
|
||||
- Since the secp256k1 key and PQ keys are all derived from the same seed, this single signature links them all
|
||||
- No second account needed — the existing account's signature is sufficient
|
||||
|
||||
### Screen A4: Publishing → Go to Phase 5
|
||||
|
||||
The web app publishes the event and requests OpenTimestamps (see Phase 5 below).
|
||||
|
||||
---
|
||||
|
||||
## Path B: Migration Path (User Has Raw nsec, No Seed Phrase)
|
||||
|
||||
*This path applies to users who logged into Amber with a raw nsec. No seed phrase exists. A new seed must be generated and linked to the old identity via a two-account migration.*
|
||||
|
||||
### Screen B1: Migration Required
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ✅ Connected to Amber │
|
||||
│ │
|
||||
│ Your identity: npub1abc...xyz │
|
||||
│ Account name: Alice │
|
||||
│ Seed phrase: ❌ Not found │
|
||||
│ │
|
||||
│ Your account was created with a raw private key │
|
||||
│ (nsec), not a seed phrase. To make your identity │
|
||||
│ quantum-resistant, we need to: │
|
||||
│ │
|
||||
│ 1. Generate a new seed phrase (your quantum-safe │
|
||||
│ backup) │
|
||||
│ 2. Add it to Amber as a second account │
|
||||
│ 3. Link your old identity to the new account │
|
||||
│ and its quantum keys │
|
||||
│ │
|
||||
│ Your npub won't change. Your followers stay. │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Start Migration │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User action**: Taps "Start Migration"
|
||||
|
||||
### Screen B2: Generate Seed Phrase
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Step 1 of 4: Your Quantum-Safe Backup │
|
||||
│ │
|
||||
│ We're generating a new seed phrase. This will │
|
||||
│ become your quantum-safe root of trust. │
|
||||
│ Write it down on paper. Never store it │
|
||||
│ digitally. Never share it with anyone. │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 1. apple 7. river │ │
|
||||
│ │ 2. garden 8. bridge │ │
|
||||
│ │ 3. forest 9. mountain │ │
|
||||
│ │ 4. ocean 10. sunset │ │
|
||||
│ │ 5. desert 11. valley │ │
|
||||
│ │ 6. meadow 12. horizon │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 📋 Copy 🖨️ Print │
|
||||
│ │
|
||||
│ ☐ I have written down my seed phrase │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Continue │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Behind the scenes**:
|
||||
- Web app generates 128 bits of random entropy (crypto.getRandomValues)
|
||||
- Web app converts entropy to 12-word BIP39 mnemonic (client-side, WASM)
|
||||
- Seed phrase exists only in browser memory — never sent to any server
|
||||
- Web app derives the BIP39 seed: `PBKDF2-HMAC-SHA512(mnemonic, "mnemonic", 2048)`
|
||||
- Web app derives Account #2's secp256k1 private key via NIP-06: `m/44'/1237'/0'/0/0`
|
||||
|
||||
**User action**: Writes down seed phrase, checks the box, taps "Continue"
|
||||
|
||||
### Screen B3: Verify Seed Phrase
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Step 1 of 4: Verify Your Backup │
|
||||
│ │
|
||||
│ Type word #3, #7, and #11 to confirm you │
|
||||
│ wrote it down correctly. │
|
||||
│ │
|
||||
│ Word #3: [ forest ] │
|
||||
│ Word #7: [ river ] │
|
||||
│ Word #11: [ valley ] │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Verify │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User action**: Types the three words, taps "Verify"
|
||||
|
||||
### Screen B4: Import Seed into Amber
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Step 2 of 4: Add to Amber │
|
||||
│ │
|
||||
│ You need to add this seed phrase to Amber as │
|
||||
│ a new account. This creates Account #2 — │
|
||||
│ your quantum-safe signing identity. │
|
||||
│ │
|
||||
│ On your phone, in Amber: │
|
||||
│ │
|
||||
│ 1. Tap the account switcher (top left) │
|
||||
│ 2. Tap "+ Add Account" │
|
||||
│ 3. Select "Recovery Phrase" │
|
||||
│ 4. Enter your 12 words │
|
||||
│ 5. Tap "Login" │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Account #2 npub: │ │
|
||||
│ │ npub1def...uvw │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ☐ I've added this account to Amber │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Continue │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Behind the scenes**:
|
||||
- Web app displays Account #2's npub (derived from the seed via NIP-06)
|
||||
- User needs to manually import the seed into Amber as a new account
|
||||
|
||||
### On Amber (Phone): Add Account
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Add Account │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Private Key │ │ Recovery │ │
|
||||
│ │ │ │ Phrase │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User action**: Taps "Recovery Phrase", enters 12 words, taps "Login"
|
||||
|
||||
**Behind the scenes**:
|
||||
- Amber derives secp256k1 key from mnemonic via NIP-06
|
||||
- Amber stores the nsec AND seed words in encrypted storage
|
||||
- Amber switches to Account #2
|
||||
|
||||
### Screen B5: Connect Account #2 to Web App
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Step 2 of 4: Connect Account #2 │
|
||||
│ │
|
||||
│ We need Amber to sign with Account #2 as well. │
|
||||
│ │
|
||||
│ In Amber, switch to Account #2 (npub1def...uvw), │
|
||||
│ then approve the connection request. │
|
||||
│ │
|
||||
│ ┌───────────────────┐ │
|
||||
│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │
|
||||
│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │
|
||||
│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │
|
||||
│ └───────────────────┘ │
|
||||
│ │
|
||||
│ nostrconnect://... │
|
||||
│ │
|
||||
│ Waiting for Amber (Account #2)... │
|
||||
│ ●●○○○ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User action**: Switches to Account #2 in Amber, approves connection
|
||||
|
||||
**Behind the scenes**:
|
||||
- Web app opens a second NIP-46 session for Account #2
|
||||
- Web app now has signing access to both Account #1 and Account #2 via Amber
|
||||
|
||||
### Screen B6: Generating Quantum Keys
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Step 3 of 4: Generating Quantum Keys │
|
||||
│ │
|
||||
│ Deriving post-quantum keys from your seed... │
|
||||
│ │
|
||||
│ ✅ ML-DSA-65 (Dilithium) signature key │
|
||||
│ Public key: 1952 bytes │
|
||||
│ │
|
||||
│ ✅ SLH-DSA-128s (SPHINCS+) signature key │
|
||||
│ Public key: 32 bytes │
|
||||
│ │
|
||||
│ ✅ ML-KEM-768 (Kyber) encryption key │
|
||||
│ Public key: 1184 bytes │
|
||||
│ │
|
||||
│ All keys derived from your seed phrase. │
|
||||
│ Same seed → same keys, every time. │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Continue │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Behind the scenes**:
|
||||
- Web app uses liboqs-wasm to generate PQ keypairs deterministically from the BIP39 seed
|
||||
- PQ private keys exist only in browser memory (WASM), never sent anywhere
|
||||
|
||||
### Screen B7: Signing — Account #2 Signs the PQ Key Statement
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Step 3 of 4: Signing │
|
||||
│ │
|
||||
│ PQ signatures (done in browser): │
|
||||
│ ✅ ML-DSA-65 signature │
|
||||
│ ✅ SLH-DSA-128s signature │
|
||||
│ │
|
||||
│ Amber signatures (need your approval): │
|
||||
│ ⏳ Account #2 signs the PQ key statement... │
|
||||
│ │
|
||||
│ Please approve the signing request in Amber. │
|
||||
│ (Make sure Account #2 is active in Amber) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Behind the scenes**:
|
||||
- Web app constructs the link statement:
|
||||
```
|
||||
"Identity <npub1> is migrating to successor <npub2>.
|
||||
All PQ keys listed below are derived from the same
|
||||
BIP39 seed as <npub2>. This link is established
|
||||
pre-quantum."
|
||||
```
|
||||
- Web app PQ-signs the statement with ML-DSA-65 and SLH-DSA-128s private keys (client-side WASM)
|
||||
- Web app sends a `signEvent` request to Amber (Account #2) to sign the statement
|
||||
|
||||
### On Amber (Phone): Sign with Account #2
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ pq-nostr.app wants to sign │
|
||||
│ │
|
||||
│ Account: npub1def...uvw (Account #2) │
|
||||
│ │
|
||||
│ Content: │
|
||||
│ "Identity npub1abc... is migrating to │
|
||||
│ successor npub1def... All PQ keys..." │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Approve │ │ Reject │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User action**: Taps "Approve"
|
||||
|
||||
### Screen B8: Signing — Account #1 Signs the Migration Event
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Step 3 of 4: Signing │
|
||||
│ │
|
||||
│ ✅ ML-DSA-65 signature (browser) │
|
||||
│ ✅ SLH-DSA-128s signature (browser) │
|
||||
│ ✅ Account #2 signature (Amber) │
|
||||
│ │
|
||||
│ ⏳ Account #1 signs the migration event... │
|
||||
│ │
|
||||
│ Please switch to Account #1 in Amber and │
|
||||
│ approve the signing request. │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User action**: Switches to Account #1 in Amber
|
||||
|
||||
### On Amber (Phone): Sign with Account #1
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ pq-nostr.app wants to sign │
|
||||
│ │
|
||||
│ Account: npub1abc...xyz (Account #1) │
|
||||
│ │
|
||||
│ Event kind: <NIP-QR kind> │
|
||||
│ │
|
||||
│ This is your quantum migration event. │
|
||||
│ It links your current identity to │
|
||||
│ quantum-resistant keys. │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Approve │ │ Reject │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User action**: Taps "Approve"
|
||||
|
||||
**Behind the scenes**:
|
||||
- Amber signs the NIP-QR event with Account #1's secp256k1 private key (the old nsec)
|
||||
- This is the critical signature — the old identity authorizing the migration
|
||||
- The event is now fully signed: Account #1's sig + Account #2's sig + PQ sigs
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Publish and Timestamp (Both Paths)
|
||||
|
||||
### Screen 9: Publishing
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Step 4 of 4: Publishing │
|
||||
│ │
|
||||
│ Publishing migration event to relays... │
|
||||
│ │
|
||||
│ ✅ wss://relay.damus.io │
|
||||
│ ✅ wss://nos.lol │
|
||||
│ ✅ wss://relay.primal.net │
|
||||
│ │
|
||||
│ Requesting OpenTimestamps attestation... │
|
||||
│ ✅ Anchored to Bitcoin block 890,123 │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ View Migration Event │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Behind the scenes**:
|
||||
- Web app assembles the complete NIP-QR event with all signatures
|
||||
- Web app publishes the event to multiple relays via WebSocket
|
||||
- Web app submits the event hash to OpenTimestamps (NIP-03)
|
||||
- OpenTimestamps anchors the event to a Bitcoin block, proving it existed at this time
|
||||
- This prevents a future quantum attacker from backdating a fraudulent migration event
|
||||
|
||||
### Screen 10: Success
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🎉 Migration Complete! │
|
||||
│ │
|
||||
│ Your Nostr identity is now quantum-resistant. │
|
||||
│ │
|
||||
│ What happened: │
|
||||
│ • Your identity (npub1abc...xyz) is now linked │
|
||||
│ to post-quantum keys │
|
||||
│ • The link is timestamped on the Bitcoin │
|
||||
│ blockchain (cannot be forged retroactively) │
|
||||
│ • Your followers, NIP-05, and social graph │
|
||||
│ are unchanged │
|
||||
│ │
|
||||
│ What to do now: │
|
||||
│ • Keep your seed phrase safe — it's your │
|
||||
│ quantum-safe backup │
|
||||
│ • No other action needed — PQ-aware clients │
|
||||
│ will recognize your new keys automatically │
|
||||
│ │
|
||||
│ Event ID: <hex event id> │
|
||||
│ OTS proof: <download .ots file> │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Done │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User action**: Downloads the .ots file (optional backup), taps "Done"
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Path A vs Path B
|
||||
|
||||
| Aspect | Path A (Has Seed) | Path B (Raw nsec) |
|
||||
|---|---|---|
|
||||
| Amber accounts needed | 1 (existing) | 2 (existing + new) |
|
||||
| Seed phrase generation | Not needed (already have one) | Generated by web app |
|
||||
| Amber import step | Not needed | Manual import of seed as Account #2 |
|
||||
| NIP-46 sessions | 1 | 2 (one per account) |
|
||||
| Amber signing requests | 1 (sign NIP-QR event) | 2 (Account #2 signs statement, Account #1 signs event) |
|
||||
| NIP-QR event structure | Single-key link (secp256k1 + PQ keys from same seed) | Two-key migration (old nsec → new seed-derived key → PQ keys) |
|
||||
| Time required | ~2 minutes | ~5 minutes |
|
||||
| User complexity | Low | Medium |
|
||||
|
||||
### NIP-QR Event Structure Differences
|
||||
|
||||
**Path A (Simple)** — single identity, seed-derived:
|
||||
```json
|
||||
{
|
||||
"kind": <NIP-QR kind>,
|
||||
"pubkey": "<secp256k1 pubkey (seed-derived)>",
|
||||
"content": {
|
||||
"statement": "Identity <npub> is linked to the following PQ keys, all derived from the same BIP39 seed. This link is established pre-quantum.",
|
||||
"pq_keys": [
|
||||
{ "algorithm": "ml-dsa-65", "public_key": "...", "signature": "..." },
|
||||
{ "algorithm": "slh-dsa-128s", "public_key": "...", "signature": "..." },
|
||||
{ "algorithm": "ml-kem-768", "public_key": "..." }
|
||||
]
|
||||
},
|
||||
"sig": "<secp256k1 Schnorr signature over event id>"
|
||||
}
|
||||
```
|
||||
|
||||
**Path B (Migration)** — two identities, old + new:
|
||||
```json
|
||||
{
|
||||
"kind": <NIP-QR kind>,
|
||||
"pubkey": "<Account #1 secp256k1 pubkey (old nsec)>",
|
||||
"tags": [
|
||||
["successor", "<Account #2 secp256k1 pubkey (seed-derived)>"]
|
||||
],
|
||||
"content": {
|
||||
"statement": "Identity <npub1> is migrating to successor <npub2>. All PQ keys listed below are derived from the same BIP39 seed as <npub2>. This link is established pre-quantum.",
|
||||
"successor_pubkey": "<Account #2 pubkey>",
|
||||
"successor_signature": "<Account #2 Schnorr signature over statement+pq_keys>",
|
||||
"pq_keys": [
|
||||
{ "algorithm": "ml-dsa-65", "public_key": "...", "signature": "..." },
|
||||
{ "algorithm": "slh-dsa-128s", "public_key": "...", "signature": "..." },
|
||||
{ "algorithm": "ml-kem-768", "public_key": "..." }
|
||||
]
|
||||
},
|
||||
"sig": "<Account #1 Schnorr signature over event id>"
|
||||
}
|
||||
```
|
||||
|
||||
The key difference: Path B includes a `successor` tag and `successor_signature` in the content, because the old identity (Account #1) is different from the seed-derived identity (Account #2). Path A doesn't need this because the identity IS the seed-derived key.
|
||||
|
||||
---
|
||||
|
||||
## Post-Migration: What Changes for the User
|
||||
|
||||
### Immediately (nothing changes)
|
||||
- Their npub is the same
|
||||
- Their followers, follows, NIP-05, profile — all unchanged
|
||||
- Existing clients work exactly as before
|
||||
- They can still sign events with their existing nsec
|
||||
|
||||
### When PQ-aware clients adopt the NIP
|
||||
- PQ-aware clients find the NIP-QR event for their npub
|
||||
- Path A: clients see secp256k1 key + PQ keys, all from the same seed
|
||||
- Path B: clients see Account #1 → Account #2 (successor) → PQ keys
|
||||
- Clients can use their PQ encryption key (ML-KEM-768) for PQ-secure messaging
|
||||
- Clients can verify PQ signatures on future events
|
||||
|
||||
### If a quantum computer breaks secp256k1
|
||||
- An attacker can forge secp256k1 signatures
|
||||
- But they CANNOT:
|
||||
- Forge PQ signatures (ML-DSA, SLH-DSA remain secure)
|
||||
- Backdate a fraudulent NIP-QR event (OTS proof anchors the real one)
|
||||
- Decrypt PQ-encrypted messages (ML-KEM remains secure)
|
||||
- The user's identity is preserved through the OTS-anchored migration event
|
||||
- For Path B: the user continues signing with Account #2 and PQ keys
|
||||
- For Path A: the user continues signing with PQ keys (secp256k1 is broken but PQ keys are safe)
|
||||
|
||||
---
|
||||
|
||||
## Error Handling and Edge Cases
|
||||
|
||||
### User loses seed phrase
|
||||
- **Path A**: PQ keys cannot be re-derived. The NIP-QR event is already published and timestamped — the link still exists. User should re-run migration with a new seed phrase (publishes an updated NIP-QR event).
|
||||
- **Path B**: Account #2's nsec is still in Amber (can be exported via NIP-49 encrypted backup). But PQ keys cannot be re-derived without the seed. Same recovery: publish a new NIP-QR event with a new seed.
|
||||
|
||||
### User loses phone (Amber)
|
||||
- If seed phrase was written down: import into new Amber installation, re-derive PQ keys
|
||||
- If seed phrase was lost too: PQ keys are unrecoverable, but the NIP-QR event still proves the link existed. User publishes a new NIP-QR event with a new seed.
|
||||
|
||||
### User doesn't have Amber
|
||||
- Fall back to NIP-07 browser extension (nos2x, etc.) for secp256k1 signing
|
||||
- Seed phrase is generated and handled entirely in the browser
|
||||
- Less secure (seed in browser) but still functional
|
||||
- Recommend installing Amber for better security
|
||||
|
||||
### User already has a seed phrase in Amber (Path A) but wants to use a different seed
|
||||
- They can choose to generate a new seed (switching to Path B flow)
|
||||
- This might be desirable if they want a dedicated PQ-only seed phrase
|
||||
|
||||
---
|
||||
|
||||
## Technical Summary
|
||||
|
||||
| Component | Where it runs | What it does |
|
||||
|---|---|---|
|
||||
| Seed generation | Browser (WASM) | BIP39 mnemonic generation (Path B only) |
|
||||
| Seed retrieval | Amber → user → browser | Manual export from Amber's backup screen |
|
||||
| NIP-06 key derivation | Browser (WASM) | secp256k1 key from seed (verify it matches Amber account) |
|
||||
| PQ key derivation | Browser (WASM, liboqs) | ML-DSA, SLH-DSA, ML-KEM from seed |
|
||||
| PQ signing | Browser (WASM, liboqs) | Sign link statement with PQ keys |
|
||||
| secp256k1 signing | Amber (Android) | Sign NIP-QR event (and successor statement for Path B) |
|
||||
| Event assembly | Browser | Combine all signatures into NIP-QR event |
|
||||
| Relay publishing | Browser (WebSocket) | Publish event to Nostr relays |
|
||||
| OpenTimestamps | Browser → OTS API | Anchor event to Bitcoin blockchain |
|
||||
| Seed phrase storage | Amber (Android encrypted storage) | BIP39 mnemonic stored encrypted |
|
||||
| nsec storage | Amber (Android encrypted storage) | Account private key(s) stored encrypted |
|
||||
|
||||
### No server-side secrets
|
||||
The web app is a **static site** — HTML, JS, and WASM files served from a CDN or GitHub Pages. There is no backend that handles keys. The only server interactions are:
|
||||
- Nostr relays (publishing the event)
|
||||
- OpenTimestamps API (requesting timestamp proof)
|
||||
- NIP-46 relay (communicating with Amber)
|
||||
|
||||
All cryptographic operations happen either in the browser (WASM) or in Amber (Android). No private key ever touches a server.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Existing Post-Quantum Proposals in the Nostr Ecosystem
|
||||
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
There is **no merged NIP** for post-quantum security. The only merged reference is an acknowledgment in [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md) that it has "No post-quantum security." However, there are **several open proposals and active community discussions** that overlap significantly with our plan. The community is clearly thinking about this but has not converged on a solution.
|
||||
|
||||
---
|
||||
|
||||
## Directly Post-Quantum Proposals
|
||||
|
||||
### 1. PR #2185: "Add NIP for PQ" (OPEN)
|
||||
- **Author**: trbouma
|
||||
- **Created**: 2026-01-09 | **Updated**: 2026-03-04
|
||||
- **URL**: https://github.com/nostr-protocol/nips/pull/2185
|
||||
- **Status**: Open, 7 comments, 1 commit, +113 lines
|
||||
|
||||
**What it proposes**:
|
||||
- Extends NIP-01 to allow events signed with **ML-DSA-44** (FIPS 204) instead of secp256k1 Schnorr
|
||||
- Relaxes the `pubkey` and `sig` field length constraints in the NIP-01 event format
|
||||
- PQ pubkey: 2624 hex chars (1312 bytes); PQ sig: 4840 hex chars (2420 bytes)
|
||||
- Detection: if `pubkey` length > 64 hex chars, use ML-DSA-44 verification
|
||||
- Includes a Python proof-of-concept using `monstr` + `oqs` (Open Quantum Safe)
|
||||
- Author also has a working PQ relay: https://github.com/trbouma/pqrelay
|
||||
|
||||
**What it does NOT address**:
|
||||
- No migration strategy for existing users
|
||||
- No key linking between old secp256k1 identity and new PQ identity
|
||||
- No encryption (NIP-44/NIP-04) PQ upgrade
|
||||
- No OpenTimestamps anchoring
|
||||
- No seed phrase / root of trust
|
||||
- Picks a single algorithm (ML-DSA-44), no multi-scheme hedging
|
||||
|
||||
**Community comments**:
|
||||
- vitorpamplona: "We will need to address NIP-44 encryptions with these new keys"
|
||||
- kehiy: "Are PQ algos developed enough so we can choose one easily? Maybe we need to wait more"
|
||||
- trbouma: "The immediate thing is that it gives nostr a post-quantum narrative to fight the FUD"
|
||||
|
||||
**Comparison to our plan**: This is the simplest possible approach — just swap the signature algorithm. Our plan is significantly more comprehensive: it addresses migration, key linking, encryption, timestamping, and algorithm uncertainty. PR #2185 could be seen as a subset of our Component 3 (PQ signatures) but without the migration scaffolding.
|
||||
|
||||
---
|
||||
|
||||
### 2. PR #391: "NIP-101 Algorithm Transition for Signatures and Encryption" (CLOSED)
|
||||
- **Author**: eznix86
|
||||
- **Created**: 2023-03-24 | **Closed**: 2025-05-22 (not merged)
|
||||
- **URL**: https://github.com/nostr-protocol/nips/pull/391
|
||||
|
||||
**What it proposes**:
|
||||
- A generic algorithm transition event (`kind: 101`) containing:
|
||||
- `old_pubkey`, `new_pubkey`, `old_alg`, `new_alg`
|
||||
- `old_signature` (signed with old key/algorithm)
|
||||
- `new_signature` (signed with new key/algorithm)
|
||||
- Cross-signed transition: both old and new keys sign the transition statement
|
||||
- Metadata update via `kind: 0` with `["alg", "<algorithm>", "<new_pubkey>"]` tag
|
||||
- Clients support both old and new during transition period
|
||||
|
||||
**What it does NOT address**:
|
||||
- No OpenTimestamps / pre-quantum anchoring
|
||||
- No encryption-specific PQ migration (mentions NIP-04 but doesn't detail PQ KEM)
|
||||
- No seed phrase root of trust
|
||||
- No multi-scheme hedging
|
||||
- No storage encryption
|
||||
|
||||
**Comparison to our plan**: This is conceptually similar to our **Component 2 (Multi-Scheme Cross-Signed Key-Link Events)**. The cross-signing concept is the same — old key signs new key and vice versa. However, our plan improves on this significantly:
|
||||
- We use **OpenTimestamps** to anchor the link pre-quantum (prevents backdated fraudulent links)
|
||||
- We support **multiple PQ schemes simultaneously** (no algorithm consensus needed)
|
||||
- We derive keys from a **seed phrase** rather than generating new random keys
|
||||
- We include **ML-KEM** for encryption, not just signatures
|
||||
|
||||
---
|
||||
|
||||
### 3. Issue #1971: "NIP-44: post-quantum security" (OPEN — most active discussion)
|
||||
- **Author**: paulmillr
|
||||
- **Created**: 2025-07-10 | **Updated**: 2026-04-13
|
||||
- **URL**: https://github.com/nostr-protocol/nips/issues/1971
|
||||
- **26 comments** — the most active PQ discussion in the Nostr community
|
||||
|
||||
**Key discussion points**:
|
||||
|
||||
1. **paulmillr's framing**: The main question is not *which* algorithm, but *how* new algorithms interop with old infrastructure. Suggests hybrid ECDH + KEM approach (like Apple iMessage, Signal, WhatsApp, OpenSSH).
|
||||
|
||||
2. **The "whole architecture" problem**: paulmillr identifies the core dilemma — if secp256k1 is broken, everything breaks (signing, identity), so fixing only NIP-44 encryption is insufficient. An attacker who breaks nsec can derive any subkeys.
|
||||
|
||||
3. **mikedilger**: The real threat is **collect-now-decrypt-later** for encrypted data on public relays. Favors SPHINCS+ (SLH-DSA) as most conservative. Advocates **decoupling encryption from identity** (PR #1647).
|
||||
|
||||
4. **trbouma**: NIP-44 is already quantum-safe *if* you use a separate key not derived from nsec. The key management is the hard part. Suggests HSM/enclave for PQ keys.
|
||||
|
||||
5. **vitorpamplona**: If nsec is broken, PQ shared keys derived from it are also broken. "Looks like we will have 2 Nostr's going" — doesn't think PQ signing can be backward compatible.
|
||||
|
||||
6. **paulmillr's proposal**: Add a `pqsig` field to NIP-01 events. Old key signs new PQ key and vice versa. Need key rotation mechanism.
|
||||
|
||||
7. **zfatherz**: Built a working PoC — version byte `0xF1`, ML-KEM-768 + X25519 hybrid KEM, XChaCha20-Poly1305 AEAD. ~15-22ms encrypt, ~1145B overhead per packet. Identified O(N) scaling problem for group chats. Later pivoted to O(1) sender-key approach.
|
||||
- Repo: https://github.com/zfatherz/nip44-0xF1-pqc
|
||||
|
||||
8. **alex-s168**: SPHINCS+ has small public keys (64 bytes) but 30KB signatures. Mentions Sphinx-in-the-Head for group signatures (but multi-MB signatures).
|
||||
|
||||
9. **paulmillr's final critique** (2026-04-13): Points out that in zfatherz's design, npub is still derived from nsec, so breaking nsec breaks everything including derived subkeys.
|
||||
|
||||
**Comparison to our plan**: This discussion validates several core elements of our plan:
|
||||
- The **collect-now-decrypt-later** threat is exactly what our OTS anchoring (Component 3) and PQ encryption (Component 5) address
|
||||
- The **"whole architecture" problem** paulmillr identifies is what our key-link events (Component 2) solve — we don't just fix encryption, we migrate identity
|
||||
- The **algorithm uncertainty** concern is addressed by our multi-scheme approach
|
||||
- The **key derivation from nsec** weakness paulmillr identifies is why our plan uses a **seed phrase** (Component 1) as the root of trust, not nsec-derived keys
|
||||
- zfatherz's PoC validates that hybrid ML-KEM + ECDH is practical for 1:1 messaging today
|
||||
|
||||
---
|
||||
|
||||
## Key Migration / Rotation Proposals (Not PQ-Specific but Relevant)
|
||||
|
||||
### 4. PR #1647: "nip4e: Decoupling encryption from identity" (OPEN)
|
||||
- **Author**: fiatjaf
|
||||
- **Created**: 2024-12-14
|
||||
- **URL**: https://github.com/nostr-protocol/nips/pull/1647
|
||||
|
||||
**What it proposes**:
|
||||
- Per-device encryption keys separate from identity key
|
||||
- `kind: 10044` — announce encryption public key
|
||||
- `kind: 4454` — announce a new device's local key
|
||||
- `kind: 4455` — share encryption secret to a new device (NIP-44 encrypted)
|
||||
- Solves: FROST/MuSig2 bunkers where identity key isn't available for encryption, offline encryption, per-device performance
|
||||
|
||||
**Comparison to our plan**: This is **not PQ-specific** but is foundational infrastructure. Our plan's Component 5 (Quantum-Safe Self-Storage) and the encryption migration could build on this pattern. The concept of separating encryption keys from identity keys is shared. mikedilger explicitly referenced this PR in the #1971 discussion as a prerequisite for PQ encryption.
|
||||
|
||||
---
|
||||
|
||||
### 5. Issue #2237: "Key Commitment and Theft-Proof Rotation" (CLOSED)
|
||||
- **Author**: leonacostaok
|
||||
- **Created**: 2026-02-24 | **Closed**: 2026-02-26
|
||||
- **URL**: https://github.com/nostr-protocol/nips/issues/2237
|
||||
|
||||
**What it proposes**:
|
||||
- Argon2id-based hash commitment scheme for key rotation
|
||||
- `kind: 13375` — publish commitment (argon2id of a secret passphrase with pubkey as salt)
|
||||
- `kind: 13376` — rotation event from new key revealing the secret
|
||||
- Clients verify: argon2id(revealed_secret, old_pubkey) == commitment
|
||||
- Aims to prove theft vs. voluntary migration
|
||||
|
||||
**Comparison to our plan**: Different approach to the rotation problem. Uses a memory-hard KDF commitment rather than cross-signing. Does not address PQ specifically. Our plan's cross-signed key-link events are more cryptographically rigorous (actual signatures, not passphrase commitments) and include OTS anchoring to prevent backdating.
|
||||
|
||||
---
|
||||
|
||||
### 6. Other Key Migration PRs (OPEN, not reviewed in detail)
|
||||
- **PR #2114**: NIP D8 Key Rotation
|
||||
- **PR #2137**: Key migration
|
||||
- **PR #2139**: Simpler key migration
|
||||
- **PR #1452**: Key Migration and Revocation
|
||||
- **PR #829**: NIP-41: simple account migration
|
||||
- **PR #2361**: Decoupling identity and encryption keys in NIP-17
|
||||
- **PR #637**: NIP-37: general methods for dealing with lost keys
|
||||
|
||||
These are all related to key migration/rotation but are not PQ-specific. Our plan's key-link event (Component 2) is in the same category but specifically designed for PQ migration with OTS anchoring and multi-scheme support.
|
||||
|
||||
---
|
||||
|
||||
## Gap Analysis: What Our Plan Uniquely Addresses
|
||||
|
||||
| Feature | PR #2185 | PR #391 | Issue #1971 | PR #1647 | **Our Plan** |
|
||||
|---|---|---|---|---|---|
|
||||
| PQ signatures | ✅ (ML-DSA-44 only) | ✅ (generic) | Discussed | ❌ | ✅ (multi-scheme) |
|
||||
| PQ encryption (KEM) | ❌ | Mentioned | ✅ (hybrid KEM) | ❌ | ✅ (ML-KEM + OTP) |
|
||||
| Key linking / migration | ❌ | ✅ (cross-signed) | Discussed | ❌ | ✅ (cross-signed + OTS) |
|
||||
| Pre-quantum timestamping | ❌ | ❌ | ❌ | ❌ | ✅ (NIP-03 OTS) |
|
||||
| Seed phrase root of trust | ❌ | ❌ | ❌ | ❌ | ✅ (BIP39/NIP-06) |
|
||||
| Multi-scheme hedging | ❌ | ❌ | ❌ (discussed) | ❌ | ✅ |
|
||||
| Existing nsec migration | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| Storage encryption | ❌ | ❌ | Discussed | Partial | ✅ (OTP / symmetric) |
|
||||
| HD wallet compartmentalization | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| Decouple encryption from identity | ❌ | ❌ | Discussed | ✅ | ✅ (via seed derivation) |
|
||||
|
||||
### Key differentiators of our plan:
|
||||
1. **OpenTimestamps anchoring** — no other proposal addresses the backdating attack on key-link events
|
||||
2. **Multi-scheme hedging** — no other proposal links to multiple PQ algorithms simultaneously
|
||||
3. **Seed phrase as root of trust** — no other proposal uses BIP39 as the algorithm-agnostic foundation
|
||||
4. **Existing nsec migration** — no other proposal addresses how raw nsec users migrate
|
||||
5. **HD wallet compartmentalization** — no other proposal leverages BIP32 chain codes for quantum containment
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Engage with PR #2185**: trbouma's PR is the most active PQ proposal. Our plan is a superset — we should comment with our more comprehensive approach, particularly the migration strategy and OTS anchoring that their PR lacks.
|
||||
|
||||
2. **Engage with Issue #1971**: This is the main community discussion. paulmillr's final critique (npub derived from nsec → everything breaks) is directly addressed by our seed-phrase approach. We should contribute our key-link + OTS design.
|
||||
|
||||
3. **Reference PR #1647**: fiatjaf's encryption/identity decoupling is complementary. Our plan could adopt the `kind: 10044` pattern for announcing PQ encryption keys.
|
||||
|
||||
4. **Reference PR #391**: eznix86's cross-signed transition event is conceptually similar to our key-link event. We should acknowledge this prior art and explain how our approach extends it (OTS, multi-scheme, seed-based).
|
||||
|
||||
5. **Watch zfatherz's work**: The `0xF1` hybrid KEM PoC is the most advanced PQ encryption implementation. Our plan's encryption component should be compatible with this approach.
|
||||
|
||||
6. **Our plan fills a unique niche**: No existing proposal combines migration strategy + OTS anchoring + multi-scheme hedging + seed phrase root of trust. This is a strong differentiator for a NIP submission.
|
||||
@@ -0,0 +1,249 @@
|
||||
# NIP-QR: Two-Account Flow Architecture
|
||||
|
||||
## The Flow (as proposed)
|
||||
|
||||
```
|
||||
User has Account #1 (existing nsec, their current Nostr identity)
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ PQ Web App (browser) │
|
||||
│ │
|
||||
│ Step 1: User creates Account #2 in │
|
||||
│ Amber (generates new seed) │
|
||||
│ │
|
||||
│ Step 2: Web app retrieves seed phrase │
|
||||
│ from Amber (Account #2) │
|
||||
│ │
|
||||
│ Step 3: Web app derives PQ keys from │
|
||||
│ seed (client-side WASM, liboqs) │
|
||||
│ - ML-DSA-65 keypair │
|
||||
│ - SLH-DSA-128s keypair │
|
||||
│ - ML-KEM-768 keypair │
|
||||
│ │
|
||||
│ Step 4: Web app builds NIP-QR event │
|
||||
│ and requests signatures: │
|
||||
│ - Amber Account #1 signs with │
|
||||
│ old nsec (secp256k1) │
|
||||
│ - Amber Account #2 signs with │
|
||||
│ seed-derived secp256k1 │
|
||||
│ - Web app PQ-signs with each │
|
||||
│ PQ private key (client-side) │
|
||||
│ │
|
||||
│ Step 5: Web app publishes NIP-QR event │
|
||||
│ to relays + requests OTS │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Why Two Accounts?
|
||||
|
||||
The two-account design solves a fundamental problem: **how do you cryptographically link an existing nsec-based identity to PQ keys, when the existing nsec has no relationship to any seed phrase?**
|
||||
|
||||
| Account | Key source | Role in NIP-QR |
|
||||
|---|---|---|
|
||||
| **Account #1** (existing) | Raw nsec (random, no seed) | The identity the social graph knows about. Signs to say "I am migrating to Account #2 and its PQ keys" |
|
||||
| **Account #2** (new) | Seed phrase (BIP39 → NIP-06) | The bridge. Its secp256k1 key is derived from the same seed as the PQ keys. Signs to say "These PQ keys come from the same seed as me" |
|
||||
|
||||
The cryptographic chain of trust is:
|
||||
|
||||
```
|
||||
Account #1 (old identity, social graph knows this npub)
|
||||
│
|
||||
│ signs: "I am the same entity as Account #2"
|
||||
│ (secp256k1 Schnorr signature, valid pre-quantum)
|
||||
▼
|
||||
Account #2 (seed-derived secp256k1 key)
|
||||
│
|
||||
│ same seed phrase ──────► PQ keys (ML-DSA, SLH-DSA, ML-KEM)
|
||||
│ │
|
||||
│ signs: "These PQ keys │ sign: "We are derived from
|
||||
│ come from my seed" │ the same seed as
|
||||
│ (secp256k1 Schnorr) │ Account #2"
|
||||
│ │ (PQ signatures)
|
||||
▼ ▼
|
||||
NIP-QR event (published under Account #1's pubkey)
|
||||
```
|
||||
|
||||
## The NIP-QR Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": <NIP-QR kind, to be assigned>,
|
||||
"pubkey": "<Account #1 secp256k1 pubkey (hex)>",
|
||||
"created_at": <unix timestamp>,
|
||||
"tags": [
|
||||
["successor", "<Account #2 secp256k1 pubkey (hex)>"],
|
||||
["algorithm", "ml-dsa-65"],
|
||||
["algorithm", "slh-dsa-128s"],
|
||||
["algorithm", "ml-kem-768"]
|
||||
],
|
||||
"content": "<JSON string, see below>",
|
||||
"sig": "<Account #1 Schnorr signature over the event id>"
|
||||
}
|
||||
```
|
||||
|
||||
### Content field (JSON string):
|
||||
|
||||
```json
|
||||
{
|
||||
"statement": "Identity <npub1> is migrating to successor <npub2>. All PQ keys listed below are derived from the same BIP39 seed as <npub2>. This link is established pre-quantum.",
|
||||
"successor_pubkey": "<Account #2 secp256k1 pubkey (hex)>",
|
||||
"successor_signature": "<Account #2 Schnorr signature over the statement+successor_pubkey+all_pq_keys>",
|
||||
"pq_keys": [
|
||||
{
|
||||
"algorithm": "ml-dsa-65",
|
||||
"public_key": "<ML-DSA-65 public key, base64>",
|
||||
"signature": "<ML-DSA signature over the statement, base64>"
|
||||
},
|
||||
{
|
||||
"algorithm": "slh-dsa-128s",
|
||||
"public_key": "<SLH-DSA-128s public key, base64>",
|
||||
"signature": "<SLH-DSA signature over the statement, base64>"
|
||||
},
|
||||
{
|
||||
"algorithm": "ml-kem-768",
|
||||
"public_key": "<ML-KEM-768 public key, base64>",
|
||||
"note": "KEM key for encryption; ownership asserted by successor_signature covering this key"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Signature layers:
|
||||
|
||||
1. **Account #1 Schnorr signature** (the event `sig` field): Signs the event ID (standard NIP-01 signing). This is the old identity saying "I authorize this migration."
|
||||
|
||||
2. **Account #2 Schnorr signature** (`successor_signature` in content): Signs the statement + all PQ public keys. This proves the seed-derived key endorses the PQ keys. Since both Account #2's secp256k1 key and the PQ keys come from the same seed, this transitively proves PQ key ownership.
|
||||
|
||||
3. **PQ signatures** (in each `pq_keys` entry): Each PQ signature scheme signs the statement. These are the post-quantum signatures that remain valid after secp256k1 is broken.
|
||||
|
||||
4. **ML-KEM**: Cannot sign (it's a KEM, not a signature scheme). Its ownership is proven by Account #2's signature covering its public key.
|
||||
|
||||
## What the Web App Does vs. What Amber Does
|
||||
|
||||
| Step | Who | What |
|
||||
|---|---|---|
|
||||
| Create Account #2 | Amber | Generates new BIP39 seed, derives secp256k1 key via NIP-06, stores both encrypted |
|
||||
| Retrieve seed phrase | Amber → Web app | User exports seed from Amber's backup screen (or future NIP-46 method) |
|
||||
| Derive PQ keys | Web app (WASM) | Uses liboqs-wasm to derive ML-DSA, SLH-DSA, ML-KEM keypairs from seed via HKDF |
|
||||
| PQ sign statement | Web app (WASM) | Signs the link statement with each PQ private key |
|
||||
| Sign with Account #1 | Amber | NIP-46 `signEvent` — old nsec signs the NIP-QR event |
|
||||
| Sign with Account #2 | Amber | NIP-46 `signEvent` — seed-derived secp256k1 signs the statement |
|
||||
| Assemble event | Web app | Combines all signatures into the NIP-QR event JSON |
|
||||
| Publish to relays | Web app | Sends the event to Nostr relays |
|
||||
| OpenTimestamps | Web app | Requests OTS attestation via NIP-03 for the event |
|
||||
|
||||
### PQ Key Derivation from Seed
|
||||
|
||||
The web app derives PQ keys from the BIP39 seed using a deterministic scheme:
|
||||
|
||||
```
|
||||
seed = PBKDF2-HMAC-SHA512(mnemonic, passphrase, 2048 iterations) // standard BIP39
|
||||
|
||||
// Derive each PQ key from the seed using HKDF with algorithm-specific labels
|
||||
ml_dsa_privkey = HKDF-SHA512(seed, "nostr-pq-ml-dsa-65", length=32) → expand to full key
|
||||
slh_dsa_privkey = HKDF-SHA512(seed, "nostr-pq-slh-dsa-128s", length=32) → expand to full key
|
||||
ml_kem_privkey = HKDF-SHA512(seed, "nostr-pq-ml-kem-768", length=32) → expand to full key
|
||||
```
|
||||
|
||||
Note: The exact PQ key derivation from a seed needs careful specification. liboqs key generation is typically random, not deterministic from a seed. We need to use the seed as a deterministic RNG seed (e.g., `DRBG(seed || algorithm_label)`) to make PQ key generation reproducible. This is a detail to nail down in the NIP.
|
||||
|
||||
## Seed Phrase Retrieval: Three Options
|
||||
|
||||
### Option 1: Manual Export (No Amber changes)
|
||||
- User goes to Amber's Account #2 backup screen
|
||||
- Amber displays the seed words ([`SeedWordsPage.kt`](../Amber/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/SeedWordsPage.kt:38))
|
||||
- User copies/types seed words into the web app
|
||||
- Web app derives PQ keys client-side
|
||||
|
||||
**Pros**: No Amber code changes. Available today.
|
||||
**Cons**: User manually handles seed phrase (friction, potential for error).
|
||||
|
||||
### Option 2: New NIP-46 Method (Amber changes)
|
||||
- Add `get_seed_words` method to Amber's NIP-46 handler
|
||||
- Web app requests seed words via NIP-46
|
||||
- Amber shows approval screen: "Web app X wants to read your seed phrase"
|
||||
- On approval, seed words are sent to the web app via NIP-46 (encrypted with NIP-44)
|
||||
|
||||
**Pros**: Smooth UX, no manual copy.
|
||||
**Cons**: Seed phrase transmitted over NIP-46 (even though encrypted). Requires Amber modification. Security concern — a malicious web app could steal the seed.
|
||||
|
||||
### Option 3: Web App Generates Seed (No Amber changes for retrieval)
|
||||
- Web app generates a new BIP39 seed phrase client-side (WASM)
|
||||
- Web app derives both secp256k1 (Account #2) and PQ keys from the seed
|
||||
- Web app displays the seed phrase for the user to write down
|
||||
- User manually imports the seed phrase into Amber as Account #2 (via Amber's mnemonic login screen)
|
||||
- Web app already has the seed, so no retrieval needed
|
||||
|
||||
**Pros**: Web app has the seed from the start. No retrieval needed. User imports to Amber for future signing.
|
||||
**Cons**: User must manually import seed into Amber (but this is a one-time step). Seed exists in browser memory temporarily.
|
||||
|
||||
**Recommendation**: Start with **Option 3** (web app generates seed, user imports to Amber). It requires zero Amber changes and gives the web app immediate access to the seed for PQ key derivation. Later, Option 2 can be added for a smoother UX.
|
||||
|
||||
## Complete User Journey
|
||||
|
||||
### First Visit (Migration)
|
||||
1. User opens the PQ web app in their browser
|
||||
2. Web app connects to Amber via NIP-46 (or NIP-07 browser extension)
|
||||
3. Web app requests Account #1's public key → gets existing npub
|
||||
4. Web app generates a new 12-word BIP39 seed phrase (client-side WASM)
|
||||
5. Web app displays the seed phrase: **"Write this down. This is your quantum-safe backup."**
|
||||
6. Web app derives Account #2's secp256k1 key from seed (NIP-06: `m/44'/1237'/0'/0/0`)
|
||||
7. Web app derives PQ keys from seed (liboqs-wasm)
|
||||
8. Web app constructs the NIP-QR event content
|
||||
9. Web app PQ-signs the statement with each PQ private key (client-side)
|
||||
10. Web app requests Amber to sign the statement with Account #2's secp256k1 key
|
||||
- But wait — Account #2 doesn't exist in Amber yet! The user needs to import the seed first.
|
||||
- **Pause**: Web app instructs user: "Open Amber, create new account with this seed phrase"
|
||||
- User imports seed into Amber as Account #2
|
||||
- Web app now requests Amber (Account #2) to sign the statement
|
||||
11. Web app requests Amber (Account #1) to sign the NIP-QR event
|
||||
12. Web app assembles the complete event with all signatures
|
||||
13. Web app publishes the event to relays
|
||||
14. Web app requests OpenTimestamps attestation (NIP-03)
|
||||
15. Web app shows success: "Your identity is now quantum-resistant. Save your seed phrase."
|
||||
|
||||
### Future Login (After Migration)
|
||||
1. User opens any PQ-aware Nostr client
|
||||
2. Client finds the NIP-QR event for their npub (Account #1)
|
||||
3. Client sees: Account #1 → Account #2 → PQ keys
|
||||
4. User signs in with Amber (Account #2, seed-derived)
|
||||
5. Client can now verify PQ signatures and use PQ encryption
|
||||
|
||||
### Post-Quantum Scenario (After secp256k1 is broken)
|
||||
1. Attacker breaks Account #1's secp256k1 key via Shor's algorithm
|
||||
2. Attacker can forge Account #1 signatures, but:
|
||||
- Cannot forge PQ signatures (ML-DSA, SLH-DSA still secure)
|
||||
- Cannot backdate a fraudulent NIP-QR event (OTS proof anchors the real one)
|
||||
- Cannot forge Account #2's signature (also secp256k1, also broken — BUT the OTS-anchored NIP-QR event already established the link pre-quantum)
|
||||
3. Clients verify: "The NIP-QR event with the earliest valid OTS proof wins"
|
||||
4. The real NIP-QR event (anchored pre-quantum) is trusted over any fraudulent event
|
||||
5. PQ signatures on the NIP-QR event remain valid and prove the PQ keys are the user's
|
||||
|
||||
## What Needs to Be Built
|
||||
|
||||
### Web App (new project)
|
||||
- [ ] Static HTML/JS/WASM site (no backend with secrets)
|
||||
- [ ] BIP39 seed generation (WASM or JS)
|
||||
- [ ] NIP-06 key derivation from seed (secp256k1)
|
||||
- [ ] liboqs-wasm integration for PQ key generation and signing
|
||||
- [ ] PQ key derivation from seed (deterministic via HKDF/DRBG)
|
||||
- [ ] NIP-46 client (connect to Amber)
|
||||
- [ ] NIP-07 client (fallback for browser extension signers)
|
||||
- [ ] NIP-QR event builder and assembler
|
||||
- [ ] Relay publisher (WebSocket)
|
||||
- [ ] NIP-03 OpenTimestamps client
|
||||
- [ ] UI: seed display, progress, verification
|
||||
|
||||
### Amber (no changes required for Option 3)
|
||||
- Already supports: multiple accounts, seed phrase login, NIP-46 signing
|
||||
- Future enhancement (Option 2): `get_seed_words` NIP-46 method
|
||||
|
||||
### NIP-QR Specification (new NIP document)
|
||||
- [ ] Event kind number
|
||||
- [ ] Content schema
|
||||
- [ ] Signature scheme (who signs what)
|
||||
- [ ] PQ key derivation from seed (exact HKDF labels, DRBG seeding)
|
||||
- [ ] OTS requirement
|
||||
- [ ] Client verification algorithm
|
||||
- [ ] Revocation/update mechanism
|
||||
@@ -0,0 +1,281 @@
|
||||
# Post-Quantum Cryptography: JavaScript Package Survey
|
||||
|
||||
## 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).
|
||||
|
||||
For our PQ Nostr web app, `@noble/post-quantum` covers everything we need: ML-DSA (signatures), SLH-DSA (signatures), ML-KEM (encryption key agreement), and hybrid KEMs (X-Wing). No WASM required, works in browsers natively.
|
||||
|
||||
---
|
||||
|
||||
## NIST-Standardized PQ Algorithms (FIPS)
|
||||
|
||||
| Algorithm | FIPS Standard | Type | Use Case | Status |
|
||||
|---|---|---|---|---|
|
||||
| ML-KEM (Kyber) | FIPS 203 | KEM (key encapsulation) | Encryption key agreement | Finalized 2024 |
|
||||
| ML-DSA (Dilithium) | FIPS 204 | Digital signature | Authentication | Finalized 2024 |
|
||||
| SLH-DSA (SPHINCS+) | FIPS 205 | Digital signature | Authentication (conservative) | Finalized 2024 |
|
||||
| FN-DSA (Falcon) | FIPS 206 | Digital signature | Authentication (compact) | Still draft |
|
||||
|
||||
### Key and Signature Sizes
|
||||
|
||||
| Variant | Public key | Secret key | Signature / Ciphertext |
|
||||
|---|---:|---:|---:|
|
||||
| ML-KEM-512 | 800 B | 1632 B | 768 B |
|
||||
| ML-KEM-768 | 1184 B | 2400 B | 1088 B |
|
||||
| ML-KEM-1024 | 1568 B | 3168 B | 1568 B |
|
||||
| ML-DSA-44 | 1312 B | 2560 B | 2420 B |
|
||||
| ML-DSA-65 | 1952 B | 4032 B | 3309 B |
|
||||
| ML-DSA-87 | 2592 B | 4896 B | 4627 B |
|
||||
| Falcon-512 | 897 B | 1281 B | ~666 B |
|
||||
| Falcon-1024 | 1793 B | 2305 B | ~1280 B |
|
||||
| SLH-DSA-128f | 32 B | 64 B | 17088 B |
|
||||
| SLH-DSA-128s | 32 B | 64 B | 7856 B |
|
||||
| SLH-DSA-192f | 48 B | 96 B | 35664 B |
|
||||
| SLH-DSA-192s | 48 B | 96 B | 16224 B |
|
||||
| SLH-DSA-256f | 64 B | 128 B | 49856 B |
|
||||
| SLH-DSA-256s | 64 B | 128 B | 29792 B |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Package: `@noble/post-quantum`
|
||||
|
||||
**npm**: https://www.npmjs.com/package/@noble/post-quantum
|
||||
**GitHub**: https://github.com/paulmillr/noble-post-quantum
|
||||
**Version**: 0.6.1 | **License**: MIT | **Stars**: 335
|
||||
|
||||
### Why This Is the Best Choice
|
||||
|
||||
1. **Covers all algorithms we need**: ML-KEM, ML-DSA, SLH-DSA, Falcon, and hybrid KEMs (X-Wing, KitchenSink) — all in one package
|
||||
2. **Pure JavaScript** — no WASM, no native bindings, works in any browser and Node.js
|
||||
3. **From the noble family** — Paul Miller's noble libraries (noble-hashes, noble-curves, noble-ciphers, noble-secp256k1) are the most trusted JS crypto libraries, used widely across the ecosystem
|
||||
4. **Tree-shakeable** — only the algorithms you import are bundled (16KB gzipped for everything)
|
||||
5. **Self-audited** at v0.6.1 (April 2026)
|
||||
6. **Deterministic key generation from seed** — `keygen(seed)` accepts an optional seed, which is exactly what we need for deriving PQ keys from a BIP39 seed phrase
|
||||
7. **Actively maintained** — last updated June 2026
|
||||
8. **No external dependencies** beyond noble-hashes and noble-curves (same author, same trust model)
|
||||
|
||||
### API Examples
|
||||
|
||||
#### ML-DSA-65 (Dilithium) — for PQ signatures
|
||||
```js
|
||||
import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js';
|
||||
|
||||
// Deterministic keygen from seed (for our seed-phrase derivation)
|
||||
const seed = new Uint8Array(32); // derived from BIP39 seed via HKDF
|
||||
const keys = ml_dsa65.keygen(seed);
|
||||
|
||||
// Sign
|
||||
const msg = new TextEncoder().encode('link statement');
|
||||
const sig = ml_dsa65.sign(msg, keys.secretKey);
|
||||
|
||||
// Verify
|
||||
const isValid = ml_dsa65.verify(sig, msg, keys.publicKey);
|
||||
```
|
||||
|
||||
#### SLH-DSA-128s (SPHINCS+) — conservative hash-based signatures
|
||||
```js
|
||||
import { slh_dsa_sha2_128s } from '@noble/post-quantum/slh-dsa.js';
|
||||
|
||||
const keys = slh_dsa_sha2_128s.keygen(seed);
|
||||
const sig = slh_dsa_sha2_128s.sign(msg, keys.secretKey);
|
||||
const isValid = slh_dsa_sha2_128s.verify(sig, msg, keys.publicKey);
|
||||
```
|
||||
|
||||
#### ML-KEM-768 (Kyber) — for PQ encryption key agreement
|
||||
```js
|
||||
import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
|
||||
|
||||
// Alice generates keys
|
||||
const aliceKeys = ml_kem768.keygen(seed);
|
||||
|
||||
// Bob encapsulates a shared secret for Alice
|
||||
const { cipherText, sharedSecret: bobShared } = ml_kem768.encapsulate(aliceKeys.publicKey);
|
||||
|
||||
// Alice decapsulates
|
||||
const aliceShared = ml_kem768.decapsulate(cipherText, aliceKeys.secretKey);
|
||||
// aliceShared === bobShared
|
||||
```
|
||||
|
||||
#### X-Wing (Hybrid KEM — ML-KEM-768 + X25519)
|
||||
```js
|
||||
import { XWing } from '@noble/post-quantum/hybrid.js';
|
||||
|
||||
const keys = XWing.keygen();
|
||||
const { cipherText, sharedSecret } = XWing.encapsulate(keys.publicKey);
|
||||
const decapSecret = XWing.decapsulate(cipherText, keys.secretKey);
|
||||
```
|
||||
|
||||
### Speed Benchmarks (Apple M4, operations/sec)
|
||||
|
||||
| Primitive | Keygen | Signing | Verification | Shared secret |
|
||||
|---|---:|---:|---:|---:|
|
||||
| ML-KEM-768 | 4661 | — | — | 4089 |
|
||||
| ML-DSA-65 | 669 | 271 | 565 | — |
|
||||
| Falcon-512 | 14 | 749 | 2160 | — |
|
||||
| SLH-DSA-SHA2-192f | 235 | 8 | 159 | — |
|
||||
| SLH-DSA-SHA2-128s | ~5 | <1 | ~1000 | — |
|
||||
| x/ed25519 (pre-quantum) | 12648 | 6157 | 1255 | 1981 |
|
||||
|
||||
**Key takeaways for our use case**:
|
||||
- ML-DSA-65 signing: ~3.7ms per signature — fast enough for a one-time key-link event
|
||||
- ML-KEM-768 keygen + encapsulate: ~0.4ms — instant
|
||||
- SLH-DSA is slow (117ms-2900ms per signature depending on variant) but it's a one-time cost for the key-link event
|
||||
- There's an experimental WASM branch with 80% faster ML-KEM, 30% faster ML-DSA, 15x faster SLH-DSA-SHAKE
|
||||
|
||||
### Security Notes
|
||||
- "The library has not been independently audited yet" (self-audited at v0.6.1)
|
||||
- No constant-time guarantees (side-channel attacks possible) — acceptable for our use case since PQ private keys are used in the browser, not on a server
|
||||
- Uses `crypto.getRandomValues` for randomness (browser CSPRNG)
|
||||
|
||||
---
|
||||
|
||||
## Alternative Packages
|
||||
|
||||
### `@oqs/liboqs-js` — WASM bindings to liboqs
|
||||
- **npm**: https://www.npmjs.com/package/@oqs/liboqs-js
|
||||
- **GitHub**: https://github.com/open-quantum-safe/liboqs-js
|
||||
- **Version**: 0.15.1 | **License**: MIT (liboqs is OQS license)
|
||||
- **Stars**: 11 (new repo, created Feb 2026)
|
||||
|
||||
**Pros**:
|
||||
- Wraps liboqs (the reference implementation from Open Quantum Safe project)
|
||||
- Supports the widest range of algorithms: ML-KEM, ML-DSA, Falcon, Classic McEliece, FrodoKEM, NTRU, HQC, CROSS, Mayo, UOV, SNOVA
|
||||
- WASM performance may be better for some algorithms
|
||||
|
||||
**Cons**:
|
||||
- Very new (created Feb 2026, only 11 stars)
|
||||
- WASM adds complexity (loading .wasm files, larger bundle)
|
||||
- Less established than noble
|
||||
- License is "NOASSERTION" (liboqs has its own license, not standard MIT)
|
||||
|
||||
**When to use**: If we need algorithms not in noble (e.g., Classic McEliece, FrodoKEM), or if WASM performance is critical.
|
||||
|
||||
### `mlkem` (crystals-kyber-js)
|
||||
- **npm**: https://www.npmjs.com/package/mlkem
|
||||
- **GitHub**: https://github.com/dajiaji/crystals-kyber-js
|
||||
- **Version**: 2.7.0 | **License**: MIT | **Stars**: 62
|
||||
|
||||
**Pros**:
|
||||
- Focused, well-maintained ML-KEM implementation
|
||||
- Pure TypeScript, no dependencies
|
||||
- Used by `@hpke/hybridkem-x-wing` for HPKE integration
|
||||
- "The fastest pure TypeScript ML-KEM implementation"
|
||||
|
||||
**Cons**:
|
||||
- Only ML-KEM (no signature algorithms)
|
||||
- Would need a separate package for ML-DSA/SLH-DSA
|
||||
|
||||
**When to use**: If we only need ML-KEM for encryption and want a focused, tested library.
|
||||
|
||||
### `@hpke/hybridkem-x-wing`
|
||||
- **npm**: https://www.npmjs.com/package/@hpke/hybridkem-x-wing
|
||||
- **Version**: 0.7.0 | **License**: MIT
|
||||
|
||||
HPKE (Hybrid Public Key Encryption) module for X-Wing (ML-KEM-768 + X25519 hybrid KEM). Part of the hpke-js family. Useful if we want to use the HPKE framework for PQ encryption, which is the IETF standard for hybrid key exchange.
|
||||
|
||||
### `dilithium-crystals-js`
|
||||
- **npm**: https://www.npmjs.com/package/dilithium-crystals-js
|
||||
- **Version**: 1.1.3
|
||||
|
||||
Dilithium implementation for Node.js and browsers. Older, less maintained than noble.
|
||||
|
||||
### `@stenvault/pqc-wasm`
|
||||
- **npm**: https://www.npmjs.com/package/@stenvault/pqc-wasm
|
||||
- **Version**: 0.2.2
|
||||
|
||||
ML-KEM-768 + ML-DSA-65 WASM wrapper (RustCrypto compiled to WebAssembly). New, untested.
|
||||
|
||||
### `pqclean`
|
||||
- **npm**: https://www.npmjs.com/package/pqclean
|
||||
- **Version**: 0.8.1
|
||||
|
||||
Node.js bindings for PQClean (all PQ implementations). Node-only, not browser-compatible.
|
||||
|
||||
---
|
||||
|
||||
## What We Need for the PQ Nostr Web App
|
||||
|
||||
| Need | Algorithm | Package | Status |
|
||||
|---|---|---|---|
|
||||
| PQ signature (primary) | ML-DSA-65 | `@noble/post-quantum` | ✅ Ready |
|
||||
| PQ signature (conservative) | SLH-DSA-128s | `@noble/post-quantum` | ✅ Ready |
|
||||
| PQ encryption key agreement | ML-KEM-768 | `@noble/post-quantum` | ✅ Ready |
|
||||
| Hybrid KEM (PQ + classical) | X-Wing (ML-KEM-768 + X25519) | `@noble/post-quantum` | ✅ Ready |
|
||||
| Deterministic keygen from seed | All above | `@noble/post-quantum` | ✅ `keygen(seed)` supported |
|
||||
| Browser compatibility | All above | `@noble/post-quantum` | ✅ Pure JS, no WASM needed |
|
||||
| BIP39 seed phrase | Mnemonic → seed | `@scure/bip39` or similar | ✅ Separate package needed |
|
||||
|
||||
### Recommended Package Set for the Web App
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@noble/post-quantum": "^0.6.1",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@scure/bip39": "^1.5.0",
|
||||
"@scure/bip32": "^1.6.0",
|
||||
"nostr-tools": "^2.10.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `@noble/post-quantum` — PQ algorithms (ML-DSA, SLH-DSA, ML-KEM, X-Wing)
|
||||
- `@noble/curves` — secp256k1 for NIP-01 signing and NIP-06 key derivation
|
||||
- `@noble/hashes` — SHA-256, HKDF, etc.
|
||||
- `@scure/bip39` — BIP39 mnemonic generation and validation
|
||||
- `@scure/bip32` — BIP32 HD wallet derivation (for NIP-06)
|
||||
- `nostr-tools` — Nostr event creation, relay communication, NIP-46
|
||||
|
||||
All of these are from the same trust ecosystem (noble / scure family by Paul Miller and Alex Obukhov).
|
||||
|
||||
---
|
||||
|
||||
## PQ Key Derivation from BIP39 Seed
|
||||
|
||||
One critical detail: `@noble/post-quantum`'s `keygen(seed)` functions accept a seed parameter for deterministic key generation. The seed length varies by algorithm:
|
||||
|
||||
| Algorithm | Seed length |
|
||||
|---|---|
|
||||
| ML-KEM-768 | 64 bytes |
|
||||
| ML-DSA-65 | 32 bytes |
|
||||
| SLH-DSA-128s | 32 bytes (or 64 for some variants) |
|
||||
| Falcon-512 | 48 bytes |
|
||||
|
||||
Our derivation scheme from BIP39 seed:
|
||||
|
||||
```js
|
||||
import { hkdf } from '@noble/hashes/hkdf';
|
||||
import { sha512 } from '@noble/hashes/sha512';
|
||||
|
||||
// BIP39 seed (64 bytes from PBKDF2-HMAC-SHA512)
|
||||
const bip39Seed = ...; // 64 bytes
|
||||
|
||||
// Derive PQ key seeds using HKDF with algorithm-specific labels
|
||||
const mlDsaSeed = hkdf(sha512, bip39Seed, undefined, 'nostr-pq-ml-dsa-65', 32);
|
||||
const slhDsaSeed = hkdf(sha512, bip39Seed, undefined, 'nostr-pq-slh-dsa-128s', 32);
|
||||
const mlKemSeed = hkdf(sha512, bip39Seed, undefined, 'nostr-pq-ml-kem-768', 64);
|
||||
|
||||
// Generate PQ keypairs deterministically
|
||||
const mlDsaKeys = ml_dsa65.keygen(mlDsaSeed);
|
||||
const slhDsaKeys = slh_dsa_sha2_128s.keygen(slhDsaSeed);
|
||||
const mlKemKeys = ml_kem768.keygen(mlKemSeed);
|
||||
```
|
||||
|
||||
This means: **same seed phrase → same PQ keys, every time**. The user can recover their PQ keys by entering their seed phrase in any implementation of this scheme.
|
||||
|
||||
---
|
||||
|
||||
## Summary: No Custom Implementations Needed
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Do we need to write PQ crypto implementations? | **No** — `@noble/post-quantum` covers all algorithms |
|
||||
| Do we need WASM? | **No** — pure JS is fast enough for our one-time key-link event |
|
||||
| Are the packages maintained? | **Yes** — noble-post-quantum updated June 2026, 335 stars |
|
||||
| Are they audited? | Self-audited at v0.6.1. No independent audit yet. |
|
||||
| Do they support deterministic keygen from seed? | **Yes** — `keygen(seed)` for all algorithms |
|
||||
| Do they work in browsers? | **Yes** — pure JS, uses `crypto.getRandomValues` |
|
||||
| What's the bundle size? | 16KB gzipped for all algorithms (tree-shakeable) |
|
||||
| What do we need to write ourselves? | The NIP-QR event builder, key derivation scheme (HKDF labels), relay publishing, OTS client, UI |
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Upload the post-quantum Nostr 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
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
SERVER="ubuntu@laantungir.net"
|
||||
REMOTE_DIR="html/post-quantum"
|
||||
LOCAL_DIR="$(dirname "$0")/www"
|
||||
|
||||
echo "🔒 Post-Quantum Nostr — Upload"
|
||||
echo "=============================="
|
||||
echo "Server: $SERVER"
|
||||
echo "Remote: $REMOTE_DIR"
|
||||
echo "Local: $LOCAL_DIR"
|
||||
echo ""
|
||||
|
||||
# Ensure the bundle is up to date
|
||||
echo "📦 Building PQ crypto bundle..."
|
||||
node "$(dirname "$0")/build-pq-bundle.js"
|
||||
echo ""
|
||||
|
||||
# Create the remote directory if it doesn't exist
|
||||
echo "📁 Ensuring remote directory exists..."
|
||||
ssh "$SERVER" "mkdir -p $REMOTE_DIR"
|
||||
echo ""
|
||||
|
||||
# Upload files using rsync
|
||||
echo "📤 Uploading files..."
|
||||
rsync -avz --delete \
|
||||
--exclude='*.map' \
|
||||
"$LOCAL_DIR/" \
|
||||
"$SERVER:$REMOTE_DIR/"
|
||||
echo ""
|
||||
|
||||
echo "✅ Upload complete!"
|
||||
echo "🌐 Site available at: https://laantungir.net/post-quantum"
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
# Post-Quantum Nostr: Why, What, How
|
||||
|
||||
## Why: The Problem
|
||||
|
||||
### Quantum computers will break Nostr's cryptography
|
||||
|
||||
Nostr's entire security model rests on secp256k1 elliptic curve cryptography:
|
||||
|
||||
- **Identity** — your pubkey IS your identity. It's in every event, it's your npub, 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/NIP-44)** — both use secp256k1 ECDH to derive a shared secret, then symmetric encryption (AES-256-CBC or ChaCha20+HMAC).
|
||||
|
||||
**Shor's algorithm** breaks the elliptic curve discrete logarithm problem 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
|
||||
|
||||
This is not a theoretical concern. NIST has already standardized post-quantum algorithms (FIPS 203, 204, 205) and published guidance prohibiting classical cryptography after 2035. Apple, Signal, WhatsApp, and OpenSSH have already deployed post-quantum encryption. The migration is happening now.
|
||||
|
||||
### Why Bitcoin's hash-the-pubkey trick doesn't work for Nostr
|
||||
|
||||
Bitcoin has limited quantum resistance because addresses are hashes of pubkeys, and pubkeys are only revealed at spend time. This doesn't work for Nostr because:
|
||||
|
||||
| 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: it IS the identity, it must be revealed for signature verification, and it's reused across thousands of events over years. Hashing the pubkey would give zero seconds of quantum resistance — the pubkey must be revealed the moment you publish your first event.
|
||||
|
||||
### The collect-now-decrypt-later threat
|
||||
|
||||
Even if quantum computers don't exist yet, adversaries can record encrypted Nostr messages today and decrypt them later when a quantum computer becomes available. Every NIP-04 and NIP-44 message stored on public relays is vulnerable to this attack. The symmetric ciphers (ChaCha20, AES-256) are quantum-safe, but the ECDH key agreement that produces the shared secret is not.
|
||||
|
||||
### The standardization uncertainty problem
|
||||
|
||||
NIST finalized ML-KEM (Kyber), ML-DSA (Dilithium), and SLH-DSA (SPHINCS+) in 2024, but the history of post-quantum cryptography includes broken schemes — SIKE was a NIST finalist broken in 2022 by a classical algorithm that ran in an hour on a laptop. If the Nostr community picks one algorithm and it's later broken, we'd be back to square one. Picking a single algorithm is risky.
|
||||
|
||||
### The migration problem
|
||||
|
||||
Most Nostr users today have a raw nsec (random private key) with no seed phrase. Their identity, social graph, NIP-05 verification, and follower relationships are all tied to that key. Any post-quantum migration that requires users to abandon their identity and start over is a non-starter. The migration must preserve identity, social graph, and user experience.
|
||||
|
||||
---
|
||||
|
||||
## What: Our Solution
|
||||
|
||||
A migration strategy that brings post-quantum security to Nostr:
|
||||
|
||||
- **Without breaking the social graph** — existing follows and identity carry forward
|
||||
- **Without requiring consensus on a single PQ algorithm** — link to all viable schemes now, converge later
|
||||
- **Without forcing existing users to abandon their identities** — the seed phrase becomes the new root of trust
|
||||
- **With pre-quantum timestamping** — establish links now, before quantum computers exist, anchored to the Bitcoin blockchain
|
||||
|
||||
### The six components
|
||||
|
||||
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 HKDF with algorithm-specific labels. 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** — A new Nostr event (NIP-QR) that links a user's secp256k1 identity to multiple PQ keys simultaneously: ML-DSA-65 (lattice-based signatures), SLH-DSA-128s (hash-based signatures), and ML-KEM-768 (lattice-based KEM for encryption). Each PQ scheme independently signs the link statement. 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).
|
||||
|
||||
4. **Migrating existing users with raw nsec** — Users who logged in with a raw nsec (no seed phrase) generate a new seed phrase. A two-account migration event links their old identity (Account #1, raw nsec) to a seed-derived successor (Account #2) and its PQ keys. The old identity signs to authorize the migration; the new identity signs to endorse the PQ keys.
|
||||
|
||||
5. **Quantum-safe self-storage** — Sensitive data stored on relays (kind 30078) can be encrypted with either a one-time pad (information-theoretic security, stronger than any computational assumption) or a symmetric key derived from the seed (128-bit PQ security via Grover's). This separates data confidentiality from identity authentication — breaking secp256k1 compromises identity but not stored data.
|
||||
|
||||
6. **Deterministic wallet compartmentalization** — BIP32/BIP39 HD wallets (NIP-06) provide quantum compartmentalization through their tree structure. When Shor's algorithm recovers a leaf private key, the attacker cannot climb the tree without the parent chain code (a secret symmetric value). Hardened derivation at the account level means breaking one account doesn't compromise others.
|
||||
|
||||
### What this is NOT
|
||||
|
||||
- It is not a new signature scheme for every event — PQ signatures are used additively for the one-time key-link event, not for routine posts
|
||||
- It is not a consensus requirement — the community doesn't need to agree on one PQ algorithm; multiple schemes are linked simultaneously
|
||||
- It is not a hard fork — legacy clients continue working; PQ is additive
|
||||
- It is not a trust-the-server solution — all cryptography happens client-side in the browser
|
||||
|
||||
---
|
||||
|
||||
## How: How It Works
|
||||
|
||||
### The cryptographic chain of trust
|
||||
|
||||
```
|
||||
Account #1 (old identity, social graph knows this npub)
|
||||
|
|
||||
| signs: "I am migrating to Account #2 and its PQ keys"
|
||||
| (secp256k1 Schnorr signature, valid pre-quantum)
|
||||
v
|
||||
Account #2 (seed-derived secp256k1 key)
|
||||
|
|
||||
| same seed phrase ---------> PQ keys (ML-DSA, SLH-DSA, ML-KEM)
|
||||
| |
|
||||
| signs: "These PQ keys come | sign: "We are derived from
|
||||
| from my seed" | the same seed as Account #2"
|
||||
| (secp256k1 Schnorr) | (PQ signatures)
|
||||
v v
|
||||
NIP-QR event (published under Account #1's pubkey)
|
||||
|
|
||||
| OpenTimestamps anchor to Bitcoin block
|
||||
| (proves the event existed at this time, pre-quantum)
|
||||
v
|
||||
Published to Nostr relays
|
||||
```
|
||||
|
||||
### The NIP-QR event
|
||||
|
||||
A kind 30078 event (NIP-78 application-specific data) containing:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30078,
|
||||
"pubkey": "<Account #1 secp256k1 pubkey>",
|
||||
"tags": [
|
||||
["d", "nip-qr-migration"],
|
||||
["successor", "<Account #2 secp256k1 pubkey>"],
|
||||
["algorithm", "ml-dsa-65"],
|
||||
["algorithm", "slh-dsa-128s"],
|
||||
["algorithm", "ml-kem-768"]
|
||||
],
|
||||
"content": "<JSON with link statement, PQ public keys, PQ signatures, successor signature>",
|
||||
"sig": "<Account #1 Schnorr signature>"
|
||||
}
|
||||
```
|
||||
|
||||
The content JSON contains:
|
||||
- A link statement binding all keys together
|
||||
- Each PQ public key (base64-encoded)
|
||||
- Each PQ signature over the statement (ML-DSA and SLH-DSA can sign; ML-KEM cannot — it's a KEM, not a signature scheme)
|
||||
- The successor's secp256k1 signature over the statement (proves the seed-derived key endorses the PQ keys)
|
||||
|
||||
### PQ key derivation from seed
|
||||
|
||||
Post-quantum keys are deterministically derived from the BIP39 seed using HKDF:
|
||||
|
||||
```
|
||||
seed = PBKDF2-HMAC-SHA512(mnemonic, "mnemonic", 2048) // standard BIP39
|
||||
|
||||
ml_dsa_seed = HKDF-SHA512(seed, "nostr-pq-ml-dsa-65", 32) -> ML-DSA-65 keypair
|
||||
slh_dsa_seed = HKDF-SHA512(seed, "nostr-pq-slh-dsa-128s", 48) -> SLH-DSA-128s keypair
|
||||
ml_kem_seed = HKDF-SHA512(seed, "nostr-pq-ml-kem-768", 64) -> ML-KEM-768 keypair
|
||||
```
|
||||
|
||||
Same seed phrase always produces the same PQ keys. Users can recover their PQ keys by entering their seed phrase in any implementation of this scheme.
|
||||
|
||||
### The web app
|
||||
|
||||
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
|
||||
3. **Derives PQ keys** — uses `@noble/post-quantum` (pure JavaScript, no WASM) to generate ML-DSA-65, SLH-DSA-128s, and ML-KEM-768 keypairs from the seed
|
||||
4. **Signs the NIP-QR event** — PQ signatures are created in the browser; the secp256k1 signature is requested from the user's signer (via `window.nostr.signEvent()`)
|
||||
5. **Publishes to relays** — sends the fully signed event to Nostr relays via direct WebSocket
|
||||
6. **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.
|
||||
|
||||
### The algorithms
|
||||
|
||||
| Algorithm | FIPS | Type | Key size | Signature size | Why we use it |
|
||||
|---|---|---|---|---|---|
|
||||
| ML-DSA-65 | 204 | Signature | 1952 bytes | 3309 bytes | Primary PQ signature (lattice-based, NIST-standardized) |
|
||||
| SLH-DSA-128s | 205 | Signature | 32 bytes | 7856 bytes | Conservative fallback (hash-based, very conservative assumptions) |
|
||||
| ML-KEM-768 | 203 | KEM | 1184 bytes | 1088 bytes (ciphertext) | PQ encryption key agreement (lattice-based, used by Signal, Apple) |
|
||||
|
||||
We use multiple schemes simultaneously because:
|
||||
- If ML-DSA is broken (lattice-based), SLH-DSA (hash-based) remains valid
|
||||
- If all lattice schemes are broken, only the hash-based fallback remains
|
||||
- The community doesn't need to agree on one algorithm — clients can verify whichever subset they support
|
||||
|
||||
### Post-quantum scenario (when secp256k1 is broken)
|
||||
|
||||
1. Attacker breaks Account #1's secp256k1 key via Shor's algorithm
|
||||
2. Attacker can forge Account #1 signatures, but:
|
||||
- **Cannot forge PQ signatures** — ML-DSA and SLH-DSA remain secure
|
||||
- **Cannot backdate a fraudulent NIP-QR event** — the real event has an OTS proof anchored to a Bitcoin block that predates quantum computers
|
||||
- **Cannot decrypt PQ-encrypted messages** — ML-KEM remains secure
|
||||
3. Clients verify: "the NIP-QR event with the earliest valid OTS proof wins"
|
||||
4. The real NIP-QR event (anchored pre-quantum) is cryptographically distinguishable from any fraudulent event
|
||||
5. The user's identity is preserved through the OTS-anchored migration event
|
||||
6. The user continues signing with Account #2 and PQ keys
|
||||
|
||||
### 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 NIP-QR event, and publish to relays. It uses:
|
||||
|
||||
- `@noble/post-quantum` for PQ cryptography (ML-DSA-65, SLH-DSA-128s, ML-KEM-768)
|
||||
- `@scure/bip39` for seed phrase generation
|
||||
- `@scure/bip32` for NIP-06 key derivation
|
||||
- `nostr-login-lite` for authentication
|
||||
- Direct WebSocket for relay publishing
|
||||
|
||||
The source code is in [`www/index.html`](www/index.html) and [`www/js/pq-crypto.mjs`](www/js/pq-crypto.mjs). To deploy updates, run `./upload.sh`.
|
||||
Binary file not shown.
Binary file not shown.
+1689
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,397 @@
|
||||
/* ************************************************************************* */
|
||||
/* SIDENAV SECTIONS (SHARED) */
|
||||
/* ************************************************************************* */
|
||||
|
||||
/* Shared section shell */
|
||||
#divRelaySection,
|
||||
#divBlossomSection,
|
||||
.sidenavSection {
|
||||
flex-shrink: 0;
|
||||
background-color: var(--secondary-color);
|
||||
border-top: 3px solid var(--primary-color);
|
||||
padding: 10px;
|
||||
transition: padding-top 0.5s ease, padding-bottom 0.5s ease;
|
||||
}
|
||||
|
||||
/* Shared section title */
|
||||
#divRelaySectionTitle,
|
||||
#divBlossomSectionTitle,
|
||||
.sidenavSectionTitle {
|
||||
font-family: pageKanji;
|
||||
font-size: 100%;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 5px;
|
||||
border-bottom: 1px solid var(--muted-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: border-color 0.2s ease, color 0.2s ease, margin-bottom 0.5s ease, padding-top 0.5s ease, padding-bottom 0.5s ease;
|
||||
}
|
||||
|
||||
#divBlossomSectionTitle.dragover,
|
||||
.sidenavSectionTitle.dragover {
|
||||
color: var(--accent-color);
|
||||
border-bottom-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.sidenavSectionToggleIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidenavSectionToggleIcon svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Shared section list */
|
||||
#divRelayList,
|
||||
#divBlossomList,
|
||||
.sidenavSectionList {
|
||||
font-size: 70%;
|
||||
font-family: var(--font-family);
|
||||
overflow: hidden;
|
||||
max-height: 55vh;
|
||||
opacity: 1;
|
||||
transition: max-height 0.5s ease, opacity 0.5s ease, margin 0.5s ease, padding 0.5s ease;
|
||||
}
|
||||
|
||||
#divRelaySection.collapsed #divRelayList,
|
||||
#divBlossomSection.collapsed #divBlossomList,
|
||||
.sidenavSection.collapsed .sidenavSectionList {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* AI list container */
|
||||
#divAiList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
#divRelaySection.collapsed,
|
||||
#divBlossomSection.collapsed,
|
||||
.sidenavSection.collapsed {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
#divRelaySection.collapsed #divRelaySectionTitle,
|
||||
#divBlossomSection.collapsed #divBlossomSectionTitle,
|
||||
.sidenavSection.collapsed .sidenavSectionTitle {
|
||||
margin-bottom: 0;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Shared row primitives */
|
||||
.sidenavRowEntry {
|
||||
border-bottom: 1px solid var(--muted-color);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.sidenavRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 100%;
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
word-break: break-all;
|
||||
transition: color 0.2s ease;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.sidenavRow:hover,
|
||||
.sidenavRowName.active {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.sidenavRowName {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sidenavRowMeta {
|
||||
font-size: 80%;
|
||||
margin-left: 5px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.sidenavRowToggle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidenavRowToggle svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidenavRowDetails {
|
||||
font-size: 85%;
|
||||
color: var(--muted-color);
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease, opacity 0.25s ease, margin 0.25s ease;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.sidenavRowDetails.expanded {
|
||||
max-height: 120px;
|
||||
opacity: 1;
|
||||
margin-top: 4px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.sidenavRowIndent,
|
||||
.relayRowIndent {
|
||||
display: inline-block;
|
||||
padding: 0 4px;
|
||||
font-size: 100%;
|
||||
line-height: 1;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidenavRow.with-indent::before {
|
||||
content: '≡';
|
||||
display: inline-block;
|
||||
padding: 0 4px;
|
||||
font-size: 100%;
|
||||
line-height: 1;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* AI section aliases to shared row primitives */
|
||||
.aiProviderEntry {
|
||||
border-bottom: 1px solid var(--muted-color);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.aiProviderRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 100%;
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
word-break: break-all;
|
||||
transition: color 0.2s ease;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.aiProviderRow::before {
|
||||
content: '≡';
|
||||
display: inline-block;
|
||||
padding: 0 4px;
|
||||
font-size: 100%;
|
||||
line-height: 1;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.aiProviderRow:hover,
|
||||
.aiProviderName.active {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.aiProviderName {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.aiProviderToggle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.aiProviderToggle svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.aiProviderDetails {
|
||||
font-size: 100%;
|
||||
color: var(--muted-color);
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease, opacity 0.25s ease, margin 0.25s ease;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.aiProviderDetails.expanded {
|
||||
max-height: none;
|
||||
opacity: 1;
|
||||
margin-top: 4px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.aiSavedProviderEmpty {
|
||||
font-size: 70%;
|
||||
color: var(--muted-color);
|
||||
font-style: italic;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* AI compact dropdown groups (inside AI section) */
|
||||
#divAiList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#divAiProvidersList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
#divAiSection:not(.collapsed) #divAiList {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.aiCompactTools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.aiCompactGroup {
|
||||
border-bottom: 1px solid var(--muted-color);
|
||||
padding-bottom: 4px;
|
||||
margin-left: 36px;
|
||||
}
|
||||
|
||||
.aiCompactGroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
font-size: 1em;
|
||||
padding: 3px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.aiCompactGroupHeader:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.aiCompactGroupTitle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.aiCompactGroupToggle {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.aiCompactGroupToggle svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.aiCompactGroupBody {
|
||||
overflow: hidden;
|
||||
max-height: 2000px;
|
||||
opacity: 1;
|
||||
font-size: 1em;
|
||||
transition: max-height 0.25s ease, opacity 0.2s ease, margin 0.2s ease;
|
||||
margin-top: 4px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.aiCompactGroupBody.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Hide old top placement; panels get re-parented into compact group bodies by JS */
|
||||
#divSideNavBody > .aiCompactSourcePanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.aiCompactGroupBody > .aiCompactSourcePanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.aiCompactGroupBody .aiSideLabel {
|
||||
font-size: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.aiCompactGroupBody .aiConfigField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.aiCompactGroupBody .aiInput,
|
||||
.aiCompactGroupBody .aiTextarea,
|
||||
.aiCompactGroupBody .aiDropdownBtn,
|
||||
.aiCompactGroupBody .btn {
|
||||
font-size: 100%;
|
||||
padding: 5px 6px;
|
||||
}
|
||||
|
||||
.aiCompactGroupBody .aiTextarea {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.aiCompactGroupBody .aiActionGroup,
|
||||
.aiCompactGroupBody .aiInvoicePanel {
|
||||
padding: 6px;
|
||||
gap: 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.aiCompactGroupBody #divRoutstrDepositInvoiceQr,
|
||||
.aiCompactGroupBody #divRoutstrTopupInvoiceQr,
|
||||
.aiCompactGroupBody #divRoutstrInvoiceQr {
|
||||
min-height: 90px;
|
||||
}
|
||||
+1344
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,715 @@
|
||||
/**
|
||||
* Post-Quantum Crypto Module for Nostr
|
||||
*
|
||||
* Provides:
|
||||
* - BIP39 seed phrase generation
|
||||
* - NIP-06 key derivation (secp256k1 from seed via BIP32)
|
||||
* - PQ key derivation from BIP32 HD wallet paths (5 algorithms)
|
||||
* - PQ signing (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512)
|
||||
* - NIP-QR event construction
|
||||
*
|
||||
* Uses @noble/post-quantum (pure JS, no WASM needed)
|
||||
*
|
||||
* BIP32 Derivation Paths (all under m/44'/1237'/0'/0/):
|
||||
* 0 — secp256k1 (NIP-06 standard, Account #2)
|
||||
* 1 — ML-DSA-44 (32-byte seed)
|
||||
* 2 — ML-DSA-65 (32-byte seed)
|
||||
* 3+4 — SLH-DSA-128s (48-byte seed, two 32-byte children concatenated)
|
||||
* 5+6 — Falcon-512 (48-byte seed, two 32-byte children concatenated)
|
||||
* 7+8 — ML-KEM-768 (64-byte seed, two 32-byte children concatenated)
|
||||
*/
|
||||
|
||||
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english.js';
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import { bech32 } from '@scure/base';
|
||||
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';
|
||||
|
||||
// ============================================================================
|
||||
// BIP32 DERIVATION PATHS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* BIP32 derivation paths for all keys.
|
||||
*
|
||||
* Base path: m/44'/1237'/0'/0/ (NIP-06 account 0, change 0)
|
||||
* Child indices:
|
||||
* 0 — secp256k1 (NIP-06 standard)
|
||||
* 1 — ML-DSA-44
|
||||
* 2 — ML-DSA-65
|
||||
* 3, 4 — SLH-DSA-128s (two children, concatenated for 48-byte seed)
|
||||
* 5, 6 — Falcon-512 (two children, concatenated for 48-byte seed)
|
||||
* 7, 8 — ML-KEM-768 (two children, concatenated for 64-byte seed)
|
||||
*/
|
||||
const PQ_DERIVATION_PATHS = {
|
||||
secp256k1: [0], // 32 bytes (standard NIP-06)
|
||||
mlDsa44: [1], // 32 bytes
|
||||
mlDsa65: [2], // 32 bytes
|
||||
slhDsa: [3, 4], // 64 bytes concatenated, take first 48
|
||||
falcon512: [5, 6], // 64 bytes concatenated, take first 48
|
||||
mlKem: [7, 8], // 64 bytes concatenated
|
||||
};
|
||||
|
||||
// Seed lengths required by each algorithm's keygen()
|
||||
const PQ_SEED_LENGTHS = {
|
||||
mlDsa44: 32,
|
||||
mlDsa65: 32,
|
||||
slhDsa: 48,
|
||||
falcon512: 48,
|
||||
mlKem: 64,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// BIP39 SEED PHRASE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a new 12-word BIP39 mnemonic.
|
||||
* @returns {string} 12-word seed phrase
|
||||
*/
|
||||
export function generateSeedPhrase() {
|
||||
return generateMnemonic(wordlist, 128); // 128 bits = 12 words
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a mnemonic to a 64-byte BIP39 seed (PBKDF2-HMAC-SHA512).
|
||||
* @param {string} mnemonic - 12/24 word seed phrase
|
||||
* @param {string} [passphrase=''] - optional BIP39 passphrase
|
||||
* @returns {Uint8Array} 64-byte seed
|
||||
*/
|
||||
export function mnemonicToSeed(mnemonic, passphrase = '') {
|
||||
if (!validateMnemonic(mnemonic, wordlist)) {
|
||||
throw new Error('Invalid mnemonic');
|
||||
}
|
||||
return mnemonicToSeedSync(mnemonic, passphrase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a BIP39 mnemonic.
|
||||
* @param {string} mnemonic
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isValidMnemonic(mnemonic) {
|
||||
return validateMnemonic(mnemonic, wordlist);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BIP32 KEY DERIVATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Derive a BIP32 child private key at a given path.
|
||||
*
|
||||
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
|
||||
* @param {number[]} childIndices - child indices under m/44'/1237'/0'/0/
|
||||
* @returns {Uint8Array} 32-byte private key
|
||||
*/
|
||||
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 private key at path ${path}`);
|
||||
}
|
||||
return child.privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a seed of the required length from BIP32 child keys.
|
||||
*
|
||||
* For 32-byte seeds: derive one child, use its 32-byte private key.
|
||||
* For 48-byte seeds: derive two children, concatenate (64 bytes), take first 48.
|
||||
* For 64-byte seeds: derive two children, concatenate (64 bytes).
|
||||
*
|
||||
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
|
||||
* @param {number[]} childIndices - child indices to derive
|
||||
* @param {number} requiredLength - required seed length
|
||||
* @returns {Uint8Array} seed bytes
|
||||
*/
|
||||
function derivePQSeedFromBIP32(bip39Seed, childIndices, requiredLength) {
|
||||
if (childIndices.length === 1) {
|
||||
// Single child — 32 bytes
|
||||
const seed = deriveBIP32Child(bip39Seed, childIndices);
|
||||
if (seed.length !== requiredLength) {
|
||||
throw new Error(`Seed length mismatch: got ${seed.length}, expected ${requiredLength}`);
|
||||
}
|
||||
return seed;
|
||||
} else {
|
||||
// Multiple children — concatenate and truncate
|
||||
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;
|
||||
}
|
||||
if (combined.length < requiredLength) {
|
||||
throw new Error(`Combined seed too short: got ${combined.length}, expected ${requiredLength}`);
|
||||
}
|
||||
return combined.slice(0, requiredLength);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NIP-06 KEY DERIVATION (secp256k1 from seed)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Derive a secp256k1 keypair from a BIP39 seed using NIP-06.
|
||||
* Path: m/44'/1237'/0'/0/0
|
||||
*
|
||||
* @param {Uint8Array} seed - 64-byte BIP39 seed
|
||||
* @param {number} [accountIndex=0] - account index
|
||||
* @returns {{privateKey: Uint8Array, publicKey: Uint8Array}} secp256k1 keypair
|
||||
*/
|
||||
export function deriveSecp256k1FromSeed(seed, accountIndex = 0) {
|
||||
const hdKey = HDKey.fromMasterSeed(seed);
|
||||
const path = `m/44'/1237'/${accountIndex}'/0/0`;
|
||||
const child = hdKey.derive(path);
|
||||
if (!child.privateKey) {
|
||||
throw new Error('Failed to derive private key');
|
||||
}
|
||||
return {
|
||||
privateKey: child.privateKey,
|
||||
publicKey: child.publicKey
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PQ KEY DERIVATION FROM BIP32 PATHS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Derive all PQ keypairs from a BIP39 seed using BIP32 derivation paths.
|
||||
*
|
||||
* Paths (under m/44'/1237'/0'/0/):
|
||||
* 1 — ML-DSA-44
|
||||
* 2 — ML-DSA-65
|
||||
* 3+4 — SLH-DSA-128s
|
||||
* 5+6 — Falcon-512
|
||||
* 7+8 — ML-KEM-768
|
||||
*
|
||||
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
|
||||
* @returns {{
|
||||
* mlDsa44: {publicKey: Uint8Array, secretKey: Uint8Array},
|
||||
* mlDsa65: {publicKey: Uint8Array, secretKey: Uint8Array},
|
||||
* slhDsa: {publicKey: Uint8Array, secretKey: Uint8Array},
|
||||
* falcon512: {publicKey: Uint8Array, secretKey: Uint8Array},
|
||||
* mlKem: {publicKey: Uint8Array, secretKey: Uint8Array}
|
||||
* }}
|
||||
*/
|
||||
export function derivePQKeysFromSeed(bip39Seed) {
|
||||
// ML-DSA-44 (32-byte seed, path child 1)
|
||||
const mlDsa44Seed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlDsa44, PQ_SEED_LENGTHS.mlDsa44);
|
||||
const mlDsa44Keys = ml_dsa44.keygen(mlDsa44Seed);
|
||||
|
||||
// ML-DSA-65 (32-byte seed, path child 2)
|
||||
const mlDsa65Seed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlDsa65, PQ_SEED_LENGTHS.mlDsa65);
|
||||
const mlDsa65Keys = ml_dsa65.keygen(mlDsa65Seed);
|
||||
|
||||
// SLH-DSA-128s (48-byte seed, paths children 3+4 concatenated)
|
||||
const slhDsaSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.slhDsa, PQ_SEED_LENGTHS.slhDsa);
|
||||
const slhDsaKeys = slh_dsa_sha2_128s.keygen(slhDsaSeed);
|
||||
|
||||
// Falcon-512 (48-byte seed, paths children 5+6 concatenated)
|
||||
const falconSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.falcon512, PQ_SEED_LENGTHS.falcon512);
|
||||
const falconKeys = falcon512.keygen(falconSeed);
|
||||
|
||||
// ML-KEM-768 (64-byte seed, paths children 7+8 concatenated)
|
||||
const mlKemSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlKem, PQ_SEED_LENGTHS.mlKem);
|
||||
const mlKemKeys = ml_kem768.keygen(mlKemSeed);
|
||||
|
||||
return {
|
||||
mlDsa44: mlDsa44Keys,
|
||||
mlDsa65: mlDsa65Keys,
|
||||
slhDsa: slhDsaKeys,
|
||||
falcon512: falconKeys,
|
||||
mlKem: mlKemKeys
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PQ SIGNING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Sign a message with ML-DSA-44.
|
||||
*/
|
||||
export function signWithMLDSA44(message, secretKey) {
|
||||
return ml_dsa44.sign(message, secretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an ML-DSA-44 signature.
|
||||
*/
|
||||
export function verifyMLDSA44(signature, message, publicKey) {
|
||||
return ml_dsa44.verify(signature, message, publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a message with ML-DSA-65.
|
||||
*/
|
||||
export function signWithMLDSA65(message, secretKey) {
|
||||
return ml_dsa65.sign(message, secretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an ML-DSA-65 signature.
|
||||
*/
|
||||
export function verifyMLDSA65(signature, message, publicKey) {
|
||||
return ml_dsa65.verify(signature, message, publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a message with SLH-DSA-128s.
|
||||
*/
|
||||
export function signWithSLHDSA(message, secretKey) {
|
||||
return slh_dsa_sha2_128s.sign(message, secretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an SLH-DSA-128s signature.
|
||||
*/
|
||||
export function verifySLHDSA(signature, message, publicKey) {
|
||||
return slh_dsa_sha2_128s.verify(signature, message, publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a message with Falcon-512.
|
||||
*/
|
||||
export function signWithFalcon(message, secretKey) {
|
||||
return falcon512.sign(message, secretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Falcon-512 signature.
|
||||
*/
|
||||
export function verifyFalcon(signature, message, publicKey) {
|
||||
return falcon512.verify(signature, message, publicKey);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert Uint8Array to base64 string.
|
||||
*/
|
||||
export function bytesToBase64(bytes) {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 string to Uint8Array.
|
||||
*/
|
||||
export function base64ToBytes(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Uint8Array to hex string.
|
||||
*/
|
||||
export function bytesToHex(bytes) {
|
||||
return Array.from(bytes)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to Uint8Array.
|
||||
*/
|
||||
export function hexToBytes(hex) {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a hex pubkey to npub (bech32 encoding).
|
||||
* @param {string} hexPubkey - 32-byte hex pubkey
|
||||
* @returns {string} npub1...
|
||||
*/
|
||||
export function hexToNpub(hexPubkey) {
|
||||
const bytes = hexToBytes(hexPubkey);
|
||||
return bech32.encode('npub', bech32.toWords(bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Nostr event's secp256k1 Schnorr signature (NIP-01).
|
||||
* @param {object} event - Nostr event with id, pubkey, created_at, kind, tags, content, sig
|
||||
* @returns {boolean} true if signature is valid
|
||||
*/
|
||||
export function verifyNostrEvent(event) {
|
||||
// Build the event hash (NIP-01 serialization)
|
||||
const serialized = JSON.stringify([
|
||||
0,
|
||||
event.pubkey,
|
||||
event.created_at,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content
|
||||
]);
|
||||
const hash = sha256(new TextEncoder().encode(serialized));
|
||||
const sig = hexToBytes(event.sig);
|
||||
const pubkey = hexToBytes(event.pubkey);
|
||||
return schnorr.verify(sig, hash, pubkey);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NIP-QR EVENT CONSTRUCTION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build the NIP-QR event content and tags.
|
||||
*
|
||||
* The content is a human-readable attestation statement (plain text).
|
||||
* The PQ public keys and signatures go in the event tags.
|
||||
* Each PQ key signs the content text (the attestation statement).
|
||||
*
|
||||
* Tag format: ['algorithm', '<algorithm>', '<base64 pubkey>', '<base64 signature>']
|
||||
* For ML-KEM (KEM, can't sign): ['algorithm', 'ml-kem-768', '<base64 pubkey>']
|
||||
*
|
||||
* @param {string} hexPubkey - The user's Nostr hex pubkey (Account #1)
|
||||
* @param {number} blockHeight - Current Bitcoin block height for pre-quantum anchoring
|
||||
* @param {object} pqKeys - PQ keypairs from derivePQKeysFromSeed()
|
||||
* @returns {{content: string, tags: Array, statementBytes: Uint8Array}}
|
||||
*/
|
||||
export function buildNIPQRContent(hexPubkey, blockHeight, pqKeys) {
|
||||
const npub = hexToNpub(hexPubkey);
|
||||
|
||||
// Human-readable attestation statement (this is the event's content field)
|
||||
// Single line with spaces (no newlines) for clean JSON display
|
||||
const content = `NOSTR identity: [hex] ${hexPubkey} [npub] ${npub} is signaling migration to successor post quantum pubkeys listed in the tags of this event. This link is established pre-quantum at blockheight ${blockHeight}.`;
|
||||
|
||||
const statementBytes = new TextEncoder().encode(content);
|
||||
|
||||
// Sign the content text with each PQ signature scheme
|
||||
const mlDsa44Sig = signWithMLDSA44(statementBytes, pqKeys.mlDsa44.secretKey);
|
||||
const mlDsa65Sig = signWithMLDSA65(statementBytes, pqKeys.mlDsa65.secretKey);
|
||||
const slhDsaSig = signWithSLHDSA(statementBytes, pqKeys.slhDsa.secretKey);
|
||||
const falconSig = signWithFalcon(statementBytes, pqKeys.falcon512.secretKey);
|
||||
|
||||
// Build tags: each PQ key's pubkey and signature in a tag
|
||||
const tags = [
|
||||
['d', 'nip-qr-migration'],
|
||||
['block_height', String(blockHeight)],
|
||||
['algorithm', 'ml-dsa-44', bytesToBase64(pqKeys.mlDsa44.publicKey), bytesToBase64(mlDsa44Sig)],
|
||||
['algorithm', 'ml-dsa-65', bytesToBase64(pqKeys.mlDsa65.publicKey), bytesToBase64(mlDsa65Sig)],
|
||||
['algorithm', 'slh-dsa-128s', bytesToBase64(pqKeys.slhDsa.publicKey), bytesToBase64(slhDsaSig)],
|
||||
['algorithm', 'falcon-512', bytesToBase64(pqKeys.falcon512.publicKey), bytesToBase64(falconSig)],
|
||||
['algorithm', 'ml-kem-768', bytesToBase64(pqKeys.mlKem.publicKey)]
|
||||
];
|
||||
|
||||
return { content, tags, statementBytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a NIP-QR event's PQ signatures.
|
||||
* Each PQ signature is verified against the event's content text.
|
||||
*
|
||||
* @param {string} contentText - The event's content field (human-readable statement)
|
||||
* @param {Array} tags - The event's tags array
|
||||
* @returns {{valid: boolean, results: Array}} verification results
|
||||
*/
|
||||
export function verifyNIPQRContent(contentText, tags) {
|
||||
const results = [];
|
||||
const msg = new TextEncoder().encode(contentText);
|
||||
|
||||
for (const tag of tags) {
|
||||
if (tag[0] !== 'algorithm') continue;
|
||||
|
||||
const algorithm = tag[1];
|
||||
const pubKeyBase64 = tag[2];
|
||||
const sigBase64 = tag[3]; // undefined for ML-KEM
|
||||
|
||||
if (algorithm === 'ml-kem-768' || !sigBase64) {
|
||||
// KEM can't sign — skip verification
|
||||
results.push({ algorithm, valid: true, note: 'KEM (no signature to verify)' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const pubKey = base64ToBytes(pubKeyBase64);
|
||||
const sig = base64ToBytes(sigBase64);
|
||||
|
||||
let valid = false;
|
||||
if (algorithm === 'ml-dsa-44') {
|
||||
valid = verifyMLDSA44(sig, msg, pubKey);
|
||||
} else if (algorithm === 'ml-dsa-65') {
|
||||
valid = verifyMLDSA65(sig, msg, pubKey);
|
||||
} else if (algorithm === 'slh-dsa-128s') {
|
||||
valid = verifySLHDSA(sig, msg, pubKey);
|
||||
} else if (algorithm === 'falcon-512') {
|
||||
valid = verifyFalcon(sig, msg, pubKey);
|
||||
}
|
||||
|
||||
results.push({ algorithm, valid });
|
||||
}
|
||||
|
||||
return {
|
||||
valid: results.every(r => r.valid),
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// KEY SIZE INFO (for display)
|
||||
// ============================================================================
|
||||
|
||||
export const PQ_KEY_INFO = {
|
||||
'ml-dsa-44': {
|
||||
name: 'ML-DSA-44 (Dilithium)',
|
||||
publicKeySize: 1312,
|
||||
signatureSize: 2420,
|
||||
fips: 'FIPS 204',
|
||||
type: 'signature',
|
||||
securityLevel: 'Category 2 (~AES-128)',
|
||||
derivationPath: "m/44'/1237'/0'/0/1"
|
||||
},
|
||||
'ml-dsa-65': {
|
||||
name: 'ML-DSA-65 (Dilithium)',
|
||||
publicKeySize: 1952,
|
||||
signatureSize: 3309,
|
||||
fips: 'FIPS 204',
|
||||
type: 'signature',
|
||||
securityLevel: 'Category 3 (~AES-192)',
|
||||
derivationPath: "m/44'/1237'/0'/0/2"
|
||||
},
|
||||
'slh-dsa-128s': {
|
||||
name: 'SLH-DSA-128s (SPHINCS+)',
|
||||
publicKeySize: 32,
|
||||
signatureSize: 7856,
|
||||
fips: 'FIPS 205',
|
||||
type: 'signature',
|
||||
securityLevel: 'Category 1 (~AES-128, hash-based)',
|
||||
derivationPath: "m/44'/1237'/0'/0/3+4"
|
||||
},
|
||||
'falcon-512': {
|
||||
name: 'Falcon-512',
|
||||
publicKeySize: 897,
|
||||
signatureSize: 666,
|
||||
fips: 'FIPS 206 (draft)',
|
||||
type: 'signature',
|
||||
securityLevel: 'Category 1 (~AES-128, lattice-based)',
|
||||
derivationPath: "m/44'/1237'/0'/0/5+6"
|
||||
},
|
||||
'ml-kem-768': {
|
||||
name: 'ML-KEM-768 (Kyber)',
|
||||
publicKeySize: 1184,
|
||||
ciphertextSize: 1088,
|
||||
fips: 'FIPS 203',
|
||||
type: 'kem',
|
||||
securityLevel: 'Category 3 (~AES-192)',
|
||||
derivationPath: "m/44'/1237'/0'/0/7+8"
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// OPENTIMESTAMPS (NIP-03)
|
||||
// ============================================================================
|
||||
|
||||
const OTS_CALENDAR_SERVERS = [
|
||||
'https://alice.btc.calendar.opentimestamps.org',
|
||||
'https://bob.btc.calendar.opentimestamps.org',
|
||||
];
|
||||
|
||||
// Detached .ots file prefix:
|
||||
// magic header (31 bytes) + version 1 varuint + SHA-256 operation tag (0x08).
|
||||
const OTS_DETACHED_PREFIX = hexToBytes(
|
||||
'004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294' +
|
||||
'01' +
|
||||
'08'
|
||||
);
|
||||
|
||||
function concatBytes(...arrays) {
|
||||
const length = arrays.reduce((sum, bytes) => sum + bytes.length, 0);
|
||||
const result = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const bytes of arrays) {
|
||||
result.set(bytes, offset);
|
||||
offset += bytes.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether bytes begin with the detached .ots magic header.
|
||||
* @param {Uint8Array} otsBytes
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isDetachedOtsFile(otsBytes) {
|
||||
if (!(otsBytes instanceof Uint8Array) || otsBytes.length < OTS_DETACHED_PREFIX.length) return false;
|
||||
for (let i = 0; i < OTS_DETACHED_PREFIX.length; i++) {
|
||||
if (otsBytes[i] !== OTS_DETACHED_PREFIX[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a hash to OpenTimestamps for timestamping.
|
||||
* Returns a pending .ots file (binary).
|
||||
*
|
||||
* The API endpoint is POST {server}/digest with the raw 32-byte hash as the body.
|
||||
*
|
||||
* @param {string} eventIdHex - The Nostr event id (hex string, 32 bytes)
|
||||
* @returns {Promise<Uint8Array>} pending .ots file bytes
|
||||
*/
|
||||
export async function timestampEvent(eventIdHex) {
|
||||
const hashBytes = hexToBytes(eventIdHex);
|
||||
|
||||
// Try each calendar server until one succeeds
|
||||
for (const server of OTS_CALENDAR_SERVERS) {
|
||||
try {
|
||||
const response = await fetch(`${server}/digest`, {
|
||||
method: 'POST',
|
||||
body: hashBytes,
|
||||
headers: {
|
||||
'Accept': 'application/vnd.opentimestamps.v1',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`[ots] Calendar ${server} returned ${response.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calendar response is a serialized timestamp fragment, not a full
|
||||
// detached .ots file. Wrap it with the OTS header, SHA-256 op, and
|
||||
// the original 32-byte event digest.
|
||||
const fragmentBuffer = await response.arrayBuffer();
|
||||
const fragmentBytes = new Uint8Array(fragmentBuffer);
|
||||
const otsBytes = concatBytes(OTS_DETACHED_PREFIX, hashBytes, fragmentBytes);
|
||||
console.log(`[ots] Received calendar fragment (${fragmentBytes.length} bytes); built detached .ots file (${otsBytes.length} bytes) from ${server}`);
|
||||
return otsBytes;
|
||||
} catch (e) {
|
||||
console.warn(`[ots] Calendar ${server} failed:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('All OpenTimestamps calendar servers failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade a detached .ots file using the same-origin standards-based helper.
|
||||
*
|
||||
* @param {Uint8Array} otsBytes - The pending or partially upgraded .ots bytes
|
||||
* @returns {Promise<{proof: Uint8Array, changed: boolean, confirmed: boolean, detail: string}>}
|
||||
*/
|
||||
export async function upgradeOts(otsBytes) {
|
||||
const upgradeUrl = (typeof window !== 'undefined' && window.location)
|
||||
? `${window.location.origin}/ots-upgrade`
|
||||
: 'https://laantungir.net/ots-upgrade';
|
||||
const response = await fetch(upgradeUrl, {
|
||||
method: 'POST',
|
||||
body: otsBytes,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`OTS upgrade helper returned HTTP ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
if (!result.proof) {
|
||||
throw new Error(result.error || 'OTS upgrade helper returned no proof');
|
||||
}
|
||||
return {
|
||||
proof: base64ToBytes(result.proof),
|
||||
changed: Boolean(result.changed),
|
||||
confirmed: Boolean(result.confirmed),
|
||||
detail: String(result.stderr || result.stdout || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an .ots file contains a confirmed Bitcoin attestation.
|
||||
* A pending .ots file contains only "pending" attestations.
|
||||
* A confirmed .ots file contains at least one Bitcoin attestation.
|
||||
*
|
||||
* @param {Uint8Array} otsBytes - The .ots file bytes
|
||||
* @returns {boolean} true if the .ots file has a Bitcoin attestation
|
||||
*/
|
||||
export function isOtsConfirmed(otsBytes) {
|
||||
// Exact 8-byte tag for BitcoinBlockHeaderAttestation.
|
||||
const bitcoinTag = hexToBytes('0588960d73d71901');
|
||||
outer: for (let i = 0; i <= otsBytes.length - bitcoinTag.length; i++) {
|
||||
for (let j = 0; j < bitcoinTag.length; j++) {
|
||||
if (otsBytes[i + j] !== bitcoinTag[j]) continue outer;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store OTS workflow data in localStorage.
|
||||
* Existing metadata is preserved unless explicitly overwritten.
|
||||
*
|
||||
* @param {string} eventId - The NIP-QR event id
|
||||
* @param {Uint8Array} otsBytes - Current detached .ots bytes
|
||||
* @param {object} metadata - Workflow metadata to merge
|
||||
*/
|
||||
export function savePendingOts(eventId, otsBytes, metadata = {}) {
|
||||
let existing = {};
|
||||
try {
|
||||
existing = JSON.parse(localStorage.getItem('pq-pending-ots') || '{}');
|
||||
} catch (e) {
|
||||
existing = {};
|
||||
}
|
||||
const data = {
|
||||
...existing,
|
||||
...metadata,
|
||||
eventId,
|
||||
ots: bytesToBase64(otsBytes),
|
||||
timestamp: existing.timestamp || Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
localStorage.setItem('pq-pending-ots', JSON.stringify(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load OTS workflow data from localStorage.
|
||||
* @returns {{eventId: string, ots: Uint8Array, timestamp: number, pendingPublished: boolean, confirmedPublished: boolean}|null}
|
||||
*/
|
||||
export function loadPendingOts() {
|
||||
const data = localStorage.getItem('pq-pending-ots');
|
||||
if (!data) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
return {
|
||||
...parsed,
|
||||
eventId: parsed.eventId,
|
||||
ots: base64ToBytes(parsed.ots),
|
||||
timestamp: parsed.timestamp,
|
||||
pendingPublished: Boolean(parsed.pendingPublished),
|
||||
confirmedPublished: Boolean(parsed.confirmedPublished)
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pending OTS data from localStorage.
|
||||
*/
|
||||
export function clearPendingOts() {
|
||||
localStorage.removeItem('pq-pending-ots');
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+299
@@ -0,0 +1,299 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Verify NIP-QR Event</title>
|
||||
|
||||
<link rel="stylesheet" href="./css/client.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="data:image/x-icon;," />
|
||||
|
||||
<style>
|
||||
#divBody {
|
||||
flex-direction: column !important;
|
||||
flex-wrap: nowrap !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
align-content: flex-start !important;
|
||||
}
|
||||
|
||||
.pq-container {
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.pq-card {
|
||||
background: var(--secondary-color);
|
||||
border: var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pq-card-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.pq-info-text {
|
||||
font-size: 14px;
|
||||
color: var(--muted-color);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.pq-info-text strong { color: var(--primary-color); }
|
||||
|
||||
.pq-button {
|
||||
background: var(--primary-color);
|
||||
color: var(--secondary-color);
|
||||
border: var(--border-width) solid var(--primary-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pq-button:hover { opacity: 0.7; }
|
||||
.pq-button:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
.pq-textarea {
|
||||
width: 100%;
|
||||
background: var(--secondary-color);
|
||||
border: var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--primary-color);
|
||||
margin: 10px 0;
|
||||
min-height: 200px;
|
||||
resize: vertical;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.pq-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.pq-status {
|
||||
padding: 10px 15px;
|
||||
border-radius: var(--border-radius);
|
||||
margin: 10px 0;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pq-status-info {
|
||||
background: var(--secondary-color);
|
||||
border: var(--border);
|
||||
}
|
||||
|
||||
.pq-status-success {
|
||||
background: var(--accent-color);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.pq-status-error {
|
||||
background: var(--accent-color);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.pq-result-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.pq-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 15px;
|
||||
background: var(--primary-color);
|
||||
color: var(--secondary-color);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pq-result-valid {
|
||||
border-left: 4px solid #00aa00;
|
||||
}
|
||||
|
||||
.pq-result-invalid {
|
||||
border-left: 4px solid #cc0000;
|
||||
}
|
||||
|
||||
.pq-event-preview {
|
||||
background: var(--primary-color);
|
||||
color: var(--secondary-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 15px;
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
text-align: left;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.pq-link {
|
||||
color: var(--accent-color);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- HEADER -->
|
||||
<div id="divHeader">
|
||||
<div id="divHeaderFlexLeft"></div>
|
||||
<div id="divHeaderFlexCenter">
|
||||
<div class="divHeaderText">Verify NIP-QR Event</div>
|
||||
</div>
|
||||
<div id="divHeaderFlexRight"></div>
|
||||
</div>
|
||||
|
||||
<!-- BODY -->
|
||||
<div id="divBody">
|
||||
<div class="pq-container">
|
||||
|
||||
<div class="pq-card">
|
||||
<div class="pq-card-title">Verify NIP-QR Migration Event</div>
|
||||
<div class="pq-info-text">
|
||||
Paste a NIP-QR event JSON below to verify all signatures.
|
||||
This will check the secp256k1 signature and all 5 post-quantum signatures (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, and ML-KEM-768).
|
||||
</div>
|
||||
<div class="pq-info-text">
|
||||
<a href="./" class="pq-link">Back to migration page</a>
|
||||
</div>
|
||||
|
||||
<textarea class="pq-textarea" id="pqEventInput" placeholder='Paste event JSON here, e.g.:
|
||||
{
|
||||
"id": "...",
|
||||
"pubkey": "...",
|
||||
"content": "NOSTR identity: ...",
|
||||
"tags": [
|
||||
["d", "nip-qr-migration"],
|
||||
["algorithm", "ml-dsa-44", "...", "..."],
|
||||
...
|
||||
],
|
||||
"sig": "..."
|
||||
}'></textarea>
|
||||
|
||||
<button class="pq-button" id="pqVerifyBtn">Verify Signatures</button>
|
||||
|
||||
<div id="pqVerifyStatus"></div>
|
||||
|
||||
<div id="pqResultsWrap" style="display: none;">
|
||||
<div class="pq-info-text" style="margin-top: 15px; margin-bottom: 5px;"><strong>Verification Results:</strong></div>
|
||||
<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>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<div id="divFooter">
|
||||
<div id="divFooterLeft" class="divFooterBox"></div>
|
||||
<div id="divFooterCenter" class="divFooterBox"></div>
|
||||
<div id="divFooterRight" class="divFooterBox"></div>
|
||||
</div>
|
||||
|
||||
<!-- SCRIPTS -->
|
||||
<script type="module">
|
||||
import {
|
||||
verifyNIPQRContent,
|
||||
verifyNostrEvent
|
||||
} from './pq-crypto.bundle.js';
|
||||
|
||||
const verifyBtn = document.getElementById('pqVerifyBtn');
|
||||
const eventInput = document.getElementById('pqEventInput');
|
||||
const verifyStatus = document.getElementById('pqVerifyStatus');
|
||||
const resultsWrap = document.getElementById('pqResultsWrap');
|
||||
const resultList = document.getElementById('pqResultList');
|
||||
const eventDisplayWrap = document.getElementById('pqEventDisplayWrap');
|
||||
const eventContent = document.getElementById('pqEventContent');
|
||||
|
||||
function setStatus(type, message) {
|
||||
verifyStatus.innerHTML = `<div class="pq-status pq-status-${type}">${message}</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);
|
||||
}
|
||||
|
||||
verifyBtn.addEventListener('click', async () => {
|
||||
verifyBtn.disabled = true;
|
||||
resultsWrap.style.display = 'none';
|
||||
eventDisplayWrap.style.display = 'none';
|
||||
resultList.innerHTML = '';
|
||||
setStatus('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)');
|
||||
}
|
||||
|
||||
// Display the event content
|
||||
eventContent.textContent = event.content;
|
||||
eventDisplayWrap.style.display = 'block';
|
||||
|
||||
let allValid = true;
|
||||
|
||||
// 1. Verify secp256k1 signature
|
||||
setStatus('info', 'Verifying secp256k1 signature...');
|
||||
const secpValid = verifyNostrEvent(event);
|
||||
addResult('secp256k1 (NIP-01 Schnorr)', secpValid, secpValid ? 'valid' : 'INVALID');
|
||||
if (!secpValid) allValid = false;
|
||||
|
||||
// 2. Verify PQ signatures
|
||||
setStatus('info', 'Verifying PQ signatures...');
|
||||
await new Promise(r => setTimeout(r, 50)); // let UI update
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Show results
|
||||
resultsWrap.style.display = 'block';
|
||||
|
||||
if (allValid) {
|
||||
setStatus('success', 'All signatures verified successfully!');
|
||||
} else {
|
||||
setStatus('error', 'Some signatures failed verification.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[verify] Error:', error);
|
||||
setStatus('error', `Error: ${error.message}`);
|
||||
} finally {
|
||||
verifyBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user