Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bb27f9596 | ||
|
|
0ceeb8468f | ||
|
|
6e61c6a5a8 |
@@ -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.
|
||||
|
||||
@@ -265,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
|
||||
@@ -287,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` |
|
||||
|
||||
@@ -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
@@ -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 1–8 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
@@ -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 1–8, 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nostr_quantum_preparation",
|
||||
"version": "0.1.5",
|
||||
"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": {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 102003–102005 match n_signer / the Rust signer; 102006–102007 are this project’s 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’s behavior specifically.",
|
||||
"The secp256k1 identity key is unchanged: NIP-06 m/44'/1237'/0'/0/0 (see the v1 vector)."
|
||||
]
|
||||
}
|
||||
Executable
+54
@@ -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"
|
||||
+55
-20
@@ -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>
|
||||
|
||||
+179
-76
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
generateSeedPhrase,
|
||||
generateSeedPhraseWithEntropy,
|
||||
mnemonicToSeed,
|
||||
isValidMnemonic,
|
||||
deriveSecp256k1FromSeed,
|
||||
derivePQKeysFromSeed,
|
||||
derivationPathForAlgorithm,
|
||||
buildKind1Announcement,
|
||||
buildProofCarrier,
|
||||
computeEventId,
|
||||
@@ -43,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
|
||||
@@ -452,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('');
|
||||
@@ -479,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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -528,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() {
|
||||
@@ -556,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%';
|
||||
@@ -563,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');
|
||||
@@ -582,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) {
|
||||
@@ -611,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
|
||||
@@ -887,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -917,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) {
|
||||
@@ -937,6 +1032,7 @@
|
||||
});
|
||||
pqSeedOwnContinueBtn.addEventListener('click', () => {
|
||||
pqMnemonic = pqSeedInput.value.trim();
|
||||
// Scheme was set by the v1-recovery checkbox; default v2 otherwise.
|
||||
setStepDone(2);
|
||||
derivePQKeys();
|
||||
});
|
||||
@@ -951,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); });
|
||||
@@ -1092,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 */ }
|
||||
|
||||
+175
-51
@@ -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 1–8 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)
|
||||
@@ -658,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,
|
||||
@@ -1543,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)',
|
||||
@@ -1552,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+)',
|
||||
@@ -1561,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',
|
||||
@@ -1570,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)',
|
||||
@@ -1579,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)
|
||||
// ============================================================================
|
||||
|
||||
@@ -606,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 {
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.1.5",
|
||||
"VERSION_NUMBER": "0.1.5",
|
||||
"BUILD_DATE": "2026-07-29T13:52:32.430Z"
|
||||
"VERSION": "v0.1.8",
|
||||
"VERSION_NUMBER": "0.1.8",
|
||||
"BUILD_DATE": "2026-08-23T12:05:43.823Z"
|
||||
}
|
||||
|
||||
+92
-32
@@ -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.
|
||||
|
||||
@@ -9944,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,
|
||||
@@ -10486,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)",
|
||||
@@ -10495,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+)",
|
||||
@@ -10504,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",
|
||||
@@ -10513,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)",
|
||||
@@ -10522,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",
|
||||
@@ -11047,6 +11104,7 @@ function parseProofArchive(archiveJson) {
|
||||
export {
|
||||
CANONICAL_DIGEST_VERSION,
|
||||
NIP_QR_KIND,
|
||||
PQ_DERIVATION_SCHEME_VERSION,
|
||||
PQ_KEY_INFO,
|
||||
base64ToBytes,
|
||||
buildKind11112Wrapper,
|
||||
@@ -11059,6 +11117,7 @@ export {
|
||||
canonicalEventDigest,
|
||||
clearPendingOts,
|
||||
computeEventId,
|
||||
derivationPathForAlgorithm,
|
||||
derivePQKeysFromSeed,
|
||||
deriveSecp256k1FromSeed,
|
||||
generateSeedPhrase,
|
||||
@@ -11073,6 +11132,7 @@ export {
|
||||
mnemonicToSeed,
|
||||
parseOtsFile,
|
||||
parseProofArchive,
|
||||
pqDerivationPath,
|
||||
savePendingOts,
|
||||
selectCanonicalProofCarrier,
|
||||
signWithFalcon,
|
||||
|
||||
Reference in New Issue
Block a user