13 KiB
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:
- Consistency with NIP-06 — PQ keys are derived the same way as secp256k1 keys, just at different child indices
- Compartmentalization — hardened derivation at the account level means breaking one key doesn't compromise siblings
- Wallet compatibility — HD wallet infrastructure (Amber, hardware wallets, etc.) can derive these same paths
- Tree structure — the derivation path documents exactly which key is where
- 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 attestation statement
A human-readable text statement is created that binds all the keys together. This is the content field of the kind 1 announcement event:
I am signaling that the post-quantum public keys listed in the tags of this event
were generated by me and I hold the private keys. I may use these keys in the future
as successors to my current Nostr identity.
My current identity:
npub: <npub>
hex: <hex pubkey>
This attestation is established pre-quantum at Bitcoin block height <height>.
Post-quantum public keys in tags:
ML-DSA-44 (Dilithium, FIPS 204, NIST Level 2)
ML-DSA-65 (Dilithium, FIPS 204, NIST Level 3)
SLH-DSA-128s (SPHINCS+, FIPS 205, NIST Level 1)
Falcon-512 (FIPS 206 draft, NIST Level 1)
ML-KEM-768 (Kyber, FIPS 203, NIST Level 3)
Each post-quantum key has cryptographically signed this attestation. This event is
pending timestamp on the Bitcoin blockchain via OpenTimestamps.
This statement is what gets signed by each PQ key.
Step 7: Sign the statement with each PQ private key
The statement (as bytes — TextEncoder.encode(content)) is signed independently by each PQ signature scheme:
statement_bytes → ML-DSA-44 sign(statement_bytes, ml_dsa44_secretKey) → signature (2420 bytes)
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 tags (without a signature), and its ownership is asserted by the attestation text and authorized by Account #1's signature over the kind 1 event (which covers the tags).
Step 8: Build the kind 1 announcement event
The attestation text and PQ signatures are assembled into a kind 1 Nostr text note. The PQ public keys and signatures go in the tags:
{
"kind": 1,
"pubkey": "<Account #1 secp256k1 pubkey (hex)>",
"created_at": 1720780000,
"content": "I am signaling that the post-quantum public keys listed in the tags of this event...",
"tags": [
["block_height", "<height>"],
["algorithm", "ml-dsa-44", "<base64 pubkey>", "<base64 signature>"],
["algorithm", "ml-dsa-65", "<base64 pubkey>", "<base64 signature>"],
["algorithm", "slh-dsa-128s", "<base64 pubkey>", "<base64 signature>"],
["algorithm", "falcon-512", "<base64 pubkey>", "<base64 signature>"],
["algorithm", "ml-kem-768", "<base64 pubkey>"]
]
}
Note: the ML-KEM-768 tag has only the pubkey (no signature field) — it's a KEM, not a signature scheme.
Step 9: Sign the kind 1 event with Account #1
This unsigned kind 1 event is sent to the user's signer (via window.nostr.signEvent()), which signs it with Account #1's secp256k1 private key — the user's existing Nostr identity. The signer returns the id and sig fields.
Note on the seed-derived secp256k1 key (Account #2): The seed-derived secp256k1 key at m/44'/1237'/0'/0/0 is derived but not used to sign in the current implementation. There is no "successor signature." The only secp256k1 signature is Account #1's on the Nostr events. The security model relies on OTS precedence (the real event is anchored pre-quantum; a forged event cannot be backdated) rather than a seed-derived signature binding the PQ keys to the seed.
Step 10: Timestamp the kind 1 event hash with OpenTimestamps
The SHA-256 of the full signed kind 1 event JSON (including its id and sig) is submitted to OpenTimestamps:
full_hash = SHA-256(JSON.stringify(signedKind1Event))
full_hash → OpenTimestamps calendar → pending .ots proof
This hash is what gets anchored to the Bitcoin blockchain.
Step 11: Build and sign the kind 11112 wrapper event
A kind 11112 wrapper event is built that embeds the full kind 1 event as JSON content and carries the OTS proof:
{
"kind": 11112,
"pubkey": "<Account #1 secp256k1 pubkey (hex)>",
"created_at": 1720780000,
"content": "<JSON string of the full signed kind 1 event>",
"tags": [
["e", "<kind 1 event id>"],
["sha256", "<hex SHA-256 of full signed kind 1 event JSON>"],
["ots", "<base64 .ots proof>"]
]
}
This wrapper is sent to the user's signer (window.nostr.signEvent()), which signs it with Account #1's secp256k1 key. The signer returns the id and sig fields.
Step 12: Publish both events
Both the kind 1 announcement and the kind 11112 wrapper are published to Nostr relays. The client then polls the OpenTimestamps upgrade service for Bitcoin confirmation and republishes the kind 11112 wrapper with the confirmed OTS proof once the Bitcoin attestation is received.
Summary: Who Signs What
| Signer | What they sign | Signature type | Purpose |
|---|---|---|---|
| ML-DSA-44 private key | The attestation text (content) | PQ signature (lattice, Cat 2) | Proves PQ key exists and attests post-quantum |
| ML-DSA-65 private key | The attestation text (content) | PQ signature (lattice, Cat 3) | Higher security level PQ signature |
| SLH-DSA-128s private key | The attestation text (content) | PQ signature (hash-based, Cat 1) | Conservative fallback if lattice schemes break |
| Falcon-512 private key | The attestation text (content) | PQ signature (lattice, Cat 1) | Compact signatures, compatible with trbouma's PR |
| Account #1 secp256k1 key | The kind 1 announcement event | Schnorr signature | Existing identity authorizes the migration |
| Account #1 secp256k1 key | The kind 11112 wrapper event | Schnorr signature | Existing identity authorizes the wrapper + OTS proof |
| ML-KEM-768 | (nothing — KEM, not signature) | N/A | Ownership asserted by attestation text; authorized by Account #1's signature covering the tags |
Summary: BIP32 Key Derivation Tree
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
| derived but NOT used to sign in the current implementation
|
+--→ 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.