Compare commits

...
11 Commits
Author SHA1 Message Date
Laan Tungir 6bb27f9596 V2 hardened derivation: PQ keys move to per-algorithm coin types (102003'-102007', all-hardened) outside the Nostr 1237' subtree, closing audit F-M3. Adds derivation_scheme tag on new events, v1 recovery mode for legacy seeds, v2 conformance vectors, 10 new tests (147 pass), updated docs/NIP proposal, and upload-test.sh staging deploy. Deployed to production. 2026-08-23 08:05:44 -04:00
Laan Tungir 0ceeb8468f Make mouse/keyboard entropy mandatory for seed generation; collect 256 events with throttled mouse deltas, performance.now() timing, and system entropy sources 2026-07-31 06:14:20 -04:00
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
20 changed files with 1523 additions and 377 deletions
+49 -9
View File
@@ -1,5 +1,14 @@
---
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]
---
# Nostr Quantum Preparation
/tmp/sb_test_save.jpg
## TLDR
@@ -23,7 +32,7 @@ I have currently used the following post quantum algorithms:
This app will generate public-private keypairs for each of these, publish on nostr your public key for each, and sign it with your current Nostr identity via your favorite signer (the nsec never leaves the signer).
You are making a statement to the world: "Here are post quantum pubkeys. If quantum computers hit, you know that the person signing using these can only be me."
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.
@@ -204,12 +213,18 @@ Bitcoin's proof-of-work chain is quantum-resistant not because Bitcoin's signatu
### Current OTS verification status
> **Trust model.** The current OTS verification is **API-assisted**, not a full Bitcoin light-client
> verification. Treat results accordingly.
> **Trust model.** The current OTS verification is **multi-explorer-checked**, not a full Bitcoin
> light-client verification. Treat results accordingly.
The current implementation parses the `.ots` proof, validates the Merkle path against a block header from a public Bitcoin explorer API (mempool.space), and detects confirmation by searching the proof bytes for the block-header attestation magic. Proof *upgrading* (asking calendars for a confirmed proof) is delegated to a server-side helper.
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:
**Not yet done:** full light-client verification (validating block headers, proof-of-work, difficulty, and chain linkage independently, without trusting an explorer API) and multi-explorer cross-checking. Because the block header is trusted from a single API, a compromised explorer could falsify a confirmation — the UI labels this as "API-checked," not "cryptographically verified on Bitcoin." The vendored `javascript-opentimestamps` library in `resources/` provides the primitives for full light-client verification, which is planned for a future release.
- `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).
Proof *upgrading* (asking calendars for a confirmed proof) is delegated to a server-side helper.
**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.
---
@@ -259,6 +274,22 @@ The NIP-06 path `m/44'/1237'/account'/0/0` has hardened derivation at three leve
**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.
### V2 hardened derivation (current)
Post-quantum keys are no longer derived under the Nostr coin branch at all. Each algorithm gets its own **coin type** in the unregistered SLIP-44 `102XXX'` range, all-hardened below the coin type (see [`plans/v2-hardened-derivation.md`](plans/v2-hardened-derivation.md)):
| Algorithm | Coin type | Path |
|---|---|---|
| ML-DSA-44 | `102006'` | `m/44'/102006'/0'/0'/0'` |
| ML-DSA-65 | `102003'` | `m/44'/102003'/0'/0'/0'` |
| SLH-DSA-128s | `102004'` | `m/44'/102004'/0'/0'/0'` + `/1'` |
| Falcon-512 | `102007'` | `m/44'/102007'/0'/0'/0'` + `/1'` |
| ML-KEM-768 | `102005'` | `m/44'/102005'/0'/0'/0'` + `/1'` |
The secp256k1 identity key stays at NIP-06 `m/44'/1237'/0'/0/0`. Because the PQ keys live outside the `1237'` subtree entirely, **no compromise of the Nostr coin branch — even a coin-level xpub leak combined with a quantum computer — can reach them**, and a leak of any single PQ branch's extended key compromises exactly one algorithm. Coin types 102003'102005' match n_signer and the Rust signer; 102006'102007' are this project's allocations.
**V1 legacy and recovery.** Events created before v2 used non-hardened children under `m/44'/1237'/0'/0/` (audit F-M3). Those events verify forever (verification is path-agnostic), and the app retains a v1 recovery mode: enter a v1-era seed, re-derive its v1 keys, confirm they match the published event, then re-run the same seed through the v2 flow to publish upgraded keys. New events carry a `derivation_scheme` tag (`2`); absent tag means v1.
---
## What Remains Unsolved
@@ -281,6 +312,9 @@ The current implementation is a static web app (`www/`) that performs the full p
|---|---|---|
| BIP39 seed phrase generation (24-word default, 12-word option, with optional user entropy) | Implemented | `www/js/pq-crypto.mjs` |
| BIP32 key derivation (secp256k1 + 5 PQ keypairs) | Implemented | `www/js/pq-crypto.mjs` |
| V2 hardened derivation (per-algorithm 102XXX' coin types, `derivation_scheme` tag) | Implemented | `www/js/pq-crypto.mjs` |
| V1 legacy recovery mode (re-derive v1 keys from a v1-era seed) | Implemented | `www/index.html` |
| V2 conformance vectors (seed → pubkeys, per-scheme) | Implemented | `test/vectors/seed-to-pubkeys.v2.json` |
| PQ signing (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512) | Implemented | `www/js/pq-crypto.mjs` |
| ML-KEM-768 keygen (KEM, no signing) | Implemented | `www/js/pq-crypto.mjs` |
| Kind 1 announcement event construction | Implemented | `www/js/pq-crypto.mjs` |
@@ -288,20 +322,25 @@ The current implementation is a static web app (`www/`) that performs the full p
| 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
| 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 |
| 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. |
@@ -329,6 +368,7 @@ The current implementation is a static web app (`www/`) that performs the full p
- [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/)
+2
View File
@@ -147,6 +147,8 @@ and have callers require `id` presence.
**Fix.** Document the requirement never to publish the xpub at `m/44'/1237'/0'/0'` (done). Consider hardened indices for PQ children if wallet-compatibility is not required.
**Status (2026-08-21): ADDRESSED by derivation scheme v2.** PQ keys now derive under per-algorithm coin types in the unregistered SLIP-44 `102XXX'` range (all-hardened below coin type), entirely outside the Nostr `1237'` subtree — so no xpub leak within the Nostr coin branch can reach PQ keys, and a single PQ branch leak costs one algorithm. V1 derivation is retained for recovery of v1-era seeds; new events carry a `derivation_scheme` tag. See `plans/v2-hardened-derivation.md` and `test/vectors/seed-to-pubkeys.v2.json`.
---
## F-M4 (Medium) — Truncation rule for concatenated BIP32 children is arbitrary
+12 -8
View File
@@ -41,16 +41,20 @@ This is the root of the HD wallet tree. From here, we derive all keys — both s
## Step 4: Derive all keys from BIP32 paths
All keys are derived under the NIP-06 base path `m/44'/1237'/0'/0/`, using different child indices:
The secp256k1 identity key is derived at the NIP-06 path `m/44'/1237'/0'/0/0`. The five PQ keys are derived under **per-algorithm coin types** in the unregistered SLIP-44 `102XXX'` range, all-hardened below the coin type (the v2 scheme — see below for why):
| Child index | Algorithm | Seed length | BIP32 path | How derived |
| Algorithm | Coin type | Seed length | BIP32 path | How derived |
|---|---|---|---|---|
| 0 | secp256k1 (NIP-06) | 32 bytes | `m/44'/1237'/0'/0/0` | Standard BIP32 — private key used directly |
| 1 | ML-DSA-44 | 32 bytes | `m/44'/1237'/0'/0/1` | Single child — 32-byte private key is the PQ seed |
| 2 | ML-DSA-65 | 32 bytes | `m/44'/1237'/0'/0/2` | Single child — 32-byte private key is the PQ seed |
| 3+4 | SLH-DSA-128s | 48 bytes | `m/44'/1237'/0'/0/3` + `m/44'/1237'/0'/0/4` | Two children concatenated (64 bytes), first 48 used |
| 5+6 | Falcon-512 | 48 bytes | `m/44'/1237'/0'/0/5` + `m/44'/1237'/0'/0/6` | Two children concatenated (64 bytes), first 48 used |
| 7+8 | ML-KEM-768 | 64 bytes | `m/44'/1237'/0'/0/7` + `m/44'/1237'/0'/0/8` | Two children concatenated (64 bytes) |
| secp256k1 (NIP-06) | 1237' | 32 bytes | `m/44'/1237'/0'/0/0` | Standard BIP32 — private key used directly |
| ML-DSA-44 | 102006' | 32 bytes | `m/44'/102006'/0'/0'/0'` | Single child — 32-byte private key is the PQ seed |
| ML-DSA-65 | 102003' | 32 bytes | `m/44'/102003'/0'/0'/0'` | Single child — 32-byte private key is the PQ seed |
| SLH-DSA-128s | 102004' | 48 bytes | `m/44'/102004'/0'/0'/0'` + `/1'` | Two children concatenated (64 bytes), first 48 used |
| Falcon-512 | 102007' | 48 bytes | `m/44'/102007'/0'/0'/0'` + `/1'` | Two children concatenated (64 bytes), first 48 used |
| ML-KEM-768 | 102005' | 64 bytes | `m/44'/102005'/0'/0'/0'` + `/1'` | Two children concatenated (64 bytes) |
**Why coin types instead of child indices under 1237'?** The v1 scheme placed PQ seeds at non-hardened children under the NIP-06 account. Post-quantum, any leaked public key (including one inside an xpub) yields its private key, and a node's private key + chain code gives every child below it — so a single xpub leak inside the Nostr coin branch could reach all five PQ seeds. Moving each algorithm to its own coin branch means no compromise of the `1237'` subtree can touch PQ keys at all, and a leak of one branch's extended key costs exactly one algorithm. Coin types 102003'102005' match n_signer and the Rust signer; 102006'102007' are this project's allocations.
**V1 legacy (recovery only).** Events created before v2 used non-hardened children 18 under `m/44'/1237'/0'/0/`. Those events verify forever, and the app can re-derive v1 keys from a v1-era seed (recovery mode) so their owner can confirm ownership and then publish upgraded v2 keys from the same seed. New events carry a `derivation_scheme` tag (`2`); absent tag means v1.
### Why some algorithms need two children
+24 -10
View File
@@ -63,18 +63,30 @@ flowchart TD
## PQ key derivation from a BIP39 seed
All PQ keys are derived from a BIP39 seed via **BIP32 hierarchical deterministic derivation**, the same standard NIP-06 uses for secp256k1 keys. PQ keys live at fixed child indices under the NIP-06 base path.
All PQ keys are derived from a BIP39 seed via **BIP32 hierarchical deterministic derivation**, the same standard NIP-06 uses for secp256k1 keys.
**Base path:** `m/44'/1237'/0'/0/` (NIP-06 account 0, change 0)
### Derivation scheme v2 (current)
| Child index/indices | Algorithm | Seed length needed | BIP32 path | Derivation |
Each PQ algorithm is derived under its own **coin type** in the unregistered SLIP-44 `102XXX'` range, all-hardened below the coin type. The secp256k1 identity key stays at the NIP-06 path.
| Algorithm | Coin type | Seed length needed | BIP32 path | Derivation |
|---|---|---|---|---|
| 0 | secp256k1 (NIP-06) | 32 bytes | `m/44'/1237'/0'/0/0` | Standard BIP32; private key used directly |
| 1 | ML-DSA-44 | 32 bytes | `m/44'/1237'/0'/0/1` | Single child; 32-byte private key is the PQ seed |
| 2 | ML-DSA-65 | 32 bytes | `m/44'/1237'/0'/0/2` | Single child; 32-byte private key is the PQ seed |
| 3 + 4 | SLH-DSA-128s | 48 bytes | `m/44'/1237'/0'/0/3` + `m/44'/1237'/0'/0/4` | Two children concatenated (64 bytes), first 48 used |
| 5 + 6 | Falcon-512 | 48 bytes | `m/44'/1237'/0'/0/5` + `m/44'/1237'/0'/0/6` | Two children concatenated (64 bytes), first 48 used |
| 7 + 8 | ML-KEM-768 | 64 bytes | `m/44'/1237'/0'/0/7` + `m/44'/1237'/0'/0/8` | Two children concatenated (64 bytes) |
| secp256k1 (NIP-06) | 1237' | 32 bytes | `m/44'/1237'/0'/0/0` | Standard BIP32; private key used directly |
| ML-DSA-44 | 102006' | 32 bytes | `m/44'/102006'/0'/0'/0'` | Single child; 32-byte private key is the PQ seed |
| ML-DSA-65 | 102003' | 32 bytes | `m/44'/102003'/0'/0'/0'` | Single child; 32-byte private key is the PQ seed |
| SLH-DSA-128s | 102004' | 48 bytes | `m/44'/102004'/0'/0'/0'` + `m/44'/102004'/0'/0'/1'` | Two children concatenated (64 bytes), first 48 used |
| Falcon-512 | 102007' | 48 bytes | `m/44'/102007'/0'/0'/0'` + `m/44'/102007'/0'/0'/1'` | Two children concatenated (64 bytes), first 48 used |
| ML-KEM-768 | 102005' | 64 bytes | `m/44'/102005'/0'/0'/0'` + `m/44'/102005'/0'/0'/1'` | Two children concatenated (64 bytes) |
The coin-type registry: 102003' (ML-DSA-65), 102004' (SLH-DSA-128s), and 102005' (ML-KEM-768) are shared with the n_signer and Rust signer projects; 102006' (ML-DSA-44) and 102007' (Falcon-512) are allocated by this proposal. The `102XXX` range is unregistered in SLIP-44 and chosen to avoid collisions with real cryptocurrencies.
**Rationale.** PQ keys sit outside the Nostr coin branch (`1237'`) entirely. Post-quantum, any leaked public key — including one inside an extended public key — yields its private key, and a node's private key plus chain code yields every child below it, hardened or not. Deriving PQ keys under per-algorithm coin types means (a) no compromise of the Nostr subtree, even a coin-level xpub leak combined with a quantum computer, can reach PQ keys, and (b) a leak of any single PQ branch's extended key compromises exactly one algorithm.
**Falcon-512 caveat.** Falcon's keygen is rejection-sampling-based with no universally implemented seed interface; identical seeds produce different keys across libraries. The conformance vectors pin `@noble/post-quantum`'s behavior; implementations using other Falcon libraries will derive different (but equally valid) Falcon keys and MUST NOT be treated as non-conforming on that basis alone.
### Derivation scheme v1 (legacy)
Events created before this scheme was introduced derived PQ keys at non-hardened child indices under `m/44'/1237'/0'/0/` (children 18, same concatenation rule). Verification is path-agnostic — v1 events remain valid forever — and implementations SHOULD retain v1 derivation so owners of v1-era seeds can recover their keys and publish upgraded v2 events from the same seed. New events MUST use v2 and include the `derivation_scheme` tag (below); an absent tag indicates v1.
### Concatenation and truncation rule (normative)
@@ -134,6 +146,7 @@ pending timestamp on the Bitcoin blockchain via OpenTimestamps.
- `["block_height", "<height>"]` — the Bitcoin block height at signing time, as a decimal string.
- `["algorithm", "<algorithm-id>", "<base64 pubkey>", "<base64 signature>"]` — one tag per PQ **signature** scheme. The signature is over `TextEncoder.encode(content)`.
- `["algorithm", "ml-kem-768", "<base64 pubkey>"]` — for ML-KEM-768. ML-KEM is a KEM, not a signature scheme, so it has no signature field. Its ownership is asserted by the attestation text and authorized by the attesting identity's secp256k1 signature over the kind 1 event (which covers the tags, including the ML-KEM pubkey).
- `["derivation_scheme", "<version>"]` — the derivation scheme version that produced the PQ keys (`"2"` for the current per-algorithm coin-type scheme). Informational metadata: verification is path-agnostic, so verifiers MUST NOT reject events over this tag, and unknown values are display-only. An absent tag indicates v1 (legacy).
### Algorithm identifiers
@@ -168,7 +181,8 @@ A future NIP revision MAY define a new policy version that drops a scheme later
["algorithm", "ml-dsa-65", "<base64 pubkey>", "<base64 signature>"],
["algorithm", "slh-dsa-128s", "<base64 pubkey>", "<base64 signature>"],
["algorithm", "falcon-512", "<base64 pubkey>", "<base64 signature>"],
["algorithm", "ml-kem-768", "<base64 pubkey>"]
["algorithm", "ml-kem-768", "<base64 pubkey>"],
["derivation_scheme", "2"]
],
"sig": "<attesting identity Schnorr signature>"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nostr_quantum_preparation",
"version": "0.0.35",
"version": "0.1.8",
"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": {
+169
View File
@@ -0,0 +1,169 @@
# V2 Hardened Derivation Scheme — Design Doc
## Status
Proposed. Addresses audit finding [F-M3 (Medium)](../audits/GLM5.2/findings.md) — BIP32 non-hardened leaf indices used for PQ seeds.
## Decision summary
| Question | Decision |
|---|---|
| v2 path scheme | **Per-algorithm coin types in the unregistered SLIP-44 `102XXX'` range** (matches n_signer) |
| Seed pipeline | **FIPS seeded interface**: BIP32 child bytes (exact length) → `keygen(seed)` (matches noble + Rust crates) |
| v1 users | **Full recovery**: v1 derivation retained; same seed derives both v1 and v2 keys; old events verify forever |
| n_signer / Rust signer | Free to migrate to the seeded API later (no users); their DRBG pipeline documented as divergence |
| New coin types | ML-DSA-44 = `102006'`, Falcon-512 = `102007'` (continuing n_signer's range) |
## Problem
V1 derives all keys under `m/44'/1237'/0'/0/` with **non-hardened** leaf children:
| Child | Key |
|---|---|
| 0 | secp256k1 (NIP-06, published as npub) |
| 1 | ML-DSA-44 |
| 2 | ML-DSA-65 |
| 3+4 | SLH-DSA-128s (48-byte seed) |
| 5+6 | Falcon-512 (48-byte seed) |
| 7+8 | ML-KEM-768 (64-byte seed) |
The project's threat model assumes the published secp256k1 key **will** be broken by Shor's algorithm. Post-quantum, any leaked public key (including the one inside an xpub) yields its private key, and holding a node's private key + chain code gives every child below it, hardened or not. So with any xpub leak at or above the change level, a quantum attacker reaches **all 5 PQ seeds** through the identity account. Non-hardened derivation buys nothing here anyway: PQ public keys come from `keygen(seed)`, not scalar multiplication, so watch-only derivation of PQ child pubkeys is impossible.
### Why alternatives were rejected
- **Hardened leaves under account 0** (`m/44'/1237'/0'/0'/{n}'` or `m/44'/1237'/0'/{n}'/0'`): still hangs PQ keys off the identity account; an account-0 xpub leak + quantum reaches everything below account 0.
- **Per-algorithm accounts under 1237'** (`m/44'/1237'/{n}'/0/0`): better (per-key isolation) but a coin-level `1237'` xpub leak + quantum still reaches all 5, and accounts 1'5' collide with NIP-06 multi-identity use (a wallet identity at account 1 would silently republish the ML-DSA-44 seed as an secp256k1 npub).
- **Per-algorithm coin types** (chosen): PQ keys leave the `1237'` subtree entirely. No compromise of the Nostr coin branch — even the coin-level xpub with quantum — can touch them. Matches n_signer's existing scheme.
## Solution
**V2 scheme: one coin type per PQ algorithm, all-hardened below coin type.**
Coin types 102003'102005' are n_signer's existing allocations ([`n_signer/documents/derivation_paths.md`](../../n_signer/documents/derivation_paths.md)); 102006'102007' are new allocations for the two algorithms this project adds. The `102XXX` range is unregistered in SLIP-44 and chosen to avoid collisions with real cryptocurrencies.
| Algorithm | Coin type | Path (account 0) | Seed length |
|---|---|---|---|
| ML-DSA-44 | `102006'` | `m/44'/102006'/0'/0'/0'` | 32 B (one child) |
| ML-DSA-65 | `102003'` | `m/44'/102003'/0'/0'/0'` | 32 B (one child) |
| SLH-DSA-128s | `102004'` | `m/44'/102004'/0'/0'/0'` + `/1'` | 48 B (two children, first 48 of 64) |
| Falcon-512 | `102007'` | `m/44'/102007'/0'/0'/0'` + `/1'` | 48 B (two children, first 48 of 64) |
| ML-KEM-768 | `102005'` | `m/44'/102005'/0'/0'/0'` + `/1'` | 64 B (two children) |
The secp256k1 identity key **stays at NIP-06 `m/44'/1237'/0'/0/0`** — unchanged, standard, published.
### Security properties
| Leak + quantum attacker | Result |
|---|---|
| Published npub only (always broken) | PQ safe |
| Account-0 xpub (standard wallet export) | PQ safe |
| Coin-level `m/44'/1237'` xpub | **PQ safe — PQ keys are not under `1237'` at all** |
| One PQ coin branch's own xpub | 1 algorithm falls (per-algorithm isolation) |
| NIP-06 multi-identity accounts | No collision — wallets never derive `102XXX'` coin types |
### Seed pipeline: FIPS seeded interface
FIPS 203/204/205 define keygen as consuming a fixed-length seed (ML-DSA 32 B, ML-KEM 64 B, SLH-DSA-128s 48 B); the SHAKE expansion happens *inside* keygen. The v2 pipeline is therefore: derive BIP32 children → concatenate/truncate to the exact seed length → `keygen(seed)`. This is what [`derivePQKeysFromSeed()`](../www/js/pq-crypto.mjs) already does via noble, and what Rust PQ crates expose — so JS and Rust implementations agree by construction.
**n_signer divergence:** n_signer feeds the derived child through a SHAKE-256 DRBG into PQClean's `randombytes()` callback (a PQClean API artifact, not a cryptographic choice). Same path + same seed bytes there produce *different* keys than the seeded interface. Since n_signer and the Rust signer have no users, the recommendation (filed separately in those projects) is to migrate them to the seeded API; this project does not replicate the DRBG.
**Falcon caveat:** Falcon (draft FIPS 206) keygen is rejection-sampling-based with no universally implemented seed interface. Even with identical seeds, noble's Falcon keys ≠ PQClean's ≠ Rust's. We pin noble's behavior in test vectors and flag Falcon as per-library in the NIP proposal.
## Compatibility — v1 users can still recover
The root of trust is the **BIP39 seed**, not the path. Verification is path-agnostic: [`verify-app.mjs`](../www/js/verify-app.mjs) checks PQ signatures against pubkeys in the kind 1 event tags and never derives from a seed. Therefore:
1. **Existing v1 events remain fully verifiable forever.** No verifier changes required for old events.
2. **A v1 user's seed still recovers their v1 keys.** V1 derivation code is retained and exposed as a legacy option.
3. **The same seed mints a v2 key-link event at any time**: load seed → derive v2 keys → publish new kind 1 → OTS anchor. The v2 PQ keys are cryptographically independent of the v1 keys (different coin branches), so the v1 xpub-leak scenario no longer matters going forward.
### Version signaling
New kind 1 events include a `derivation_scheme` tag:
```
['derivation_scheme', '2']
```
- Absent tag → v1 (legacy). Informational for display; signature verification is unaffected either way.
- Unknown future values → informational only (fail-open for display; this tag is metadata, not evidence — unlike `digest_version`, which fails closed because it changes what is hashed).
## File-by-file changes
### 1. `www/js/pq-crypto.mjs`
- Add versioned scheme table:
```js
const PQ_DERIVATION_SCHEMES = {
v1: { // legacy — retained for recovery, never default
base: "m/44'/1237'/0'/0", hardenedLeaves: false,
children: { mlDsa44: [1], mlDsa65: [2], slhDsa: [3,4], falcon512: [5,6], mlKem: [7,8] } },
v2: { // per-algorithm coin types (n_signer-compatible)
hardenedLeaves: true,
children: {
mlDsa44: { coin: 102006, indices: [0] },
mlDsa65: { coin: 102003, indices: [0] },
slhDsa: { coin: 102004, indices: [0, 1] },
falcon512: { coin: 102007, indices: [0, 1] },
mlKem: { coin: 102005, indices: [0, 1] },
} },
};
export const PQ_DERIVATION_SCHEME_VERSION = 2;
```
- Refactor [`deriveBIP32Child()`](../www/js/pq-crypto.mjs) and `derivePQSeedFromBIP32()` to take the scheme instead of the hardcoded v1 base.
- `derivePQKeysFromSeed(seed, scheme = 'v2')` — default v2; `'v1'` still works for recovery.
- `buildKind1Announcement()` gains a `derivationScheme` parameter (default 2) and emits the `derivation_scheme` tag.
- `PQ_KEY_INFO` derivation paths become scheme-aware so the UI shows the correct path.
### 2. `www/js/index-app.mjs`
- Default flow derives v2 and shows v2 paths in the UI.
- Add a **v1 recovery mode**: user enters a v1-era seed → app derives v1 keys → matches them against the user's published kind 1 event (by npub) → confirms "these are your v1 keys" → offers to mint a v2 event from the same seed.
### 3. `www/pq-crypto.bundle.js`
- Rebuild via `node build-pq-bundle.js` after source changes.
### 4. `test/vectors/generate-vectors.mjs` + vectors
- Emit `seed-to-pubkeys.v2.json` (same fixed test seed, v2 paths) alongside the pinned v1 file. V1 vectors stay untouched as the legacy conformance reference.
### 5. `test/pq-crypto.test.mjs`
- v2 derivation reproduces the v2 vector.
- v1 derivation still reproduces the v1 vector (regression).
- **Independence test:** v1 and v2 keys from the same seed share no key material (pubkeys differ for every algorithm).
- New events carry `derivation_scheme: '2'`; v1 events omit it.
- Recovery path: v1 seed → v1 keys → match published event tags.
### 6. Docs
- `README.md`: Component 6 gains the v2 scheme and the coin-type isolation rationale; implementation status table updated.
- `explanation.md`, `nip_proposal.md`: replace the wallet-compatibility justification for non-hardened leaves with the v2 scheme; document `derivation_scheme` tag; document v1 legacy/recovery; document the `102XXX'` coin-type registry (102003'102005' per n_signer, 102006'102007' new); flag Falcon as per-library.
- `audits/GLM5.2/findings.md` F-M3: annotate as addressed-by-v2 (append status; do not rewrite history).
### 7. `www/js/version.json`
- Bump to `0.2.0` (minor: new derivation scheme, backward compatible).
## What we are explicitly NOT doing
- **Not** deleting or changing v1 derivation (recovery depends on it).
- **Not** re-deriving or re-signing existing events (impossible — and unnecessary, verification is path-agnostic).
- **Not** making `derivation_scheme` fail-closed in the verifier (display metadata, not evidence).
- **Not** moving the secp256k1 identity key off NIP-06 (ecosystem compatibility).
- **Not** replicating n_signer's SHAKE-256 DRBG pipeline (locks us out of the FIPS seeded interface; n_signer/Rust should migrate instead — separate effort, no users to break).
- **Not** claiming cross-implementation Falcon determinism (rejection sampling; pin noble's vectors, flag in NIP).
## Test matrix summary
| Test | Asserts |
|---|---|
| v2 vector reproduction | Same seed → pinned v2 pubkeys |
| v1 vector regression | Same seed → pinned v1 pubkeys (unchanged) |
| v1/v2 independence | No shared pubkeys across schemes |
| Tag emission | New events have `derivation_scheme 2`; legacy path omits it |
| Recovery flow | v1 seed → v1 keys match published event |
| Existing suite | All current tests still pass (no behavioral change to verification) |
+164 -7
View File
@@ -1523,7 +1523,7 @@ describe('F-D6: cross-implementation conformance vectors', () => {
}
});
test('F-D6: implementation reproduces the pinned seed-to-pubkeys vector', () => {
test('F-D6: implementation reproduces the pinned v1 seed-to-pubkeys vector (legacy regression)', () => {
const vecPath = join(vectorsDir, 'seed-to-pubkeys.v1.json');
const vec = JSON.parse(readFileSync(vecPath, 'utf8'));
const seed = m.mnemonicToSeed(vec.mnemonic);
@@ -1534,16 +1534,173 @@ describe('F-D6: cross-implementation conformance vectors', () => {
assert.equal(m.bytesToHex(secpKp.publicKey), vec.derivedPublicKeys.secp256k1.publicKeyHex,
'secp256k1 pubkey should match the pinned vector');
const pqKeys = m.derivePQKeysFromSeed(seed);
// v1 vector must be reproduced with the EXPLICIT legacy scheme — the
// default is v2 now, and v1 keys must never change (recovery depends on it).
const pqKeys = m.derivePQKeysFromSeed(seed, 'v1');
assert.equal(m.bytesToHex(pqKeys.mlDsa44.publicKey), vec.derivedPublicKeys['ml-dsa-44'].publicKeyHex,
'ml-dsa-44 pubkey should match the pinned vector');
'ml-dsa-44 pubkey should match the pinned v1 vector');
assert.equal(m.bytesToHex(pqKeys.mlDsa65.publicKey), vec.derivedPublicKeys['ml-dsa-65'].publicKeyHex,
'ml-dsa-65 pubkey should match the pinned vector');
'ml-dsa-65 pubkey should match the pinned v1 vector');
assert.equal(m.bytesToHex(pqKeys.slhDsa.publicKey), vec.derivedPublicKeys['slh-dsa-128s'].publicKeyHex,
'slh-dsa-128s pubkey should match the pinned vector');
'slh-dsa-128s pubkey should match the pinned v1 vector');
assert.equal(m.bytesToHex(pqKeys.falcon512.publicKey), vec.derivedPublicKeys['falcon-512'].publicKeyHex,
'falcon-512 pubkey should match the pinned vector');
'falcon-512 pubkey should match the pinned v1 vector');
assert.equal(m.bytesToHex(pqKeys.mlKem.publicKey), vec.derivedPublicKeys['ml-kem-768'].publicKeyHex,
'ml-kem-768 pubkey should match the pinned vector');
'ml-kem-768 pubkey should match the pinned v1 vector');
});
});
// ============================================================================
// V2 HARDENED DERIVATION SCHEME (per-algorithm coin types)
// ============================================================================
//
// See plans/v2-hardened-derivation.md. V2 moves PQ keys off the Nostr coin
// branch (1237') entirely, into per-algorithm coin types in the unregistered
// SLIP-44 102XXX' range, all-hardened below the coin type. This isolates PQ
// keys from any compromise of the Nostr subtree (audit F-M3) and matches the
// coin-type registry shared with n_signer / the Rust signer.
describe('V2 hardened derivation scheme', () => {
const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
test('v2: seed-to-pubkeys.v2.json vector is loadable and well-formed', () => {
const vecPath = join(vectorsDir, 'seed-to-pubkeys.v2.json');
let vec;
try {
vec = JSON.parse(readFileSync(vecPath, 'utf8'));
} catch (e) {
assert.fail(`Could not load ${vecPath}: ${e.message}`);
}
assert.equal(vec.vectorType, 'nostr-pq-link-seed-to-pubkeys');
assert.equal(vec.vectorVersion, 2);
assert.equal(vec.derivationScheme, 2);
assert.equal(typeof vec.mnemonic, 'string');
assert.equal(typeof vec.bip39SeedHex, 'string');
for (const alg of ['ml-dsa-44', 'ml-dsa-65', 'slh-dsa-128s', 'falcon-512', 'ml-kem-768']) {
assert.ok(vec.derivedPublicKeys[alg], `should have ${alg} entry`);
assert.equal(typeof vec.derivedPublicKeys[alg].publicKeyHex, 'string');
assert.ok(vec.derivedPublicKeys[alg].derivationPath.includes("102"), `${alg} path should use a 102XXX coin type`);
}
});
test('v2: implementation reproduces the pinned v2 vector', () => {
const vecPath = join(vectorsDir, 'seed-to-pubkeys.v2.json');
const vec = JSON.parse(readFileSync(vecPath, 'utf8'));
const seed = m.mnemonicToSeed(vec.mnemonic);
assert.equal(m.bytesToHex(seed), vec.bip39SeedHex, 'BIP39 seed should match the v2 vector');
const pqKeys = m.derivePQKeysFromSeed(seed, 'v2');
assert.equal(m.bytesToHex(pqKeys.mlDsa44.publicKey), vec.derivedPublicKeys['ml-dsa-44'].publicKeyHex,
'ml-dsa-44 pubkey should match the pinned v2 vector');
assert.equal(m.bytesToHex(pqKeys.mlDsa65.publicKey), vec.derivedPublicKeys['ml-dsa-65'].publicKeyHex,
'ml-dsa-65 pubkey should match the pinned v2 vector');
assert.equal(m.bytesToHex(pqKeys.slhDsa.publicKey), vec.derivedPublicKeys['slh-dsa-128s'].publicKeyHex,
'slh-dsa-128s pubkey should match the pinned v2 vector');
assert.equal(m.bytesToHex(pqKeys.falcon512.publicKey), vec.derivedPublicKeys['falcon-512'].publicKeyHex,
'falcon-512 pubkey should match the pinned v2 vector');
assert.equal(m.bytesToHex(pqKeys.mlKem.publicKey), vec.derivedPublicKeys['ml-kem-768'].publicKeyHex,
'ml-kem-768 pubkey should match the pinned v2 vector');
});
test('v2: default scheme is v2 (no argument = v2 keys)', () => {
const seed = m.mnemonicToSeed(TEST_MNEMONIC);
const byDefault = m.derivePQKeysFromSeed(seed);
const explicitV2 = m.derivePQKeysFromSeed(seed, 'v2');
assert.equal(m.bytesToHex(byDefault.mlDsa65.publicKey), m.bytesToHex(explicitV2.mlDsa65.publicKey),
'default derivation must be v2');
// numeric form also accepted
const numericV2 = m.derivePQKeysFromSeed(seed, 2);
assert.equal(m.bytesToHex(numericV2.mlDsa65.publicKey), m.bytesToHex(explicitV2.mlDsa65.publicKey),
'numeric scheme 2 must equal v2');
});
test('v2: v1 and v2 keys from the same seed are independent (no shared key material)', () => {
const seed = m.mnemonicToSeed(TEST_MNEMONIC);
const v1Keys = m.derivePQKeysFromSeed(seed, 'v1');
const v2Keys = m.derivePQKeysFromSeed(seed, 'v2');
assert.notEqual(m.bytesToHex(v1Keys.mlDsa44.publicKey), m.bytesToHex(v2Keys.mlDsa44.publicKey), 'ml-dsa-44');
assert.notEqual(m.bytesToHex(v1Keys.mlDsa65.publicKey), m.bytesToHex(v2Keys.mlDsa65.publicKey), 'ml-dsa-65');
assert.notEqual(m.bytesToHex(v1Keys.slhDsa.publicKey), m.bytesToHex(v2Keys.slhDsa.publicKey), 'slh-dsa-128s');
assert.notEqual(m.bytesToHex(v1Keys.falcon512.publicKey), m.bytesToHex(v2Keys.falcon512.publicKey), 'falcon-512');
assert.notEqual(m.bytesToHex(v1Keys.mlKem.publicKey), m.bytesToHex(v2Keys.mlKem.publicKey), 'ml-kem-768');
});
test('v2: secp256k1 NIP-06 identity key is unchanged across schemes', () => {
const seed = m.mnemonicToSeed(TEST_MNEMONIC);
const kp = m.deriveSecp256k1FromSeed(seed);
// NIP-06 path is scheme-independent; both v1 and v2 users keep the same identity key
const vec1 = JSON.parse(readFileSync(join(vectorsDir, 'seed-to-pubkeys.v1.json'), 'utf8'));
if (vec1.mnemonic === TEST_MNEMONIC) {
assert.equal(m.bytesToHex(kp.publicKey), vec1.derivedPublicKeys.secp256k1.publicKeyHex);
}
});
test('v2: unknown scheme throws', () => {
const seed = m.mnemonicToSeed(TEST_MNEMONIC);
assert.throws(() => m.derivePQKeysFromSeed(seed, 'v3'), /Unknown derivation scheme/);
assert.throws(() => m.derivePQKeysFromSeed(seed, 99), /Unknown derivation scheme/);
});
test('v2: derivation paths use per-algorithm coin types, all hardened', () => {
assert.equal(m.pqDerivationPath('mlDsa44', 'v2'), "m/44'/102006'/0'/0'/0'");
assert.equal(m.pqDerivationPath('mlDsa65', 'v2'), "m/44'/102003'/0'/0'/0'");
assert.equal(m.pqDerivationPath('slhDsa', 'v2'), "m/44'/102004'/0'/0'/0' + m/44'/102004'/0'/0'/1'");
assert.equal(m.pqDerivationPath('falcon512', 'v2'), "m/44'/102007'/0'/0'/0' + m/44'/102007'/0'/0'/1'");
assert.equal(m.pqDerivationPath('mlKem', 'v2'), "m/44'/102005'/0'/0'/0' + m/44'/102005'/0'/0'/1'");
// v1 paths unchanged (legacy)
assert.equal(m.pqDerivationPath('mlDsa44', 'v1'), "m/44'/1237'/0'/0/1");
assert.equal(m.pqDerivationPath('mlKem', 'v1'), "m/44'/1237'/0'/0/7 + m/44'/1237'/0'/0/8");
// algorithm-id helper
assert.equal(m.derivationPathForAlgorithm('ml-dsa-65'), "m/44'/102003'/0'/0'/0'");
assert.equal(m.derivationPathForAlgorithm('ml-dsa-65', 'v1'), "m/44'/1237'/0'/0/2");
});
test('v2: PQ_DERIVATION_SCHEME_VERSION is 2', () => {
assert.equal(m.PQ_DERIVATION_SCHEME_VERSION, 2);
});
test('v2: kind 1 announcement carries derivation_scheme tag', async () => {
const seed = m.mnemonicToSeed(TEST_MNEMONIC);
const pqKeys = m.derivePQKeysFromSeed(seed, 'v2');
const kp = m.deriveSecp256k1FromSeed(seed);
const ev = m.buildKind1Announcement(m.bytesToHex(kp.publicKey), 800000, pqKeys, 'v2');
const tag = ev.tags.find(t => t[0] === 'derivation_scheme');
assert.ok(tag, 'v2 event must have a derivation_scheme tag');
assert.equal(tag[1], '2');
// v1 events (legacy path) omit the tag
const v1Keys = m.derivePQKeysFromSeed(seed, 'v1');
const ev1 = m.buildKind1Announcement(m.bytesToHex(kp.publicKey), 800000, v1Keys, 'v1');
assert.ok(!ev1.tags.some(t => t[0] === 'derivation_scheme'), 'v1 event must NOT have the tag');
});
test('v2: v1 recovery flow — v1 seed re-derives v1 keys that verify against a v1 event', async () => {
// Simulate the recovery path: a v1-era seed produces keys whose signatures
// verify inside a kind 1 announcement built the v1 way.
const seed = m.mnemonicToSeed(TEST_MNEMONIC);
const v1Keys = m.derivePQKeysFromSeed(seed, 'v1');
const kp = m.deriveSecp256k1FromSeed(seed);
const ev = m.buildKind1Announcement(m.bytesToHex(kp.publicKey), 800000, v1Keys, 'v1');
// Each algorithm tag must verify against the re-derived v1 public keys
const algTag = (id) => ev.tags.find(t => t[0] === 'algorithm' && t[1] === id);
const statement = new TextEncoder().encode(ev.content);
const t44 = algTag('ml-dsa-44');
assert.ok(m.verifyMLDSA44(m.base64ToBytes(t44[3]), statement, v1Keys.mlDsa44.publicKey),
'recovered v1 ml-dsa-44 key must verify its own event signature');
const t65 = algTag('ml-dsa-65');
assert.ok(m.verifyMLDSA65(m.base64ToBytes(t65[3]), statement, v1Keys.mlDsa65.publicKey),
'recovered v1 ml-dsa-65 key must verify its own event signature');
const tSlh = algTag('slh-dsa-128s');
assert.ok(m.verifySLHDSA(m.base64ToBytes(tSlh[3]), statement, v1Keys.slhDsa.publicKey),
'recovered v1 slh-dsa-128s key must verify its own event signature');
const tFal = algTag('falcon-512');
assert.ok(m.verifyFalcon(m.base64ToBytes(tFal[3]), statement, v1Keys.falcon512.publicKey),
'recovered v1 falcon-512 key must verify its own event signature');
// KEM has no signature — pubkey presence is the check
const tKem = algTag('ml-kem-768');
assert.equal(tKem[2], m.bytesToBase64(v1Keys.mlKem.publicKey),
'recovered v1 ml-kem-768 pubkey must match the event tag');
});
});
+104 -28
View File
@@ -26,14 +26,6 @@ import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const VECTORS_DIR = __dirname;
const PQ_DERIVATION_PATHS = {
secp256k1: [0],
mlDsa44: [1],
mlDsa65: [2],
slhDsa: [3, 4],
falcon512: [5, 6],
mlKem: [7, 8],
};
const PQ_SEED_LENGTHS = {
mlDsa44: 32,
mlDsa65: 32,
@@ -42,26 +34,43 @@ const PQ_SEED_LENGTHS = {
mlKem: 64,
};
// V1 (legacy): non-hardened children under the NIP-06 account 0 change level.
const V1_PATHS = {
mlDsa44: ["m/44'/1237'/0'/0/1"],
mlDsa65: ["m/44'/1237'/0'/0/2"],
slhDsa: ["m/44'/1237'/0'/0/3", "m/44'/1237'/0'/0/4"],
falcon512: ["m/44'/1237'/0'/0/5", "m/44'/1237'/0'/0/6"],
mlKem: ["m/44'/1237'/0'/0/7", "m/44'/1237'/0'/0/8"],
};
// V2 (default): per-algorithm coin types in the unregistered SLIP-44 102XXX'
// range, all-hardened below the coin type. See plans/v2-hardened-derivation.md.
const V2_PATHS = {
mlDsa44: ["m/44'/102006'/0'/0'/0'"],
mlDsa65: ["m/44'/102003'/0'/0'/0'"],
slhDsa: ["m/44'/102004'/0'/0'/0'", "m/44'/102004'/0'/0'/1'"],
falcon512: ["m/44'/102007'/0'/0'/0'", "m/44'/102007'/0'/0'/1'"],
mlKem: ["m/44'/102005'/0'/0'/0'", "m/44'/102005'/0'/0'/1'"],
};
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
function deriveBIP32Child(bip39Seed, childIndices) {
function deriveBIP32Child(bip39Seed, path) {
const hdKey = HDKey.fromMasterSeed(bip39Seed);
const path = `m/44'/1237'/0'/0/${childIndices.join('/')}`;
const child = hdKey.derive(path);
if (!child.privateKey) throw new Error(`Failed to derive at ${path}`);
return child.privateKey;
}
function derivePQSeedFromBIP32(bip39Seed, childIndices, requiredLength) {
if (childIndices.length === 1) {
const seed = deriveBIP32Child(bip39Seed, childIndices);
return seed;
function derivePQSeedFromBIP32(bip39Seed, paths, requiredLength) {
if (paths.length === 1) {
return deriveBIP32Child(bip39Seed, paths[0]);
} else {
let combined = new Uint8Array(0);
for (const idx of childIndices) {
const child = deriveBIP32Child(bip39Seed, [idx]);
for (const path of paths) {
const child = deriveBIP32Child(bip39Seed, path);
const newCombined = new Uint8Array(combined.length + child.length);
newCombined.set(combined);
newCombined.set(child, combined.length);
@@ -81,18 +90,30 @@ const seed = mnemonicToSeedSync(MNEMONIC, '');
const secpHd = HDKey.fromMasterSeed(seed).derive("m/44'/1237'/0'/0/0");
const secpPub = secpHd.publicKey;
// PQ keys
const mlDsa44Seed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.mlDsa44, PQ_SEED_LENGTHS.mlDsa44);
// PQ keys — v1 (legacy) paths
const mlDsa44Seed = derivePQSeedFromBIP32(seed, V1_PATHS.mlDsa44, PQ_SEED_LENGTHS.mlDsa44);
const mlDsa44Keys = ml_dsa44.keygen(mlDsa44Seed);
const mlDsa65Seed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.mlDsa65, PQ_SEED_LENGTHS.mlDsa65);
const mlDsa65Seed = derivePQSeedFromBIP32(seed, V1_PATHS.mlDsa65, PQ_SEED_LENGTHS.mlDsa65);
const mlDsa65Keys = ml_dsa65.keygen(mlDsa65Seed);
const slhDsaSeed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.slhDsa, PQ_SEED_LENGTHS.slhDsa);
const slhDsaSeed = derivePQSeedFromBIP32(seed, V1_PATHS.slhDsa, PQ_SEED_LENGTHS.slhDsa);
const slhDsaKeys = slh_dsa_sha2_128s.keygen(slhDsaSeed);
const falconSeed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.falcon512, PQ_SEED_LENGTHS.falcon512);
const falconSeed = derivePQSeedFromBIP32(seed, V1_PATHS.falcon512, PQ_SEED_LENGTHS.falcon512);
const falconKeys = falcon512.keygen(falconSeed);
const mlKemSeed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.mlKem, PQ_SEED_LENGTHS.mlKem);
const mlKemSeed = derivePQSeedFromBIP32(seed, V1_PATHS.mlKem, PQ_SEED_LENGTHS.mlKem);
const mlKemKeys = ml_kem768.keygen(mlKemSeed);
// PQ keys — v2 (per-algorithm coin types, all hardened)
const v2MlDsa44Seed = derivePQSeedFromBIP32(seed, V2_PATHS.mlDsa44, PQ_SEED_LENGTHS.mlDsa44);
const v2MlDsa44Keys = ml_dsa44.keygen(v2MlDsa44Seed);
const v2MlDsa65Seed = derivePQSeedFromBIP32(seed, V2_PATHS.mlDsa65, PQ_SEED_LENGTHS.mlDsa65);
const v2MlDsa65Keys = ml_dsa65.keygen(v2MlDsa65Seed);
const v2SlhDsaSeed = derivePQSeedFromBIP32(seed, V2_PATHS.slhDsa, PQ_SEED_LENGTHS.slhDsa);
const v2SlhDsaKeys = slh_dsa_sha2_128s.keygen(v2SlhDsaSeed);
const v2FalconSeed = derivePQSeedFromBIP32(seed, V2_PATHS.falcon512, PQ_SEED_LENGTHS.falcon512);
const v2FalconKeys = falcon512.keygen(v2FalconSeed);
const v2MlKemSeed = derivePQSeedFromBIP32(seed, V2_PATHS.mlKem, PQ_SEED_LENGTHS.mlKem);
const v2MlKemKeys = ml_kem768.keygen(v2MlKemSeed);
const vector = {
vectorType: 'nostr-pq-link-seed-to-pubkeys',
vectorVersion: 1,
@@ -129,7 +150,8 @@ const vector = {
notes: [
'The truncation rule is normative: for 48-byte seeds, two BIP32 children are concatenated (64 bytes) and the FIRST 48 bytes are used. For 64-byte seeds, all 64 bytes are used.',
'A future implementer who takes the last 48 bytes, or concatenates in the opposite order, will produce different keys and break seed-phrase recoverability.',
'A second implementation should run its own keygen from the same mnemonic and compare against the publicKeyHex values in this file.'
'A second implementation should run its own keygen from the same mnemonic and compare against the publicKeyHex values in this file.',
'LEGACY: this v1 vector pins the pre-v2 derivation (non-hardened children under m/44\'/1237\'/0\'/0/). It must never change — v1 seed recovery depends on it. New derivations use seed-to-pubkeys.v2.json.'
]
};
@@ -137,8 +159,62 @@ const outPath = join(VECTORS_DIR, 'seed-to-pubkeys.v1.json');
writeFileSync(outPath, JSON.stringify(vector, null, 2) + '\n');
console.log(`Wrote ${outPath}`);
console.log('secp256k1 pubkey:', bytesToHex(secpPub));
console.log('ml-dsa-44 pubkey:', bytesToHex(mlDsa44Keys.publicKey));
console.log('ml-dsa-65 pubkey:', bytesToHex(mlDsa65Keys.publicKey));
console.log('slh-dsa-128s pubkey:', bytesToHex(slhDsaKeys.publicKey));
console.log('falcon-512 pubkey:', bytesToHex(falconKeys.publicKey));
console.log('ml-kem-768 pubkey:', bytesToHex(mlKemKeys.publicKey));
console.log('ml-dsa-44 pubkey (v1):', bytesToHex(mlDsa44Keys.publicKey));
console.log('ml-dsa-65 pubkey (v1):', bytesToHex(mlDsa65Keys.publicKey));
console.log('slh-dsa-128s pubkey (v1):', bytesToHex(slhDsaKeys.publicKey));
console.log('falcon-512 pubkey (v1):', bytesToHex(falconKeys.publicKey));
console.log('ml-kem-768 pubkey (v1):', bytesToHex(mlKemKeys.publicKey));
// ── V2 vector: per-algorithm coin types, all hardened ────────────────────────
const v2Vector = {
vectorType: 'nostr-pq-link-seed-to-pubkeys',
vectorVersion: 2,
derivationScheme: 2,
description: 'V2 hardened derivation: per-algorithm coin types in the unregistered SLIP-44 102XXX range, all-hardened below the coin type. PQ keys are outside the Nostr coin branch (1237\'), so no compromise of the Nostr subtree can reach them (audit F-M3).',
mnemonic: MNEMONIC,
bip39SeedHex: bytesToHex(seed),
coinTypes: {
'ml-dsa-44': 102006,
'ml-dsa-65': 102003,
'slh-dsa-128s': 102004,
'falcon-512': 102007,
'ml-kem-768': 102005,
},
derivedPublicKeys: {
'ml-dsa-44': {
derivationPath: "m/44'/102006'/0'/0'/0'",
publicKeyHex: bytesToHex(v2MlDsa44Keys.publicKey)
},
'ml-dsa-65': {
derivationPath: "m/44'/102003'/0'/0'/0'",
publicKeyHex: bytesToHex(v2MlDsa65Keys.publicKey)
},
'slh-dsa-128s': {
derivationPath: "m/44'/102004'/0'/0'/0' + m/44'/102004'/0'/0'/1' (concatenated, first 48 bytes used)",
publicKeyHex: bytesToHex(v2SlhDsaKeys.publicKey)
},
'falcon-512': {
derivationPath: "m/44'/102007'/0'/0'/0' + m/44'/102007'/0'/0'/1' (concatenated, first 48 bytes used)",
publicKeyHex: bytesToHex(v2FalconKeys.publicKey)
},
'ml-kem-768': {
derivationPath: "m/44'/102005'/0'/0'/0' + m/44'/102005'/0'/0'/1' (concatenated, all 64 bytes used)",
publicKeyHex: bytesToHex(v2MlKemKeys.publicKey)
}
},
notes: [
'Same truncation rule as v1: two hardened children concatenated, first 48 bytes for 48-byte seeds, all 64 for 64-byte seeds.',
'Coin types 102003\u2013102005 match n_signer / the Rust signer; 102006\u2013102007 are this project\u2019s allocations for ML-DSA-44 and Falcon-512.',
'Falcon-512 is per-library: its keygen is rejection-sampling-based with no universal seed interface, so this vector pins @noble/post-quantum\u2019s behavior specifically.',
'The secp256k1 identity key is unchanged: NIP-06 m/44\'/1237\'/0\'/0/0 (see the v1 vector).'
]
};
const v2OutPath = join(VECTORS_DIR, 'seed-to-pubkeys.v2.json');
writeFileSync(v2OutPath, JSON.stringify(v2Vector, null, 2) + '\n');
console.log(`Wrote ${v2OutPath}`);
console.log('ml-dsa-44 pubkey (v2):', bytesToHex(v2MlDsa44Keys.publicKey));
console.log('ml-dsa-65 pubkey (v2):', bytesToHex(v2MlDsa65Keys.publicKey));
console.log('slh-dsa-128s pubkey (v2):', bytesToHex(v2SlhDsaKeys.publicKey));
console.log('falcon-512 pubkey (v2):', bytesToHex(v2FalconKeys.publicKey));
console.log('ml-kem-768 pubkey (v2):', bytesToHex(v2MlKemKeys.publicKey));
+2 -1
View File
@@ -34,6 +34,7 @@
"notes": [
"The truncation rule is normative: for 48-byte seeds, two BIP32 children are concatenated (64 bytes) and the FIRST 48 bytes are used. For 64-byte seeds, all 64 bytes are used.",
"A future implementer who takes the last 48 bytes, or concatenates in the opposite order, will produce different keys and break seed-phrase recoverability.",
"A second implementation should run its own keygen from the same mnemonic and compare against the publicKeyHex values in this file."
"A second implementation should run its own keygen from the same mnemonic and compare against the publicKeyHex values in this file.",
"LEGACY: this v1 vector pins the pre-v2 derivation (non-hardened children under m/44'/1237'/0'/0/). It must never change — v1 seed recovery depends on it. New derivations use seed-to-pubkeys.v2.json."
]
}
+43
View File
@@ -0,0 +1,43 @@
{
"vectorType": "nostr-pq-link-seed-to-pubkeys",
"vectorVersion": 2,
"derivationScheme": 2,
"description": "V2 hardened derivation: per-algorithm coin types in the unregistered SLIP-44 102XXX range, all-hardened below the coin type. PQ keys are outside the Nostr coin branch (1237'), so no compromise of the Nostr subtree can reach them (audit F-M3).",
"mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"bip39SeedHex": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
"coinTypes": {
"ml-dsa-44": 102006,
"ml-dsa-65": 102003,
"slh-dsa-128s": 102004,
"falcon-512": 102007,
"ml-kem-768": 102005
},
"derivedPublicKeys": {
"ml-dsa-44": {
"derivationPath": "m/44'/102006'/0'/0'/0'",
"publicKeyHex": "2c9763a5b69e423e6930b0430b1dd13db7ff14696a84bba4fe46ab8a1b2dee581b262caf3f31e2d4e14302b4f57c2f16d3d53f44c98a5e6a033a7e4066c3552a9dfce3fa5b0802599e1fbdb13d4e5e4f14aba08ef93f4b7ba254693952fc39405f74460a52e5b714ea1350ce1e4589534168c23c145e192cff98093398f7930fa747c56cac51a044dc2067527e53b7752eb9114279ca4a97aabb0e9b43ae3d321eb462cfdf2b51d7d93b926df6d02bfa4875c10e95cd2ea08ad1a345602cbc2cb12b22671a677911f72373e3f25e113cd7c34888c022afc79c24f8be57bb4eb3c3ffdb08a96bb80bc07a36ae947eb561178f9bab4c24f49e8188392a20476759a3462323b8a9d04edcd1b88b7734c94b0178215b3908cc55e753c2405ab37d12ca13ec15173b0694a51043dd98093929dad48460aabd3b3e80453bda0a4d0ebe0a5b76654e45900737d87f109997b5e34ef7141004c9537c01a246fc4c04268378cd30935d8e7d2b3847f910821bf9357dc858bb059270822a6f9a4f980b57f8bf4de69b1e55058888e7f6d6524220174b991302f55bfa0f5d979b374fb2c9394aba4e2a1bfcd55fc6495c397482b845b484d68ab1d4b58953f3d79708e0c4647b8deb3fdc89acbc83773d4eaac7b8e47ba30a443e827e180f686b0ae5484e9e780cb529ba3df65128597989cefed5891a01e66f1e4834ef960e819d92a24ee00a84d60878434717503d6f457c465b63302bbd4820df3a37edc66a23124aba228dc12ca6294ca99e1e8669d5a742122ef97ec42e7041e48198b3924f65528d2a766c7032a13ab50094e4cd1b2c5ad06dd1dcb59f70cd0b865a40b1e2198ca413750f6473a001fd908d5ddc640553786847c8dde4f65bef059cc132c8b75a68e2e2d8e4b08f437e1834ae7bb0d7f5e944030749fa73ad786e69cf361d242d1b8ce646ada8bf8e1dd19f87cf513d972f0b590139c1ab20ad44be86b49f5e6c38d5712355ebb484ed2b0f224ed8c8ad7eaa01389d9a5799950edfcb2aab19bf0fdfaaa7759b8deca275b031ebbbaf93e33840fa49c7833fd470917525447db2d0b926a82352cf7960d416d68a4defa51466e0123166fc567348c8aba1ae4d5c0fdc10aa017aa64f88d792ae61690cb4852a79b2d660e1cd405efe3b2f7e5d75edb338e5119a950925b6bdffb057502d241504b00a89dc2700753213912c0a543521674973ebd95aee5c9177962e33813240d44e8f5da85c059166dc1a5c8af10622b30eefd2cd1553ebba63a68f900bce19835cdba115177d22c85d2fd0502c4bb21f1d78bd14f480bbe8548ae02701bdce6f72060b87125faef235acee9ed229935071e4ac4951901f171cfe82f22987269a786fdca03b6ca2c6944b58d2756707a655c225da187d09d8263048f6ea29b248857f9cba4c7ec06698f5a049cff52f6c87a60078464fec9fb36048e5e7e93a4c201d0eecfecbcd9bb2364578ece831d56702854cbb33487fcfcdac49f8bcf4a88d884a890ce3907ec03c8ff11cb73b426ce2ebf6119929151959fededfa0c46ed3649220899b4996064f32f2bc995bf048e197d25ee64cade03a15663cd1f59e6deb11a9269e35c4523816e393be9e547f7879593c4fd253f1cafffa0530457cc3c58270ee2c1ce96ee4607f3026274120a064a7344d6321fdaf6821e1b19e8cef2684ad9e5aed4f34fa28846252c9ac1f7dc4bba3837ed0569a7d242e4a3c9f0956cdaa0087364ea797a75e85e044ead9eaced7cee38678cec7ab4f024c69b71c0858513bd2adeeb283a3e73dda8b2a413e4a5f56fd121275de5960df49b26555e7c651afea746f820b59c85c96a2"
},
"ml-dsa-65": {
"derivationPath": "m/44'/102003'/0'/0'/0'",
"publicKeyHex": "ea4ce61a7606342f76b5fa2462c27c010f5cd3e5467bb65961d68e87288856ee8339a05093eae7f6cd3f1e5b8a8685da980b7a5cc20597bfaf89cf604bdbe1820b3e58d457f684d8484cd4c87deaf2eafa2d654557f9c0c1b1c733772847bc25be39e98fdbe7768039ff6e87c2548a8841b88a6c17b84581f03aa4171fa737786d6639b9354e761703b7a68d6e49e41585f7ec1992e5b9d083b6a7a1d07b65a714bdfa3d7df23b432845264c0c786b9f1ec214f66b54ffa5b65b0d9ede667e40d10136e643aec6bb25cd1cae873d245fde196fb5e2c081e10a69e2dfd3b160adf1cc5a0deefb1a5256d9b6ba15033a89d5fe8b0eef5afc8c1fb1ce6d1c63d177127ead73ba469c30b6ac2502f12aa96f9d930c0287bf793b740b3fc13c0a7d61c170cba75ad7e63f93a6937c369bbdae4d35035583f5478b943e1c21014b2d356449872ee978f0210fa4d4661548d34b2a8ad3f1686fa834f5c431d9e8e55725974cf84acfbcbf2f559dc43f00cf81d53a5c87b7d158162733006721d43fe581ec278331b78a87587011c752b782153ef5d09ccee9e00742d4cf0cc07d343df557790e7674915cc631113ae2fd4921c477c1bf42214d6ab5a42cea1c596f72d17101e0467a8fc54c0a2e19dc5db7d623196ea68f320bda3a8c519ce30666ea0c963de4ee9036beb1a025263aedac8137f5348ceb5762dfc8b689dbfdd889c531920a01e19dc6a40a183bcc4b8dd2100c72746532d9db2c3f668b5e21df428e9b74194464e66f0636516c8c79295f0ec0c08d227bccdb83a92fd4dfcdd552b972e7c3b0530bfe5310603c64e6c2972350762448d448e15279becc2582654a1b398619ce6f2450546b1c602dd1a3f640e878089e43cd6a1594bf1daaf4005aee41ab91b60e9dca9dab461c95d20986085c344a3fc9c3801548484b09e42a507ae44de14efbc471083e7e2992d210c8b022b2d3d840319f59c3554defd027afdf824b955a84385724f1aef727c06d6dda723bf82dff4e7e45265ab56d5d492b2104c6cc363632d905cf4b92e5253e9d10a6496e85959b0e637ee21ca6a8c63716d9ee8e722960113b013a393459079ec53248ea1df90f28bef28f182bdcfb93e94ba455390693eebac77fc670ffb8a0e23585a8b6743a20ebf5555738a23efc24ec681bb3beeeda82e346487c3295f5ea25464f6de5b96f9eebe4539a42392b598365724e786d9a39a5266e4e1a2e1527f5161b3d2f76404b9b52b5d158835f50ecbd6011b172bf55157eb5ae661a75593d6af0adbeaf755244f7d5204e2bc20ad2a2384ea83156df29fcb5cb3ab105fbc4489ddebc395eaf7fed7e71ec07294cb004639c17cc6d8ca5e7a6472208768f82fe25999c551d58552114d8af3dc9b15242dc784654f58517d06d76f99ecddf9c38c5b20d672cc7f6981514ef8f781378c5db2a9d0410428fe97b33708fe946c60aa35e08d85de2d5dbf8659867074960d9b1c345949c1ee3625ed47935007bf496f856c9a40d7ab0ada31d6466730c073e534de7e28292c40237d4db41cffa97836459a585d957dbc7f9983557f4d4894b342a85539343461ab1d86e5d25389959e7ebf3bda57df9a8de8b2096162b5b73388cb4297434355ab36fb884f67c5f3dbd7dcdd75aca4a660219d0e52af7ed2dfa0d8c50bbf3088c947f05a6373dc18321c3e33717780a9e074cea762bda740e520e28ea750014bdc407ff7e581fdcf589f84c26e1b7cdfd76e21fd63b8a2dffba9fe2e3351708f9281f0344a96ef2b10c19709e980fa152f926569bc80cad4a4741d6349ffe52a2162c97e08c47d26a56727f84ab4df26be45318be6286015cb68ad0a26905b321fbe1a889151eed0e8b5a30e7132a4d04c7243804e34addfb7e1e68eb82f3ea647a181e731982de413dd77742d623cf78fdf497ea263452926890f6a4f5d7e9c1b5e07e05544bc21496ef09ecbc32e6852dbd5d5529a32227455d02334c292abdcd9e55b4e1d173c3d3bb44e42205d12465ef56f6ca15c2eac1e18d2afeb4393ab69c55084f37217d078894a44cb43fb2d884a8b44cd39587db1b72f1f4622014945ac1f2d317316a1fe6cae7134b7028fe21055b50a2d7de38281617ba340130f2933ff93c755aac68cc8938ff12efd5055cf646764e4ef4868d6e1cc54e1087798c9bf5c7be5ac8d59983a82eb2aae3157c4f0cb00e33d296a2c1b1165f985a5a104ebcb25845831d796df16e70c70a4b40706240f2ea747c751c3fff24b44a9ba38ab7c98aebba68bf0a128d6251fea943ac86e1ff838c67166a13264ac512f4e708719f5df6ce6cb5622ec21add51a071c7b1b4c67801d487238e1eab8f7806ff5590bd79ce55b4891570a5cda6158c9d4db71ec1db65081525a980b5c19ebfcc72376493f360378994f3e791b59cce41ab980820203b94a70bfe6b011c77b9adf7211a54affa5880f15fc8bbb00f833d85fba1b4d17135e3d70662c588198f9fca09f87c01430ddcad975974a36f5dd76fd0c5b89b7be76b6d4740fde86b2e33e6919af86b68368dae5ad3f4dd0beae887dc6a13ac051095b4cb8504ae617f6fecf6f90c3bd9798d658952a5e71b3904520b22b88d1e40bd51bb02fdf864ef96942dd42a757944092c582eb69f39b75b4bdf9280bf49d0b0a6e41ac2c3194cd0bd9d28b896dc353e15266824b4768df4f7153e0a33e670125a8518073dfcdcbfe0f452d193b870a72c6bc34cafa7e28f18b9d7a1b7"
},
"slh-dsa-128s": {
"derivationPath": "m/44'/102004'/0'/0'/0' + m/44'/102004'/0'/0'/1' (concatenated, first 48 bytes used)",
"publicKeyHex": "193676e5236af19da41c0175581ee7f888b51633a1d125ad8f32a344b60af970"
},
"falcon-512": {
"derivationPath": "m/44'/102007'/0'/0'/0' + m/44'/102007'/0'/0'/1' (concatenated, first 48 bytes used)",
"publicKeyHex": "0958504aa577d76896d84902d64f2e3cf298a7b7c5949dfcacd73dc4e1b34e3fa154d01e57c4ed17eb503e629c0f649dc8500be4807866282573a0cc6a98ea27a28cd113411a076a34350bdf1a263bc1069932a4194c384060af06ab6b79378530ebf378fed2181a1e7142ed9dd54eecf2a0391b9640260d1602e82bcec2080b641120409a66b8cebc2817e34c1fb9cd77c1657a2e5cf1115c6f17844814b702aa742d0cab72631f22aee2fa984b4e818adaba139a2e5f3aca8eb53b9fa7a4fe305239e6412ea436c997efb2436202244fa4461b0c1cda0925a58f7acbe4b5a8958c5eeb89f88bbb88b9cf15c90db59fed0406e719c9be4cd718a3876e1e9a00a9850fbd396d90c6c6224982f8a2d7b6473926755c13cbc9f748c942e4ea5a38a8b94ff5fd8f322aedbce7752264135541c648a47657545fd5d99d934366f37173dc701bb91938ef0689507c758a091f756004f3d19044d42cb2940980a60696153370e74d9b1f902ff6b84e856ffc7d0b6117e08f18569759101c057610659f8cb67d12fc85cf0a24a5121016068f7ba521389009db745d282c2b8dc917a4d194955de5b4b6409d365241e7ad68f970a3ae1e3909b0d426e46625b427a24c153a5fdd0501b05e77244e71578c908eafdacf9a52e16b4d8902c13bc168837e5079ec8103791ca541e74a37b119c786291e141d4167a1a949bd8f6ac7a4bd4d0c6c8d51b04353da62cdd8a98e8f7c04446825439ce80dbe1808776542154cb9497894ed0ab57c35502b2d534a3895038b3b639a8947652d922ded9dc1b2d2e6624c19c8340bc90baad91213c822512001f7d3a906b3d4c4e0e255fb04eeb822feafc595e0e2bbee0eff1a6ddfd991cff22c951333e2e6daa1f4e06455d61761b5105a1ce8f89591d475d41fad09b6f248b96f323fd2b0e18853ee29f0abf9171e4f9a900d574ea76e28aee1a45edf011b8cf009e183c72e3e7b4107a9d0215182709865bc50114088e6521007567684cee58a9504d927be41f0b0aafdf01c9551a12e38f6a203da7ffdd0c3cc14ba49b83ec9ac2d801ed282752fced71c0abf794d5ea83ef5d7f56064a4099197219829439d98197a898fc46cdd8630b29f555ca4510b8e8a78ac2c3b5a62a0aa7f483a7a345ad972b2de6484d01a31f86fc9cf6ee836e27b62aace33b6f0c3145b644f5d2ea665231aed69d6c490222346138d73c0de998f3e15387256ad7b6d1cf2f8eb83cb2b76cf206a41f864dc5d2398c5c9"
},
"ml-kem-768": {
"derivationPath": "m/44'/102005'/0'/0'/0' + m/44'/102005'/0'/0'/1' (concatenated, all 64 bytes used)",
"publicKeyHex": "0980c83da4b081fa8bf69b3e00ddbd47824da54410bfd539fe58783c9b66679c272f32adff0278902a92d2c5a6f5649d5a928797d893b8ba789f67870786aa90a260eed4b7eaa43e6beac4e82c5c14192b44cac0521c4584c02a10740e6f54cd86f3293f10c285c20f33f4af0a87afae853043ca4afc99baaca3a79d483befea212a1b25c12cac8672a238257ba9a876dc6558eb6c397a1694a6e9bcdcf6b121789f8a64a7ced10cea2b5e3ac15bf9807e00ad37b7b1b46dd5c88f21b93482b94ae884744172e2603d15550df77988bac53a5ad22cd357992b2a7795e516a5763a1f093be33a86634cc81f1c33c1f61104629c9c2022040988a44778a52844618241ce0a90790041e461b2cc5212a8220177e08a9bbca3a608663a558287f91095d21321233e9d675599b82867c082096519fea29107a36d6304a183e0c38a590f3c272a06e36e8e62ba6a70b31d3acfb1c52d9205cd5713a96f40a9b0683badd3bcb1e974ade1a68e6a74e1f2097856bc09b75212a503bc97cd90c7ba1a625c4b5829eed65213880f9dcb99a4604eb3a479a0a08f4e28c5b29c9e8773991f2847e310be826bb1fa968b1bb21601c25ec778ae62d1582f94c83fb63749d5206ef89507fa2c331a18e5f11adbabcce14a5b36b39f6c1a3a21c733c9412083e089171ca69a3362dc9c7198b83bf9a26174879f0ed32f2afbcbfa4ac92dac74234a1017197a422220e68834fb2b9ddda915002bc392b69db3985236898a7f602b306c05b26b85f4c9553a7630c1b68890f97bafc96a9e0b06b3f3222cf47f6118894fa469ef8386c14172b2fc19e030574f34c170f33e85849923072cc131508055436fb7c7f88c3e7eeb0c41c02d48d1c759d72c1609240e027d6a6a314784ae924c40257c6bed08508ba907d06cc3db8b619c5594413311ad914895a3946ab31b6f35373a286721d3107fa108da0766af2b2ab2813d030254ea635dd1252153e210ee74c2120722727683d5874dae0b9d6e5bbd10939d543c95d227722903c99402c48a2341d48b0ebd143e5ff0500e910c9f071d5f4a198f0068e241388e4b23fd0582269158c3e0af13d889c2f1afeaf308d6c3cbcfe653345c0384d08a399b6902c550721963e397112ec31f430773c68900aea96b6832709eb918ca3483affb6866488e4fa726b7550f2ed106a9d71bcca0928885947f935255b56e18a54ce18a5257934bc02b3ea531978d61501e435203458f51d1bd58d555c1134cebd6ccd5e19b3545a3545b21aea4919123996be824e11b2f312b43b3f52db7d08d98156fb2dcc8986b78192a97419102290123660346dd6ccd89544f0aa246ddca1cf586266d187e227872cb9c6b503c8546249eff344a3ea9c697031858c88a09201a8194a0ba1a9db2663a936762e3c50293e8a591ebc84a9c128ce2cf8ff941b883bb448075af7868325b906c75b51e5394a6e617bcbaa80a839e81a0489210b7c3c74c260a7d1025b982e192d5b192c0f91e32c079fde60b100501e25564e29368e9591abaa80a7f79cc375489c64980bf9785959c590a50265c32097b5866dda92542198068237ab0d73367f99c846b8d4649b8878520b4bc40252c0719967bfd1a2d08f234788f82ef24b5a6f1f59bf0829d7461754110f0f21088f547beb0"
}
},
"notes": [
"Same truncation rule as v1: two hardened children concatenated, first 48 bytes for 48-byte seeds, all 64 for 64-byte seeds.",
"Coin types 102003102005 match n_signer / the Rust signer; 102006102007 are this projects allocations for ML-DSA-44 and Falcon-512.",
"Falcon-512 is per-library: its keygen is rejection-sampling-based with no universal seed interface, so this vector pins @noble/post-quantums behavior specifically.",
"The secp256k1 identity key is unchanged: NIP-06 m/44'/1237'/0'/0/0 (see the v1 vector)."
]
}
Executable
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
#
# Upload the Nostr Quantum Preparation web app to the TEST endpoint.
#
# Usage: ./upload-test.sh
#
# Uploads the contents of www/ to ubuntu@laantungir.net:html/quantum-prep-test/
# The site will be accessible at https://laantungir.net/quantum-prep-test/
#
# This is the staging deployment for the v2 hardened derivation scheme.
# The production site (https://laantungir.net/quantum-prep/) is NOT touched.
# Once the v2 flow is confirmed working here, run ./upload.sh to promote it.
#
set -e
SERVER="ubuntu@laantungir.net"
REMOTE_DIR="html/quantum-prep-test"
LOCAL_DIR="$(dirname "$0")/www"
echo "🔒 Nostr Quantum Preparation — Upload (TEST endpoint)"
echo "====================================================="
echo "Server: $SERVER"
echo "Remote: $REMOTE_DIR"
echo "Local: $LOCAL_DIR"
echo ""
echo "⚠️ This deploys to the TEST endpoint only."
echo " Production (quantum-prep) is NOT modified."
echo ""
# Ensure the bundle is up to date
echo "📦 Building PQ crypto bundle..."
node "$(dirname "$0")/build-pq-bundle.js"
echo ""
# Create the remote directory if it doesn't exist.
# /var/www/html is root-owned, so creating a NEW top-level directory needs
# sudo; we then chown it to ubuntu so subsequent rsyncs work normally.
echo "📁 Ensuring remote directory exists..."
ssh "$SERVER" "sudo mkdir -p $REMOTE_DIR && sudo chown ubuntu:ubuntu $REMOTE_DIR"
echo ""
# Upload files using rsync
echo "📤 Uploading files..."
rsync -avz --delete \
--exclude='*.map' \
"$LOCAL_DIR/" \
"$SERVER:$REMOTE_DIR/"
echo ""
echo "✅ Upload complete!"
echo "🧪 Test site: https://laantungir.net/quantum-prep-test"
echo ""
echo "To promote to production after confirmation: ./upload.sh"
+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);
+55 -29
View File
@@ -162,6 +162,28 @@
word-break: break-word;
}
/* Mandatory entropy-collection state for the seed box. While collecting,
the box is an interactive zone (not a word grid): centered prompt,
crosshair cursor, focus outline for keyboard users. Once 64 events are
collected the .pq-seed-collecting class is removed and the box reverts
to the word-grid layout above. */
.pq-seed-display.pq-seed-collecting {
display: block;
min-height: 120px;
cursor: crosshair;
text-align: center;
}
.pq-seed-display.pq-seed-collecting:focus {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
}
.pq-seed-collect-prompt {
font-size: 14px;
color: var(--primary-color);
line-height: 1.6;
padding: 18px 10px;
}
.pq-seed-word { display: flex; align-items: center; gap: 8px; }
.pq-seed-number { color: var(--muted-color); font-size: 12px; min-width: 20px; }
@@ -422,7 +444,23 @@
<label style="margin-right: 12px;"><input type="radio" name="pqWordCount" value="256" id="pqWordCount24" checked> 24 words (recommended)</label>
<label><input type="radio" name="pqWordCount" value="128" id="pqWordCount12"> 12 words (testing only)</label>
</div>
<div class="pq-seed-display" id="pqSeedDisplay"></div>
<!--
Mandatory entropy collection zone.
Before generation: shows a prompt + progress bar and collects
mouse-move / keypress events. After 64 events the seed is
generated (CSPRNG + user entropy via SHA-256) and the box
switches to the word-grid layout. tabindex makes it keyboard-
focusable so laptop-only users can type into it.
-->
<div class="pq-seed-display pq-seed-collecting" id="pqSeedDisplay" tabindex="0">
<div class="pq-seed-collect-prompt" id="pqSeedCollectPrompt">
Move your mouse and/or press random keys inside this box to generate your seed phrase.
<div style="margin-top: 10px; height: 6px; background: var(--muted-color); border-radius: 3px; overflow: hidden;">
<div id="pqSeedEntropyBar" style="height: 100%; width: 0%; background: var(--accent-color); transition: width 0.2s;"></div>
</div>
<div id="pqSeedEntropyCount" style="margin-top: 6px; font-size: 12px; color: var(--muted-color);">0 / 64</div>
</div>
</div>
<div class="pq-info-text" style="margin-top: 12px; font-size: 13px; color: var(--accent-color);">
<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
@@ -430,20 +468,6 @@
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>
<div class="pq-info-text" style="margin-top: 15px; font-size: 13px;">
<strong>Optional:</strong> Add extra entropy by moving your mouse and typing in the box below.
This mixes your input with the browser's random number generator, making the seed unpredictable
even if the browser's RNG is compromised. You can skip this — a seed has already been generated for you above.
</div>
<div id="pqEntropyBox" style="background: var(--secondary-color); color: var(--primary-color); border: var(--border); border-radius: var(--border-radius); padding: 15px; font-size: 13px; min-height: 60px; margin: 10px 0; cursor: text; text-align: left;" tabindex="0">
<span id="pqEntropyHint">Optional: click here and type random characters, move your mouse to add entropy...</span>
<div style="margin-top: 8px; height: 6px; background: var(--muted-color); border-radius: 3px; overflow: hidden;">
<div id="pqEntropyBar" style="height: 100%; width: 0%; background: var(--accent-color); transition: width 0.2s;"></div>
</div>
</div>
<div class="pq-button-row">
<button class="pq-button pq-button-secondary pq-hidden" id="pqRegenerateWithEntropyBtn" disabled>Generate with Extra Entropy</button>
</div>
<label class="pq-checkbox-row">
<input type="checkbox" id="pqSeedConfirmed" />
I have written down my seed phrase
@@ -464,6 +488,17 @@
</div>
<textarea class="pq-textarea" id="pqSeedInput" style="min-height: 80px; width: 100%; background: var(--secondary-color); border: var(--border); border-radius: var(--border-radius); padding: 12px; font-size: 14px; color: var(--primary-color); font-family: monospace; margin: 10px 0;" placeholder="abandon ability able about above absent absorb abstract absurd abuse access accident..."></textarea>
<div id="pqSeedValidation" style="font-size: 13px; margin: 5px 0;"></div>
<label class="pq-checkbox-row" style="font-size: 13px;">
<input type="checkbox" id="pqSeedV1Recovery" />
This is a v1-era seed (created before the v2 hardened scheme) — derive its v1 keys for recovery
</label>
<div class="pq-info-text" id="pqSeedV1Note" style="display: none; font-size: 12px; color: var(--accent-color);">
<strong>v1 recovery mode:</strong> your keys will be derived with the legacy v1 paths
(non-hardened children under m/44'/1237'/0'/0/) so you can confirm they match your published
key-link event. After confirming, you can re-run the flow with the same seed to mint a new
v2 event — the v2 keys are independent of the v1 keys, so the old xpub-leak risk no longer
applies going forward.
</div>
<button class="pq-button" id="pqSeedOwnContinueBtn" disabled>Continue</button>
</div>
</div>
@@ -481,32 +516,32 @@
<div class="pq-checklist" id="pqKeyList" style="margin-bottom: 15px;">
<div class="pq-checklist-item" id="pqKeyMlDsa44">
<span class="pq-checklist-box"></span>
<span>ML-DSA-44 (Dilithium) — FIPS 204 · Cat 2 · 1312-byte pubkey · m/44'/1237'/0'/0/1
<span>ML-DSA-44 (Dilithium) — FIPS 204 · Cat 2 · 1312-byte pubkey · <span class="pq-derive-path" data-alg="ml-dsa-44">m/44'/102006'/0'/0'/0'</span>
<div class="pq-key-pubkey" id="pqKeyMlDsa44Pub" style="margin-top: 2px;"></div>
</span>
</div>
<div class="pq-checklist-item" id="pqKeyMlDsa65">
<span class="pq-checklist-box"></span>
<span>ML-DSA-65 (Dilithium) — FIPS 204 · Cat 3 · 1952-byte pubkey · m/44'/1237'/0'/0/2
<span>ML-DSA-65 (Dilithium) — FIPS 204 · Cat 3 · 1952-byte pubkey · <span class="pq-derive-path" data-alg="ml-dsa-65">m/44'/102003'/0'/0'/0'</span>
<div class="pq-key-pubkey" id="pqKeyMlDsa65Pub" style="margin-top: 2px;"></div>
</span>
</div>
<div class="pq-checklist-item" id="pqKeySlhDsa">
<span class="pq-checklist-box"></span>
<span>SLH-DSA-128s (SPHINCS+) — FIPS 205 · Cat 1 · 32-byte pubkey · m/44'/1237'/0'/0/3+4
<span>SLH-DSA-128s (SPHINCS+) — FIPS 205 · Cat 1 · 32-byte pubkey · <span class="pq-derive-path" data-alg="slh-dsa-128s">m/44'/102004'/0'/0'/0' + /1'</span>
<div class="pq-key-pubkey" id="pqKeySlhDsaPub" style="margin-top: 2px;"></div>
</span>
</div>
<div class="pq-checklist-item" id="pqKeyFalcon">
<span class="pq-checklist-box"></span>
<span>Falcon-512 — FIPS 206 (draft) · Cat 1 · 897-byte pubkey · m/44'/1237'/0'/0/5+6
<span>Falcon-512 — FIPS 206 (draft) · Cat 1 · 897-byte pubkey · <span class="pq-derive-path" data-alg="falcon-512">m/44'/102007'/0'/0'/0' + /1'</span>
<div class="pq-key-pubkey" id="pqKeyFalconPub" style="margin-top: 2px;"></div>
<div style="font-size: 11px; color: var(--accent-color); margin-top: 4px;">⚠ Draft standard — may need re-issuing if FIPS 206 changes</div>
</span>
</div>
<div class="pq-checklist-item" id="pqKeyMlKem">
<span class="pq-checklist-box"></span>
<span>ML-KEM-768 (Kyber) — FIPS 203 · Cat 3 · 1184-byte pubkey · m/44'/1237'/0'/0/7+8
<span>ML-KEM-768 (Kyber) — FIPS 203 · Cat 3 · 1184-byte pubkey · <span class="pq-derive-path" data-alg="ml-kem-768">m/44'/102005'/0'/0'/0' + /1'</span>
<div class="pq-key-pubkey" id="pqKeyMlKemPub" style="margin-top: 2px;"></div>
</span>
</div>
@@ -549,9 +584,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>
@@ -632,12 +664,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>
+222 -88
View File
@@ -1,10 +1,10 @@
import {
generateSeedPhrase,
generateSeedPhraseWithEntropy,
mnemonicToSeed,
isValidMnemonic,
deriveSecp256k1FromSeed,
derivePQKeysFromSeed,
derivationPathForAlgorithm,
buildKind1Announcement,
buildProofCarrier,
computeEventId,
@@ -16,6 +16,7 @@
bytesToBase64,
base64ToBytes,
bytesToHex,
hexToNpub,
PQ_KEY_INFO,
NIP_QR_KIND,
buildUpgradedEvent,
@@ -42,6 +43,9 @@
let pqSecpKeys = null;
let pqEvent = null; // kind 9999 proof carrier event
let kind1Event = null; // kind 1 announcement event
// Derivation scheme for this run: 'v2' (default, hardened coin-type paths)
// or 'v1' (legacy recovery of a pre-v2 seed). See plans/v2-hardened-derivation.md.
let pqScheme = 'v2';
/* ================================================================
DOM REFERENCES
@@ -451,26 +455,93 @@
/* ================================================================
STEP 2: SEED PHRASE
================================================================ */
// Mandatory user-entropy collection. The seed box (pqSeedDisplay) is the
// collection zone: the user must move their mouse and/or press keys inside
// it until ENTROPY_REQUIRED_EVENTS samples are gathered. The seed is then
// generated by mixing CSPRNG output with the collected user entropy via
// SHA-256 (generateSeedPhraseWithEntropy), so the seed stays unpredictable
// even if the browser's CSPRNG is compromised. Either mouse or keyboard
// input counts toward the total, so laptop-only users aren't blocked.
const ENTROPY_REQUIRED_EVENTS = 256; // ~512 bits of true user entropy
// Minimum interval between accepted mousemove samples (ms). Browsers fire
// mousemove at ~60-1000+ Hz; spacing samples out reduces correlation
// between consecutive deltas (each sample carries more independent
// hand-jitter), increasing the true entropy per sample.
const MOUSE_SAMPLE_MIN_INTERVAL_MS = 32;
let userEntropyChunks = [];
let entropyCollecting = false;
let entropyCollected = 0;
let entropyListenersBound = false;
// Last mouse position, used to capture deltas (dx, dy) which hold the
// unpredictable hand-jitter entropy better than absolute coordinates.
let lastMouseX = null;
let lastMouseY = null;
// Timestamp of the last accepted mousemove sample (for throttling).
let lastMouseSampleTime = 0;
function getSelectedEntropyBits() {
const radio = document.querySelector('input[name="pqWordCount"]:checked');
return radio ? parseInt(radio.value, 10) : 256;
}
// Render the seed box in its collecting (pre-generation) state.
function renderSeedCollectingState() {
pqSeedDisplay.classList.add('pq-seed-collecting');
pqSeedDisplay.innerHTML =
'<div class="pq-seed-collect-prompt" id="pqSeedCollectPrompt">'
+ 'Move your mouse and/or press random keys inside this box to generate your seed phrase.'
+ '<div style="margin-top: 10px; height: 6px; background: var(--muted-color); border-radius: 3px; overflow: hidden;">'
+ '<div id="pqSeedEntropyBar" style="height: 100%; width: 0%; background: var(--accent-color); transition: width 0.2s;"></div>'
+ '</div>'
+ '<div id="pqSeedEntropyCount" style="margin-top: 6px; font-size: 12px; color: var(--muted-color);">0 / '
+ ENTROPY_REQUIRED_EVENTS + '</div>'
+ '</div>';
const bar = document.getElementById('pqSeedEntropyBar');
if (bar) bar.style.width = '0%';
}
function updateEntropyProgress() {
const bar = document.getElementById('pqSeedEntropyBar');
const count = document.getElementById('pqSeedEntropyCount');
const pct = Math.min(100, Math.round((entropyCollected / ENTROPY_REQUIRED_EVENTS) * 100));
if (bar) bar.style.width = pct + '%';
if (count) count.textContent = `${entropyCollected} / ${ENTROPY_REQUIRED_EVENTS}`;
}
// Collect low-entropy environment/fingerprint values and mix them into the
// seed. Each of these is low-entropy on its own (and a remote attacker can
// often query the same values), but together they add a few extra bits and
// cost nothing. They are mixed via SHA-256 alongside the CSPRNG and user
// input, so they can only help, never hurt.
function collectSystemEntropy() {
const parts = [];
try { parts.push('ua:' + navigator.userAgent); } catch (e) {}
try { parts.push('plat:' + navigator.platform); } catch (e) {}
try { parts.push('lang:' + navigator.language); } catch (e) {}
try { parts.push('langs:' + (navigator.languages || []).join(',')); } catch (e) {}
try { parts.push('sw:' + screen.width); } catch (e) {}
try { parts.push('sh:' + screen.height); } catch (e) {}
try { parts.push('scd:' + screen.colorDepth); } catch (e) {}
try { parts.push('dpr:' + window.devicePixelRatio); } catch (e) {}
try { parts.push('hwc:' + navigator.hardwareConcurrency); } catch (e) {}
try { parts.push('mem:' + navigator.deviceMemory); } catch (e) {}
try { parts.push('tz:' + Intl.DateTimeFormat().resolvedOptions().timeZone); } catch (e) {}
// performance.now() at collection time adds a little timing jitter.
try { parts.push('now:' + performance.now()); } catch (e) {}
return parts.join('|');
}
// Generate the seed from the collected user entropy + system entropy +
// CSPRNG and reveal the word grid in the same box.
function generateAndShowSeed() {
const entropyBits = getSelectedEntropyBits();
// Use user entropy if available, otherwise pure CSPRNG
if (userEntropyChunks.length > 0) {
const userEntropy = new TextEncoder().encode(userEntropyChunks.join(''));
pqMnemonic = generateSeedPhraseWithEntropy(userEntropy, entropyBits);
otsLog(`Seed generated with ${userEntropyChunks.length} entropy chunks from user input (${entropyBits}-bit).`);
} else {
pqMnemonic = generateSeedPhrase(entropyBits);
otsLog(`Seed generated (${entropyBits}-bit).`);
}
// Prepend system entropy so it is always mixed in, regardless of how
// many mouse/keyboard samples were collected.
const combined = collectSystemEntropy() + '\n' + userEntropyChunks.join('\n');
const userEntropy = new TextEncoder().encode(combined);
pqMnemonic = generateSeedPhraseWithEntropy(userEntropy, entropyBits);
otsLog(`Seed generated with ${userEntropyChunks.length} user entropy samples + system entropy (${entropyBits}-bit).`);
const words = pqMnemonic.split(' ');
pqSeedDisplay.classList.remove('pq-seed-collecting');
pqSeedDisplay.innerHTML = words.map((word, i) =>
`<div class="pq-seed-word"><span class="pq-seed-number">${i + 1}.</span>${word}</div>`
).join('');
@@ -478,46 +549,70 @@
pqSeedContinueBtn.disabled = true;
}
// Entropy collection from mouse and keyboard (optional)
function startEntropyCollection() {
const entropyBox = document.getElementById('pqEntropyBox');
const entropyBar = document.getElementById('pqEntropyBar');
const entropyHint = document.getElementById('pqEntropyHint');
const regenWithEntropyBtn = document.getElementById('pqRegenerateWithEntropyBtn');
const maxChunks = 64; // collect up to 64 events
let collected = 0;
// Reset the collection zone to its pre-generation state and (re)bind the
// mouse/keyboard listeners. Called when entering step 2, switching word
// counts, or switching back to the "Generate New Seed" panel.
function resetSeedCollection() {
userEntropyChunks = [];
entropyCollected = 0;
lastMouseX = null;
lastMouseY = null;
lastMouseSampleTime = 0;
pqMnemonic = null;
pqSeedConfirmed.checked = false;
pqSeedContinueBtn.disabled = true;
renderSeedCollectingState();
updateEntropyProgress();
bindEntropyListeners();
}
function updateBar() {
const pct = Math.min(100, Math.round((collected / maxChunks) * 100));
entropyBar.style.width = pct + '%';
if (collected >= maxChunks) {
entropyHint.textContent = 'Enough entropy collected. Click "Generate with Extra Entropy" to use it.';
} else if (collected > 0) {
entropyHint.textContent = `Collected ${collected}/${maxChunks} entropy samples. Keep going, or click "Generate with Extra Entropy" to use what you have.`;
}
// Show the "Generate with Extra Entropy" button once we have at least 1 chunk
if (collected > 0 && regenWithEntropyBtn) {
regenWithEntropyBtn.classList.remove('pq-hidden');
regenWithEntropyBtn.disabled = false;
}
}
// Bind mouse/keyboard collection listeners to the seed box. Idempotent.
function bindEntropyListeners() {
if (entropyListenersBound) return;
entropyListenersBound = true;
function collectEvent(data) {
if (collected >= maxChunks) return;
userEntropyChunks.push(data + ':' + Date.now() + ':' + Math.random());
collected++;
updateBar();
if (entropyCollected >= ENTROPY_REQUIRED_EVENTS) return;
// performance.now() gives sub-millisecond monotonic timing jitter,
// which is harder for a remote attacker to predict than Date.now().
userEntropyChunks.push(data + ':' + performance.now() + ':' + Math.random());
entropyCollected++;
updateEntropyProgress();
if (entropyCollected >= ENTROPY_REQUIRED_EVENTS) {
// Threshold reached — generate the seed immediately.
generateAndShowSeed();
}
}
entropyBox.addEventListener('mousemove', (e) => {
collectEvent(`m${e.clientX},${e.clientY}`);
pqSeedDisplay.addEventListener('mousemove', (e) => {
// Throttle: only accept a sample if enough time has passed since the
// last one. This doubles the effective interval between samples
// (browsers normally fire at ~16ms/frame), reducing correlation
// between consecutive deltas so each accepted sample carries more
// independent entropy.
const now = performance.now();
if (now - lastMouseSampleTime < MOUSE_SAMPLE_MIN_INTERVAL_MS) return;
lastMouseSampleTime = now;
// Capture deltas (dx, dy) between consecutive samples rather than
// absolute coordinates. The micro-jitter in deltas is the real
// unpredictable entropy in mouse movement; absolute positions are
// highly correlated between samples and bounded by the box size.
const dx = lastMouseX === null ? 0 : e.clientX - lastMouseX;
const dy = lastMouseY === null ? 0 : e.clientY - lastMouseY;
lastMouseX = e.clientX;
lastMouseY = e.clientY;
collectEvent(`m${dx},${dy}`);
});
entropyBox.addEventListener('keypress', (e) => {
pqSeedDisplay.addEventListener('keypress', (e) => {
collectEvent(`k${e.key}:${e.keyCode}`);
});
entropyBox.addEventListener('keydown', (e) => {
// Also capture non-printable keys
if (e.key.length > 1) collectEvent(`k${e.key}:${e.keyCode}`);
pqSeedDisplay.addEventListener('keydown', (e) => {
// Capture non-printable keys too (Arrow, Shift, etc.). Prevent default
// scrolling/behavior when the box is focused and still collecting.
if (entropyCollected < ENTROPY_REQUIRED_EVENTS) {
if (e.key.length > 1) e.preventDefault();
collectEvent(`k${e.key}:${e.keyCode}`);
}
});
}
@@ -527,16 +622,8 @@
document.getElementById('pqSeedOwnPanel').style.display = 'none';
document.getElementById('pqSeedToggleGenerate').classList.add('pq-seed-toggle-active');
document.getElementById('pqSeedToggleOwn').classList.remove('pq-seed-toggle-active');
// Generate a fresh seed when switching to the generate panel
userEntropyChunks = [];
generateAndShowSeed();
// Reset the entropy UI
const entropyBar = document.getElementById('pqEntropyBar');
const entropyHint = document.getElementById('pqEntropyHint');
const regenWithEntropyBtn = document.getElementById('pqRegenerateWithEntropyBtn');
if (entropyBar) entropyBar.style.width = '0%';
if (entropyHint) entropyHint.textContent = 'Optional: click here and type random characters, move your mouse to add entropy...';
if (regenWithEntropyBtn) { regenWithEntropyBtn.classList.add('pq-hidden'); regenWithEntropyBtn.disabled = true; }
// Entering / re-entering the generate panel resets the collection zone.
resetSeedCollection();
}
function showSeedOwnPanel() {
@@ -555,6 +642,11 @@
pqDeriveProgress.style.width = '0%';
pqDeriveContinueBtn.classList.add('pq-hidden');
// Show the paths for the active scheme in the key checklist
document.querySelectorAll('.pq-derive-path').forEach(el => {
try { el.textContent = derivationPathForAlgorithm(el.dataset.alg, pqScheme); } catch (_) { /* leave default */ }
});
try {
pqSeed = mnemonicToSeed(pqMnemonic);
pqDeriveProgress.style.width = '15%';
@@ -562,7 +654,7 @@
pqDeriveProgress.style.width = '30%';
await new Promise(r => setTimeout(r, 100));
pqKeys = derivePQKeysFromSeed(pqSeed);
pqKeys = derivePQKeysFromSeed(pqSeed, pqScheme);
pqDeriveProgress.style.width = '60%';
setKeyIcon('pqKeyMlDsa44', 'Done');
@@ -581,7 +673,10 @@
document.getElementById('pqKeyMlKemPub').textContent = bytesToBase64(pqKeys.mlKem.publicKey).substring(0, 60) + '...';
pqDeriveProgress.style.width = '100%';
setStatus(pqDeriveStatus, 'success', 'All 5 post-quantum keys derived successfully!');
setStatus(pqDeriveStatus, 'success',
pqScheme === 'v1'
? 'All 5 post-quantum keys derived with legacy v1 paths (recovery mode). Verify these match your published event, then re-run with v2 to upgrade.'
: 'All 5 post-quantum keys derived successfully (v2 hardened scheme)!');
pqDeriveContinueBtn.classList.remove('pq-hidden');
setStepDone(3);
} catch (error) {
@@ -610,7 +705,7 @@
// ---- Phase 1: Build and sign the kind 1 announcement event ----
setStatus(pqSignStatus, 'info', ' Building kind 1 announcement event...');
const kind1Template = buildKind1Announcement(currentPubkey, blockHeight, pqKeys);
const kind1Template = buildKind1Announcement(currentPubkey, blockHeight, pqKeys, pqScheme);
// Strip the non-NIP-01 helper field 'statementBytes' before sending to
// the signer. buildKind1Announcement includes it for internal use, but
@@ -886,29 +981,21 @@
});
document.getElementById('pqStartBtn').addEventListener('click', () => {
userEntropyChunks = [];
generateAndShowSeed();
showView('pqSeedStep');
setStepActive(2);
startEntropyCollection();
// Reset the collection zone and start mandatory mouse/keyboard collection.
resetSeedCollection();
});
// Seed mode toggle
document.getElementById('pqSeedToggleGenerate').addEventListener('click', () => showSeedGeneratePanel());
document.getElementById('pqSeedToggleOwn').addEventListener('click', () => showSeedOwnPanel());
// Word-count selector: regenerate when the user switches between 12/24 words
// Word-count selector: switching 12/24 words resets the collection zone
// so the user re-collects entropy for the new word count.
document.querySelectorAll('input[name="pqWordCount"]').forEach((radio) => {
radio.addEventListener('change', () => {
userEntropyChunks = [];
generateAndShowSeed();
// Reset entropy UI
const entropyBar = document.getElementById('pqEntropyBar');
const entropyHint = document.getElementById('pqEntropyHint');
const regenWithEntropyBtn = document.getElementById('pqRegenerateWithEntropyBtn');
if (entropyBar) entropyBar.style.width = '0%';
if (entropyHint) entropyHint.textContent = 'Optional: click here and type random characters, move your mouse to add entropy...';
if (regenWithEntropyBtn) { regenWithEntropyBtn.classList.add('pq-hidden'); regenWithEntropyBtn.disabled = true; }
resetSeedCollection();
});
});
@@ -916,6 +1003,15 @@
const pqSeedInput = document.getElementById('pqSeedInput');
const pqSeedValidation = document.getElementById('pqSeedValidation');
const pqSeedOwnContinueBtn = document.getElementById('pqSeedOwnContinueBtn');
// v1 recovery toggle: derive legacy v1 keys from a pre-v2 seed
const pqSeedV1Recovery = document.getElementById('pqSeedV1Recovery');
const pqSeedV1Note = document.getElementById('pqSeedV1Note');
if (pqSeedV1Recovery) {
pqSeedV1Recovery.addEventListener('change', () => {
pqScheme = pqSeedV1Recovery.checked ? 'v1' : 'v2';
if (pqSeedV1Note) pqSeedV1Note.style.display = pqSeedV1Recovery.checked ? 'block' : 'none';
});
}
pqSeedInput.addEventListener('input', () => {
const value = pqSeedInput.value.trim();
if (!value) {
@@ -936,6 +1032,7 @@
});
pqSeedOwnContinueBtn.addEventListener('click', () => {
pqMnemonic = pqSeedInput.value.trim();
// Scheme was set by the v1-recovery checkbox; default v2 otherwise.
setStepDone(2);
derivePQKeys();
});
@@ -950,9 +1047,6 @@
}
});
// "Generate with Extra Entropy" — generates a fresh seed mixing CSPRNG + user entropy
document.getElementById('pqRegenerateWithEntropyBtn').addEventListener('click', () => { generateAndShowSeed(); });
pqSeedConfirmed.addEventListener('change', () => { pqSeedContinueBtn.disabled = !pqSeedConfirmed.checked; });
pqSeedContinueBtn.addEventListener('click', () => { setStepDone(2); derivePQKeys(); });
pqDeriveContinueBtn.addEventListener('click', () => { showView('pqSignStep'); setStepActive(4); });
@@ -1050,16 +1144,6 @@
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 () => {
@@ -1101,18 +1185,28 @@
pqSecpKeys = null;
pqEvent = null;
kind1Event = null; // F-L1: clear all secret references
pqScheme = 'v2'; // reset derivation scheme to the default
// Reset the v1-recovery toggle UI
const v1Toggle = document.getElementById('pqSeedV1Recovery');
if (v1Toggle) v1Toggle.checked = false;
const v1Note = document.getElementById('pqSeedV1Note');
if (v1Note) v1Note.style.display = 'none';
userEntropyChunks = [];
entropyCollected = 0;
lastMouseX = null;
lastMouseY = null;
lastMouseSampleTime = 0;
pendingOtsBytes = null;
currentBlockHeight = 0;
pqRelays = DEFAULT_RELAYS.slice();
// G56-10: Clear DOM elements that displayed secret material
// G56-10: Clear DOM elements that displayed secret material.
// Reset the seed box to its collecting (pre-generation) state so no
// generated words remain visible after sign-out.
const seedDisplay = document.getElementById('pqSeedDisplay');
if (seedDisplay) seedDisplay.innerHTML = '';
if (seedDisplay) renderSeedCollectingState();
const seedInput = document.getElementById('pqSeedInput');
if (seedInput) seedInput.value = '';
const entropyHint = document.getElementById('pqEntropyHint');
if (entropyHint) entropyHint.textContent = 'Optional: click here and type random characters, move your mouse to add entropy...';
// G56-10: Clear the clipboard (in case the user copied the seed phrase)
try { navigator.clipboard.writeText(''); } catch (e) { /* clipboard may not be available */ }
@@ -1318,7 +1412,7 @@
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
@@ -1350,7 +1444,7 @@
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...');
@@ -1385,6 +1479,46 @@
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);
+177 -52
View File
@@ -10,8 +10,23 @@
*
* Uses @noble/post-quantum (pure JS, no WASM needed)
*
* BIP32 Derivation Paths (all under m/44'/1237'/0'/0/):
* 0 — secp256k1 (NIP-06 standard, Account #2)
* Derivation schemes (see plans/v2-hardened-derivation.md):
*
* V2 (default) — per-algorithm coin types in the unregistered SLIP-44
* 102XXX' range, all-hardened below coin type. PQ keys are NOT under the
* Nostr coin branch (1237'), so no compromise of the Nostr subtree (even a
* coin-level xpub leak + quantum) can reach them. Coin types 102003'102005'
* match n_signer/the Rust signer; 102006'102007' are this project's
* allocations for ML-DSA-44 and Falcon-512.
* ML-DSA-44 m/44'/102006'/0'/0'/0' (32-byte seed)
* ML-DSA-65 m/44'/102003'/0'/0'/0' (32-byte seed)
* SLH-DSA-128s m/44'/102004'/0'/0'/0' + /1' (48-byte seed)
* Falcon-512 m/44'/102007'/0'/0'/0' + /1' (48-byte seed)
* ML-KEM-768 m/44'/102005'/0'/0'/0' + /1' (64-byte seed)
*
* V1 (legacy, retained for recovery only) — all keys under
* m/44'/1237'/0'/0/ with non-hardened leaf children:
* 0 — secp256k1 (NIP-06 standard)
* 1 — ML-DSA-44 (32-byte seed)
* 2 — ML-DSA-65 (32-byte seed)
* 3+4 — SLH-DSA-128s (48-byte seed, two 32-byte children concatenated)
@@ -37,26 +52,83 @@ import { DEFAULT_POLICY, knownAlgorithms, isMandatorySignature, isKem } from './
// ============================================================================
/**
* BIP32 derivation paths for all keys.
* Versioned PQ derivation schemes.
*
* Base path: m/44'/1237'/0'/0/ (NIP-06 account 0, change 0)
* Child indices:
* 0 — secp256k1 (NIP-06 standard)
* 1 — ML-DSA-44
* 2 — ML-DSA-65
* 3, 4 — SLH-DSA-128s (two children, concatenated for 48-byte seed)
* 5, 6 — Falcon-512 (two children, concatenated for 48-byte seed)
* 7, 8 — ML-KEM-768 (two children, concatenated for 64-byte seed)
* V2 (default): per-algorithm coin types (102XXX' range), all-hardened below
* the coin type. Each algorithm gets its own coin branch, so a leak of any
* one branch's extended key compromises exactly one algorithm, and no leak
* within the Nostr coin branch (1237') can reach PQ keys at all.
*
* V1 (legacy): all PQ seeds at non-hardened children 18 under the NIP-06
* account 0 change level. Retained ONLY so v1-era seeds can recover their
* v1 keys; never used for new derivations. See audit F-M3.
*
* `children` maps each algorithm to either:
* - v1: an array of child indices under the shared base path
* - v2: { coin, indices } — hardened children under m/44'/coin'/0'/0'
*/
const PQ_DERIVATION_PATHS = {
secp256k1: [0], // 32 bytes (standard NIP-06)
mlDsa44: [1], // 32 bytes
mlDsa65: [2], // 32 bytes
slhDsa: [3, 4], // 64 bytes concatenated, take first 48
falcon512: [5, 6], // 64 bytes concatenated, take first 48
mlKem: [7, 8], // 64 bytes concatenated
const PQ_DERIVATION_SCHEMES = {
v1: {
version: 1,
base: "m/44'/1237'/0'/0",
hardenedLeaves: false,
children: {
mlDsa44: [1],
mlDsa65: [2],
slhDsa: [3, 4],
falcon512: [5, 6],
mlKem: [7, 8],
},
},
v2: {
version: 2,
hardenedLeaves: true,
children: {
mlDsa44: { coin: 102006, indices: [0] },
mlDsa65: { coin: 102003, indices: [0] },
slhDsa: { coin: 102004, indices: [0, 1] },
falcon512: { coin: 102007, indices: [0, 1] },
mlKem: { coin: 102005, indices: [0, 1] },
},
},
};
/**
* The derivation scheme version used for new key derivations and announced
* in the kind 1 event via the `derivation_scheme` tag.
*/
export const PQ_DERIVATION_SCHEME_VERSION = 2;
/**
* Resolve a scheme identifier ('v1' | 'v2' | 1 | 2) to its definition.
* @param {string|number} scheme
* @returns {object} scheme definition
*/
function resolveScheme(scheme) {
const key = typeof scheme === 'number' ? `v${scheme}` : scheme;
const def = PQ_DERIVATION_SCHEMES[key];
if (!def) {
throw new Error(`Unknown derivation scheme: ${scheme}. Supported: 'v1', 'v2'`);
}
return def;
}
/**
* Full derivation path for one algorithm under a scheme (for display/tests).
* @param {string} alg - algorithm key in scheme.children
* @param {string|number} scheme
* @returns {string} e.g. "m/44'/102003'/0'/0'/0'" (v2) or "m/44'/1237'/0'/0/2" (v1)
*/
export function pqDerivationPath(alg, scheme = 'v2') {
const def = resolveScheme(scheme);
const child = def.children[alg];
if (!child) throw new Error(`Unknown algorithm: ${alg}`);
if (def.version === 1) {
return child.map(i => `${def.base}/${i}`).join(' + ');
}
return child.indices.map(i => `m/44'/${child.coin}'/0'/0'/${i}'`).join(' + ');
}
// Seed lengths required by each algorithm's keygen()
const PQ_SEED_LENGTHS = {
mlDsa44: 32,
@@ -149,15 +221,14 @@ export function isValidMnemonic(mnemonic) {
// ============================================================================
/**
* Derive a BIP32 child private key at a given path.
* Derive a BIP32 child private key at an explicit full path.
*
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
* @param {number[]} childIndices - child indices under m/44'/1237'/0'/0/
* @param {string} path - full derivation path, e.g. "m/44'/102003'/0'/0'/0'"
* @returns {Uint8Array} 32-byte private key
*/
function deriveBIP32Child(bip39Seed, childIndices) {
function deriveBIP32Child(bip39Seed, path) {
const hdKey = HDKey.fromMasterSeed(bip39Seed);
const path = `m/44'/1237'/0'/0/${childIndices.join('/')}`;
const child = hdKey.derive(path);
if (!child.privateKey) {
throw new Error(`Failed to derive private key at path ${path}`);
@@ -165,6 +236,25 @@ function deriveBIP32Child(bip39Seed, childIndices) {
return child.privateKey;
}
/**
* Resolve the full derivation paths for one algorithm under a scheme.
*
* v1: children are indices under the shared base m/44'/1237'/0'/0/
* v2: children are hardened indices under m/44'/<coin>'/0'/0'
*
* @param {object} schemeDef - resolved scheme definition
* @param {string} alg - algorithm key in scheme.children
* @returns {string[]} full paths (one per child index)
*/
function schemePathsFor(schemeDef, alg) {
const child = schemeDef.children[alg];
if (!child) throw new Error(`Unknown algorithm: ${alg}`);
if (schemeDef.version === 1) {
return child.map(idx => `${schemeDef.base}/${idx}`);
}
return child.indices.map(i => `m/44'/${child.coin}'/0'/0'/${i}'`);
}
/**
* Derive a seed of the required length from BIP32 child keys.
*
@@ -173,14 +263,14 @@ function deriveBIP32Child(bip39Seed, childIndices) {
* For 64-byte seeds: derive two children, concatenate (64 bytes).
*
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
* @param {number[]} childIndices - child indices to derive
* @param {string[]} paths - full child paths to derive
* @param {number} requiredLength - required seed length
* @returns {Uint8Array} seed bytes
*/
function derivePQSeedFromBIP32(bip39Seed, childIndices, requiredLength) {
if (childIndices.length === 1) {
function derivePQSeedFromBIP32(bip39Seed, paths, requiredLength) {
if (paths.length === 1) {
// Single child — 32 bytes
const seed = deriveBIP32Child(bip39Seed, childIndices);
const seed = deriveBIP32Child(bip39Seed, paths[0]);
if (seed.length !== requiredLength) {
throw new Error(`Seed length mismatch: got ${seed.length}, expected ${requiredLength}`);
}
@@ -188,8 +278,8 @@ function derivePQSeedFromBIP32(bip39Seed, childIndices, requiredLength) {
} else {
// Multiple children — concatenate and truncate
let combined = new Uint8Array(0);
for (const idx of childIndices) {
const child = deriveBIP32Child(bip39Seed, [idx]);
for (const path of paths) {
const child = deriveBIP32Child(bip39Seed, path);
const newCombined = new Uint8Array(combined.length + child.length);
newCombined.set(combined);
newCombined.set(child, combined.length);
@@ -234,14 +324,12 @@ export function deriveSecp256k1FromSeed(seed, accountIndex = 0) {
/**
* Derive all PQ keypairs from a BIP39 seed using BIP32 derivation paths.
*
* Paths (under m/44'/1237'/0'/0/):
* 1 — ML-DSA-44
* 2 — ML-DSA-65
* 3+4 — SLH-DSA-128s
* 5+6 — Falcon-512
* 7+8 — ML-KEM-768
* V2 (default) — per-algorithm coin types, all-hardened (see module header).
* V1 (legacy) — non-hardened children under m/44'/1237'/0'/0/. Pass 'v1'
* ONLY to recover keys for a v1-era seed; never for new derivations.
*
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
* @param {string|number} [scheme='v2'] - 'v1' | 'v2' | 1 | 2
* @returns {{
* mlDsa44: {publicKey: Uint8Array, secretKey: Uint8Array},
* mlDsa65: {publicKey: Uint8Array, secretKey: Uint8Array},
@@ -250,25 +338,27 @@ export function deriveSecp256k1FromSeed(seed, accountIndex = 0) {
* mlKem: {publicKey: Uint8Array, secretKey: Uint8Array}
* }}
*/
export function derivePQKeysFromSeed(bip39Seed) {
// ML-DSA-44 (32-byte seed, path child 1)
const mlDsa44Seed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlDsa44, PQ_SEED_LENGTHS.mlDsa44);
export function derivePQKeysFromSeed(bip39Seed, scheme = 'v2') {
const schemeDef = resolveScheme(scheme);
// ML-DSA-44 (32-byte seed)
const mlDsa44Seed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, 'mlDsa44'), PQ_SEED_LENGTHS.mlDsa44);
const mlDsa44Keys = ml_dsa44.keygen(mlDsa44Seed);
// ML-DSA-65 (32-byte seed, path child 2)
const mlDsa65Seed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlDsa65, PQ_SEED_LENGTHS.mlDsa65);
// ML-DSA-65 (32-byte seed)
const mlDsa65Seed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, 'mlDsa65'), PQ_SEED_LENGTHS.mlDsa65);
const mlDsa65Keys = ml_dsa65.keygen(mlDsa65Seed);
// SLH-DSA-128s (48-byte seed, paths children 3+4 concatenated)
const slhDsaSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.slhDsa, PQ_SEED_LENGTHS.slhDsa);
// SLH-DSA-128s (48-byte seed, two children concatenated)
const slhDsaSeed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, 'slhDsa'), PQ_SEED_LENGTHS.slhDsa);
const slhDsaKeys = slh_dsa_sha2_128s.keygen(slhDsaSeed);
// Falcon-512 (48-byte seed, paths children 5+6 concatenated)
const falconSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.falcon512, PQ_SEED_LENGTHS.falcon512);
// Falcon-512 (48-byte seed, two children concatenated)
const falconSeed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, 'falcon512'), PQ_SEED_LENGTHS.falcon512);
const falconKeys = falcon512.keygen(falconSeed);
// ML-KEM-768 (64-byte seed, paths children 7+8 concatenated)
const mlKemSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlKem, PQ_SEED_LENGTHS.mlKem);
// ML-KEM-768 (64-byte seed, two children concatenated)
const mlKemSeed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, 'mlKem'), PQ_SEED_LENGTHS.mlKem);
const mlKemKeys = ml_kem768.keygen(mlKemSeed);
return {
@@ -614,9 +704,10 @@ export const NIP_QR_KIND = 9999;
* @param {string} hexPubkey - The user's Nostr hex pubkey (Account #1)
* @param {number} blockHeight - Current Bitcoin block height for pre-quantum anchoring
* @param {object} pqKeys - PQ keypairs from derivePQKeysFromSeed()
* @param {string|number} [derivationScheme='v2'] - scheme the keys were derived with
* @returns {{kind: number, content: string, tags: Array, pubkey: string, created_at: number, statementBytes: Uint8Array}}
*/
export function buildKind1Announcement(hexPubkey, blockHeight, pqKeys) {
export function buildKind1Announcement(hexPubkey, blockHeight, pqKeys, derivationScheme = 'v2') {
const npub = hexToNpub(hexPubkey);
// Human-readable attestation statement (signed by each PQ key)
@@ -638,7 +729,8 @@ Post-quantum public keys in tags:
Each post-quantum key has cryptographically signed this attestation. This event is pending timestamp on the Bitcoin blockchain via OpenTimestamps.
Verify this attestation: https://laantungir.net/quantum-prep/verify.html
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);
@@ -657,6 +749,14 @@ Created at: https://laantungir.net/quantum-prep/`;
['algorithm', 'falcon-512', bytesToBase64(pqKeys.falcon512.publicKey), bytesToBase64(falconSig)],
['algorithm', 'ml-kem-768', bytesToBase64(pqKeys.mlKem.publicKey)]
];
// Informational metadata: which derivation scheme produced these keys.
// Absent tag = v1 (legacy events predate the tag). Not evidence — signature
// verification is path-agnostic — so verifiers treat unknown values as
// display-only.
const schemeVersion = resolveScheme(derivationScheme).version;
if (schemeVersion >= 2) {
tags.push(['derivation_scheme', String(schemeVersion)]);
}
return {
kind: 1,
@@ -1542,7 +1642,8 @@ export const PQ_KEY_INFO = {
fips: 'FIPS 204',
type: 'signature',
securityLevel: 'Category 2 (~AES-128)',
derivationPath: "m/44'/1237'/0'/0/1"
coinType: 102006,
derivationPath: "m/44'/102006'/0'/0'/0'"
},
'ml-dsa-65': {
name: 'ML-DSA-65 (Dilithium)',
@@ -1551,7 +1652,8 @@ export const PQ_KEY_INFO = {
fips: 'FIPS 204',
type: 'signature',
securityLevel: 'Category 3 (~AES-192)',
derivationPath: "m/44'/1237'/0'/0/2"
coinType: 102003,
derivationPath: "m/44'/102003'/0'/0'/0'"
},
'slh-dsa-128s': {
name: 'SLH-DSA-128s (SPHINCS+)',
@@ -1560,7 +1662,8 @@ export const PQ_KEY_INFO = {
fips: 'FIPS 205',
type: 'signature',
securityLevel: 'Category 1 (~AES-128, hash-based)',
derivationPath: "m/44'/1237'/0'/0/3+4"
coinType: 102004,
derivationPath: "m/44'/102004'/0'/0'/0' + /1'"
},
'falcon-512': {
name: 'Falcon-512',
@@ -1569,7 +1672,8 @@ export const PQ_KEY_INFO = {
fips: 'FIPS 206 (draft)',
type: 'signature',
securityLevel: 'Category 1 (~AES-128, lattice-based)',
derivationPath: "m/44'/1237'/0'/0/5+6"
coinType: 102007,
derivationPath: "m/44'/102007'/0'/0'/0' + /1'"
},
'ml-kem-768': {
name: 'ML-KEM-768 (Kyber)',
@@ -1578,10 +1682,31 @@ export const PQ_KEY_INFO = {
fips: 'FIPS 203',
type: 'kem',
securityLevel: 'Category 3 (~AES-192)',
derivationPath: "m/44'/1237'/0'/0/7+8"
coinType: 102005,
derivationPath: "m/44'/102005'/0'/0'/0' + /1'"
}
};
/**
* Derivation path for an algorithm id (as used in event tags / PQ_KEY_INFO
* keys) under a given scheme. Defaults to v2.
*
* @param {string} algorithmId - e.g. 'ml-dsa-44'
* @param {string|number} [scheme='v2']
* @returns {string} display path
*/
export function derivationPathForAlgorithm(algorithmId, scheme = 'v2') {
const algKey = {
'ml-dsa-44': 'mlDsa44',
'ml-dsa-65': 'mlDsa65',
'slh-dsa-128s': 'slhDsa',
'falcon-512': 'falcon512',
'ml-kem-768': 'mlKem',
}[algorithmId];
if (!algKey) throw new Error(`Unknown algorithm id: ${algorithmId}`);
return pqDerivationPath(algKey, scheme);
}
// ============================================================================
// OPENTIMESTAMPS (NIP-03)
// ============================================================================
+263 -59
View File
@@ -49,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');
@@ -78,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.';
}
}
/* ================================================================
@@ -175,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';
@@ -238,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]) {
@@ -255,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
@@ -279,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
================================================================ */
@@ -323,6 +507,7 @@
queryBtn.addEventListener('click', async () => {
queryBtn.disabled = true;
setButtonSpinner(queryBtn);
setStatus(queryStatus, 'info', 'Querying relays...');
const pubkeyInput = document.getElementById('pqPubkeyInput').value.trim();
@@ -332,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;
}
@@ -339,6 +525,7 @@
if (relayUrls.length === 0) {
setStatus(queryStatus, 'error', 'Enter at least one relay URL.');
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
return;
}
@@ -374,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;
}
@@ -398,6 +586,7 @@
if (!kind1Event) {
setStatus(queryStatus, 'error', 'Could not parse kind 1 announcement from any candidate.');
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
return;
}
@@ -417,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 {
@@ -428,6 +619,7 @@
}
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
});
/* ================================================================
@@ -496,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)
================================================================ */
@@ -540,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}`);
@@ -750,12 +936,30 @@
});
/* ================================================================
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.
URL ?npub= PARAMETER + AUTO-DETECT SIGNED-IN USER
On page load, first check for a ?npub= query parameter (used by the
"Verify this attestation" link in the kind 1 event). If present,
pre-fill the pubkey field and auto-query relays for that pubkey's
kind 9999 event. Otherwise, fall back to detecting a NIP-07 signer /
nostr-login-lite session and auto-query for the signed-in user.
================================================================ */
async function initAutoDetect() {
// 1. Check for ?npub= (or ?pubkey=) URL parameter first
const params = new URLSearchParams(window.location.search);
const npubParam = params.get('npub') || params.get('pubkey');
if (npubParam) {
const pubkeyHex = normalizePubkey(npubParam);
if (pubkeyHex) {
console.log('[verify] URL parameter pubkey:', pubkeyHex);
const pubkeyInput = document.getElementById('pqPubkeyInput');
const npub = hexToNpub(pubkeyHex);
pubkeyInput.value = npub || pubkeyHex;
queryBtn.click();
return;
}
}
// 2. Fall back to signed-in user detection
if (window.NOSTR_LOGIN_LITE) {
try {
await initNostrLoginLite();
+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.35",
"VERSION_NUMBER": "0.0.35",
"BUILD_DATE": "2026-07-27T17:03:35.968Z"
"VERSION": "v0.1.8",
"VERSION_NUMBER": "0.1.8",
"BUILD_DATE": "2026-08-23T12:05:43.823Z"
}
+94 -33
View File
@@ -9627,20 +9627,49 @@ function isKem(algorithmId, policy = DEFAULT_POLICY) {
}
// www/js/pq-crypto.mjs
var PQ_DERIVATION_PATHS = {
secp256k1: [0],
// 32 bytes (standard NIP-06)
mlDsa44: [1],
// 32 bytes
mlDsa65: [2],
// 32 bytes
slhDsa: [3, 4],
// 64 bytes concatenated, take first 48
falcon512: [5, 6],
// 64 bytes concatenated, take first 48
mlKem: [7, 8]
// 64 bytes concatenated
var PQ_DERIVATION_SCHEMES = {
v1: {
version: 1,
base: "m/44'/1237'/0'/0",
hardenedLeaves: false,
children: {
mlDsa44: [1],
mlDsa65: [2],
slhDsa: [3, 4],
falcon512: [5, 6],
mlKem: [7, 8]
}
},
v2: {
version: 2,
hardenedLeaves: true,
children: {
mlDsa44: { coin: 102006, indices: [0] },
mlDsa65: { coin: 102003, indices: [0] },
slhDsa: { coin: 102004, indices: [0, 1] },
falcon512: { coin: 102007, indices: [0, 1] },
mlKem: { coin: 102005, indices: [0, 1] }
}
}
};
var PQ_DERIVATION_SCHEME_VERSION = 2;
function resolveScheme(scheme) {
const key = typeof scheme === "number" ? `v${scheme}` : scheme;
const def = PQ_DERIVATION_SCHEMES[key];
if (!def) {
throw new Error(`Unknown derivation scheme: ${scheme}. Supported: 'v1', 'v2'`);
}
return def;
}
function pqDerivationPath(alg, scheme = "v2") {
const def = resolveScheme(scheme);
const child = def.children[alg];
if (!child) throw new Error(`Unknown algorithm: ${alg}`);
if (def.version === 1) {
return child.map((i) => `${def.base}/${i}`).join(" + ");
}
return child.indices.map((i) => `m/44'/${child.coin}'/0'/0'/${i}'`).join(" + ");
}
var PQ_SEED_LENGTHS = {
mlDsa44: 32,
mlDsa65: 32,
@@ -9675,26 +9704,33 @@ function mnemonicToSeed(mnemonic, passphrase = "") {
function isValidMnemonic(mnemonic) {
return validateMnemonic(mnemonic, wordlist);
}
function deriveBIP32Child(bip39Seed, childIndices) {
function deriveBIP32Child(bip39Seed, path) {
const hdKey = HDKey.fromMasterSeed(bip39Seed);
const path = `m/44'/1237'/0'/0/${childIndices.join("/")}`;
const child = hdKey.derive(path);
if (!child.privateKey) {
throw new Error(`Failed to derive private key at path ${path}`);
}
return child.privateKey;
}
function derivePQSeedFromBIP32(bip39Seed, childIndices, requiredLength) {
if (childIndices.length === 1) {
const seed = deriveBIP32Child(bip39Seed, childIndices);
function schemePathsFor(schemeDef, alg) {
const child = schemeDef.children[alg];
if (!child) throw new Error(`Unknown algorithm: ${alg}`);
if (schemeDef.version === 1) {
return child.map((idx) => `${schemeDef.base}/${idx}`);
}
return child.indices.map((i) => `m/44'/${child.coin}'/0'/0'/${i}'`);
}
function derivePQSeedFromBIP32(bip39Seed, paths, requiredLength) {
if (paths.length === 1) {
const seed = deriveBIP32Child(bip39Seed, paths[0]);
if (seed.length !== requiredLength) {
throw new Error(`Seed length mismatch: got ${seed.length}, expected ${requiredLength}`);
}
return seed;
} else {
let combined = new Uint8Array(0);
for (const idx of childIndices) {
const child = deriveBIP32Child(bip39Seed, [idx]);
for (const path of paths) {
const child = deriveBIP32Child(bip39Seed, path);
const newCombined = new Uint8Array(combined.length + child.length);
newCombined.set(combined);
newCombined.set(child, combined.length);
@@ -9718,16 +9754,17 @@ function deriveSecp256k1FromSeed(seed, accountIndex = 0) {
publicKey: child.publicKey
};
}
function derivePQKeysFromSeed(bip39Seed) {
const mlDsa44Seed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlDsa44, PQ_SEED_LENGTHS.mlDsa44);
function derivePQKeysFromSeed(bip39Seed, scheme = "v2") {
const schemeDef = resolveScheme(scheme);
const mlDsa44Seed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, "mlDsa44"), PQ_SEED_LENGTHS.mlDsa44);
const mlDsa44Keys = ml_dsa44.keygen(mlDsa44Seed);
const mlDsa65Seed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlDsa65, PQ_SEED_LENGTHS.mlDsa65);
const mlDsa65Seed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, "mlDsa65"), PQ_SEED_LENGTHS.mlDsa65);
const mlDsa65Keys = ml_dsa65.keygen(mlDsa65Seed);
const slhDsaSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.slhDsa, PQ_SEED_LENGTHS.slhDsa);
const slhDsaSeed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, "slhDsa"), PQ_SEED_LENGTHS.slhDsa);
const slhDsaKeys = slh_dsa_sha2_128s.keygen(slhDsaSeed);
const falconSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.falcon512, PQ_SEED_LENGTHS.falcon512);
const falconSeed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, "falcon512"), PQ_SEED_LENGTHS.falcon512);
const falconKeys = falcon512.keygen(falconSeed);
const mlKemSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlKem, PQ_SEED_LENGTHS.mlKem);
const mlKemSeed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, "mlKem"), PQ_SEED_LENGTHS.mlKem);
const mlKemKeys = ml_kem768.keygen(mlKemSeed);
return {
mlDsa44: mlDsa44Keys,
@@ -9910,7 +9947,7 @@ function validateSignerOutput(signedEvent, template, expectedPubkey) {
return signedEvent;
}
var NIP_QR_KIND = 9999;
function buildKind1Announcement(hexPubkey, blockHeight, pqKeys) {
function buildKind1Announcement(hexPubkey, blockHeight, pqKeys, derivationScheme = "v2") {
const npub = hexToNpub(hexPubkey);
const content = `I am signaling that the post-quantum public keys listed in the tags of this event were generated by me and I hold the private keys. I may use these keys in the future as successors to my current Nostr identity.
@@ -9929,7 +9966,8 @@ Post-quantum public keys in tags:
Each post-quantum key has cryptographically signed this attestation. This event is pending timestamp on the Bitcoin blockchain via OpenTimestamps.
Verify this attestation: https://laantungir.net/quantum-prep/verify.html
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);
@@ -9943,6 +9981,10 @@ Created at: https://laantungir.net/quantum-prep/`;
["algorithm", "falcon-512", bytesToBase64(pqKeys.falcon512.publicKey), bytesToBase64(falconSig)],
["algorithm", "ml-kem-768", bytesToBase64(pqKeys.mlKem.publicKey)]
];
const schemeVersion = resolveScheme(derivationScheme).version;
if (schemeVersion >= 2) {
tags.push(["derivation_scheme", String(schemeVersion)]);
}
return {
kind: 1,
content,
@@ -10485,7 +10527,8 @@ var PQ_KEY_INFO = {
fips: "FIPS 204",
type: "signature",
securityLevel: "Category 2 (~AES-128)",
derivationPath: "m/44'/1237'/0'/0/1"
coinType: 102006,
derivationPath: "m/44'/102006'/0'/0'/0'"
},
"ml-dsa-65": {
name: "ML-DSA-65 (Dilithium)",
@@ -10494,7 +10537,8 @@ var PQ_KEY_INFO = {
fips: "FIPS 204",
type: "signature",
securityLevel: "Category 3 (~AES-192)",
derivationPath: "m/44'/1237'/0'/0/2"
coinType: 102003,
derivationPath: "m/44'/102003'/0'/0'/0'"
},
"slh-dsa-128s": {
name: "SLH-DSA-128s (SPHINCS+)",
@@ -10503,7 +10547,8 @@ var PQ_KEY_INFO = {
fips: "FIPS 205",
type: "signature",
securityLevel: "Category 1 (~AES-128, hash-based)",
derivationPath: "m/44'/1237'/0'/0/3+4"
coinType: 102004,
derivationPath: "m/44'/102004'/0'/0'/0' + /1'"
},
"falcon-512": {
name: "Falcon-512",
@@ -10512,7 +10557,8 @@ var PQ_KEY_INFO = {
fips: "FIPS 206 (draft)",
type: "signature",
securityLevel: "Category 1 (~AES-128, lattice-based)",
derivationPath: "m/44'/1237'/0'/0/5+6"
coinType: 102007,
derivationPath: "m/44'/102007'/0'/0'/0' + /1'"
},
"ml-kem-768": {
name: "ML-KEM-768 (Kyber)",
@@ -10521,9 +10567,21 @@ var PQ_KEY_INFO = {
fips: "FIPS 203",
type: "kem",
securityLevel: "Category 3 (~AES-192)",
derivationPath: "m/44'/1237'/0'/0/7+8"
coinType: 102005,
derivationPath: "m/44'/102005'/0'/0'/0' + /1'"
}
};
function derivationPathForAlgorithm(algorithmId, scheme = "v2") {
const algKey = {
"ml-dsa-44": "mlDsa44",
"ml-dsa-65": "mlDsa65",
"slh-dsa-128s": "slhDsa",
"falcon-512": "falcon512",
"ml-kem-768": "mlKem"
}[algorithmId];
if (!algKey) throw new Error(`Unknown algorithm id: ${algorithmId}`);
return pqDerivationPath(algKey, scheme);
}
var OTS_CALENDAR_SERVERS = [
"https://alice.btc.calendar.opentimestamps.org",
"https://bob.btc.calendar.opentimestamps.org",
@@ -11046,6 +11104,7 @@ function parseProofArchive(archiveJson) {
export {
CANONICAL_DIGEST_VERSION,
NIP_QR_KIND,
PQ_DERIVATION_SCHEME_VERSION,
PQ_KEY_INFO,
base64ToBytes,
buildKind11112Wrapper,
@@ -11058,6 +11117,7 @@ export {
canonicalEventDigest,
clearPendingOts,
computeEventId,
derivationPathForAlgorithm,
derivePQKeysFromSeed,
deriveSecp256k1FromSeed,
generateSeedPhrase,
@@ -11072,6 +11132,7 @@ export {
mnemonicToSeed,
parseOtsFile,
parseProofArchive,
pqDerivationPath,
savePendingOts,
selectCanonicalProofCarrier,
signWithFalcon,
+57 -45
View File
@@ -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>
@@ -314,27 +331,24 @@
<div id="pqSignInStatus"></div>
</div>
<!-- Shared results area -->
<!-- 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>
@@ -342,6 +356,11 @@
<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;">
@@ -357,13 +376,6 @@
</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>