Compare commits

...
15 Commits
Author SHA1 Message Date
Laan Tungir 6e61c6a5a8 Render pending-only canonical proof carrier selection as info instead of error on verify page 2026-07-30 06:46:55 -04:00
Laan Tungir 399a633697 Hide OTS upgrade/republish buttons for non-owners; only the event owner (signed-in pubkey matches) can upgrade the proof 2026-07-29 09:52:32 -04:00
Laan Tungir a662686a0b Removed empty footer from both pages; version badge now shows cleanly at bottom-right 2026-07-29 09:21:04 -04:00
Laan Tungir 668fb65efb Removed ML-KEM note from TLDR area per feedback; keep TLDR unchanged 2026-07-29 09:18:28 -04:00
Laan Tungir 270cddc433 Updated README: multi-explorer OTS verification status, proof archive/canonical selection/signer validation/test vectors now implemented, added ML-KEM clarifying note and FIPS 206 reference 2026-07-29 09:17:12 -04:00
Laan Tungir 33e06adc4a Added braille spinner to verify Query & Verify button; restructured verify results into a single card flow with OTS stamp/upgraded cards and a final summary card 2026-07-29 09:00:26 -04:00
Laan Tungir b46d081211 v0.1.0: verify page restructure, sign-in tab, proof archive simplification, attestation reply event 2026-07-28 06:01:19 -04:00
Laan Tungir 57d8d2fcf4 Publish kind 1 reply announcing confirmed OTS attestation with block height 2026-07-27 17:08:32 -04:00
Laan Tungir 79185f9909 Simplified downloads to proof archive only; verify link now opens specific attestation via ?npub param 2026-07-27 16:31:03 -04:00
Laan Tungir 94f03f2999 Added Sign In tab to verify.html for nostr-login-lite authentication 2026-07-27 13:03:36 -04:00
Laan Tungir 200eb41f39 Restructured verify.html: moved tab selection to top, explanatory text to bottom, and rewrote the Note 2026-07-27 12:53:32 -04:00
Laan Tungir ed572e21e5 Move 'don't enter valuable seed' warning from sign-in card to 'I Have a Seed Phrase' panel in step 2 2026-07-26 11:58:52 -04:00
Laan Tungir 8039af473a Add inline braille spinner next to 'Wait for Bitcoin confirmation' label during OTS polling 2026-07-26 11:52:36 -04:00
Laan Tungir 2a1ddf3b17 Move site to /quantum-prep/, fix laantungir relay trailing-slash 301 WebSocket failure, swap damus→sendit.nosflare.com relay, add takeaway intro to index.html, add braille spinner to step-6 OTS polling 2026-07-26 11:44:07 -04:00
Laan Tungir 68050fc8a3 Fix signEvent: strip non-NIP-01 statementBytes field from kind 1 template before sending to signer (validateSignerOutput rejects extra fields) 2026-07-25 13:00:12 -04:00
17 changed files with 923 additions and 893 deletions
+147 -653
View File
@@ -1,18 +1,66 @@
# Post-Quantum Nostr
---
slug: nostr-quantum-preparation
title: Nostr Quantum Preparation README.md
summary: This project is an interim solution to the problem for those who are concerned about keeping their identities secure through the transition, and also to give developers breathing room.
image: https://blossom.laantungir.net/5b96cc0c5ba4a8081e5ee8e5a2e718f28c0cbc77f218c55d956b9be57c120ef1.jpg
tags: [nostr, quantum, readme.md]
---
> **⚠️ Research prototype.** This project is an experimental pre-quantum key-commitment and
> identity-link system. It does **not**, by itself, make a Nostr identity post-quantum secure:
> it links PQ keys to an existing identity and anchors that link in Bitcoin. Complete migration
> requires companion protocols (PQ event authentication, rotation, encryption, client adoption)
> that are not yet specified or implemented. Do not enter a valuable existing mnemonic into the
> web app. See the [implementation status](#implementation-status) section for what exists today.
# Nostr Quantum Preparation
A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.
/tmp/sb_test_save.jpg
## TLDR
### Problem
Quantum computers will break secp256k1 which nostr relies on for its public private key pair. This means that given an npub, a quantum computer will be able to derive your nsec, read all your encrypted data and sign events as you.
We have new cryptography that we think is secure in a post quantum world that we can use in nostr, but the field is still evolving, and we have no idea of how much time we have left to migrate nostr. It could be we have 1 year or 100 years until quantum computers arrive. This makes settling on new cryptography difficult.
### Solution
This project is an interim solution to the problem for those who are concerned about keeping their identities secure through the transition, and also to give developers breathing room.
This project takes what I have found as the likely post quantum algorithms, and will generate for you a new public private key pair for all of them.
I have currently used the following post quantum algorithms:
- ML-DSA-44
- ML-DSA-65
- SLH-DSA-128s
- Falcon-512
- ML-KEM-768
This app will generate public-private keypairs for each of these, publish on nostr your public key for each, and sign it with your current Nostr identity via your favorite signer (the nsec never leaves the signer).
You are making a statement to the world: "Here are post quantum pubkeys. If quantum computers hit, you know that the person using these can only be me."
This entire kind 1 event is then hashed and stamped into a block on Bitcoin using OpenTimestamps.
The OpenTimestamps pending proof is published to nostr as a (new) kind 9999 event. If the app is left open, it will watch for the confirmation that your event has been timestamped on Bitcoin, and then publish a second kind 9999 with the upgraded timestamp.
You can then store the new seed phrase that was generated, along with the kind 9999 event(s), and if quantum computers suddenly appear, you have an identity that could only have been created by you.
It's interesting to note that even if a nostr consensus decides not to use any of these algorithms, as long as any of them are resistant to quantum attacks, you can use it to prove your transition from your current nostr identity to your new nostr identity.
I invite you to try it out, and give me any feedback. Run through it with a test identity before you use it on your real identity.
Note that I consider using this app low stakes, in that you are not revealing to the app your current private key. You are using a nostr signer. And also, you don't HAVE to use these public keys in the future. They are just sitting out there in case you ever need them. And if you screw up, or lose your seed phrase, just run the whole thing again.
https://laantungir.net/quantum-prep/
> **⚠️ Warning.** Do not enter a valuable existing mnemonic into the web app. This project links
> post-quantum keys to an existing Nostr identity and anchors that link in Bitcoin. It does **not**,
> by itself, make a Nostr identity post-quantum secure: complete migration requires companion
> protocols (PQ event authentication, rotation, encryption, client adoption) that are not yet
> specified or implemented. See the [implementation status](#implementation-status) section for what
> exists today.
A preparation strategy for the eventual post-quantum migration of Nostr — without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.
## Table of Contents
- [The Problem](#the-problem)
- [Why Bitcoin's Hash-the-Pubkey Trick Doesn't Work for Nostr](#why-bitcoins-hash-the-pubkey-trick-doesnt-work-for-nostr)
- [The Strategy: Overview](#the-strategy-overview)
- [Component 1: Seed Phrase as Algorithm-Agnostic Root of Trust](#component-1-seed-phrase-as-algorithm-agnostic-root-of-trust)
- [Component 2: Multi-Scheme Cross-Signed Key-Link Events](#component-2-multi-scheme-cross-signed-key-link-events)
@@ -20,7 +68,6 @@ A migration strategy for bringing post-quantum security to Nostr without breakin
- [Component 4: Migrating Existing Users with Raw nsec (Design Only)](#component-4-migrating-existing-users-with-raw-nsec-design-only)
- [Component 5: Quantum-Safe Self-Storage (Design Only)](#component-5-quantum-safe-self-storage-design-only)
- [Component 6: Deterministic Wallet Compartmentalization](#component-6-deterministic-wallet-compartmentalization)
- [The Complete Migration Flow](#the-complete-migration-flow)
- [What Remains Unsolved](#what-remains-unsolved)
- [Implementation Status](#implementation-status)
@@ -28,52 +75,9 @@ A migration strategy for bringing post-quantum security to Nostr without breakin
## The Problem
Nostr's cryptography is built entirely on secp256k1:
Nostr's identity, authentication, and encryption (NIP-04 and NIP-44) all rely on secp256k1. **Shor's algorithm** breaks secp256k1 in polynomial time on a sufficiently large quantum computer, so an attacker who has recorded any of your events can recover your private key, forge signatures, decrypt your messages, and impersonate you.
- **Identity**: Your pubkey IS your identity — it's in every event, it's your npub, it's what filters use, it's what NIP-05 verifies, it's what the social graph is built on.
- **Authentication**: Every event is signed with a Schnorr signature over secp256k1. Signature verification requires the public key, which is published in plaintext on every event.
- **Encryption (NIP-04)**: Uses secp256k1 ECDH to derive a shared secret, then AES-256-CBC. The shared X coordinate is used directly as the AES key. The IV is published in plaintext.
- **Encryption (NIP-44)**: Uses secp256k1 ECDH to derive a shared secret, then HKDF → ChaCha20 + HMAC-SHA256. The nonce is published in plaintext inside the payload.
**Shor's algorithm** breaks the elliptic curve discrete logarithm problem on secp256k1 in polynomial time. With a sufficiently large fault-tolerant quantum computer, an attacker who has recorded any Nostr event can:
1. Extract the public key from the event.
2. Run Shor's algorithm to recover the private key.
3. Forge signatures, decrypt all NIP-04/NIP-44 messages, and impersonate the user.
### NIP-04 vs NIP-44: No Quantum Advantage
NIP-44 is a substantial classical-security upgrade over NIP-04 (authenticated encryption, key separation, per-message keys, better cipher, length-hiding padding), but it offers **zero** post-quantum security advantage. Both derive their shared secret from secp256k1 ECDH. Everything NIP-44 adds (HKDF, per-message nonces, ChaCha20, HMAC-SHA256, custom padding) sits downstream of the broken ECDH step.
The NIP-44 spec itself acknowledges this:
> No post-quantum security: a powerful quantum computer would be able to decrypt the messages
The symmetric primitives in both NIPs (AES-256, ChaCha20, HMAC-SHA256, HKDF-SHA256) are adequately post-quantum (~128-bit PQ security via Grover's quadratic speedup). The weakness is exclusively the secp256k1 ECDH key agreement, which has zero post-quantum security in both NIPs.
---
## Why Bitcoin's Hash-the-Pubkey Trick Doesn't Work for Nostr
Bitcoin has limited quantum resistance because addresses are hashes of pubkeys (RIPEMD160(SHA256(pubkey))), and pubkeys are only revealed at spend time. This works because of three properties that Nostr does not have:
| Property | Bitcoin | Nostr |
|---|---|---|
| Identifier | Address (hash of pubkey) | Pubkey directly |
| Pubkey revealed | At spend time only | On every event |
| After reveal | UTXO consumed, key discarded | Identity persists, key reused |
In Nostr, the pubkey is load-bearing in ways it isn't in Bitcoin:
1. **The pubkey IS the identity.** It's in every event, it's your npub, it's what filters use, it's what NIP-05 verifies, it's what the social graph is built on.
2. **The pubkey must be revealed for every event.** Schnorr signature verification requires the public key. You can't verify a signature with a hash of the pubkey.
3. **Identity is never consumed.** You publish thousands of events over years with the same pubkey. There's no "consume and rotate" moment.
If Nostr published hash(pubkey) instead, you'd get quantum resistance for exactly **zero seconds** — the time between key generation and your first event publication, at which point the pubkey must be revealed for signature verification.
The only ways to avoid revealing the pubkey (ZK-SNARKs, giving up public verifiability, one-time keys per event) are impractical or fundamentally change what Nostr is.
**The real fix is post-quantum signatures and post-quantum key agreement, not hashing the pubkey.** This document describes how to get there without breaking Nostr.
NIP-44 is a real classical-security upgrade over NIP-04, but it offers **zero** post-quantum advantage: both derive their shared secret from secp256k1 ECDH, and everything NIP-44 adds (HKDF, ChaCha20, HMAC, padding) sits downstream of that broken step. The symmetric primitives themselves (AES-256, ChaCha20, HMAC-SHA256) are adequately post-quantum; the weakness is exclusively the ECDH key agreement.
---
@@ -83,7 +87,7 @@ The only ways to avoid revealing the pubkey (ZK-SNARKs, giving up public verifia
flowchart TD
subgraph SECURITY["Security Layers - All Quantum-Resistant"]
L1[Layer 1: Seed phrase - PQ-safe root of trust]
L2[Layer 2: Key-link event + OTS - PQ-safe identity migration]
L2[Layer 2: Key-link event + OTS - PQ-safe identity preparation]
L3[Layer 3: PQ signatures - PQ-safe future authentication]
L4[Layer 4: OTS on important events - PQ-safe historical provenance]
L5[Layer 5: OTP or PQ encryption - PQ-safe confidentiality]
@@ -94,7 +98,7 @@ flowchart TD
L2 --> L4
```
The strategy uses existing Nostr infrastructure (NIP-06, NIP-03) plus one new event type (a key-link NIP). No new cryptographic primitives are needed for the migration itself — PQ algorithms are used additively, and the community doesn't need to agree on which one to use.
The strategy uses existing Nostr infrastructure (NIP-06, NIP-03) plus one new event type (a key-link NIP). No new cryptographic primitives are needed for the preparation itself — PQ algorithms are used additively, and the community doesn't need to agree on which one to use.
### Design Principles
@@ -109,48 +113,9 @@ The strategy uses existing Nostr infrastructure (NIP-06, NIP-03) plus one new ev
## Component 1: Seed Phrase as Algorithm-Agnostic Root of Trust
The BIP39 seed is a 64-byte entropy string derived from a mnemonic via PBKDF2-HMAC-SHA512 (2048 iterations). This is a symmetric KDF no public-key cryptography involved. A quantum computer provides no advantage beyond Grover's quadratic speedup.
The BIP39 seed is 64 bytes of entropy derived from a mnemonic via PBKDF2-HMAC-SHA512 a symmetric KDF with no public-key cryptography, so a quantum computer gains nothing beyond Grover's quadratic speedup. The seed is algorithm-agnostic: you can derive any key type from it.
> **⚠️ Continuity is asserted, not proven (G56-06).** The old Nostr identity *authorizes* the PQ
> keys by signing an event that contains them. This is an authorization, not a cryptographic proof
> that the PQ keys were derived from the seed phrase shown to the user. A compromised browser could
> display one mnemonic while publishing attacker-controlled PQ keys — and any in-browser signature
> (secp or PQ) could be faked by the same compromised browser. No in-browser cryptographic trick
> can prove the seed and the published keys are bound, because the browser is the potential adversary.
>
> **The only real proof is out-of-band verification:** after the migration, the user should take
> their seed phrase to a *different* device or browser, derive the keys independently, and confirm
> the public keys match the published event. This is the one step a compromised browser cannot fake.
>
> **Why not verify the mnemonic against the current identity?** Deriving the NIP-06 secp key from
> an entered mnemonic and requiring it to match the signed-in pubkey would provide a strong
> continuity proof. However, no current Nostr signers (NIP-07 extensions) accept seed phrases, so
> this would require the user to type their valuable existing mnemonic into the web page — an
> unacceptable security risk. This mode is deferred until hardware PQ signers or seed-accepting
> signers exist.
**The seed is algorithm-agnostic.** It's just entropy. You can derive any key type from it:
```mermaid
flowchart TD
SEED[BIP39 Seed - 64 bytes, recovery root]
SEED --> BIP32[BIP32 derivation - secp256k1 keypair]
SEED --> PQKEM[BIP32 derivation - ML-KEM keypair]
SEED --> PQDSA[BIP32 derivation - ML-DSA keypair]
SEED --> SLHDSA[BIP32 derivation - SLH-DSA keypair]
SEED --> FALCON[BIP32 derivation - Falcon keypair]
style SEED fill:#cfc
style BIP32 fill:#fcc
style PQKEM fill:#cfc
style PQDSA fill:#cfc
style SLHDSA fill:#cfc
style FALCON fill:#cfc
```
### PQ Key Derivation from BIP39 Seeds
All keys — both secp256k1 and PQ — are derived from the BIP39 seed via **BIP32 hierarchical deterministic derivation**, the same standard used by NIP-06 for secp256k1 keys. PQ keys are derived at different child indices under the NIP-06 base path, using the BIP32 child private key (32 bytes) as the deterministic seed for each PQ algorithm's `keygen()` function.
All keys — both secp256k1 and PQ — are derived from the BIP39 seed via **BIP32 hierarchical deterministic derivation**, the same standard NIP-06 uses for secp256k1. PQ keys are derived at different child indices under the NIP-06 base path, using the BIP32 child private key (32 bytes) as the deterministic seed for each PQ algorithm's `keygen()`.
**Base path:** `m/44'/1237'/0'/0/` (NIP-06 account 0, change 0)
@@ -163,519 +128,140 @@ All keys — both secp256k1 and PQ — are derived from the BIP39 seed via **BIP
| 5 + 6 | Falcon-512 | 48 bytes | `m/44'/1237'/0'/0/5` + `m/44'/1237'/0'/0/6` | Two children concatenated (64 bytes), first 48 used |
| 7 + 8 | ML-KEM-768 | 64 bytes | `m/44'/1237'/0'/0/7` + `m/44'/1237'/0'/0/8` | Two children concatenated (64 bytes) |
### Why some algorithms need two children
**Truncation rule (pinned for compatibility):** when a PQ seed needs more than 32 bytes, two BIP32 children are derived and concatenated (64 bytes total). For 48-byte seeds, the **first 48 bytes** are used; for 64-byte seeds, all 64 bytes are used. Implementations must use the first N bytes — taking the last 48 would produce different keys and break compatibility.
BIP32 child derivation produces exactly 32 bytes (the private key) per child. Some PQ algorithms need more than 32 bytes for their `keygen()` seed:
> **⚠️ Continuity is asserted, not proven (G56-06).** The old Nostr identity *authorizes* the PQ
> keys by signing an event that contains them. This is an authorization, not a cryptographic proof
> that the PQ keys were derived from the seed phrase shown to the user. A compromised browser could
> display one mnemonic while publishing attacker-controlled PQ keys. **The only real proof is
> out-of-band verification:** after the key link is published, take the seed phrase to a *different* device,
> derive the keys independently, and confirm the public keys match the published event. This is the
> one step a compromised browser cannot fake.
- **ML-DSA-44** and **ML-DSA-65**: need 32 bytes — one child is enough
- **SLH-DSA-128s**: needs 48 bytes — derive two children (64 bytes), use the first 48
- **Falcon-512**: needs 48 bytes — derive two children (64 bytes), use the first 48
- **ML-KEM-768**: needs 64 bytes — derive two children and concatenate (64 bytes)
### Seed entropy
**Truncation rule (must be pinned for compatibility):** when a PQ seed requires more than 32 bytes, two BIP32 children are derived and concatenated (64 bytes total). For 48-byte seeds, the **first 48 bytes** of the concatenation are used. For 64-byte seeds, all 64 bytes are used. A future implementer who takes the *last* 48 bytes would produce different keys and break compatibility — implementations must use the first N bytes.
The implementation defaults to 24-word (256-bit) mnemonics, with a 12-word option for testing. Under Grover's quadratic speedup, 12 words leaves only ~64 bits of PQ security; 24 words gives ~128 bits. Use 24 words for a real PQ recovery root. The bigger risk to seed phrases is classical (theft, phishing), not quantum — treat the phrase as a high-value secret and store it offline.
### Why BIP32 and not HKDF?
### Secret material lifetime
BIP32 is the standard for HD wallets. Using BIP32 paths gives us:
1. **Consistency with NIP-06** — PQ keys are derived the same way as secp256k1 keys, just at different child indices
2. **Compartmentalization** — hardened derivation at the account level means breaking one key doesn't compromise siblings
3. **Wallet compatibility** — HD wallet infrastructure (Amber, hardware wallets, etc.) can derive these same paths
4. **Tree structure** — the derivation path documents exactly which key is where
5. **Security** — BIP32 uses HMAC-SHA512 (the same HMAC used everywhere in HD wallets), not a custom construction
### What is HMAC?
HMAC (Hash-based Message Authentication Code) is the cryptographic primitive that BIP32 is built on. Every BIP32 child key derivation is:
```
HMAC-SHA512(parent_chain_code, data) → 32-byte private key + 32-byte chain code
```
This is not a separate or weaker construction — it IS BIP32. Using BIP32 paths means we're using the same HMAC-based derivation that all HD wallets use.
### Seed Phrase Entropy Considerations
| Mnemonic length | Entropy | Post-quantum security (Grover's) | Assessment |
|---|---|---|---|
| 12 words | 128 bits | ~64 bits | Testing only — weaker under a quantum brute-force model |
| 24 words | 256 bits | ~128 bits | Recommended for a PQ recovery root |
The implementation defaults to 24-word (256-bit) mnemonics, with a 12-word option available for
testing. Users concerned about quantum attacks on the seed entropy itself should use 24 words. The
bigger risk to seed phrases is classical (theft, phishing), not quantum. Note that BIP39's PBKDF2
iteration count is low; the seed phrase should be treated as a high-value secret and stored offline.
### Secret Material Lifetime and Zeroization (G56-10)
The web app derives the BIP39 seed, secp256k1 key, and five PQ secret keys in browser memory.
On sign-out, the app **zeroizes all `Uint8Array` secret buffers** (fills with zeros before
nulling references), **clears the DOM** (seed display, seed input, entropy hint), and **clears
the clipboard** (in case the user copied the seed phrase).
**Important limitation:** JavaScript garbage collection does **not** guarantee erasure. The
browser runtime may copy buffers internally, and zeroizing a `Uint8Array` only overwrites the
JS-visible buffer — the engine may retain copies in its own memory management. Browser
extensions, devtools memory inspectors, and page snapshots may still recover secrets during
the session. The real fix is moving key derivation and signing into a hardware or native signer
boundary (see G56-06 and G56-08). The zeroization implemented here is a best-effort reduction
of the exposure window, not a guarantee.
On sign-out, the app zeroizes all `Uint8Array` secret buffers (BIP39 seed, secp private key, five PQ secret keys), clears the DOM, and clears the clipboard. **JavaScript garbage collection does not guarantee erasure** — the browser runtime may retain copies internally. The real fix is moving key derivation and signing into a hardware or native signer boundary; the in-browser zeroization is a best-effort reduction of the exposure window, not a guarantee.
---
## Component 2: Multi-Scheme Cross-Signed Key-Link Events
This is the core innovation. Instead of choosing one PQ algorithm, **cross-sign with all viable PQ schemes now.** Let consensus emerge later.
This is the core innovation. Instead of choosing one PQ algorithm, **cross-sign with all viable PQ schemes now** and let consensus emerge later. The PQ standardization landscape is still evolving (ML-DSA and ML-KEM are finalized, Falcon is still draft, SIKE was a NIST finalist broken in 2022 on a laptop), so hedging across schemes hedges against any single scheme falling.
### The Standardization Uncertainty Problem
The key link uses **two Nostr events**:
The PQ standardization landscape is still evolving:
### 1. Kind 1 Announcement (human-readable, visible in Nostr feeds)
| Scheme | NIST Status | Concern |
|---|---|---|
| ML-DSA (Dilithium) | FIPS 204, finalized 2024 | Lattice-based — could have undiscovered structural weaknesses |
| SLH-DSA (SPHINCS+) | FIPS 205, finalized 2024 | Hash-based, very conservative, but huge signatures (8-50 KB) |
| FN-DSA (Falcon) | FIPS 206, still draft | Compact but complex FFT, implementation concerns |
| ML-KEM (Kyber) | FIPS 203, finalized 2024 | Lattice-based — same class as Dilithium |
A regular Nostr text note whose `content` is a human-readable attestation naming the user's npub and listing the PQ algorithms. The PQ public keys and signatures go in `algorithm` tags. Each PQ signature scheme signs `TextEncoder.encode(content)`; the event is signed with the user's **existing** Nostr identity via `window.nostr.signEvent` (NIP-07).
History gives reason to be cautious: SIKE was a NIST PQ finalist that was broken in 2022 using a classical algorithm that ran in an hour on a laptop. If the Nostr community had picked SIKE early, we'd be back to square one.
### 2. Kind 9999 Proof Carrier (machine-verifiable, carries the OTS proof)
### The Multi-Scheme Solution
A non-replaceable event (kind 9999, in the 09999 range) whose `content` is the full signed kind 1 event as JSON (so verifiers don't need to fetch it from relays) and whose tags carry the OpenTimestamps proof. Each publication is permanent — upgrades publish a *new* kind 9999 with an `upgrade_of` tag referencing the previous one; both remain on relays. The non-replaceable range is load-bearing for this append-only design.
The implementation includes **five** PQ schemes — four signature schemes and one KEM:
**Tags:** `['e', '<kind1 id>']`, `['sha256', '<hex canonical digest of the signed kind 1 event>']`, `['digest_version', '<version>']`, `['ots', '<base64 .ots proof>']`, `['ots_status', 'pending'|'confirmed']`, and optionally `['upgrade_of', '<previous proof carrier id>']`. The wrapper is signed with the user's existing identity.
```mermaid
flowchart TD
SEED[BIP39 Seed] --> SECP[secp256k1 keypair - child 0]
SEED --> MLDSA44[ML-DSA-44 keypair - child 1]
SEED --> MLDSA65[ML-DSA-65 keypair - child 2]
SEED --> SLHDSA[SLH-DSA-128s keypair - children 3+4]
SEED --> FALCON[Falcon-512 keypair - children 5+6]
SEED --> MLKEM[ML-KEM-768 keypair - children 7+8]
### What this proves and what it does not
MLDSA44 --> SIGN1[ML-DSA-44 sign attestation]
MLDSA65 --> SIGN2[ML-DSA-65 sign attestation]
SLHDSA --> SIGN3[SLH-DSA-128s sign attestation]
FALCON --> SIGN4[Falcon-512 sign attestation]
**Proves:**
- Each PQ signature scheme (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512) signed the attestation text — verified against the pubkey in its tag.
- The user's existing Nostr identity authorized the key link — verified by the secp256k1 Schnorr signature on both events.
- The event is anchored to a pre-quantum Bitcoin block via OpenTimestamps — the event with the earliest valid OTS proof wins.
SIGN1 --> KIND1[Kind 1 Announcement Event]
SIGN2 --> KIND1
SIGN3 --> KIND1
SIGN4 --> KIND1
MLKEM --> KIND1
**Does NOT prove:**
- The PQ keys cannot *cryptographically* prove they share a common seed origin. They prove only that five PQ keypairs exist and each signed the attestation. Common-origin is **asserted** in the attestation and **authorized** by the existing identity's signature, not proven by a seed-derived signature.
- The seed-derived secp256k1 key (child 0) is derived but **not used to sign**. There is no "successor signature."
SECP --> ACCT1[Account 1 - existing identity signs kind 1 and kind 9999]
This is deliberate. The security model relies on **OTS precedence** (the real event is anchored pre-quantum; a forged event cannot be backdated) rather than a seed-derived signature.
KIND1 --> OTS[OpenTimestamp the kind 1 event hash]
OTS --> KIND9999[Kind 9999 Proof Carrier - carries OTS proof]
KIND9999 --> RELAY[Published to relays NOW]
### ML-KEM cannot sign
RELAY --> CLIENT1[PQ-aware client - picks ML-DSA]
RELAY --> CLIENT2[PQ-aware client - picks SLH-DSA]
RELAY --> CLIENT3[Legacy client - ignores all PQ keys]
ML-KEM (Kyber) is a KEM (Key Encapsulation Mechanism), not a signature scheme, so it cannot sign the attestation. Its public key is included in an `algorithm` tag without a signature field; its ownership is asserted by the attestation text and authorized by the existing identity's signature over the kind 1 event (which covers the tags). This is weaker than a cross-signature but sufficient: the secp256k1 signature is valid now (pre-quantum), and the ML-KEM key is for encryption, not authentication — a false ownership claim is self-correcting (the claimant couldn't decrypt messages sent to that key).
style KIND1 fill:#ffa
style KIND9999 fill:#ffa
style OTS fill:#cfc
```
### Revocation
### The Two-Event Structure
The migration uses **two Nostr events**:
#### 1. Kind 1 Announcement (human-readable, visible in Nostr feeds)
A regular Nostr text note (kind 1) that serves as a public announcement of the post-quantum key migration. The content is human-readable text with newlines. The PQ public keys and signatures go in the tags.
**Content:** A human-readable attestation statement, e.g.:
```
I am signaling that the post-quantum public keys listed in the tags of this event
were generated by me and I hold the private keys. I may use these keys in the future
as successors to my current Nostr identity.
My current identity:
npub: <npub>
hex: <hex pubkey>
This attestation is established pre-quantum at Bitcoin block height <height>.
Post-quantum public keys in tags:
ML-DSA-44 (Dilithium, FIPS 204, NIST Level 2)
ML-DSA-65 (Dilithium, FIPS 204, NIST Level 3)
SLH-DSA-128s (SPHINCS+, FIPS 205, NIST Level 1)
Falcon-512 (FIPS 206 draft, NIST Level 1)
ML-KEM-768 (Kyber, FIPS 203, NIST Level 3)
Each post-quantum key has cryptographically signed this attestation. This event is
pending timestamp on the Bitcoin blockchain via OpenTimestamps.
```
**Tags:**
- `['block_height', '<height>']` — the Bitcoin block height at signing time
- `['algorithm', '<algorithm>', '<base64 pubkey>', '<base64 signature>']` — one per PQ signature scheme
- `['algorithm', 'ml-kem-768', '<base64 pubkey>']` — for ML-KEM (KEM, no signature)
Each PQ signature scheme signs `TextEncoder.encode(content)` — the human-readable attestation text. The event is signed with the user's **existing** Nostr identity (Account #1) via `window.nostr.signEvent` (NIP-07).
#### 2. Kind 9999 Proof Carrier (machine-verifiable, carries the OTS proof)
A non-replaceable event (kind 9999, in the 09999 range) that wraps the kind 1 announcement and carries the OpenTimestamps proof. The content is the full kind 1 event as JSON (so verifiers don't need to fetch it from relays). Each publication is permanent — upgrades publish a new event with an `upgrade_of` tag referencing the previous one.
**Content:** `JSON.stringify(kind1Event)` — the full signed kind 1 event embedded as a JSON string.
**Tags:**
- `['e', '<kind1 event id>']` — reference to the kind 1 announcement
- `['sha256', '<hex SHA-256 of the full signed kind 1 event JSON>']` — what was timestamped
- `['ots', '<base64 .ots proof>']` — the OpenTimestamps proof (pending or confirmed)
The wrapper is signed with the user's existing identity (Account #1) via `window.nostr.signEvent`.
### What This Proves and What It Does Not
**What the event proves:**
- Each PQ signature scheme (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512) signed the attestation text — verified by checking each PQ signature against the pubkey in its tag.
- The user's existing Nostr identity (Account #1) authorized the migration — verified by the secp256k1 Schnorr signature on the kind 1 and kind 9999 events.
- The event is anchored to a pre-quantum Bitcoin block via OpenTimestamps — the event with the earliest valid OTS proof wins (see Component 3).
**What the event does NOT prove:**
- The PQ keys cannot *cryptographically* prove they share a common seed origin. They prove only that five PQ keypairs exist and each signed the attestation text. Common-origin is **asserted** in the attestation text and **authorized** by Account #1's signature, but it is not proven by a seed-derived signature.
- The seed-derived secp256k1 key (Account #2, child 0) is derived but **not used to sign** in the current implementation. There is no "successor signature" binding the PQ keys to the seed.
This is a deliberate design choice. The security model relies on **OTS precedence** (the real event is anchored pre-quantum; a forged event cannot be backdated) rather than a seed-derived signature. After a quantum break, an attacker who forges Account #1 can publish a fraudulent migration event, but it will have a later OTS timestamp than the real event — so the real event is cryptographically distinguishable by its earlier Bitcoin anchor.
### ML-KEM Cannot Sign
ML-KEM (Kyber) is a KEM (Key Encapsulation Mechanism), not a signature scheme. It cannot sign the attestation. Instead, its public key is included in an `algorithm` tag (without a signature field), and its ownership is asserted by the attestation text and authorized by Account #1's signature over the kind 1 event (which covers the tags, including the ML-KEM pubkey).
This is weaker than a cross-signature but sufficient because:
- The secp256k1 signature is valid NOW (pre-quantum), establishing the link while we can trust secp256k1
- The ML-KEM key is used for encryption, not authentication — a false claim of ownership would just mean someone can't decrypt messages sent to you, which is self-correcting
### Why This Is Better Than Picking One Scheme
| Concern | Pick-one approach | Multi-scheme approach |
|---|---|---|
| Scheme gets broken later | Migration fails, need new link | Other links still valid |
| Standardization changes | May need to re-link | Already covered |
| Consensus not reached | Can't proceed | Proceed immediately |
| Event size | Small | Larger (~20-30 KB), but one-time cost |
| Client complexity | Support one scheme | Support any subset |
| Race condition risk | Must publish before quantum | Published now, all schemes |
Event size is a non-issue because this is a **one-time publication**, not per-event overhead.
### Revocation: If a Scheme Is Broken Later
If a specific PQ scheme is later found to be weak (like SIKE was):
1. **Clients simply stop trusting the broken scheme** — no revocation event needed; clients just ignore links to algorithms on a known-broken list
2. The remaining links are still valid
3. Optionally, publish a new key-link event without the broken scheme, signed by the remaining valid PQ keys
If a PQ scheme is later found to be weak (like SIKE), clients simply stop trusting it — no revocation event needed. The remaining links stay valid. A user may optionally publish a new key-link event omitting the broken scheme, signed by the still-valid PQ keys.
---
## Component 3: OpenTimestamps for Pre-Quantum Anchoring
NIP-03 (OpenTimestamps Attestations for Events) anchors Nostr event hashes to the Bitcoin blockchain via OpenTimestamps. The implementation submits the **SHA-256 of the full signed kind 1 event JSON** (including its `id` and `sig`) to the OpenTimestamps calendar servers, and embeds the resulting `.ots` proof in the kind 9999 proof carrier's `ots` tag.
NIP-03 (OpenTimestamps Attestations for Events) anchors Nostr event hashes to the Bitcoin blockchain. The implementation submits the **canonical digest** of the fully signed kind 1 event to OpenTimestamps calendar servers and embeds the resulting `.ots` proof in the kind 9999 proof carrier's `ots` tag. The proof carrier *carries* the proof; the kind 1 event *is* what's timestamped.
### Why Bitcoin Timestamping Is Quantum-Resistant
The canonical digest (version 1) is `sha256(JSON.stringify([0, pubkey, created_at, kind, tags, content, id, sig]))` — the NIP-01 event-ID serialization array extended with the `id` and `sig` fields. This is reproducible by any conforming implementation (including non-JS ones and verifiers that re-fetch the event from a relay with different key order), unlike `sha256(JSON.stringify(event))` which is key-order-dependent. The proof carrier records the digest version in a `digest_version` tag; verifiers fail closed on unknown versions. Legacy v0 proof carriers (pre-canonicalization) committed to `sha256(JSON.stringify(event))` and are accepted only for historical inspection, not as valid for the key link.
The Bitcoin proof-of-work chain is quantum-resistant — not because Bitcoin's signatures are PQ-safe (they're not for revealed pubkeys), but because **re-mining historical blocks is infeasible even with a quantum computer.** Grover's algorithm gives only a quadratic speedup on mining, which doesn't help retroactively alter past blocks.
### Why Bitcoin timestamping is quantum-resistant
An attacker who breaks your secp256k1 key via Shor's can forge new events but **cannot backdate an event to before a specific Bitcoin block** — they'd need to produce a valid OTS proof anchoring it to a past block, which requires either:
- Re-mining that Bitcoin block (impossible, even with a quantum computer, due to cumulative proof-of-work)
- Finding a SHA256 preimage for the event hash (still infeasible with Grover's quadratic speedup on 256-bit hashes)
Bitcoin's proof-of-work chain is quantum-resistant not because Bitcoin's signatures are PQ-safe (they're not, for revealed pubkeys) but because **re-mining historical blocks is infeasible even with a quantum computer.** Grover's gives only a quadratic speedup on mining, which doesn't help retroactively alter past blocks. An attacker who breaks your secp256k1 key via Shor's can forge new events but **cannot backdate an event to before a specific Bitcoin block** — they'd need to re-mine that block (impossible) or find a SHA-256 preimage (still infeasible).
### The Critical Refinement: OpenTimestamp the Key-Link Event
The kind 1 announcement event MUST be timestamped via OpenTimestamps. This prevents a race condition attack:
### Why the key-link event must be anchored
**Without OTS on the key-link event:**
- Attacker breaks secp256k1 key via Shor's
- Attacker publishes a fraudulent key-link event linking your secp256k1 key to their PQ key
- Attacker publishes a fraudulent key-link event linking your key to their PQ key
- Both real and fraudulent events have valid secp256k1 signatures (key is compromised)
- Clients can't distinguish them — `created_at` is forgeable
**With OTS on the key-link event:**
- Your real key-link event is anchored to Bitcoin block N (before quantum computers)
- Attacker's fraudulent event is published later and cannot produce an OTS proof for block N or earlier
- Your real event is anchored to Bitcoin block N (before quantum computers)
- The attacker's fraudulent event is published later and cannot produce an OTS proof for block N or earlier
- Clients verify: "the key-link event with the earliest valid OTS proof wins"
- **The real key-link is cryptographically distinguishable from the fraudulent one**
### What Is Timestamped
### Current OTS verification status
The implementation timestamps the **SHA-256 of the full signed kind 1 event JSON** (computed by hashing `JSON.stringify(kind1Event)`, which includes the `id` and `sig` fields). This hash is:
1. Submitted to OpenTimestamps calendar servers to obtain a pending `.ots` proof.
2. Recorded in the kind 9999 proof carrier's `sha256` tag.
3. The `.ots` proof is embedded in the kind 9999 proof carrier's `ots` tag.
> **Trust model.** The current OTS verification is **multi-explorer-checked**, not a full Bitcoin
> light-client verification. Treat results accordingly.
The kind 9999 proof carrier *carries* the OTS proof; the kind 1 event *is* what's timestamped. Verifiers re-compute the hash from the embedded kind 1 event and check it matches the `sha256` tag.
The current implementation parses the `.ots` proof, walks the full Merkle path, and validates the resulting Merkle root against a Bitcoin block header fetched from **two independent public explorer APIs** (blockstream.info and mempool.space). The two providers are cross-checked and the verifier fails closed on disagreement, making coordinated false responses harder. The result's `trustMode` field reports the achieved trust level:
### Current OTS Verification Status
- `multi-explorer-checked` — both providers agreed on the Merkle root (strongest available mode).
- `single-explorer-checked` — only one provider responded (weaker; the UI labels it accordingly).
- `structural-only` — no Bitcoin attestation was verified (pending or none).
> **Research prototype — trust model.** The current OTS verification is **API-assisted**, not a
> full Bitcoin light-client verification. Treat results accordingly.
Proof *upgrading* (asking calendars for a confirmed proof) is delegated to a server-side helper.
The current implementation:
- Parses the `.ots` proof and validates the Merkle path against a block header obtained from a
public Bitcoin explorer API (mempool.space).
- Detects Bitcoin confirmation by searching the proof bytes for the block-header attestation magic
bytes and confirming the attested block height via the same API.
- Delegates proof *upgrading* (asking calendars for a confirmed proof) to a server-side helper.
**What is NOT done:**
- **Full light-client verification** — validating the block header's proof-of-work, difficulty, and
chain linkage independently, without trusting an explorer API. The vendored
`javascript-opentimestamps` library in `resources/` provides primitives for this and will be
integrated in a future release.
- **Multi-explorer cross-checking** — comparing independent APIs and rejecting disagreement.
Because the block header is trusted from a single API, a compromised or malicious explorer could
falsify a confirmation. The UI labels this as "API-checked," not "cryptographically verified on
Bitcoin."
### What Should Be OpenTimestamped
Not every event needs OTS. Priority list:
1. **Key-link events** — absolutely critical, foundation of PQ migration
2. **Identity-defining events** — NIP-05 verification, profile events (kind 0)
3. **Important content** — contracts, announcements, evidence
4. **High-value events** — financial events (NIP-60 Cashu wallets, zaps)
Routine events (regular posts, reactions) probably don't need OTS — the impact of retroactive forgery is low.
**Not yet done:** full light-client verification — validating block headers, proof-of-work, difficulty, and chain linkage independently, without trusting any explorer API. Because the block header is trusted from explorer APIs (even when cross-checked), a compromised/colluding set of explorers could still falsify a confirmation. The UI labels results as "multi-explorer-checked (cross-verified)" or "single-explorer-checked (trusted API)," never as "trustless." The vendored `javascript-opentimestamps` library in `resources/` provides the primitives for full light-client verification, which is planned for a future release.
---
## Component 4: Migrating Existing Users with Raw nsec (Design Only)
> **Status: Design only — not yet implemented.** The current implementation supports users who already have a Nostr identity (via NIP-07 signer or nostr-login-lite). The raw-nsec migration described below is future work.
> **Status: Design only — not yet implemented.** The current implementation supports users who already have a Nostr identity via a NIP-07 signer. The raw-nsec preparation below is future work.
Most Nostr users today have a raw nsec (random private key) with no seed phrase. Asking them to abandon their identity and social graph to adopt a seed phrase is a non-starter. This component addresses how to migrate them.
Most Nostr users today have a raw nsec (random private key) with no seed phrase. Asking them to abandon their identity and social graph to adopt a seed phrase is a non-starter. The challenge is that a raw nsec is 32 random bytes — it can't be "derived" from a seed (that's a preimage problem), so it must be **encoded** (directly or encrypted) alongside a new seed phrase.
### The Challenge
Three approaches have been designed:
A secp256k1 private key is 32 bytes (256 bits). BIP39 encodes 11 bits per word. Encoding the old nsec requires 24 extra words. The old nsec was generated randomly — it cannot be "derived" from a seed (that's a preimage problem). It must be **encoded** (directly or encrypted).
- **Approach A — 36-word phrase:** Generate a 12-word BIP39 seed, derive an encryption key from it, encrypt the old nsec with ChaCha20, and encode the 32-byte ciphertext as 24 more BIP39 words. The full 36-word phrase recovers both keys; the first 12 alone recover the PQ keys after the eventual migration.
- **Approach B — 12 words + relay backup (recommended):** Generate a 12-word phrase, derive the new secp key, PQ keys, and a symmetric encryption key, encrypt the old nsec, and publish it as a kind 30078 event signed with the new key. Recovery needs only the 12 words plus relay access to fetch and decrypt the event.
- **Approach C — Hybrid:** Primary backup is 12 words (relay recovery); optional fallback is also writing the 24 extra encrypted-nsec words in case relays are unavailable.
### Approach A: Extended Phrase (36 words)
```mermaid
flowchart LR
subgraph CREATION["One-time migration"]
OLD[Old nsec - 32 bytes]
NEW[Generate 12-word BIP39 phrase]
NEW --> SEED[BIP39 seed]
SEED --> ENCKEY[Derive encryption key via HKDF]
ENCKEY --> ENC[Encrypt old nsec: ChaCha20]
OLD --> ENC
ENC --> CIPHER[32 bytes encrypted nsec]
CIPHER --> WORDS[Encode as 24 BIP39 words + checksum]
NEW --> PHRASE[Full phrase: 12 + 24 = 36 words]
WORDS --> PHRASE
end
subgraph RECOVERY["Recovery with full phrase"]
FULL[36 words entered]
FULL --> FIRST[First 12 words]
FULL --> LAST[Last 24 words]
FIRST --> SEED2[BIP39 seed]
SEED2 --> ENCKEY2[Derive encryption key]
LAST --> CIPHER2[Decode to 32 bytes]
ENCKEY2 --> DEC[Decrypt]
CIPHER2 --> DEC
DEC --> OLDRECOV[Old nsec recovered]
SEED2 --> PQKEYS[PQ keys derived]
end
subgraph FUTURE["After migration - drop old key"]
FIRST12[First 12 words only]
FIRST12 --> SEED3[BIP39 seed]
SEED3 --> PQKEYS2[PQ keys only]
end
```
**Design:**
1. Generate a standard 12-word BIP39 phrase (128 bits entropy)
2. Derive a 32-byte encryption key from the BIP39 seed: `enc_key = HKDF-Extract(salt="nostr-legacy-key", ikm=bip39_seed)`
3. Encrypt the old nsec: `encrypted_nsec = ChaCha20(enc_key, nonce=0, old_nsec)` → 32 bytes
4. Encode the 32-byte encrypted nsec as 24 BIP39 words (with its own checksum)
5. Full phrase: 12 words (new seed) + 24 words (encrypted old nsec) = 36 words
**Recovery with 36 words:** First 12 → BIP39 seed → PQ keys + encryption key. Last 24 → decode → decrypt → old nsec. Both keys recovered.
**Recovery with 12 words (after migration):** First 12 → PQ keys only. Old nsec no longer needed.
**Why encrypt the old nsec?** The extra 24 words are meaningless without the first 12 (partial compromise protection). If someone steals only the last 24 words: useless. If someone steals only the first 12 words: they get PQ keys but not the old nsec.
### Approach B: 12 Words + Relay Backup (Recommended)
```mermaid
flowchart TD
subgraph SETUP["One-time migration"]
OLD[Old nsec]
NEW[Generate 12-word BIP39 phrase]
NEW --> SEED[BIP39 seed]
SEED --> NEWSECP[New secp256k1 key via NIP-06]
SEED --> PQKEYS[PQ keys via BIP32]
SEED --> ENCKEY[Symmetric encryption key via HKDF]
ENCKEY --> ENC[Encrypt old nsec]
OLD --> ENC
ENC --> EVENT30078[kind 30078: encrypted old nsec]
NEWSECP --> SIGN1[Sign event with new secp256k1 key]
SIGN1 --> EVENT30078
EVENT30078 --> RELAY[Publish to relays]
OLD --> KEYLINK[Key-link event: cross-signed]
NEWSECP --> KEYLINK
PQKEYS --> KEYLINK
KEYLINK --> OTS[OpenTimestamp via NIP-03]
OTS --> BTC[Anchored in Bitcoin]
KEYLINK --> RELAY
end
subgraph RECOVER["Recovery from 12 words only"]
WORDS[12 words entered]
WORDS --> SEED2[BIP39 seed]
SEED2 --> NEWSECP2[New secp256k1 key]
SEED2 --> PQKEYS2[PQ keys]
SEED2 --> ENCKEY2[Encryption key]
NEWSECP2 --> FETCH[Fetch kind 30078 from relays]
FETCH --> CIPHER[Encrypted old nsec]
ENCKEY2 --> DEC[Decrypt]
CIPHER --> DEC
DEC --> OLDRECOV[Old nsec recovered]
end
```
**Design:**
1. Generate a 12-word BIP39 phrase
2. Derive from seed: new secp256k1 key (NIP-06 path), PQ keys (BIP32), symmetric encryption key (HKDF)
3. Encrypt old nsec with the symmetric key: `encrypted_nsec = ChaCha20-Poly1305(enc_key, old_nsec)`
4. Publish as kind 30078 event, signed with the **new** secp256k1 key
5. Publish key-link event (cross-signed, OpenTimestamped via NIP-03)
6. User backs up only 12 words
**Recovery:** 12 words → derive new key + encryption key → fetch kind 30078 from relays → decrypt → old nsec. Both keys recovered from 12 words + relay access.
**No circular dependency:** The kind 30078 event is found by the new pubkey (derived from the 12-word seed). The new secp256k1 key is the "storage identity." The old nsec is just data inside the event.
### Approach C: Hybrid (Best of Both)
- Primary backup: 12-word phrase (PQ keys + relay recovery of old nsec)
- Optional fallback: also write down 24 extra words (encrypted old nsec) in case relays are unavailable
- If relays available: 12 words suffice
- If relays down: 12 + 24 = 36 words recover everything
### Comparison
| Property | 36-word phrase | 12-word + relay backup | Hybrid |
|---|---|---|---|
| Backup size | 36 words | 12 words | 12 or 36 words |
| Relay dependency | None | Yes | Optional |
| Custom format | Yes | No | Optional |
| Self-contained | Yes | No | Yes (with 36) |
| Quantum-safe | Yes | Yes | Yes |
All three are quantum-safe. Approach B is recommended because it minimizes what the user must back up.
---
## Component 5: Quantum-Safe Self-Storage (Design Only)
> **Status: Design only — not yet implemented.** The current implementation does not include encrypted self-storage. The approaches below are design proposals for future work.
> **Status: Design only — not yet implemented.**
For self-storage use cases (e.g., kind 30078 application data), NIP-04 and NIP-44 are unnecessary and quantum-vulnerable. Two quantum-safe alternatives:
For self-storage use cases (e.g., kind 30078 application data), NIP-04 and NIP-44 are unnecessary and quantum-vulnerable. Two quantum-safe alternatives are proposed:
### Option A: One-Time Pad (Information-Theoretic Security)
- **One-time pad (OTP):** Information-theoretic security — the strongest guarantee in cryptography, stronger than any computational security including post-quantum. The pad must be truly random, as long as the data, never reused, and stored securely (e.g., USB drive). Self-storage sidesteps OTP's usual key-distribution problem since you're encrypting for yourself. Pad reuse is fatal: reusing any section breaks the encryption.
- **Symmetric key from seed:** Derive a 256-bit key from the BIP39 seed (independent of secp256k1) via HKDF and encrypt with ChaCha20-Poly1305 or AES-256-GCM. Simpler than OTP (no pad management), computationally PQ-secure (~128-bit), and adequate for most use cases.
A one-time pad provides **information-theoretic security** — the strongest security guarantee in cryptography, stronger than any computational security (including post-quantum). It's a mathematical proof of perfect secrecy: no computer that could ever exist, in any universe governed by the laws of physics, can break it.
**Requirements:**
- Pad must be truly random (hardware RNG recommended, e.g., TrueRNG)
- Pad must be as long as the data
- Pad must never be reused
- Pad must be stored securely (e.g., USB drive)
**For self-storage, OTP is a natural fit** because you sidestep OTP's biggest limitation (key distribution between parties) — you're encrypting for yourself, so the pad lives on your device.
**What a quantum attacker can and cannot do with OTP-encrypted kind 30078:**
- ❌ Read your stored data — OTP is information-theoretically secure
- ✅ Forge your signature — Shor's breaks secp256k1 (identity compromised)
- ✅ Impersonate you, delete/replace your events
- ❌ Decrypt your stored data — even with your private key, the data is protected by the pad
This gives **clean separation**: quantum breakage of your secp256k1 key compromises your identity/authentication but NOT your stored data's confidentiality.
**Critical warning: pad reuse is fatal.** If any pad section is used twice, XOR of the two ciphertexts equals XOR of the two plaintexts — the encryption breaks. Multi-device sync requires an atomic reservation protocol for pad sections.
### Option B: Symmetric Key from Seed (Computational PQ Security)
Derive a 256-bit symmetric key from the BIP39 seed (independent of secp256k1) and encrypt with ChaCha20-Poly1305 or AES-256-GCM:
```
storage_key = HKDF-Extract(salt="nostr-storage-v1", ikm=bip39_seed)
ciphertext = ChaCha20-Poly1305(storage_key, nonce, plaintext)
```
This is post-quantum because:
- HKDF-SHA256 is symmetric (~128-bit PQ security)
- ChaCha20 / AES-256 are symmetric (~128-bit PQ security)
- The secp256k1 key is never part of the encryption path
**Tradeoff vs OTP:** Simpler (no pad management, no USB drive), but computationally secure rather than information-theoretically secure. Adequate for most use cases.
### Comparison
| Property | OTP | Symmetric key from seed |
|---|---|---|
| Security level | Information-theoretic (perfect secrecy) | Computational, ~128-bit PQ |
| Key material | Pad as long as all data | Single 256-bit key |
| Storage overhead | Pad on USB drive | Just the seed phrase |
| Quantum resistance | Unconditional | Conditional (Grover's is optimal) |
| Future-proofing | Permanent | Could theoretically fall to new quantum algorithms |
| Multi-device | Requires pad sync + offset coordination | Just share the seed |
| Practicality | More complex | Simpler |
OTP gives clean separation: a quantum break of your secp256k1 key compromises identity/authentication but **not** stored-data confidentiality.
---
## Component 6: Deterministic Wallet Compartmentalization
BIP32/BIP39 deterministic wallets (NIP-06) provide meaningful quantum compartmentalization through their tree structure.
BIP32/BIP39 deterministic wallets (NIP-06) provide meaningful quantum compartmentalization through their tree structure. The key insight is that **chain codes are secret** — when Shor's recovers a leaf private key from its published public key, the attacker gets only that key. To climb the tree they need the parent chain code, which is a symmetric HMAC-SHA512 primitive with no quantum advantage. Without chain codes, the attacker is stuck at the leaf level.
### The Core Protection: Chain Codes Are Secret
When Shor's algorithm recovers a leaf private key from its published public key, the attacker gets only that key. To climb the tree (recover the parent private key), they need the parent chain code:
```
parent_private_key = child_private_key - IL mod n
where IL = HMAC-SHA512(parent_chain_code, parent_public_key || index)[:32]
```
The parent chain code is NOT published in normal Nostr usage. HMAC-SHA512 is a symmetric primitive — a quantum computer provides no advantage. **Without chain codes, the attacker is stuck at the leaf level.**
### The Firewall Effect of Hardened Derivation
The NIP-06 path `m/44'/1237'/account'/0/0` has hardened derivation at three levels. This means:
- Breaking all keys in Account 0 does NOT compromise Account 1 (hardened at `account'` level)
- The seed/master key remains secure even if all account keys are broken
- Hardened derivation requires the parent **private** key, not just the public key
### Note on the PQ Derivation Paths
The PQ keys in this implementation are derived at **non-hardened** children (indices 18) under the hardened parent `m/44'/1237'/0'/0'`. Non-hardened derivation means anyone with the parent xpub (extended public key + chain code) at `m/44'/1237'/0'/0'` could derive the child *public* keys — but **not** the child *private* keys (which are what the PQ seeds are). So the PQ seeds remain secret unless the parent private key leaks. **Never publish the xpub at `m/44'/1237'/0'/0'`** — doing so would expose the child public keys (though not the PQ seeds themselves).
### The Critical Vulnerability: Chain Code Leakage
If an attacker obtains a chain code at any level AND breaks one child key at that level via Shor's, they can:
1. Compute `IL` using the chain code + child public key + index
2. Recover the parent private key
3. Derive ALL children at that level
4. Continue climbing if parent chain codes are also known
**Where chain codes leak:** published xpubs (extended public keys), wallet software that exports xpubs, backup exposure. In standard Nostr usage, chain codes are not published.
### Summary
The NIP-06 path `m/44'/1237'/account'/0/0` has hardened derivation at three levels, so breaking all keys in Account 0 does **not** compromise Account 1, and the seed/master key remains secure even if all account keys are broken.
| Scenario | Compromised? |
|---|---|
@@ -686,123 +272,25 @@ If an attacker obtains a chain code at any level AND breaks one child key at tha
| Seed phrase stolen (classical) | Everything |
| Seed safe, quantum attacker, no chain codes leaked | Only published keys |
### Recommendations
1. Use separate accounts for separate Nostr identities — hardened derivation contains the damage
2. Never publish xpubs — chain codes are the keys to the kingdom
3. Use 24-word mnemonics for adequate PQ security on the seed entropy
4. Don't reuse keys across services — each identity gets its own account index
5. Assume published keys WILL be broken — plan for containment, not prevention
---
## The Complete Migration Flow
```mermaid
flowchart TD
subgraph NOW["Pre-quantum - NOW"]
USER[User with existing Nostr identity]
USER --> SIGNIN[Sign in via NIP-07 signer]
SIGNIN --> SEED[Generate 24-word BIP39 phrase]
SEED --> DERIVE[Derive 5 PQ keypairs from seed via BIP32]
DERIVE --> SIGN[Each PQ key signs the attestation text]
SIGN --> KIND1[Build kind 1 announcement with PQ sigs in tags]
KIND1 --> SECP1[Account 1 signs kind 1 via signer]
SECP1 --> HASH[SHA-256 of full signed kind 1 event JSON]
HASH --> OTS[Submit hash to OpenTimestamps]
OTS --> KIND9999[Build kind 9999 proof carrier with OTS proof]
KIND9999 --> SECP2[Account 1 signs kind 9999 via signer]
SECP2 --> RELAY[Publish both events to relays]
SEED --> BACKUP[User writes down 24-word phrase]
end
subgraph CONFIRM["Bitcoin confirmation"]
POLL[Poll OTS upgrade service]
POLL --> UPGRADE[Upgrade .ots proof with Bitcoin attestation]
UPGRADE --> REPUBLISH[Publish new kind 9999 with confirmed proof]
end
subgraph FUTURE["Post-quantum - when quantum computers arrive"]
ATTACK[Attacker breaks secp256k1 via Shors]
ATTACK --> FORGE[Can forge secp256k1 signatures]
ATTACK --> CANNOT1[Cannot forge PQ signatures]
ATTACK --> CANNOT2[Cannot backdate events - OTS proofs anchor history]
VERIFY[Verify key-link event via OTS precedence]
VERIFY --> USEPQ[Switch to PQ signatures]
USEPQ --> REJECT[Reject forged secp256k1-only events]
end
NOW --> CONFIRM --> FUTURE
style NOW fill:#cfc
style CONFIRM fill:#ffa
style FUTURE fill:#fcc
```
### User Experience
1. User signs in with their existing Nostr identity (NIP-07 signer or nostr-login-lite)
2. Client generates a 24-word (256-bit) phrase, shows it to the user to write down
3. Client derives 5 PQ keypairs from the seed via BIP32 paths
4. Each PQ signature scheme signs the human-readable attestation text
5. The user's signer signs the kind 1 announcement and the kind 9999 proof carrier
6. The SHA-256 of the full signed kind 1 event is submitted to OpenTimestamps
7. Both events are published to relays
8. The client polls for Bitcoin confirmation and publishes a new kind 9999 proof carrier with the confirmed OTS proof (append-only — the original remains on relays)
9. After quantum migration: clients use PQ keys, old key retired
**Recommendations:** use separate accounts for separate identities; never publish xpubs (chain codes are the keys to the kingdom); use 24-word mnemonics; don't reuse keys across services; assume published keys *will* be broken and plan for containment, not prevention.
---
## What Remains Unsolved
### Historical Event Authenticity (Partially Solved)
Past secp256k1-signed events that were NOT OpenTimestamped remain forgeable after quantum break. An attacker can create fake old events that appear to be from you.
**Mitigation:** OpenTimestamp important events now (NIP-03). Events with valid OTS proofs are cryptographically anchored to a pre-quantum timestamp and cannot be forged retroactively. Events without OTS proofs become untrustworthy after quantum break.
**Not fully solved:** Routine events (regular posts, reactions) that aren't worth OpenTimestamping will become forgeable. The impact is low for most content, but high for events used as evidence, contracts, or historical records.
### Relay Trust for Recovery
Approach B (12 words + relay backup, Component 4 — design only) depends on relays retaining the kind 30078 event containing the encrypted old nsec. If all relays delete it, the old nsec is lost.
**Mitigations:**
- Publish to multiple relays
- Use relays you control or trust
- Use the hybrid approach (Approach C) with 36-word fallback
- The event is small and signed, so it's easy to preserve
### PQ Signature Size
PQ signatures are much larger than secp256k1 Schnorr signatures:
| Scheme | Signature size |
|---|---|
| secp256k1 Schnorr | 64 bytes |
| ML-DSA-44 | ~2.4 KB |
| ML-DSA-65 | ~3.3 KB |
| SLH-DSA-128s | ~8 KB |
| Falcon-512 | ~0.7 KB |
This increases the kind 1 announcement event size (~20-30 KB total for all PQ signatures). This is a one-time cost for the migration event, not per-event overhead.
### PQ Algorithm Risk
Any PQ algorithm could theoretically be broken by a new classical or quantum algorithm (as SIKE was). The multi-scheme approach hedges against this — if one scheme falls, the others remain linked. But if ALL lattice-based schemes are broken simultaneously, only SLH-DSA (hash-based, very conservative) remains as a fallback.
### Common-Origin Not Cryptographically Proven
The current implementation does not include a seed-derived secp256k1 signature binding the PQ keys to the seed (see Component 2, "What This Proves and What It Does Not"). The PQ keys self-attest by signing the attestation text, and the user's existing identity authorizes the migration. Common-origin is asserted, not proven. This is acceptable because OTS precedence (not a seed-derived signature) is the mechanism that distinguishes real from forged events after a quantum break.
- **Historical event authenticity (partially solved):** Past secp256k1-signed events that were *not* OpenTimestamped remain forgeable after a quantum break. OpenTimestamping important events now (NIP-03) anchors them cryptographically; routine events that aren't worth timestamping will become forgeable, but the impact is low for most content.
- **Relay trust for recovery:** Approach B (Component 4, design only) depends on relays retaining the kind 30078 event containing the encrypted old nsec. Mitigations: publish to multiple relays, use relays you control, or use the hybrid 36-word fallback.
- **PQ signature size:** PQ signatures are much larger than secp256k1 Schnorr (64 bytes) — ML-DSA-44 ~2.4 KB, ML-DSA-65 ~3.3 KB, SLH-DSA-128s ~8 KB, Falcon-512 ~0.7 KB. The kind 1 announcement is ~2030 KB total, a one-time cost for the key-link event.
- **PQ algorithm risk:** Any PQ algorithm could theoretically be broken (as SIKE was). The multi-scheme approach hedges; if all lattice-based schemes fall simultaneously, only SLH-DSA (hash-based, very conservative) remains.
- **Common-origin not cryptographically proven:** See Component 2. OTS precedence, not a seed-derived signature, is the mechanism that distinguishes real from forged events after a quantum break.
---
## Implementation Status
### What Is Implemented
### What is implemented
The current implementation is a static web app (`www/`) that performs the full migration flow for users who already have a Nostr identity:
The current implementation is a static web app (`www/`) that performs the full preparation flow for users who already have a Nostr identity:
| Component | Status | Location |
|---|---|---|
@@ -815,20 +303,25 @@ The current implementation is a static web app (`www/`) that performs the full m
| NIP-01 event ID computation and Schnorr verification | Implemented | `www/js/pq-crypto.mjs` |
| OpenTimestamps submission (pending proof) | Implemented | `www/js/pq-crypto.mjs` |
| OTS upgrade polling (via server helper) | Implemented | `www/js/pq-crypto.mjs` |
| OTS full Merkle-path verification, multi-explorer cross-checked (blockstream + mempool) | Implemented | `www/js/pq-crypto.mjs` |
| Canonical proof carrier selection (earliest valid Bitcoin anchor wins) | Implemented | `www/js/pq-crypto.mjs` |
| Signer output validation (G56-05: reject mutated/invalid signer responses) | Implemented | `www/js/pq-crypto.mjs` |
| Proof archive export/import (offline verification without a relay) | Implemented | `www/js/pq-crypto.mjs`, `www/verify.html` |
| Versioned PQ algorithm policy (F-D3: mandatory/KEM/ignored sets) | Implemented | `www/js/nip-qr-policy.mjs` |
| Relay publishing (WebSocket) | Implemented | `www/index.html` |
| Verification page (query relay or paste event JSON) | Implemented | `www/verify.html` |
| Verification page (query relay, paste event JSON, or import proof archive) | Implemented | `www/verify.html` |
| NIP-07 signer integration (nostr-login-lite) | Implemented | `www/index.html` |
| Test suite + deterministic test vectors (seed → pubkeys) | Implemented | `test/pq-crypto.test.mjs`, `test/vectors/` |
### What Is Not Yet Implemented
### What is not yet implemented
| Component | Description |
|---|---|
| Full light-client Bitcoin verification | Validate block headers, proof-of-work, difficulty, and chain linkage independently of an explorer API (the vendored `javascript-opentimestamps` library in `resources/` provides primitives). Current path trusts a single explorer API for the header. |
| Multi-explorer cross-checking | Compare independent Bitcoin APIs and reject disagreement |
| Raw-nsec migration (Component 4) | Encrypt old nsec, publish kind 30078, 36-word phrase encoding |
| Full light-client Bitcoin verification | Validate block headers, proof-of-work, difficulty, and chain linkage independently of any explorer API (the vendored `javascript-opentimestamps` library in `resources/` provides primitives). The current path cross-checks two explorer APIs but still trusts them for the header rather than verifying PoW/difficulty/chain-linkage itself. |
| Raw-nsec preparation (Component 4) | Encrypt old nsec, publish kind 30078, 36-word phrase encoding |
| Quantum-safe self-storage (Component 5) | OTP or symmetric-key encryption for kind 30078 data |
| PQ event authentication / rotation / revocation | Companion protocols for signing future events with PQ keys, rotating/revoking keys, PQ encryption |
| Independent implementation / test vectors | A second implementation reproducing canonical encoding, derivation, and selection |
| Independent implementation | A second, independent implementation reproducing canonical encoding, derivation, and selection (deterministic test vectors already exist in `test/vectors/`; a third-party reimplementation is the remaining step). |
| Relay event-size interop testing | The kind 1 announcement is ~2030 KiB; the kind 9999 proof carrier embeds it plus OTS proof data. The UI shows the event size and warns if it exceeds ~60 KiB (some relays reject large events). Testing against target relay policies and a compact binary format remain as future work. |
| Reproducible-build verification of the deployed bundle | The deployed `www/pq-crypto.bundle.js` is a build artifact produced by `build-pq-bundle.js` from the audited source in `www/js/pq-crypto.mjs`. Users must currently trust that the deployed bundle matches the source. Reproducible-build verification (so anyone can rebuild the bundle byte-for-byte and confirm the deployed file matches) is on the roadmap. |
@@ -856,6 +349,7 @@ The current implementation is a static web app (`www/`) that performs the full m
- [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)
- [NIST FIPS 206: Falcon (draft)](https://csrc.nist.gov/pubs/fips/206/ipd) — Falcon-512 signature scheme
- [OpenTimestamps](https://opentimestamps.org/)
- [Open Quantum Safe](https://openquantumsafe.org/)
@@ -863,4 +357,4 @@ The current implementation is a static web app (`www/`) that performs the full m
## License
This document is released into the public domain. The migration strategy described herein is intended as a community resource for the Nostr ecosystem.
This document is released into the public domain. The preparation strategy described herein is intended as a community resource for the Nostr ecosystem.
+2 -2
View File
@@ -1,6 +1,6 @@
# Nginx security headers for post-quantum-nostr (G56-08)
# Nginx security headers for nostr-quantum-preparation (G56-08)
#
# Add these to the nginx server block for laantungir.net/post-quantum.
# Add these to the nginx server block for laantungir.net/quantum-prep.
# These headers cannot be fully enforced from HTML meta tags alone —
# they must be set by the server.
+1 -1
View File
@@ -59,7 +59,7 @@ done
# Get the latest git tag (authoritative version)
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo "🔒 Post-Quantum Nostr — Version Increment"
echo "🔒 Nostr Quantum Preparation — Version Increment"
echo "=========================================="
echo "Latest tag: $LATEST_TAG"
echo "Bump type: $BUMP_TYPE"
+1 -1
View File
@@ -366,7 +366,7 @@ This NIP builds on and is compatible with several existing community proposals:
> migration. It links PQ keys to an existing identity and anchors that link in Bitcoin; it does not
> yet define PQ authentication for routine events, key rotation/revocation, or PQ encryption.
A static, client-side-only web implementation exists at `https://laantungir.net/post-quantum/`.
A static, client-side-only web implementation exists at `https://laantungir.net/quantum-prep/`.
Cryptographic operations (BIP39, BIP32, PQ keygen/sign/verify, OTS submission and Merkle-path
verification) happen in the browser. Private keys are handled in the browser or via a NIP-07 signer
extension, which may be a remote signer (NIP-46); in that case the signing key does not touch the
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "post_quantum_nostr",
"name": "nostr_quantum_preparation",
"version": "0.0.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "post_quantum_nostr",
"name": "nostr_quantum_preparation",
"version": "0.0.24",
"license": "ISC",
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "post_quantum_nostr",
"version": "0.0.29",
"name": "nostr_quantum_preparation",
"version": "0.1.6",
"description": "A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.",
"main": "index.js",
"scripts": {
+6 -6
View File
@@ -1,20 +1,20 @@
#!/bin/bash
#
# Upload the post-quantum Nostr web app to the server.
# Upload the Nostr Quantum Preparation web app to the server.
#
# Usage: ./upload.sh
#
# Uploads the contents of www/ to ubuntu@laantungir.net:html/post-quantum/
# The site will be accessible at https://laantungir.net/post-quantum
# Uploads the contents of www/ to ubuntu@laantungir.net:html/quantum-prep/
# The site will be accessible at https://laantungir.net/quantum-prep/
#
set -e
SERVER="ubuntu@laantungir.net"
REMOTE_DIR="html/post-quantum"
REMOTE_DIR="html/quantum-prep"
LOCAL_DIR="$(dirname "$0")/www"
echo "🔒 Post-Quantum Nostr — Upload"
echo "🔒 Nostr Quantum Preparation — Upload"
echo "=============================="
echo "Server: $SERVER"
echo "Remote: $REMOTE_DIR"
@@ -40,4 +40,4 @@ rsync -avz --delete \
echo ""
echo "✅ Upload complete!"
echo "🌐 Site available at: https://laantungir.net/post-quantum"
echo "🌐 Site available at: https://laantungir.net/quantum-prep"
+1 -1
View File
@@ -218,7 +218,7 @@ We use multiple schemes simultaneously because:
### The demo
A working demo is deployed at **https://laantungir.net/post-quantum** that implements the full flow: sign in, generate seed, derive PQ keys, sign the kind 1 and kind 9999 events, publish to relays, and timestamp via OpenTimestamps. It uses:
A working demo is deployed at **https://laantungir.net/quantum-prep** that implements the full flow: sign in, generate seed, derive PQ keys, sign the kind 1 and kind 9999 events, publish to relays, and timestamp via OpenTimestamps. It uses:
- `@noble/post-quantum` for PQ cryptography (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768)
- `@scure/bip39` for seed phrase generation
+15
View File
@@ -823,6 +823,21 @@ a.nostr-embed-preview-text:hover {
z-index: 3;
}
/* Small, unobtrusive version badge fixed to the bottom-right of the page.
Rendered by js/version-display.js. */
.pq-version-badge {
position: fixed;
right: 8px;
bottom: 6px;
font-size: 10px;
line-height: 1;
color: var(--muted-color);
opacity: 0.6;
pointer-events: none;
z-index: 9999;
font-family: var(--font-family, monospace);
}
.svgHeaderButtons {
fill: none;
stroke: var(--button-color);
+69 -42
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none';" />
<meta name="referrer" content="no-referrer" />
<title>Post-Quantum Nostr</title>
<title>Nostr Quantum Preparation</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="shortcut icon" type="image/x-icon" href="data:image/x-icon;," />
@@ -94,6 +94,17 @@
border-color: var(--accent-color);
}
/* Braille spinner shown in the checklist box while a step is working
(e.g. step 6 waiting for Bitcoin confirmation). The frames are
advanced by JS (setStepSpinner); this class just restyles the box. */
.pq-checklist-box.pq-spinner {
border: none;
color: var(--accent-color);
font-size: 18px;
line-height: 20px;
}
.pq-checklist-link {
color: var(--accent-color);
text-decoration: underline;
@@ -272,7 +283,7 @@
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText" id="divHeaderText">Post-Quantum Nostr</div>
<div class="divHeaderText" id="divHeaderText">Nostr Quantum Preparation</div>
</div>
<div id="divHeaderFlexRight">
<button class="pq-button pq-button-secondary pq-hidden" id="pqSignOutBtn" style="width: auto; padding: 6px 16px; font-size: 13px;">Sign Out</button>
@@ -286,7 +297,7 @@
PERSISTENT CHECKLIST (always visible at top)
================================================================ -->
<div class="pq-checklist">
<div class="pq-checklist-title">Migration Steps</div>
<div class="pq-checklist-title">Preparation Steps</div>
<div class="pq-checklist-item" id="step-1">
<span class="pq-checklist-box"></span>
<span>1. Sign in with your Nostr identity</span>
@@ -301,7 +312,7 @@
</div>
<div class="pq-checklist-item" id="step-4">
<span class="pq-checklist-box"></span>
<span>4. Sign the NIP-QR migration event</span>
<span>4. Sign the key-link event</span>
</div>
<div class="pq-checklist-item" id="step-5">
<span class="pq-checklist-box"></span>
@@ -323,20 +334,36 @@
<!-- STEP 0: NOT AUTHENTICATED -->
<div id="pqNotAuthed" class="pq-card">
<div class="pq-card-title">Post-Quantum Nostr</div>
<div class="pq-card-title">Nostr Quantum Preparation</div>
<div class="pq-info-text" style="font-size: 16px; color: var(--primary-color);">
Create an experimental pre-quantum PQ key commitment
Link your Nostr identity to post-quantum keys — before quantum computers arrive
</div>
<div class="pq-info-text">
Sign in with your Nostr signer to begin. Your npub won't change, your followers stay,
and your social graph is preserved. We'll generate a seed phrase and link
it to your current identity.
Quantum computers will eventually break the cryptography Nostr uses today. When that
happens, anyone who recorded your public key could forge your signature, impersonate you,
and read your encrypted messages. This tool lets you prepare now: it generates a new seed
phrase, creates post-quantum keys from it, and publishes a signed statement linking those
keys to your current Nostr identity. The statement is then anchored to a Bitcoin block, so
no one can backdate a fake one later.
</div>
<div class="pq-info-text" style="font-size: 13px; color: var(--accent-color);">
<strong>Research prototype.</strong> Do not enter a valuable existing mnemonic. This tool
does not make your Nostr identity post-quantum secure on its own; it creates a commitment
that future PQ protocols can build on.
<div class="pq-info-text">
Your current nsec, npub, your followers, and your social graph stay exactly as they are.
This program doesn't change anything about your current nostr setup.
</div>
<div class="pq-info-text">
Upon completing this process, you should have saved 2 things:
<ol style="margin: 8px 0 0 0; padding-left: 22px;">
<li>A new seed phrase. This should be kept secret and private.</li>
<li>A proof archive. This information is published to relays, but keeping a copy yourself is useful in case the relays don't keep the event.</li>
</ol>
</div>
<div class="pq-info-text">
In case quantum computers break nostr, you can use your new seed phrase to create a new nostr
identity, and prove that your old nostr identity created this new one.
</div>
<div class="pq-info-text" id="noSignerWarning" style="color: var(--accent-color); display: none;">
No Nostr signer detected. Install a NIP-07 browser extension (like nos2x or Alby) and reload.
</div>
@@ -346,7 +373,7 @@
<!-- STEP 1: AUTHENTICATED - INTRO -->
<div id="pqAuthed" class="pq-card pq-hidden">
<div class="pq-card-title">Post-Quantum Migration</div>
<div class="pq-card-title">Post-Quantum Preparation</div>
<div class="pq-info-text">
<strong>Connected as:</strong>
<div class="pq-npub-display" id="pqNpub"></div>
@@ -360,11 +387,11 @@
<ol>
<li>Generate a new <strong>seed phrase</strong> (your recovery backup)</li>
<li>Derive <strong>post-quantum keys</strong> from that seed via BIP32 paths (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768)</li>
<li>Sign a <strong>NIP-QR migration event</strong> linking your identity to the PQ keys</li>
<li>Sign a <strong>key-link event</strong> linking your identity to the post-quantum keys</li>
<li>Publish the event to relays</li>
</ol>
</div>
<button class="pq-button" id="pqStartBtn">Start Migration</button>
<button class="pq-button" id="pqStartBtn">Start Preparation</button>
</div>
<!-- STEP 2: SEED PHRASE GENERATION -->
@@ -397,9 +424,9 @@
</div>
<div class="pq-seed-display" id="pqSeedDisplay"></div>
<div class="pq-info-text" style="margin-top: 12px; font-size: 13px; color: var(--accent-color);">
<strong>Verify out-of-band after migration:</strong> This seed phrase is your backup — but
<strong>Verify out-of-band after linking:</strong> This seed phrase is your backup — but
this browser cannot cryptographically prove the published PQ keys came from it. After completing
the migration, take your seed phrase to a <em>different</em> device or browser, derive the keys
the key link, take your seed phrase to a <em>different</em> device or browser, derive the keys
independently, and confirm the public keys match the published event. This is the only way to
catch a compromised browser that might have substituted attacker keys.
</div>
@@ -426,6 +453,11 @@
<!-- Bring your own mode -->
<div id="pqSeedOwnPanel" style="display: none;">
<div class="pq-info-text" style="font-size: 13px; color: var(--accent-color);">
<strong>Warning:</strong> Don't enter a valuable existing seed phrase. This tool doesn't make
your Nostr identity post-quantum secure on its own — it creates a public link your future self
can prove is yours. Run through it once with a test identity before you use it on your real one.
</div>
<div class="pq-info-text">
Paste your 12 or 24-word BIP39 seed phrase below. It will be validated and used to
derive your post-quantum keys. Make sure you generated it offline or with a trusted tool.
@@ -485,12 +517,12 @@
<!-- STEP 4: SIGNING -->
<div id="pqSignStep" class="pq-card pq-hidden">
<div class="pq-card-title">Sign Migration Event</div>
<div class="pq-card-title">Sign Key-Link Event</div>
<div class="pq-info-text">
We'll now create two events: a kind 1 announcement (public, visible in Nostr feeds) and
a kind 9999 proof carrier (carries the OpenTimestamps proof). Each PQ key signs the attestation
statement, and your signer signs both events with your secp256k1 key. The SHA-256 of the
full kind 1 event (including its secp256k1 signature) is submitted to OpenTimestamps.
We'll now create two events: a public announcement (kind 1, visible in Nostr feeds) and
a proof carrier (kind 9999, which holds the Bitcoin timestamp). Each post-quantum key signs
the announcement text, and your signer signs both events with your current Nostr key. The
hash of the signed announcement is then sent to OpenTimestamps to be anchored in Bitcoin.
</div>
<div id="pqSignStatus"></div>
<button class="pq-button" id="pqSignBtn">Sign Event</button>
@@ -517,9 +549,6 @@
<div id="pqEventIdDisplay" style="margin: 5px 0; font-size: 11px; color: var(--primary-color); word-break: break-all;"></div>
<div class="pq-event-preview" id="pqSuccessEventPreview"></div>
<div class="pq-info-text" id="pqEventSizeWarning" style="display: none; font-size: 13px; margin-top: 8px;"></div>
<div class="pq-button-row" style="margin-top: 10px;">
<button class="pq-button pq-button-secondary" id="pqCopyProofCarrierBtn">Copy Proof Carrier Event</button>
</div>
<div style="margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Relays to publish to:</strong></div>
@@ -545,10 +574,10 @@
<div id="pqOtsStep" class="pq-card pq-hidden">
<div class="pq-card-title">OpenTimestamps Attestation - Leave This Page Open</div>
<div class="pq-info-text">
Your event hash has already been submitted to OpenTimestamps in the previous step, and a
<strong>pending</strong> timestamp proof is embedded in the kind 9999 event you just published.
OpenTimestamps anchors that hash into the Bitcoin blockchain, proving your event existed at this
point in time — preventing a future quantum attacker from backdating a fraudulent event.
Your event's hash was already sent to OpenTimestamps in the previous step, and a
<strong>pending</strong> timestamp proof is embedded in the event you just published.
OpenTimestamps anchors that hash into the Bitcoin blockchain, proving your event existed at
this moment — so a future quantum attacker can't fake an older event and claim it came first.
</div>
<div class="pq-info-text">
Bitcoin confirmation typically takes <strong>10-30 minutes</strong>. You can
@@ -574,7 +603,7 @@
<span>Pending proof embedded in kind 9999 event</span>
</div>
<div class="pq-checklist-item pq-active" id="pqOtsConfirm">
<span class="pq-checklist-box"></span>
<span class="pq-checklist-box" id="pqOtsConfirmBox"></span>
<span>Wait for Bitcoin confirmation
<div class="pq-key-meta" id="pqOtsConfirmDetail" style="margin-top: 2px;">Polling every 60 seconds...</div>
</span>
@@ -584,11 +613,15 @@
<span>Publish new kind 9999 with confirmed proof — automatic on confirmation</span>
</div>
</div>
<div id="pqOtsProofWrap" class="pq-hidden" style="margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 5px;">OTS proof file (base64):</div>
<div class="pq-event-preview" id="pqOtsProof" style="max-height: 150px;"></div>
<div class="pq-button-row" style="margin-top: 10px;">
<button class="pq-button pq-button-secondary" id="pqOtsDownloadBtn">Download .ots file</button>
<div id="pqOtsArchiveWrap" class="pq-hidden" style="margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 8px;">
Bitcoin confirmation verified and the upgraded proof carrier has been published to relays.
Download the upgraded proof archive below — it contains the kind 1 event, the upgraded kind 9999
proof carrier, and the <strong>confirmed</strong> OTS proof (with Bitcoin attestation). Keep a copy
in case relays delete the event.
</div>
<div class="pq-button-row">
<button class="pq-button pq-button-secondary" id="pqOtsArchiveBtn">Download Upgraded Proof Archive</button>
</div>
</div>
</div>
@@ -596,12 +629,6 @@
</div>
</div>
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
</div>
<!-- SCRIPTS -->
<script src="/nostr-login-lite/nostr.bundle.js" integrity="sha384-LNnPDD++DaWxljIhMLmfaoEoKB0B1HmACC6gNGLG+Qnmosprf/AX7u84pVY8xMpM" crossorigin="anonymous"></script>
<script src="/nostr-login-lite/nostr-lite.js" integrity="sha384-IQwa65eDC5trGjKn4cuEazmFiHTHD65gIqsjpzDtouc+j0MlyI927SZgHMWSi2aC" crossorigin="anonymous"></script>
+161 -36
View File
@@ -16,6 +16,7 @@
bytesToBase64,
base64ToBytes,
bytesToHex,
hexToNpub,
PQ_KEY_INFO,
NIP_QR_KIND,
buildUpgradedEvent,
@@ -85,7 +86,7 @@
if (el) {
el.classList.add('pq-done');
el.classList.remove('pq-active');
el.querySelector('.pq-checklist-box').textContent = '';
clearStepSpinner(stepNum);
}
}
@@ -101,10 +102,66 @@
const el = stepElements[stepNum];
if (el) {
el.classList.remove('pq-active', 'pq-done');
el.querySelector('.pq-checklist-box').textContent = '';
clearStepSpinner(stepNum);
}
}
/* Braille spinner: shown in a step's checklist box while that step is
actively working (e.g. step 6 waiting for Bitcoin confirmation).
Advances through the standard braille spinner frames every 80ms. */
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const stepSpinnerIntervals = {};
function setStepSpinner(stepNum) {
const el = stepElements[stepNum];
if (!el) return;
const box = el.querySelector('.pq-checklist-box');
if (!box) return;
clearStepSpinner(stepNum);
el.classList.add('pq-active');
el.classList.remove('pq-done');
box.classList.add('pq-spinner');
let frame = 0;
box.textContent = SPINNER_FRAMES[0];
stepSpinnerIntervals[stepNum] = setInterval(() => {
frame = (frame + 1) % SPINNER_FRAMES.length;
box.textContent = SPINNER_FRAMES[frame];
}, 80);
}
function clearStepSpinner(stepNum) {
const el = stepElements[stepNum];
if (el) {
const box = el.querySelector('.pq-checklist-box');
if (box) { box.classList.remove('pq-spinner'); box.textContent = ''; }
}
if (stepSpinnerIntervals[stepNum]) {
clearInterval(stepSpinnerIntervals[stepNum]);
delete stepSpinnerIntervals[stepNum];
}
}
/* Braille spinner on the "Wait for Bitcoin confirmation" checklist box
(pqOtsConfirm). Same frames as the step-6 checklist-box spinner. */
let otsConfirmSpinnerInterval = null;
function startOtsConfirmSpinner() {
const box = document.getElementById('pqOtsConfirmBox');
if (!box) return;
stopOtsConfirmSpinner();
box.classList.add('pq-spinner');
let frame = 0;
box.textContent = SPINNER_FRAMES[0];
otsConfirmSpinnerInterval = setInterval(() => {
frame = (frame + 1) % SPINNER_FRAMES.length;
box.textContent = SPINNER_FRAMES[frame];
}, 80);
}
function stopOtsConfirmSpinner() {
if (otsConfirmSpinnerInterval) { clearInterval(otsConfirmSpinnerInterval); otsConfirmSpinnerInterval = null; }
const box = document.getElementById('pqOtsConfirmBox');
if (box) { box.classList.remove('pq-spinner'); box.textContent = ''; }
}
/* ================================================================
VIEW MANAGEMENT
================================================================ */
@@ -556,11 +613,25 @@
setStatus(pqSignStatus, 'info', ' Building kind 1 announcement event...');
const kind1Template = buildKind1Announcement(currentPubkey, blockHeight, pqKeys);
// Strip the non-NIP-01 helper field 'statementBytes' before sending to
// the signer. buildKind1Announcement includes it for internal use, but
// it is not a valid NIP-01 event field and validateSignerOutput (G56-05)
// rejects extra fields. The signer must only see the 6 NIP-01 template
// fields (pubkey, created_at, kind, tags, content — id/sig are added by
// the signer).
const kind1SignerTemplate = {
pubkey: kind1Template.pubkey,
created_at: kind1Template.created_at,
kind: kind1Template.kind,
tags: kind1Template.tags,
content: kind1Template.content
};
setStatus(pqSignStatus, 'info', 'PQ signatures complete. Requesting secp256k1 signature for kind 1 announcement...');
const kind1Signed = await window.nostr.signEvent(kind1Template);
const kind1Signed = await window.nostr.signEvent(kind1SignerTemplate);
// G56-05: validate signer output — never reconstruct from mixed fields
kind1Event = validateSignerOutput(kind1Signed, kind1Template, currentPubkey);
kind1Event = validateSignerOutput(kind1Signed, kind1SignerTemplate, currentPubkey);
otsLog(`Kind 1 announcement signed. Event ID: ${kind1Event.id.substring(0, 32)}...`);
// ---- Phase 2: Timestamp the canonical kind 1 event digest (F-D1) ----
@@ -705,8 +776,8 @@
RELAY LIST MANAGEMENT
================================================================ */
const DEFAULT_RELAYS = [
'wss://laantungir.net/relay',
'wss://relay.damus.io',
'wss://laantungir.net/relay/',
'wss://sendit.nosflare.com/',
'wss://nos.lol',
'wss://relay.primal.net'
];
@@ -980,22 +1051,14 @@
if (e.key === 'Enter') { e.preventDefault(); document.getElementById('pqRelayAddBtn').click(); }
});
// Copy Proof Carrier Event button
document.getElementById('pqCopyProofCarrierBtn').addEventListener('click', () => {
if (!pqEvent) return;
navigator.clipboard.writeText(JSON.stringify(pqEvent, null, 2)).then(() => {
const btn = document.getElementById('pqCopyProofCarrierBtn');
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy Proof Carrier Event'; }, 2000);
});
});
// Sign out button
const pqSignOutBtn = document.getElementById('pqSignOutBtn');
pqSignOutBtn.addEventListener('click', async () => {
console.log('[post-quantum] Signing out...');
// Stop OTS polling, but preserve its persisted workflow for the next login.
if (otsPollInterval) { clearInterval(otsPollInterval); otsPollInterval = null; }
clearStepSpinner(6);
stopOtsConfirmSpinner();
pendingOtsBytes = null;
const persistedOts = localStorage.getItem('pq-pending-ots');
// Logout from nostr-login-lite
@@ -1092,6 +1155,8 @@
if (otsLogEl) otsLogEl.innerHTML = '';
setKeyIcon('pqOtsConfirm', '');
setKeyIcon('pqOtsConfirmedPublish', '');
stopOtsConfirmSpinner();
hideUpgradedArchiveButton();
// Check for a saved workflow from a previous session (same event ID)
let existing = loadPendingOts();
@@ -1110,10 +1175,10 @@
setStepDone(6);
setStepActive(7);
otsStatus.innerHTML = '<div class="pq-status pq-status-success">Confirmed timestamp proof was already published.</div>';
showOtsProof(pendingOtsBytes);
showUpgradedArchiveButton();
return;
}
showOtsProof(pendingOtsBytes);
hideUpgradedArchiveButton();
setKeyIcon('pqOtsPendingPublish', 'Done');
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof is embedded in the kind 9999 event. Waiting for Bitcoin confirmation...</div>';
startOtsPolling();
@@ -1128,7 +1193,6 @@
pendingOtsBytes = base64ToBytes(otsTag[1]);
if (isDetachedOtsFile(pendingOtsBytes)) {
otsLog(`Found pending OTS proof embedded in event (${pendingOtsBytes.length} bytes).`);
showOtsProof(pendingOtsBytes);
savePendingOts(pqEvent.id, pendingOtsBytes, {
ownerPubkey: currentPubkey,
targetKind: pqEvent.kind,
@@ -1164,8 +1228,6 @@
const otsBytes = await timestampEvent(fullHash);
pendingOtsBytes = otsBytes;
otsLog(`Created pending detached .ots file (${otsBytes.length} bytes).`);
showOtsProof(otsBytes);
// Republish the event with the OTS proof embedded
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Publishing new kind 9999 event with embedded OTS proof...</div>';
await publishUpgradedEvent(otsBytes);
@@ -1188,17 +1250,28 @@
}
}
function showOtsProof(otsBytes) {
const proofWrap = document.getElementById('pqOtsProofWrap');
const proofEl = document.getElementById('pqOtsProof');
proofEl.textContent = bytesToBase64(otsBytes);
proofWrap.classList.remove('pq-hidden');
// The raw OTS proof base64 display and standalone .ots download have been
// removed from step 6. Instead, after confirmation + publish, the user is
// offered an upgraded proof archive (kind 1 + upgraded kind 9999 + confirmed
// OTS proof) via showUpgradedArchiveButton().
function showUpgradedArchiveButton() {
const wrap = document.getElementById('pqOtsArchiveWrap');
if (wrap) wrap.classList.remove('pq-hidden');
}
function hideUpgradedArchiveButton() {
const wrap = document.getElementById('pqOtsArchiveWrap');
if (wrap) wrap.classList.add('pq-hidden');
}
function startOtsPolling() {
const confirmDetail = document.getElementById('pqOtsConfirmDetail');
let pollCount = 0;
if (otsPollInterval) clearInterval(otsPollInterval);
// Show a braille spinner in the step-6 checklist box and inline next
// to the "Wait for Bitcoin confirmation" label so the user can see
// the page is still actively working while waiting for Bitcoin.
setStepSpinner(6);
startOtsConfirmSpinner();
async function poll() {
pollCount++;
@@ -1213,8 +1286,6 @@
proofCarrierEvent: pqEvent,
kind1Event
});
showOtsProof(pendingOtsBytes);
// Run full cryptographic verification: parse the proof, bind the
// target digest to the event's sha256 tag, walk the Merkle path,
// and check the block header against a Bitcoin API.
@@ -1231,13 +1302,14 @@
: verifyResult.trustMode || 'unknown';
otsLog(`Poll ${pollCount}: Bitcoin attestation VERIFIED (block ${att ? att.height : '?'}, mined ${date} UTC, trust: ${trustLabel}). Proof: ${pendingOtsBytes.length} bytes.`);
setKeyIcon('pqOtsConfirm', 'Done');
stopOtsConfirmSpinner();
confirmDetail.textContent = `Verified on Bitcoin (block ${att ? att.height : '?'}, ${trustLabel})`;
if (otsPollInterval) { clearInterval(otsPollInterval); otsPollInterval = null; }
const saved = loadPendingOts();
if (!saved || !saved.confirmedPublished) {
setStatus(document.getElementById('pqOtsStatus'), 'success', 'Bitcoin attestation verified. Publishing new kind 9999 with confirmed proof...');
await publishUpgradedEvent(pendingOtsBytes);
await publishUpgradedEvent(pendingOtsBytes, att ? att.height : null, att ? att.time : null);
}
} else if (isOtsConfirmed(pendingOtsBytes)) {
// Structural check found a Bitcoin attestation tag but full
@@ -1269,12 +1341,12 @@
otsPollInterval = setInterval(poll, 60000);
}
async function publishUpgradedEvent(upgradedOtsBytes) {
async function publishUpgradedEvent(upgradedOtsBytes, bitcoinHeight = null, bitcoinTime = null) {
const otsStatus = document.getElementById('pqOtsStatus');
try {
otsLog('Building upgraded proof carrier with confirmed OTS proof...');
const savedWorkflow = loadPendingOts();
const configuredRelays = getRelayUrlsString() || (savedWorkflow && savedWorkflow.relayUrls) || 'wss://laantungir.net/relay';
const configuredRelays = getRelayUrlsString() || (savedWorkflow && savedWorkflow.relayUrls) || 'wss://laantungir.net/relay/';
// Build the upgraded event: copy all tags from the original, replace the ots tag
const upgradedTemplate = buildUpgradedEvent(pqEvent, upgradedOtsBytes);
@@ -1304,9 +1376,50 @@
kind1Event
});
// Publish a kind 1 reply announcing the confirmed attestation.
// This is a regular Nostr text note (reply to the original kind 1
// announcement) so it shows up in feeds/thread views. It only needs
// the secp256k1 signature — no PQ signatures.
if (kind1Event && kind1Event.id) {
try {
otsLog('Publishing kind 1 reply announcing confirmed attestation...');
const npub = hexToNpub(currentPubkey);
const dateStr = bitcoinTime
? new Date(bitcoinTime * 1000).toISOString().substring(0, 19)
: 'unknown';
const blockStr = bitcoinHeight ? `Bitcoin block ${bitcoinHeight} (mined ${dateStr} UTC)` : 'a Bitcoin block';
const replyContent = `✅ The OpenTimestamps proof for my post-quantum key attestation has been confirmed in ${blockStr}. The link between my current Nostr identity and my post-quantum keys is now anchored to the Bitcoin blockchain and cannot be backdated.
Verify it here: https://laantungir.net/quantum-prep/verify.html?npub=${npub}
This is a reply to my original attestation announcement. The confirmed proof is carried in a new kind 9999 event referencing the original.`;
const replyTemplate = {
kind: 1,
content: replyContent,
tags: [
['e', kind1Event.id, '', 'root'],
['e', validatedEvent.id, '', 'mention']
],
pubkey: currentPubkey,
created_at: Math.floor(Date.now() / 1000)
};
const replySigned = await window.nostr.signEvent(replyTemplate);
const replyValidated = validateSignerOutput(replySigned, replyTemplate, currentPubkey);
const replyResults = await publishToRelays(replyValidated, relayUrls);
const replyOk = replyResults.filter(r => r.success).length;
const replyFail = replyResults.filter(r => !r.success).length;
otsLog(`Kind 1 reply published. Successful: ${replyOk}; failed: ${replyFail}.`);
} catch (replyErr) {
// The upgraded proof carrier is already published; a failed reply
// is non-fatal — log it but don't fail the whole operation.
otsLog(`WARNING: kind 1 reply failed: ${replyErr.message}`);
}
}
setStatus(otsStatus, 'success', `Upgraded proof carrier published to ${successCount} relays. Timestamping complete.`);
setStepDone(6);
setStepActive(7);
showUpgradedArchiveButton();
return validatedEvent;
} catch (error) {
otsLog(`ERROR publishing upgraded event: ${error.message}`);
@@ -1315,16 +1428,28 @@
}
}
// OTS event listeners
document.getElementById('pqOtsDownloadBtn').addEventListener('click', () => {
if (pendingOtsBytes) {
const blob = new Blob([pendingOtsBytes], { type: 'application/octet-stream' });
// OTS event listener: download the upgraded proof archive (kind 1 event +
// upgraded kind 9999 proof carrier + confirmed OTS proof) after Bitcoin
// confirmation and publish.
document.getElementById('pqOtsArchiveBtn').addEventListener('click', () => {
if (!kind1Event || !pqEvent || !pendingOtsBytes) {
console.warn('[post-quantum] Cannot build upgraded archive: missing kind1Event, proofCarrier, or OTS proof');
return;
}
try {
const archiveJson = buildProofArchive(kind1Event, pqEvent, pendingOtsBytes);
const blob = new Blob([archiveJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${pqEvent.id.substring(0, 16)}.ots`;
a.download = `nostr-pq-proof-${pqEvent.id.substring(0, 12)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log('[post-quantum] Upgraded proof archive downloaded');
} catch (e) {
console.error('[post-quantum] Failed to build upgraded archive:', e);
}
});
+3 -2
View File
@@ -638,8 +638,9 @@ Post-quantum public keys in tags:
Each post-quantum key has cryptographically signed this attestation. This event is pending timestamp on the Bitcoin blockchain via OpenTimestamps.
Verify this attestation: https://laantungir.net/post-quantum/verify.html
Created at: https://laantungir.net/post-quantum/`;
Verify this attestation: https://laantungir.net/quantum-prep/verify.html?npub=${npub}
Created at: https://laantungir.net/quantum-prep/`;
const statementBytes = new TextEncoder().encode(content);
+408 -67
View File
@@ -22,19 +22,26 @@
================================================================ */
const tabRelay = document.getElementById('pqTabRelay');
const tabPaste = document.getElementById('pqTabPaste');
const tabSignIn = document.getElementById('pqTabSignIn');
const panelRelay = document.getElementById('pqPanelRelay');
const panelPaste = document.getElementById('pqPanelPaste');
const panelSignIn = document.getElementById('pqPanelSignIn');
function switchTab(which) {
const isRelay = which === 'relay';
const isPaste = which === 'paste';
const isSignIn = which === 'signin';
tabRelay.classList.toggle('pq-tab-active', isRelay);
tabPaste.classList.toggle('pq-tab-active', !isRelay);
tabPaste.classList.toggle('pq-tab-active', isPaste);
tabSignIn.classList.toggle('pq-tab-active', isSignIn);
panelRelay.classList.toggle('pq-tab-panel-active', isRelay);
panelPaste.classList.toggle('pq-tab-panel-active', !isRelay);
panelPaste.classList.toggle('pq-tab-panel-active', isPaste);
panelSignIn.classList.toggle('pq-tab-panel-active', isSignIn);
}
tabRelay.addEventListener('click', () => switchTab('relay'));
tabPaste.addEventListener('click', () => switchTab('paste'));
tabSignIn.addEventListener('click', () => switchTab('signin'));
/* ================================================================
SHARED DOM + STATE
@@ -42,19 +49,56 @@
const resultsWrap = document.getElementById('pqResultsWrap');
const resultList = document.getElementById('pqResultList');
const eventJsonWrap = document.getElementById('pqEventJsonWrap');
const downloadEventBtn = document.getElementById('pqDownloadEventBtn');
const eventJsonEl = document.getElementById('pqEventJson');
const otsBadge = document.getElementById('pqOtsBadge');
const otsUpgradeWrap = document.getElementById('pqOtsUpgradeWrap');
const otsInfo = document.getElementById('pqOtsInfo');
const otsUpgradeBtn = document.getElementById('pqOtsUpgradeBtn');
const otsRepublishBtn = document.getElementById('pqOtsRepublishBtn');
const otsUpgradeStatus = document.getElementById('pqOtsUpgradeStatus');
const summaryWrap = document.getElementById('pqSummaryWrap');
const summaryCard = document.getElementById('pqSummaryCard');
// OTS cards are created dynamically and appended into the shared result
// list so they flow as part of the same card list as the signature checks.
let otsStampCard = null;
let otsUpgradedCard = null;
let currentEvent = null;
let currentOtsBytes = null;
let currentExpectedAuthor = null;
/* Braille spinner: shown inside a button while its action is running
(e.g. Query & Verify). Advances through the standard braille spinner
frames every 80ms, matching the spinners on the preparation page. */
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const buttonSpinnerIntervals = new Map();
function setButtonSpinner(btn) {
if (!btn) return;
clearButtonSpinner(btn);
let span = btn.querySelector('.pq-btn-spinner');
if (!span) {
span = document.createElement('span');
span.className = 'pq-btn-spinner';
btn.insertBefore(span, btn.firstChild);
}
let frame = 0;
span.textContent = SPINNER_FRAMES[0];
buttonSpinnerIntervals.set(btn, setInterval(() => {
frame = (frame + 1) % SPINNER_FRAMES.length;
span.textContent = SPINNER_FRAMES[frame];
}, 80));
}
function clearButtonSpinner(btn) {
if (!btn) return;
if (buttonSpinnerIntervals.has(btn)) {
clearInterval(buttonSpinnerIntervals.get(btn));
buttonSpinnerIntervals.delete(btn);
}
const span = btn.querySelector('.pq-btn-spinner');
if (span) span.remove();
}
// F-H3: Build status DOM safely — type is internally controlled, message uses textContent
function setStatus(element, type, message) {
const div = document.createElement('div');
@@ -71,13 +115,70 @@
resultList.appendChild(item);
}
// F-H3: Helper to set OTS badge (static HTML, safe) and info (textContent, safe)
function setOtsBadge(className, text) {
otsBadge.innerHTML = `<span class="pq-ots-badge ${className}"></span>`;
otsBadge.querySelector('span').textContent = text;
// OTS state tracked for the summary card.
let otsStampState = { valid: null, label: '', detail: '' };
let otsUpgradedState = { valid: null, label: '', detail: '' };
// Render an OTS card (stamp or upgraded). The card is created on demand
// and appended to the shared result list so it flows with the signature
// checks. `which` is 'stamp' or 'upgraded'; `state` is one of
// 'valid' | 'invalid' | 'pending'.
function setOtsCard(which, state, head, body) {
const card = which === 'upgraded' ? otsUpgradedCard : otsStampCard;
if (!card) {
// Create the card and append it to the result list.
const el = document.createElement('div');
el.className = 'pq-result-item pq-result-detail';
resultList.appendChild(el);
if (which === 'upgraded') otsUpgradedCard = el; else otsStampCard = el;
}
const target = which === 'upgraded' ? otsUpgradedCard : otsStampCard;
target.style.display = 'block';
target.classList.remove('pq-result-valid', 'pq-result-invalid', 'pq-result-pending');
if (state === 'valid') target.classList.add('pq-result-valid');
else if (state === 'invalid') target.classList.add('pq-result-invalid');
else if (state === 'pending') target.classList.add('pq-result-pending');
// F-H3: build DOM safely — head and body use textContent
target.innerHTML = '';
const headEl = document.createElement('div');
headEl.className = 'pq-result-head';
headEl.textContent = head;
const bodyEl = document.createElement('div');
bodyEl.className = 'pq-result-body';
bodyEl.textContent = body;
target.appendChild(headEl);
target.appendChild(bodyEl);
}
function setOtsInfo(text) {
otsInfo.textContent = text;
function hideOtsCard(which) {
const card = which === 'upgraded' ? otsUpgradedCard : otsStampCard;
if (card) { card.remove(); }
if (which === 'upgraded') otsUpgradedCard = null; else otsStampCard = null;
}
// Render the final summary card based on signature validity and OTS state.
function renderSummary(allValid) {
const stampOk = otsStampState.valid === true;
const upgradedOk = otsUpgradedState.valid === true;
const anyOtsConfirmed = stampOk || upgradedOk;
summaryWrap.style.display = 'block';
summaryCard.classList.remove('pq-result-valid', 'pq-result-invalid', 'pq-result-pending');
if (allValid && anyOtsConfirmed) {
summaryCard.classList.add('pq-result-valid');
const anchor = upgradedOk
? (otsUpgradedState.label || 'the upgraded proof')
: (otsStampState.label || 'the original proof');
summaryCard.textContent = `All signatures are verified and valid, and the event is stamped on the Bitcoin blockchain (via ${anchor}).`;
} else if (allValid && otsStampState.valid === 'pending') {
summaryCard.classList.add('pq-result-pending');
summaryCard.textContent = 'All signatures are verified and valid. The OpenTimestamps proof is pending — not yet stamped on the Bitcoin blockchain. You can upgrade it below.';
} else if (allValid) {
summaryCard.classList.add('pq-result-invalid');
summaryCard.textContent = 'All signatures are verified and valid, but no Bitcoin timestamp attestation was found.';
} else {
summaryCard.classList.add('pq-result-invalid');
summaryCard.textContent = 'Some checks failed verification. See the results above for details.';
}
}
/* ================================================================
@@ -168,9 +269,13 @@
resultsWrap.style.display = 'none';
eventJsonWrap.style.display = 'none';
otsUpgradeWrap.style.display = 'none';
otsBadge.innerHTML = '';
hideOtsCard('stamp');
hideOtsCard('upgraded');
summaryWrap.style.display = 'none';
otsStampState = { valid: null, label: '', detail: '' };
otsUpgradedState = { valid: null, label: '', detail: '' };
// Display full proof carrier JSON
// Display full proof carrier JSON at the top of the page
eventJsonEl.textContent = JSON.stringify(event, null, 2);
eventJsonWrap.style.display = 'block';
@@ -231,9 +336,10 @@
}
// 3. Inspect OTS proof tag and verify it matches the sha256 tag.
// First do a quick structural check for immediate UI feedback, then
// run full cryptographic verification (async — fetches Bitcoin block
// headers and validates the Merkle path).
// The OTS result is rendered as a dedicated card (pqOtsStampCard)
// alongside the signature results. If the proof is pending, an
// upgrade card (pqOtsUpgradedCard) is reserved for a later upgraded
// proof. A final summary card is rendered at the end.
const otsTag = event.tags.find(t => t[0] === 'ots');
const sha256Tag = event.tags.find(t => t[0] === 'sha256');
if (otsTag && otsTag[1]) {
@@ -248,17 +354,27 @@
: `WARNING: sha256 tag (${sha256Tag[1].substring(0, 16)}...) does not match computed hash (${fullHash.substring(0, 16)}...)`)
: 'No sha256 tag found';
// Quick structural display first
otsUpgradeWrap.style.display = 'block';
if (!validFile) {
setOtsBadge('pq-ots-badge-none', 'OTS: Invalid format');
setOtsInfo(`OTS tag present but does not appear to be a valid detached .ots file (${currentOtsBytes.length} bytes). ${hashMatchNote}.`);
otsUpgradeWrap.style.display = 'none';
} else if (hasBitcoinAttestation) {
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verifying...');
setOtsInfo(`OpenTimestamps proof contains a Bitcoin attestation. Performing full cryptographic verification (fetching block header)... Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
otsUpgradeWrap.style.display = 'block';
// Invalid format — no upgrade possible
otsStampState = { valid: false, label: '', detail: '' };
setOtsCard('stamp', 'invalid', 'OTS Stamp: Invalid format',
`OTS tag present but does not appear to be a valid detached .ots file (${currentOtsBytes.length} bytes). ${hashMatchNote}.`);
hideOtsCard('upgraded');
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'none';
renderSummary(allValid);
} else if (hasBitcoinAttestation) {
// Contains a Bitcoin attestation — run full async verification.
// Show a "verifying" stamp card first, then update it.
otsStampState = { valid: null, label: '', detail: '' };
setOtsCard('stamp', 'pending', 'OTS Stamp: Verifying...',
`OpenTimestamps proof contains a Bitcoin attestation. Performing full cryptographic verification (fetching block header)... Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
hideOtsCard('upgraded');
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'none';
renderSummary(allValid);
// Run full async verification: parse the proof, bind the target
// digest to the sha256 tag, walk the Merkle path, and check the
@@ -272,42 +388,117 @@
: result.trustMode === 'single-explorer-checked'
? 'single-explorer-checked (trusted API)'
: result.trustMode || 'unknown';
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
setOtsInfo(`OpenTimestamps proof verified. Bitcoin block ${att.height} (mined ${date} UTC). Merkle root matches. Trust mode: ${trustLabel} — block header is trusted from explorer API(s), not independently verified against PoW. Proof commits to the event hash. Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
otsStampState = {
valid: true,
label: `Bitcoin block ${att.height}`,
detail: `OpenTimestamps proof verified. Bitcoin block ${att.height} (mined ${date} UTC). Merkle root matches. Trust mode: ${trustLabel} — block header is trusted from explorer API(s), not independently verified against PoW. Proof commits to the event hash. Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`
};
setOtsCard('stamp', 'valid', 'OTS Stamp: Verified (Bitcoin)', otsStampState.detail);
otsUpgradeBtn.style.display = 'none';
renderSummary(allValid);
} else {
setOtsBadge('pq-ots-badge-none', 'OTS: Verification failed');
const errDetail = result.errors.length > 0 ? ` Errors: ${result.errors.join('; ')}` : '';
setOtsInfo(`OpenTimestamps proof could NOT be cryptographically verified. ${hashMatchNote}.${errDetail}`);
otsStampState = { valid: false, label: '', detail: '' };
setOtsCard('stamp', 'invalid', 'OTS Stamp: Verification failed',
`OpenTimestamps proof could NOT be cryptographically verified. ${hashMatchNote}.${errDetail}`);
otsUpgradeBtn.style.display = 'inline-block';
renderSummary(allValid);
}
}).catch(err => {
setOtsBadge('pq-ots-badge-none', 'OTS: Verification error');
setOtsInfo(`OpenTimestamps verification error: ${err.message}. ${hashMatchNote}.`);
otsStampState = { valid: false, label: '', detail: '' };
setOtsCard('stamp', 'invalid', 'OTS Stamp: Verification error',
`OpenTimestamps verification error: ${err.message}. ${hashMatchNote}.`);
otsUpgradeBtn.style.display = 'inline-block';
renderSummary(allValid);
});
} else {
setOtsBadge('pq-ots-badge-pending', 'OTS: Pending');
setOtsInfo(`OpenTimestamps proof is pending (no Bitcoin attestation yet). Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}. You can try upgrading it below.`);
otsUpgradeWrap.style.display = 'block';
// Pending — no Bitcoin attestation yet. Reserve the upgraded card
// for when the user upgrades the proof below.
otsStampState = { valid: 'pending', label: '', detail: '' };
setOtsCard('stamp', 'pending', 'OTS Stamp: Pending',
`OpenTimestamps proof is pending (no Bitcoin attestation yet). Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}. You can try upgrading it below.`);
hideOtsCard('upgraded');
otsUpgradeBtn.style.display = 'inline-block';
otsRepublishBtn.style.display = 'none';
renderSummary(allValid);
}
} catch (e) {
setOtsBadge('pq-ots-badge-none', 'OTS: Error');
setOtsInfo(`Failed to parse OTS proof: ${e.message}`);
otsUpgradeWrap.style.display = 'none';
otsStampState = { valid: false, label: '', detail: '' };
setOtsCard('stamp', 'invalid', 'OTS Stamp: Error',
`Failed to parse OTS proof: ${e.message}`);
hideOtsCard('upgraded');
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'none';
renderSummary(allValid);
}
} else {
setOtsBadge('pq-ots-badge-none', 'OTS: None');
setOtsInfo('No OpenTimestamps proof tag found in this event.');
otsStampState = { valid: false, label: '', detail: '' };
setOtsCard('stamp', 'invalid', 'OTS Stamp: None',
'No OpenTimestamps proof tag found in this event.');
hideOtsCard('upgraded');
otsUpgradeWrap.style.display = 'none';
renderSummary(allValid);
}
resultsWrap.style.display = 'block';
// Only the event owner (signed-in pubkey matches event pubkey) may
// upgrade/republish. Non-owners see the verification result but the
// upgrade action is hidden to avoid confusion and endpoint abuse.
applyOwnershipGate(event);
return allValid;
}
/* ================================================================
OWNERSHIP GATE: only the event owner can upgrade/republish the OTS
proof. Non-owners see the verification result but the upgrade action
buttons are hidden and replaced with an explanatory note.
================================================================ */
// Cache the signed-in pubkey so we don't call getPublicKey repeatedly.
let cachedSignedInPubkey = null;
let cachedSignedInPubkeyResolved = false;
async function getSignedInPubkey() {
if (cachedSignedInPubkeyResolved) return cachedSignedInPubkey;
cachedSignedInPubkeyResolved = true;
if (!window.nostr || !window.nostr.getPublicKey) return null;
try {
cachedSignedInPubkey = await window.nostr.getPublicKey();
} catch (e) {
cachedSignedInPubkey = null;
}
return cachedSignedInPubkey;
}
function applyOwnershipGate(event) {
// Default: hide the upgrade action row until we confirm ownership.
const actionRow = otsUpgradeWrap.querySelector('.pq-button-row');
if (!actionRow) return;
getSignedInPubkey().then(signedInPubkey => {
const isOwner = signedInPubkey && event && event.pubkey &&
signedInPubkey.toLowerCase() === event.pubkey.toLowerCase();
if (isOwner) {
actionRow.style.display = '';
const note = document.getElementById('pqOtsOwnerNote');
if (note) note.remove();
} else {
// Non-owner (or not signed in): hide the buttons and show a note.
actionRow.style.display = 'none';
let note = document.getElementById('pqOtsOwnerNote');
if (!note) {
note = document.createElement('div');
note.id = 'pqOtsOwnerNote';
note.className = 'pq-info-text';
note.style.fontSize = '13px';
note.style.marginTop = '8px';
otsUpgradeWrap.appendChild(note);
}
note.textContent = signedInPubkey
? 'Only the event owner can upgrade and republish the OpenTimestamps proof. You are signed in as a different identity.'
: 'Only the event owner can upgrade and republish the OpenTimestamps proof. Sign in with the Nostr identity that published this event to enable upgrading.';
}
});
}
/* ================================================================
TAB 1: QUERY RELAY
================================================================ */
@@ -316,6 +507,7 @@
queryBtn.addEventListener('click', async () => {
queryBtn.disabled = true;
setButtonSpinner(queryBtn);
setStatus(queryStatus, 'info', 'Querying relays...');
const pubkeyInput = document.getElementById('pqPubkeyInput').value.trim();
@@ -325,6 +517,7 @@
if (!pubkeyHex) {
setStatus(queryStatus, 'error', 'Invalid pubkey. Enter a 64-char hex pubkey or an npub.');
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
return;
}
@@ -332,6 +525,7 @@
if (relayUrls.length === 0) {
setStatus(queryStatus, 'error', 'Enter at least one relay URL.');
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
return;
}
@@ -367,6 +561,7 @@
if (allCandidates.length === 0) {
setStatus(queryStatus, 'error', `No proof carrier events found for this pubkey on any of the ${relayUrls.length} relay(s)${lastError ? ' (last error: ' + lastError + ')' : ''}.`);
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
return;
}
@@ -391,6 +586,7 @@
if (!kind1Event) {
setStatus(queryStatus, 'error', 'Could not parse kind 1 announcement from any candidate.');
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
return;
}
@@ -410,7 +606,9 @@
const trustModeNote = selection.canonicalTrustMode === 'pending-only'
? ' ⚠ PENDING-ONLY — no Bitcoin attestation has been verified yet; this is not a trust decision.'
: (selection.canonicalTrustMode === 'none' ? ' ⚠ no trustable anchor found.' : '');
const statusClass = selection.canonicalTrustMode === 'pending-only' ? 'error' : 'success';
// F-D4/L-2: a pending-only selection is informational, not an error —
// it is not a trust decision, so do not render it in the error (red) style.
const statusClass = selection.canonicalTrustMode === 'pending-only' ? 'info' : 'success';
setStatus(queryStatus, statusClass, `Selected canonical proof carrier: ${selection.canonical.id.substring(0, 16)}... (${heightNote}). ${confirmedCount} confirmed, ${pendingCount} pending, ${allCandidates.length} total candidate(s).${trustModeNote}`);
await verifyEvent(selection.canonical);
} else {
@@ -421,6 +619,7 @@
}
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
});
/* ================================================================
@@ -489,22 +688,6 @@
});
}
/* ================================================================
DOWNLOAD EVENT JSON
================================================================ */
downloadEventBtn.addEventListener('click', () => {
if (!currentEvent) return;
const blob = new Blob([JSON.stringify(currentEvent, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (currentEvent.id ? currentEvent.id.substring(0, 16) : 'event') + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
/* ================================================================
OTS UPGRADE (from verify page)
================================================================ */
@@ -533,10 +716,20 @@
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Trust mode: ${trustLabel}. Proof upgraded (${currentOtsBytes.length} bytes). You can publish a new kind 9999 event carrying the confirmed proof below; it will reference the original via an upgrade_of tag.`);
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'inline-block';
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
setOtsInfo(`OpenTimestamps proof verified. Bitcoin block ${att ? att.height : '?'}. Trust mode: ${trustLabel}. Proof size: ${currentOtsBytes.length} bytes.`);
// Render the upgraded proof as a second OTS card and refresh summary.
otsUpgradedState = {
valid: true,
label: `Bitcoin block ${att ? att.height : '?'}`,
detail: `OpenTimestamps proof verified. Bitcoin block ${att ? att.height : '?'} (mined ${date} UTC). Trust mode: ${trustLabel}. Proof size: ${currentOtsBytes.length} bytes.`
};
setOtsCard('upgraded', 'valid', 'OTS Upgraded: Verified (Bitcoin)', otsUpgradedState.detail);
renderSummary(true);
} else {
setStatus(otsUpgradeStatus, 'info', `Proof upgraded but still pending (no Bitcoin attestation yet). ${result.detail ? 'Detail: ' + result.detail : ''} Try again later.`);
otsUpgradedState = { valid: 'pending', label: '', detail: '' };
setOtsCard('upgraded', 'pending', 'OTS Upgraded: Still pending',
`Proof upgraded but still pending (no Bitcoin attestation yet). ${result.detail ? 'Detail: ' + result.detail : ''} Try again later.`);
renderSummary(true);
}
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Upgrade failed: ${error.message}`);
@@ -608,20 +801,168 @@
});
/* ================================================================
AUTO-DETECT SIGNED-IN USER
On page load, check for a NIP-07 signer / nostr-login-lite. If the
user is already signed in, pre-fill the pubkey field and auto-query
relays for their kind 9999 event.
AUTHENTICATION (nostr-login-lite / NIP-07)
================================================================ */
async function initNostrLoginLite() {
if (!window.NOSTR_LOGIN_LITE) return false;
if (!window.NOSTR_LOGIN_LITE._initialized) {
await window.NOSTR_LOGIN_LITE.init({
methods: { extension: true, local: true, nip46: true, seedphrase: true, nsigner: true }
});
}
return true;
}
// Fill the pubkey field and auto-query relays for the signed-in user's
// kind 9999 event. Switches to the Query Relay tab so the user sees the
// results. Returns the pubkey hex on success, null otherwise.
async function queryAsSignedInUser() {
if (!window.nostr || !window.nostr.getPublicKey) {
throw new Error('No Nostr signer available. Install a NIP-07 browser extension (nos2x, Alby) or use nostr-login-lite.');
}
const pubkeyHex = await window.nostr.getPublicKey();
if (!pubkeyHex) throw new Error('No pubkey returned by signer.');
console.log('[verify] Signed-in pubkey:', pubkeyHex);
const pubkeyInput = document.getElementById('pqPubkeyInput');
const npub = hexToNpub(pubkeyHex);
pubkeyInput.value = npub || pubkeyHex;
// Show the query tab so the user sees the results
switchTab('relay');
queryBtn.click();
return pubkeyHex;
}
/* ================================================================
TAB 3: SIGN IN
================================================================ */
const signInBtn = document.getElementById('pqSignInBtn');
const signInError = document.getElementById('pqSignInError');
const signInStatus = document.getElementById('pqSignInStatus');
signInBtn.addEventListener('click', async () => {
signInBtn.disabled = true;
signInError.innerHTML = '';
setStatus(signInStatus, 'info', 'Signing in...');
try {
if (window.NOSTR_LOGIN_LITE) {
await initNostrLoginLite();
// Try a restored session first
if (window.nostr) {
try {
const pk = await window.nostr.getPublicKey();
if (pk) {
await queryAsSignedInUser();
setStatus(signInStatus, 'success', 'Signed in. Querying your event...');
return;
}
} catch (e) {
console.log('[verify] Not yet authenticated, launching login modal...');
}
}
// Launch the login modal and wait for auth events
const pubkeyHex = await new Promise((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => {
if (!settled) { settled = true; cleanup(); reject(new Error('Login timed out')); }
}, 120000);
async function tryGetPubkey() {
if (settled) return;
if (window.nostr) {
try {
const pk = await window.nostr.getPublicKey();
if (pk && !settled) { settled = true; cleanup(); resolve(pk); }
} catch (e) { console.log('[verify] getPublicKey not ready:', e.message); }
}
}
function authHandler(e) {
if (e.type === 'nlMethodSelected' && e.detail && e.detail.pubkey) {
if (!settled) { settled = true; cleanup(); resolve(e.detail.pubkey); }
return;
}
tryGetPubkey();
setTimeout(tryGetPubkey, 200);
setTimeout(tryGetPubkey, 500);
setTimeout(tryGetPubkey, 1000);
setTimeout(tryGetPubkey, 2000);
}
function cleanup() {
window.removeEventListener('nlAuth', authHandler);
window.removeEventListener('nlAuthRestored', authHandler);
window.removeEventListener('nlMethodSelected', authHandler);
window.removeEventListener('nlAuthComplete', authHandler);
clearTimeout(timeout);
}
window.addEventListener('nlAuth', authHandler);
window.addEventListener('nlAuthRestored', authHandler);
window.addEventListener('nlMethodSelected', authHandler);
window.addEventListener('nlAuthComplete', authHandler);
try {
const result = window.NOSTR_LOGIN_LITE.launch();
if (result && typeof result.then === 'function') {
result.then(() => {
tryGetPubkey(); setTimeout(tryGetPubkey, 200); setTimeout(tryGetPubkey, 500);
}).catch(e => { if (!settled) { settled = true; cleanup(); reject(e); } });
}
} catch (e) {
if (!settled) { settled = true; cleanup(); reject(e); }
}
});
// Fill the pubkey field with the launched-session pubkey and query
const pubkeyInput = document.getElementById('pqPubkeyInput');
const npub = hexToNpub(pubkeyHex);
pubkeyInput.value = npub || pubkeyHex;
switchTab('relay');
queryBtn.click();
setStatus(signInStatus, 'success', 'Signed in. Querying your event...');
} else {
// No nostr-login-lite — fall back to a plain NIP-07 extension
await queryAsSignedInUser();
setStatus(signInStatus, 'success', 'Signed in. Querying your event...');
}
} catch (error) {
console.error('[verify] Sign-in failed:', error);
setStatus(signInError, 'error', error.message);
setStatus(signInStatus, 'error', 'Sign-in failed.');
} finally {
signInBtn.disabled = false;
}
});
/* ================================================================
URL ?npub= PARAMETER + AUTO-DETECT SIGNED-IN USER
On page load, first check for a ?npub= query parameter (used by the
"Verify this attestation" link in the kind 1 event). If present,
pre-fill the pubkey field and auto-query relays for that pubkey's
kind 9999 event. Otherwise, fall back to detecting a NIP-07 signer /
nostr-login-lite session and auto-query for the signed-in user.
================================================================ */
async function initAutoDetect() {
// Initialize nostr-login-lite if available (shares session with main page)
// 1. Check for ?npub= (or ?pubkey=) URL parameter first
const params = new URLSearchParams(window.location.search);
const npubParam = params.get('npub') || params.get('pubkey');
if (npubParam) {
const pubkeyHex = normalizePubkey(npubParam);
if (pubkeyHex) {
console.log('[verify] URL parameter pubkey:', pubkeyHex);
const pubkeyInput = document.getElementById('pqPubkeyInput');
const npub = hexToNpub(pubkeyHex);
pubkeyInput.value = npub || pubkeyHex;
queryBtn.click();
return;
}
}
// 2. Fall back to signed-in user detection
if (window.NOSTR_LOGIN_LITE) {
try {
if (!window.NOSTR_LOGIN_LITE._initialized) {
await window.NOSTR_LOGIN_LITE.init({
methods: { extension: true, local: true, nip46: true, seedphrase: true, nsigner: true }
});
}
await initNostrLoginLite();
} catch (e) {
console.log('[verify] nostr-login-lite init failed:', e.message);
}
+13 -4
View File
@@ -1,8 +1,10 @@
/**
* Version display: fetch version.json and update title + header.
* Version display: fetch version.json and render a small, unobtrusive
* version badge fixed to the bottom-right of the page.
* Extracted from inline <script> for CSP compliance (G56-08).
*
* Auto-detects the page title prefix from the existing <title> tag.
* The page <title> is still suffixed with the version for tab/bookmark
* context, but the in-page header keeps only the human-readable title.
*/
(function () {
// Derive the title prefix from the existing <title> element
@@ -13,9 +15,16 @@
.then(function (r) { return r.json(); })
.then(function (v) {
var versionText = v.VERSION || '';
// Suffix the browser tab/bookmark title with the version.
document.title = titlePrefix + ' ' + versionText;
var header = document.getElementById('divHeaderText');
if (header) header.textContent = titlePrefix + ' ' + versionText;
// Render a small fixed version badge in the bottom-right corner.
if (versionText) {
var badge = document.createElement('div');
badge.className = 'pq-version-badge';
badge.textContent = versionText;
document.body.appendChild(badge);
}
})
.catch(function () { /* version.json not found — keep default title */ });
})();
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.0.29",
"VERSION_NUMBER": "0.0.29",
"BUILD_DATE": "2026-07-25T15:14:52.913Z"
"VERSION": "v0.1.6",
"VERSION_NUMBER": "0.1.6",
"BUILD_DATE": "2026-07-30T10:46:55.076Z"
}
+3 -2
View File
@@ -9929,8 +9929,9 @@ Post-quantum public keys in tags:
Each post-quantum key has cryptographically signed this attestation. This event is pending timestamp on the Bitcoin blockchain via OpenTimestamps.
Verify this attestation: https://laantungir.net/post-quantum/verify.html
Created at: https://laantungir.net/post-quantum/`;
Verify this attestation: https://laantungir.net/quantum-prep/verify.html?npub=${npub}
Created at: https://laantungir.net/quantum-prep/`;
const statementBytes = new TextEncoder().encode(content);
const mlDsa44Sig = signWithMLDSA44(statementBytes, pqKeys.mlDsa44.secretKey);
const mlDsa65Sig = signWithMLDSA65(statementBytes, pqKeys.mlDsa65.secretKey);
+86 -69
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none';" />
<meta name="referrer" content="no-referrer" />
<title>Verify NIP-QR Event</title>
<title>Verify Key-Link Event</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="shortcut icon" type="image/x-icon" href="data:image/x-icon;," />
@@ -69,6 +69,22 @@
.pq-button:hover { opacity: 0.7; }
.pq-button:disabled { opacity: 0.3; cursor: not-allowed; }
/* Braille spinner shown inside a button while its action is running.
Frames are advanced by JS (setButtonSpinner); this class just styles
the inline spinner glyph. */
.pq-btn-spinner {
display: inline-block;
color: var(--accent-color);
font-size: 16px;
line-height: 1;
margin-right: 6px;
vertical-align: middle;
}
/* Keep the spinner fully visible even while the button is dimmed
(disabled) during a running action. */
.pq-button:disabled .pq-btn-spinner { opacity: 1; }
.pq-button-row {
display: flex;
gap: 10px;
@@ -164,6 +180,31 @@
border-left: 4px solid #cc0000;
}
.pq-result-pending {
border-left: 4px solid #f0ad4e;
}
/* Multi-line OTS / summary cards wrap their detail text. */
.pq-result-item.pq-result-detail {
display: block;
line-height: 1.6;
white-space: normal;
}
.pq-result-detail .pq-result-head {
font-weight: bold;
margin-bottom: 4px;
}
.pq-result-detail .pq-result-body {
font-size: 13px;
font-weight: normal;
}
.pq-summary-card {
font-size: 16px;
font-weight: bold;
padding: 15px 18px;
}
.pq-event-preview {
background: var(--secondary-color);
color: var(--primary-color);
@@ -211,30 +252,6 @@
.pq-tab-panel { display: none; }
.pq-tab-panel.pq-tab-panel-active { display: block; }
.pq-ots-badge {
display: inline-block;
padding: 3px 10px;
border-radius: var(--border-radius);
font-size: 12px;
font-weight: bold;
margin-left: 8px;
}
.pq-ots-badge-pending {
background: #f0ad4e;
color: #fff;
}
.pq-ots-badge-confirmed {
background: #00aa00;
color: #fff;
}
.pq-ots-badge-none {
background: var(--muted-color);
color: var(--secondary-color);
}
</style>
</head>
@@ -243,7 +260,7 @@
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText" id="divHeaderText">Verify NIP-QR Event</div>
<div class="divHeaderText" id="divHeaderText">Verify Post-Quantum Key Link</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
@@ -253,42 +270,25 @@
<div class="pq-container">
<div class="pq-card">
<div class="pq-card-title">Verify NIP-QR Migration Event</div>
<div class="pq-info-text" style="color: var(--accent-color); font-size: 13px;">
<strong>Research prototype.</strong> Verification confirms key linkage and signature
validity; it does not mean the identity is post-quantum secure. Bitcoin OTS confirmation
is API-assisted (trusts a public explorer), not a full light-client verification.
</div>
<div class="pq-info-text">
Verify post-quantum migration events (kind 9999) by querying relays for a user's pubkey,
or by pasting event JSON directly. The kind 9999 event wraps a kind 1 announcement
(embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks,
reported as separate result lines:
the kind 9999 secp256k1 signature, the embedded kind 1 event's secp256k1 signature,
the e tag (kind 1 event ID), the sha256 tag (full kind 1 event hash), each PQ signature
individually (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512; ML-KEM-768 has no signature),
the PQ algorithm policy (all mandatory algorithms present and verified), identity binding
(outer/embedded/expected author match), and the OpenTimestamps proof.
</div>
<div class="pq-info-text">
<a href="./" class="pq-link">Back to migration page</a>
</div>
<div class="pq-card-title">Verify Post-Quantum Key Link</div>
<div class="pq-tabs">
<div class="pq-tab pq-tab-active" id="pqTabRelay">Query Relay</div>
<div class="pq-tab" id="pqTabPaste">Paste Event JSON</div>
<div class="pq-tab" id="pqTabSignIn">Sign In</div>
</div>
<!-- TAB 1: Query Relay -->
<div class="pq-tab-panel pq-tab-panel-active" id="pqPanelRelay">
<div class="pq-info-text">
Enter a Nostr pubkey (hex or npub) and one or more relay URLs. The page will query the
relay(s) for the latest kind 9999 event from that pubkey and verify it.
relay(s) for the latest kind 9999 event from that pubkey and verify the signatures are
valid, and whether it has been submitted to Open Time Stamps.
</div>
<input type="text" class="pq-input" id="pqPubkeyInput"
placeholder="Pubkey (hex or npub), e.g. 3bf0c63f869a8972... or npub1..." />
<input type="text" class="pq-input" id="pqRelayInput"
value="wss://laantungir.net/relay,wss://relay.damus.io,wss://nos.lol"
value="wss://laantungir.net/relay/,wss://sendit.nosflare.com/,wss://nos.lol"
placeholder="wss://relay1.com,wss://relay2.com" />
<button class="pq-button" id="pqQueryBtn">Query & Verify</button>
<div id="pqQueryStatus"></div>
@@ -320,45 +320,62 @@
<input type="file" id="pqArchiveFileInput" accept="application/json,.json" style="margin-top: 6px; font-size: 13px;" />
</div>
<!-- Shared results area -->
<!-- TAB 3: SIGN IN -->
<div class="pq-tab-panel" id="pqPanelSignIn">
<div class="pq-info-text">
Sign in with your Nostr identity (NIP-07 extension or nostr-login-lite) to look up your
own kind 9999 event automatically.
</div>
<div id="pqSignInError"></div>
<button class="pq-button" id="pqSignInBtn">Sign In</button>
<div id="pqSignInStatus"></div>
</div>
<!-- Full event JSON (shown at the top once an event is loaded) -->
<div id="pqEventJsonWrap" style="display: none; margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Full event JSON:</strong></div>
<div class="pq-event-preview" id="pqEventJson"></div>
</div>
<!-- Shared results area: signature + PQ checks + OTS cards, all in
one continuous card list. The OTS stamp/upgraded cards are
appended into pqResultList by JS so the flow is unbroken. -->
<div id="pqResultsWrap" style="display: none;">
<div class="pq-info-text" style="margin-top: 15px; margin-bottom: 5px;">
<strong>Verification Results:</strong>
<span id="pqOtsBadge"></span>
</div>
<div class="pq-result-list" id="pqResultList"></div>
</div>
<div id="pqEventJsonWrap" style="display: none; margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Full event JSON:</strong></div>
<div class="pq-event-preview" id="pqEventJson"></div>
<div class="pq-button-row" style="margin-top: 10px;">
<button class="pq-button pq-button-secondary" id="pqDownloadEventBtn">Download Event JSON</button>
</div>
</div>
<!-- OTS upgrade section -->
<div id="pqOtsUpgradeWrap" style="display: none; margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>OpenTimestamps:</strong></div>
<div id="pqOtsInfo" class="pq-status pq-status-info"></div>
<!-- OTS upgrade actions (cards live in pqResultList above) -->
<div id="pqOtsUpgradeWrap" style="display: none; margin-top: 10px;">
<div class="pq-button-row">
<button class="pq-button" id="pqOtsUpgradeBtn">Upgrade OTS Proof</button>
<button class="pq-button" id="pqOtsRepublishBtn" style="display:none;">Publish Upgraded Event</button>
</div>
<div id="pqOtsUpgradeStatus"></div>
</div>
<!-- Summary card (shown once verification completes) -->
<div id="pqSummaryWrap" style="display: none; margin-top: 15px;">
<div class="pq-result-item pq-summary-card" id="pqSummaryCard"></div>
</div>
<hr style="border: var(--border); margin: 25px 0 15px;" />
<div class="pq-info-text" style="color: var(--accent-color); font-size: 13px;">
<strong>Note:</strong> Passing every check does not mean that your current nostr identity
is now post-quantum secure. This tool is the preparation step. Full quantum safety for
day-to-day Nostr use still requires companion protocols that are not yet built.
</div>
<div class="pq-info-text">
<a href="./" class="pq-link">Back to preparation page</a>
</div>
</div>
</div>
</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 src="/nostr-login-lite/nostr.bundle.js" integrity="sha384-LNnPDD++DaWxljIhMLmfaoEoKB0B1HmACC6gNGLG+Qnmosprf/AX7u84pVY8xMpM" crossorigin="anonymous"></script>
<script src="/nostr-login-lite/nostr-lite.js" integrity="sha384-IQwa65eDC5trGjKn4cuEazmFiHTHD65gIqsjpzDtouc+j0MlyI927SZgHMWSi2aC" crossorigin="anonymous"></script>