From 6bb27f959643bb8c63a89a64ed88aaf2e78f3c0b Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Sun, 23 Aug 2026 08:05:44 -0400 Subject: [PATCH] V2 hardened derivation: PQ keys move to per-algorithm coin types (102003'-102007', all-hardened) outside the Nostr 1237' subtree, closing audit F-M3. Adds derivation_scheme tag on new events, v1 recovery mode for legacy seeds, v2 conformance vectors, 10 new tests (147 pass), updated docs/NIP proposal, and upload-test.sh staging deploy. Deployed to production. --- README.md | 19 +++ audits/GLM5.2/findings.md | 2 + explanation.md | 20 ++- nip_proposal.md | 34 ++-- package.json | 2 +- plans/v2-hardened-derivation.md | 169 ++++++++++++++++++++ test/pq-crypto.test.mjs | 171 +++++++++++++++++++- test/vectors/generate-vectors.mjs | 132 ++++++++++++---- test/vectors/seed-to-pubkeys.v1.json | 3 +- test/vectors/seed-to-pubkeys.v2.json | 43 +++++ upload-test.sh | 54 +++++++ www/index.html | 21 ++- www/js/index-app.mjs | 34 +++- www/js/pq-crypto.mjs | 226 +++++++++++++++++++++------ www/js/version.json | 6 +- www/pq-crypto.bundle.js | 124 +++++++++++---- 16 files changed, 911 insertions(+), 149 deletions(-) create mode 100644 plans/v2-hardened-derivation.md create mode 100644 test/vectors/seed-to-pubkeys.v2.json create mode 100755 upload-test.sh diff --git a/README.md b/README.md index b76a923..3829a1c 100644 --- a/README.md +++ b/README.md @@ -274,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 @@ -296,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` | diff --git a/audits/GLM5.2/findings.md b/audits/GLM5.2/findings.md index b87f227..acfcd55 100644 --- a/audits/GLM5.2/findings.md +++ b/audits/GLM5.2/findings.md @@ -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 diff --git a/explanation.md b/explanation.md index 616a49b..e899d71 100644 --- a/explanation.md +++ b/explanation.md @@ -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 diff --git a/nip_proposal.md b/nip_proposal.md index 189f31e..b556e10 100644 --- a/nip_proposal.md +++ b/nip_proposal.md @@ -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", ""]` — the Bitcoin block height at signing time, as a decimal string. - `["algorithm", "", "", ""]` — one tag per PQ **signature** scheme. The signature is over `TextEncoder.encode(content)`. - `["algorithm", "ml-kem-768", ""]` — 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", ""]` — 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", "", ""], ["algorithm", "slh-dsa-128s", "", ""], ["algorithm", "falcon-512", "", ""], - ["algorithm", "ml-kem-768", ""] + ["algorithm", "ml-kem-768", ""], + ["derivation_scheme", "2"] ], "sig": "" } diff --git a/package.json b/package.json index bf75f68..57c26db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nostr_quantum_preparation", - "version": "0.1.7", + "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": { diff --git a/plans/v2-hardened-derivation.md b/plans/v2-hardened-derivation.md new file mode 100644 index 0000000..cf28605 --- /dev/null +++ b/plans/v2-hardened-derivation.md @@ -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) | diff --git a/test/pq-crypto.test.mjs b/test/pq-crypto.test.mjs index 3c2bd21..6cc01c4 100644 --- a/test/pq-crypto.test.mjs +++ b/test/pq-crypto.test.mjs @@ -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'); }); }); diff --git a/test/vectors/generate-vectors.mjs b/test/vectors/generate-vectors.mjs index 0b48a83..6857533 100644 --- a/test/vectors/generate-vectors.mjs +++ b/test/vectors/generate-vectors.mjs @@ -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)); diff --git a/test/vectors/seed-to-pubkeys.v1.json b/test/vectors/seed-to-pubkeys.v1.json index e70897e..aee9e70 100644 --- a/test/vectors/seed-to-pubkeys.v1.json +++ b/test/vectors/seed-to-pubkeys.v1.json @@ -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." ] } diff --git a/test/vectors/seed-to-pubkeys.v2.json b/test/vectors/seed-to-pubkeys.v2.json new file mode 100644 index 0000000..a78cb3b --- /dev/null +++ b/test/vectors/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)." + ] +} diff --git a/upload-test.sh b/upload-test.sh new file mode 100755 index 0000000..a70ff47 --- /dev/null +++ b/upload-test.sh @@ -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" diff --git a/www/index.html b/www/index.html index f23d148..3c5d693 100644 --- a/www/index.html +++ b/www/index.html @@ -488,6 +488,17 @@
+ + @@ -505,32 +516,32 @@
- ML-DSA-44 (Dilithium) — FIPS 204 · Cat 2 · 1312-byte pubkey · m/44'/1237'/0'/0/1 + ML-DSA-44 (Dilithium) — FIPS 204 · Cat 2 · 1312-byte pubkey · m/44'/102006'/0'/0'/0'
- ML-DSA-65 (Dilithium) — FIPS 204 · Cat 3 · 1952-byte pubkey · m/44'/1237'/0'/0/2 + ML-DSA-65 (Dilithium) — FIPS 204 · Cat 3 · 1952-byte pubkey · m/44'/102003'/0'/0'/0'
- SLH-DSA-128s (SPHINCS+) — FIPS 205 · Cat 1 · 32-byte pubkey · m/44'/1237'/0'/0/3+4 + SLH-DSA-128s (SPHINCS+) — FIPS 205 · Cat 1 · 32-byte pubkey · m/44'/102004'/0'/0'/0' + /1'
- Falcon-512 — FIPS 206 (draft) · Cat 1 · 897-byte pubkey · m/44'/1237'/0'/0/5+6 + Falcon-512 — FIPS 206 (draft) · Cat 1 · 897-byte pubkey · m/44'/102007'/0'/0'/0' + /1'
⚠ Draft standard — may need re-issuing if FIPS 206 changes
- ML-KEM-768 (Kyber) — FIPS 203 · Cat 3 · 1184-byte pubkey · m/44'/1237'/0'/0/7+8 + ML-KEM-768 (Kyber) — FIPS 203 · Cat 3 · 1184-byte pubkey · m/44'/102005'/0'/0'/0' + /1'
diff --git a/www/js/index-app.mjs b/www/js/index-app.mjs index 464774d..9f30e49 100644 --- a/www/js/index-app.mjs +++ b/www/js/index-app.mjs @@ -4,6 +4,7 @@ isValidMnemonic, deriveSecp256k1FromSeed, derivePQKeysFromSeed, + derivationPathForAlgorithm, buildKind1Announcement, buildProofCarrier, computeEventId, @@ -42,6 +43,9 @@ let pqSecpKeys = null; let pqEvent = null; // kind 9999 proof carrier event let kind1Event = null; // kind 1 announcement event + // Derivation scheme for this run: 'v2' (default, hardened coin-type paths) + // or 'v1' (legacy recovery of a pre-v2 seed). See plans/v2-hardened-derivation.md. + let pqScheme = 'v2'; /* ================================================================ DOM REFERENCES @@ -638,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%'; @@ -645,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'); @@ -664,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) { @@ -693,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 @@ -991,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) { @@ -1011,6 +1032,7 @@ }); pqSeedOwnContinueBtn.addEventListener('click', () => { pqMnemonic = pqSeedInput.value.trim(); + // Scheme was set by the v1-recovery checkbox; default v2 otherwise. setStepDone(2); derivePQKeys(); }); @@ -1163,6 +1185,12 @@ 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; diff --git a/www/js/pq-crypto.mjs b/www/js/pq-crypto.mjs index 554f8f7..f865fda 100644 --- a/www/js/pq-crypto.mjs +++ b/www/js/pq-crypto.mjs @@ -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'/'/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) // ============================================================================ diff --git a/www/js/version.json b/www/js/version.json index b978e0c..467995b 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -1,5 +1,5 @@ { - "VERSION": "v0.1.7", - "VERSION_NUMBER": "0.1.7", - "BUILD_DATE": "2026-07-31T10:14:20.015Z" + "VERSION": "v0.1.8", + "VERSION_NUMBER": "0.1.8", + "BUILD_DATE": "2026-08-23T12:05:43.823Z" } diff --git a/www/pq-crypto.bundle.js b/www/pq-crypto.bundle.js index 0afe58f..39b9bc1 100644 --- a/www/pq-crypto.bundle.js +++ b/www/pq-crypto.bundle.js @@ -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,