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.

This commit is contained in:
Laan Tungir
2026-08-23 08:05:44 -04:00
parent 0ceeb8468f
commit 6bb27f9596
16 changed files with 911 additions and 149 deletions
+19
View File
@@ -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` |
+2
View File
@@ -147,6 +147,8 @@ and have callers require `id` presence.
**Fix.** Document the requirement never to publish the xpub at `m/44'/1237'/0'/0'` (done). Consider hardened indices for PQ children if wallet-compatibility is not required.
**Status (2026-08-21): ADDRESSED by derivation scheme v2.** PQ keys now derive under per-algorithm coin types in the unregistered SLIP-44 `102XXX'` range (all-hardened below coin type), entirely outside the Nostr `1237'` subtree — so no xpub leak within the Nostr coin branch can reach PQ keys, and a single PQ branch leak costs one algorithm. V1 derivation is retained for recovery of v1-era seeds; new events carry a `derivation_scheme` tag. See `plans/v2-hardened-derivation.md` and `test/vectors/seed-to-pubkeys.v2.json`.
---
## F-M4 (Medium) — Truncation rule for concatenated BIP32 children is arbitrary
+12 -8
View File
@@ -41,16 +41,20 @@ This is the root of the HD wallet tree. From here, we derive all keys — both s
## Step 4: Derive all keys from BIP32 paths
All keys are derived under the NIP-06 base path `m/44'/1237'/0'/0/`, using different child indices:
The secp256k1 identity key is derived at the NIP-06 path `m/44'/1237'/0'/0/0`. The five PQ keys are derived under **per-algorithm coin types** in the unregistered SLIP-44 `102XXX'` range, all-hardened below the coin type (the v2 scheme — see below for why):
| Child index | Algorithm | Seed length | BIP32 path | How derived |
| Algorithm | Coin type | Seed length | BIP32 path | How derived |
|---|---|---|---|---|
| 0 | secp256k1 (NIP-06) | 32 bytes | `m/44'/1237'/0'/0/0` | Standard BIP32 — private key used directly |
| 1 | ML-DSA-44 | 32 bytes | `m/44'/1237'/0'/0/1` | Single child — 32-byte private key is the PQ seed |
| 2 | ML-DSA-65 | 32 bytes | `m/44'/1237'/0'/0/2` | Single child — 32-byte private key is the PQ seed |
| 3+4 | SLH-DSA-128s | 48 bytes | `m/44'/1237'/0'/0/3` + `m/44'/1237'/0'/0/4` | Two children concatenated (64 bytes), first 48 used |
| 5+6 | Falcon-512 | 48 bytes | `m/44'/1237'/0'/0/5` + `m/44'/1237'/0'/0/6` | Two children concatenated (64 bytes), first 48 used |
| 7+8 | ML-KEM-768 | 64 bytes | `m/44'/1237'/0'/0/7` + `m/44'/1237'/0'/0/8` | Two children concatenated (64 bytes) |
| secp256k1 (NIP-06) | 1237' | 32 bytes | `m/44'/1237'/0'/0/0` | Standard BIP32 — private key used directly |
| ML-DSA-44 | 102006' | 32 bytes | `m/44'/102006'/0'/0'/0'` | Single child — 32-byte private key is the PQ seed |
| ML-DSA-65 | 102003' | 32 bytes | `m/44'/102003'/0'/0'/0'` | Single child — 32-byte private key is the PQ seed |
| SLH-DSA-128s | 102004' | 48 bytes | `m/44'/102004'/0'/0'/0'` + `/1'` | Two children concatenated (64 bytes), first 48 used |
| Falcon-512 | 102007' | 48 bytes | `m/44'/102007'/0'/0'/0'` + `/1'` | Two children concatenated (64 bytes), first 48 used |
| ML-KEM-768 | 102005' | 64 bytes | `m/44'/102005'/0'/0'/0'` + `/1'` | Two children concatenated (64 bytes) |
**Why coin types instead of child indices under 1237'?** The v1 scheme placed PQ seeds at non-hardened children under the NIP-06 account. Post-quantum, any leaked public key (including one inside an xpub) yields its private key, and a node's private key + chain code gives every child below it — so a single xpub leak inside the Nostr coin branch could reach all five PQ seeds. Moving each algorithm to its own coin branch means no compromise of the `1237'` subtree can touch PQ keys at all, and a leak of one branch's extended key costs exactly one algorithm. Coin types 102003'–102005' match n_signer and the Rust signer; 102006'–102007' are this project's allocations.
**V1 legacy (recovery only).** Events created before v2 used non-hardened children 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
View File
@@ -63,18 +63,30 @@ flowchart TD
## PQ key derivation from a BIP39 seed
All PQ keys are derived from a BIP39 seed via **BIP32 hierarchical deterministic derivation**, the same standard NIP-06 uses for secp256k1 keys. PQ keys live at fixed child indices under the NIP-06 base path.
All PQ keys are derived from a BIP39 seed via **BIP32 hierarchical deterministic derivation**, the same standard NIP-06 uses for secp256k1 keys.
**Base path:** `m/44'/1237'/0'/0/` (NIP-06 account 0, change 0)
### Derivation scheme v2 (current)
| Child index/indices | Algorithm | Seed length needed | BIP32 path | Derivation |
Each PQ algorithm is derived under its own **coin type** in the unregistered SLIP-44 `102XXX'` range, all-hardened below the coin type. The secp256k1 identity key stays at the NIP-06 path.
| Algorithm | Coin type | Seed length needed | BIP32 path | Derivation |
|---|---|---|---|---|
| 0 | secp256k1 (NIP-06) | 32 bytes | `m/44'/1237'/0'/0/0` | Standard BIP32; private key used directly |
| 1 | ML-DSA-44 | 32 bytes | `m/44'/1237'/0'/0/1` | Single child; 32-byte private key is the PQ seed |
| 2 | ML-DSA-65 | 32 bytes | `m/44'/1237'/0'/0/2` | Single child; 32-byte private key is the PQ seed |
| 3 + 4 | SLH-DSA-128s | 48 bytes | `m/44'/1237'/0'/0/3` + `m/44'/1237'/0'/0/4` | Two children concatenated (64 bytes), first 48 used |
| 5 + 6 | Falcon-512 | 48 bytes | `m/44'/1237'/0'/0/5` + `m/44'/1237'/0'/0/6` | Two children concatenated (64 bytes), first 48 used |
| 7 + 8 | ML-KEM-768 | 64 bytes | `m/44'/1237'/0'/0/7` + `m/44'/1237'/0'/0/8` | Two children concatenated (64 bytes) |
| secp256k1 (NIP-06) | 1237' | 32 bytes | `m/44'/1237'/0'/0/0` | Standard BIP32; private key used directly |
| ML-DSA-44 | 102006' | 32 bytes | `m/44'/102006'/0'/0'/0'` | Single child; 32-byte private key is the PQ seed |
| ML-DSA-65 | 102003' | 32 bytes | `m/44'/102003'/0'/0'/0'` | Single child; 32-byte private key is the PQ seed |
| SLH-DSA-128s | 102004' | 48 bytes | `m/44'/102004'/0'/0'/0'` + `m/44'/102004'/0'/0'/1'` | Two children concatenated (64 bytes), first 48 used |
| Falcon-512 | 102007' | 48 bytes | `m/44'/102007'/0'/0'/0'` + `m/44'/102007'/0'/0'/1'` | Two children concatenated (64 bytes), first 48 used |
| ML-KEM-768 | 102005' | 64 bytes | `m/44'/102005'/0'/0'/0'` + `m/44'/102005'/0'/0'/1'` | Two children concatenated (64 bytes) |
The coin-type registry: 102003' (ML-DSA-65), 102004' (SLH-DSA-128s), and 102005' (ML-KEM-768) are shared with the n_signer and Rust signer projects; 102006' (ML-DSA-44) and 102007' (Falcon-512) are allocated by this proposal. The `102XXX` range is unregistered in SLIP-44 and chosen to avoid collisions with real cryptocurrencies.
**Rationale.** PQ keys sit outside the Nostr coin branch (`1237'`) entirely. Post-quantum, any leaked public key — including one inside an extended public key — yields its private key, and a node's private key plus chain code yields every child below it, hardened or not. Deriving PQ keys under per-algorithm coin types means (a) no compromise of the Nostr subtree, even a coin-level xpub leak combined with a quantum computer, can reach PQ keys, and (b) a leak of any single PQ branch's extended key compromises exactly one algorithm.
**Falcon-512 caveat.** Falcon's keygen is rejection-sampling-based with no universally implemented seed interface; identical seeds produce different keys across libraries. The conformance vectors pin `@noble/post-quantum`'s behavior; implementations using other Falcon libraries will derive different (but equally valid) Falcon keys and MUST NOT be treated as non-conforming on that basis alone.
### Derivation scheme v1 (legacy)
Events created before this scheme was introduced derived PQ keys at non-hardened child indices under `m/44'/1237'/0'/0/` (children 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
View File
@@ -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": {
+169
View File
@@ -0,0 +1,169 @@
# V2 Hardened Derivation Scheme — Design Doc
## Status
Proposed. Addresses audit finding [F-M3 (Medium)](../audits/GLM5.2/findings.md) — BIP32 non-hardened leaf indices used for PQ seeds.
## Decision summary
| Question | Decision |
|---|---|
| v2 path scheme | **Per-algorithm coin types in the unregistered SLIP-44 `102XXX'` range** (matches n_signer) |
| Seed pipeline | **FIPS seeded interface**: BIP32 child bytes (exact length) → `keygen(seed)` (matches noble + Rust crates) |
| v1 users | **Full recovery**: v1 derivation retained; same seed derives both v1 and v2 keys; old events verify forever |
| n_signer / Rust signer | Free to migrate to the seeded API later (no users); their DRBG pipeline documented as divergence |
| New coin types | ML-DSA-44 = `102006'`, Falcon-512 = `102007'` (continuing n_signer's range) |
## Problem
V1 derives all keys under `m/44'/1237'/0'/0/` with **non-hardened** leaf children:
| Child | Key |
|---|---|
| 0 | secp256k1 (NIP-06, published as npub) |
| 1 | ML-DSA-44 |
| 2 | ML-DSA-65 |
| 3+4 | SLH-DSA-128s (48-byte seed) |
| 5+6 | Falcon-512 (48-byte seed) |
| 7+8 | ML-KEM-768 (64-byte seed) |
The project's threat model assumes the published secp256k1 key **will** be broken by Shor's algorithm. Post-quantum, any leaked public key (including the one inside an xpub) yields its private key, and holding a node's private key + chain code gives every child below it, hardened or not. So with any xpub leak at or above the change level, a quantum attacker reaches **all 5 PQ seeds** through the identity account. Non-hardened derivation buys nothing here anyway: PQ public keys come from `keygen(seed)`, not scalar multiplication, so watch-only derivation of PQ child pubkeys is impossible.
### Why alternatives were rejected
- **Hardened leaves under account 0** (`m/44'/1237'/0'/0'/{n}'` or `m/44'/1237'/0'/{n}'/0'`): still hangs PQ keys off the identity account; an account-0 xpub leak + quantum reaches everything below account 0.
- **Per-algorithm accounts under 1237'** (`m/44'/1237'/{n}'/0/0`): better (per-key isolation) but a coin-level `1237'` xpub leak + quantum still reaches all 5, and accounts 1'–5' collide with NIP-06 multi-identity use (a wallet identity at account 1 would silently republish the ML-DSA-44 seed as an secp256k1 npub).
- **Per-algorithm coin types** (chosen): PQ keys leave the `1237'` subtree entirely. No compromise of the Nostr coin branch — even the coin-level xpub with quantum — can touch them. Matches n_signer's existing scheme.
## Solution
**V2 scheme: one coin type per PQ algorithm, all-hardened below coin type.**
Coin types 102003'–102005' are n_signer's existing allocations ([`n_signer/documents/derivation_paths.md`](../../n_signer/documents/derivation_paths.md)); 102006'–102007' are new allocations for the two algorithms this project adds. The `102XXX` range is unregistered in SLIP-44 and chosen to avoid collisions with real cryptocurrencies.
| Algorithm | Coin type | Path (account 0) | Seed length |
|---|---|---|---|
| ML-DSA-44 | `102006'` | `m/44'/102006'/0'/0'/0'` | 32 B (one child) |
| ML-DSA-65 | `102003'` | `m/44'/102003'/0'/0'/0'` | 32 B (one child) |
| SLH-DSA-128s | `102004'` | `m/44'/102004'/0'/0'/0'` + `/1'` | 48 B (two children, first 48 of 64) |
| Falcon-512 | `102007'` | `m/44'/102007'/0'/0'/0'` + `/1'` | 48 B (two children, first 48 of 64) |
| ML-KEM-768 | `102005'` | `m/44'/102005'/0'/0'/0'` + `/1'` | 64 B (two children) |
The secp256k1 identity key **stays at NIP-06 `m/44'/1237'/0'/0/0`** — unchanged, standard, published.
### Security properties
| Leak + quantum attacker | Result |
|---|---|
| Published npub only (always broken) | PQ safe |
| Account-0 xpub (standard wallet export) | PQ safe |
| Coin-level `m/44'/1237'` xpub | **PQ safe — PQ keys are not under `1237'` at all** |
| One PQ coin branch's own xpub | 1 algorithm falls (per-algorithm isolation) |
| NIP-06 multi-identity accounts | No collision — wallets never derive `102XXX'` coin types |
### Seed pipeline: FIPS seeded interface
FIPS 203/204/205 define keygen as consuming a fixed-length seed (ML-DSA 32 B, ML-KEM 64 B, SLH-DSA-128s 48 B); the SHAKE expansion happens *inside* keygen. The v2 pipeline is therefore: derive BIP32 children → concatenate/truncate to the exact seed length → `keygen(seed)`. This is what [`derivePQKeysFromSeed()`](../www/js/pq-crypto.mjs) already does via noble, and what Rust PQ crates expose — so JS and Rust implementations agree by construction.
**n_signer divergence:** n_signer feeds the derived child through a SHAKE-256 DRBG into PQClean's `randombytes()` callback (a PQClean API artifact, not a cryptographic choice). Same path + same seed bytes there produce *different* keys than the seeded interface. Since n_signer and the Rust signer have no users, the recommendation (filed separately in those projects) is to migrate them to the seeded API; this project does not replicate the DRBG.
**Falcon caveat:** Falcon (draft FIPS 206) keygen is rejection-sampling-based with no universally implemented seed interface. Even with identical seeds, noble's Falcon keys ≠ PQClean's ≠ Rust's. We pin noble's behavior in test vectors and flag Falcon as per-library in the NIP proposal.
## Compatibility — v1 users can still recover
The root of trust is the **BIP39 seed**, not the path. Verification is path-agnostic: [`verify-app.mjs`](../www/js/verify-app.mjs) checks PQ signatures against pubkeys in the kind 1 event tags and never derives from a seed. Therefore:
1. **Existing v1 events remain fully verifiable forever.** No verifier changes required for old events.
2. **A v1 user's seed still recovers their v1 keys.** V1 derivation code is retained and exposed as a legacy option.
3. **The same seed mints a v2 key-link event at any time**: load seed → derive v2 keys → publish new kind 1 → OTS anchor. The v2 PQ keys are cryptographically independent of the v1 keys (different coin branches), so the v1 xpub-leak scenario no longer matters going forward.
### Version signaling
New kind 1 events include a `derivation_scheme` tag:
```
['derivation_scheme', '2']
```
- Absent tag → v1 (legacy). Informational for display; signature verification is unaffected either way.
- Unknown future values → informational only (fail-open for display; this tag is metadata, not evidence — unlike `digest_version`, which fails closed because it changes what is hashed).
## File-by-file changes
### 1. `www/js/pq-crypto.mjs`
- Add versioned scheme table:
```js
const PQ_DERIVATION_SCHEMES = {
v1: { // legacy — retained for recovery, never default
base: "m/44'/1237'/0'/0", hardenedLeaves: false,
children: { mlDsa44: [1], mlDsa65: [2], slhDsa: [3,4], falcon512: [5,6], mlKem: [7,8] } },
v2: { // per-algorithm coin types (n_signer-compatible)
hardenedLeaves: true,
children: {
mlDsa44: { coin: 102006, indices: [0] },
mlDsa65: { coin: 102003, indices: [0] },
slhDsa: { coin: 102004, indices: [0, 1] },
falcon512: { coin: 102007, indices: [0, 1] },
mlKem: { coin: 102005, indices: [0, 1] },
} },
};
export const PQ_DERIVATION_SCHEME_VERSION = 2;
```
- Refactor [`deriveBIP32Child()`](../www/js/pq-crypto.mjs) and `derivePQSeedFromBIP32()` to take the scheme instead of the hardcoded v1 base.
- `derivePQKeysFromSeed(seed, scheme = 'v2')` — default v2; `'v1'` still works for recovery.
- `buildKind1Announcement()` gains a `derivationScheme` parameter (default 2) and emits the `derivation_scheme` tag.
- `PQ_KEY_INFO` derivation paths become scheme-aware so the UI shows the correct path.
### 2. `www/js/index-app.mjs`
- Default flow derives v2 and shows v2 paths in the UI.
- Add a **v1 recovery mode**: user enters a v1-era seed → app derives v1 keys → matches them against the user's published kind 1 event (by npub) → confirms "these are your v1 keys" → offers to mint a v2 event from the same seed.
### 3. `www/pq-crypto.bundle.js`
- Rebuild via `node build-pq-bundle.js` after source changes.
### 4. `test/vectors/generate-vectors.mjs` + vectors
- Emit `seed-to-pubkeys.v2.json` (same fixed test seed, v2 paths) alongside the pinned v1 file. V1 vectors stay untouched as the legacy conformance reference.
### 5. `test/pq-crypto.test.mjs`
- v2 derivation reproduces the v2 vector.
- v1 derivation still reproduces the v1 vector (regression).
- **Independence test:** v1 and v2 keys from the same seed share no key material (pubkeys differ for every algorithm).
- New events carry `derivation_scheme: '2'`; v1 events omit it.
- Recovery path: v1 seed → v1 keys → match published event tags.
### 6. Docs
- `README.md`: Component 6 gains the v2 scheme and the coin-type isolation rationale; implementation status table updated.
- `explanation.md`, `nip_proposal.md`: replace the wallet-compatibility justification for non-hardened leaves with the v2 scheme; document `derivation_scheme` tag; document v1 legacy/recovery; document the `102XXX'` coin-type registry (102003'–102005' per n_signer, 102006'–102007' new); flag Falcon as per-library.
- `audits/GLM5.2/findings.md` F-M3: annotate as addressed-by-v2 (append status; do not rewrite history).
### 7. `www/js/version.json`
- Bump to `0.2.0` (minor: new derivation scheme, backward compatible).
## What we are explicitly NOT doing
- **Not** deleting or changing v1 derivation (recovery depends on it).
- **Not** re-deriving or re-signing existing events (impossible — and unnecessary, verification is path-agnostic).
- **Not** making `derivation_scheme` fail-closed in the verifier (display metadata, not evidence).
- **Not** moving the secp256k1 identity key off NIP-06 (ecosystem compatibility).
- **Not** replicating n_signer's SHAKE-256 DRBG pipeline (locks us out of the FIPS seeded interface; n_signer/Rust should migrate instead — separate effort, no users to break).
- **Not** claiming cross-implementation Falcon determinism (rejection sampling; pin noble's vectors, flag in NIP).
## Test matrix summary
| Test | Asserts |
|---|---|
| v2 vector reproduction | Same seed → pinned v2 pubkeys |
| v1 vector regression | Same seed → pinned v1 pubkeys (unchanged) |
| v1/v2 independence | No shared pubkeys across schemes |
| Tag emission | New events have `derivation_scheme 2`; legacy path omits it |
| Recovery flow | v1 seed → v1 keys match published event |
| Existing suite | All current tests still pass (no behavioral change to verification) |
+164 -7
View File
@@ -1523,7 +1523,7 @@ describe('F-D6: cross-implementation conformance vectors', () => {
}
});
test('F-D6: implementation reproduces the pinned seed-to-pubkeys vector', () => {
test('F-D6: implementation reproduces the pinned v1 seed-to-pubkeys vector (legacy regression)', () => {
const vecPath = join(vectorsDir, 'seed-to-pubkeys.v1.json');
const vec = JSON.parse(readFileSync(vecPath, 'utf8'));
const seed = m.mnemonicToSeed(vec.mnemonic);
@@ -1534,16 +1534,173 @@ describe('F-D6: cross-implementation conformance vectors', () => {
assert.equal(m.bytesToHex(secpKp.publicKey), vec.derivedPublicKeys.secp256k1.publicKeyHex,
'secp256k1 pubkey should match the pinned vector');
const pqKeys = m.derivePQKeysFromSeed(seed);
// v1 vector must be reproduced with the EXPLICIT legacy scheme — the
// default is v2 now, and v1 keys must never change (recovery depends on it).
const pqKeys = m.derivePQKeysFromSeed(seed, 'v1');
assert.equal(m.bytesToHex(pqKeys.mlDsa44.publicKey), vec.derivedPublicKeys['ml-dsa-44'].publicKeyHex,
'ml-dsa-44 pubkey should match the pinned vector');
'ml-dsa-44 pubkey should match the pinned v1 vector');
assert.equal(m.bytesToHex(pqKeys.mlDsa65.publicKey), vec.derivedPublicKeys['ml-dsa-65'].publicKeyHex,
'ml-dsa-65 pubkey should match the pinned vector');
'ml-dsa-65 pubkey should match the pinned v1 vector');
assert.equal(m.bytesToHex(pqKeys.slhDsa.publicKey), vec.derivedPublicKeys['slh-dsa-128s'].publicKeyHex,
'slh-dsa-128s pubkey should match the pinned vector');
'slh-dsa-128s pubkey should match the pinned v1 vector');
assert.equal(m.bytesToHex(pqKeys.falcon512.publicKey), vec.derivedPublicKeys['falcon-512'].publicKeyHex,
'falcon-512 pubkey should match the pinned vector');
'falcon-512 pubkey should match the pinned v1 vector');
assert.equal(m.bytesToHex(pqKeys.mlKem.publicKey), vec.derivedPublicKeys['ml-kem-768'].publicKeyHex,
'ml-kem-768 pubkey should match the pinned vector');
'ml-kem-768 pubkey should match the pinned v1 vector');
});
});
// ============================================================================
// V2 HARDENED DERIVATION SCHEME (per-algorithm coin types)
// ============================================================================
//
// See plans/v2-hardened-derivation.md. V2 moves PQ keys off the Nostr coin
// branch (1237') entirely, into per-algorithm coin types in the unregistered
// SLIP-44 102XXX' range, all-hardened below the coin type. This isolates PQ
// keys from any compromise of the Nostr subtree (audit F-M3) and matches the
// coin-type registry shared with n_signer / the Rust signer.
describe('V2 hardened derivation scheme', () => {
const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
test('v2: seed-to-pubkeys.v2.json vector is loadable and well-formed', () => {
const vecPath = join(vectorsDir, 'seed-to-pubkeys.v2.json');
let vec;
try {
vec = JSON.parse(readFileSync(vecPath, 'utf8'));
} catch (e) {
assert.fail(`Could not load ${vecPath}: ${e.message}`);
}
assert.equal(vec.vectorType, 'nostr-pq-link-seed-to-pubkeys');
assert.equal(vec.vectorVersion, 2);
assert.equal(vec.derivationScheme, 2);
assert.equal(typeof vec.mnemonic, 'string');
assert.equal(typeof vec.bip39SeedHex, 'string');
for (const alg of ['ml-dsa-44', 'ml-dsa-65', 'slh-dsa-128s', 'falcon-512', 'ml-kem-768']) {
assert.ok(vec.derivedPublicKeys[alg], `should have ${alg} entry`);
assert.equal(typeof vec.derivedPublicKeys[alg].publicKeyHex, 'string');
assert.ok(vec.derivedPublicKeys[alg].derivationPath.includes("102"), `${alg} path should use a 102XXX coin type`);
}
});
test('v2: implementation reproduces the pinned v2 vector', () => {
const vecPath = join(vectorsDir, 'seed-to-pubkeys.v2.json');
const vec = JSON.parse(readFileSync(vecPath, 'utf8'));
const seed = m.mnemonicToSeed(vec.mnemonic);
assert.equal(m.bytesToHex(seed), vec.bip39SeedHex, 'BIP39 seed should match the v2 vector');
const pqKeys = m.derivePQKeysFromSeed(seed, 'v2');
assert.equal(m.bytesToHex(pqKeys.mlDsa44.publicKey), vec.derivedPublicKeys['ml-dsa-44'].publicKeyHex,
'ml-dsa-44 pubkey should match the pinned v2 vector');
assert.equal(m.bytesToHex(pqKeys.mlDsa65.publicKey), vec.derivedPublicKeys['ml-dsa-65'].publicKeyHex,
'ml-dsa-65 pubkey should match the pinned v2 vector');
assert.equal(m.bytesToHex(pqKeys.slhDsa.publicKey), vec.derivedPublicKeys['slh-dsa-128s'].publicKeyHex,
'slh-dsa-128s pubkey should match the pinned v2 vector');
assert.equal(m.bytesToHex(pqKeys.falcon512.publicKey), vec.derivedPublicKeys['falcon-512'].publicKeyHex,
'falcon-512 pubkey should match the pinned v2 vector');
assert.equal(m.bytesToHex(pqKeys.mlKem.publicKey), vec.derivedPublicKeys['ml-kem-768'].publicKeyHex,
'ml-kem-768 pubkey should match the pinned v2 vector');
});
test('v2: default scheme is v2 (no argument = v2 keys)', () => {
const seed = m.mnemonicToSeed(TEST_MNEMONIC);
const byDefault = m.derivePQKeysFromSeed(seed);
const explicitV2 = m.derivePQKeysFromSeed(seed, 'v2');
assert.equal(m.bytesToHex(byDefault.mlDsa65.publicKey), m.bytesToHex(explicitV2.mlDsa65.publicKey),
'default derivation must be v2');
// numeric form also accepted
const numericV2 = m.derivePQKeysFromSeed(seed, 2);
assert.equal(m.bytesToHex(numericV2.mlDsa65.publicKey), m.bytesToHex(explicitV2.mlDsa65.publicKey),
'numeric scheme 2 must equal v2');
});
test('v2: v1 and v2 keys from the same seed are independent (no shared key material)', () => {
const seed = m.mnemonicToSeed(TEST_MNEMONIC);
const v1Keys = m.derivePQKeysFromSeed(seed, 'v1');
const v2Keys = m.derivePQKeysFromSeed(seed, 'v2');
assert.notEqual(m.bytesToHex(v1Keys.mlDsa44.publicKey), m.bytesToHex(v2Keys.mlDsa44.publicKey), 'ml-dsa-44');
assert.notEqual(m.bytesToHex(v1Keys.mlDsa65.publicKey), m.bytesToHex(v2Keys.mlDsa65.publicKey), 'ml-dsa-65');
assert.notEqual(m.bytesToHex(v1Keys.slhDsa.publicKey), m.bytesToHex(v2Keys.slhDsa.publicKey), 'slh-dsa-128s');
assert.notEqual(m.bytesToHex(v1Keys.falcon512.publicKey), m.bytesToHex(v2Keys.falcon512.publicKey), 'falcon-512');
assert.notEqual(m.bytesToHex(v1Keys.mlKem.publicKey), m.bytesToHex(v2Keys.mlKem.publicKey), 'ml-kem-768');
});
test('v2: secp256k1 NIP-06 identity key is unchanged across schemes', () => {
const seed = m.mnemonicToSeed(TEST_MNEMONIC);
const kp = m.deriveSecp256k1FromSeed(seed);
// NIP-06 path is scheme-independent; both v1 and v2 users keep the same identity key
const vec1 = JSON.parse(readFileSync(join(vectorsDir, 'seed-to-pubkeys.v1.json'), 'utf8'));
if (vec1.mnemonic === TEST_MNEMONIC) {
assert.equal(m.bytesToHex(kp.publicKey), vec1.derivedPublicKeys.secp256k1.publicKeyHex);
}
});
test('v2: unknown scheme throws', () => {
const seed = m.mnemonicToSeed(TEST_MNEMONIC);
assert.throws(() => m.derivePQKeysFromSeed(seed, 'v3'), /Unknown derivation scheme/);
assert.throws(() => m.derivePQKeysFromSeed(seed, 99), /Unknown derivation scheme/);
});
test('v2: derivation paths use per-algorithm coin types, all hardened', () => {
assert.equal(m.pqDerivationPath('mlDsa44', 'v2'), "m/44'/102006'/0'/0'/0'");
assert.equal(m.pqDerivationPath('mlDsa65', 'v2'), "m/44'/102003'/0'/0'/0'");
assert.equal(m.pqDerivationPath('slhDsa', 'v2'), "m/44'/102004'/0'/0'/0' + m/44'/102004'/0'/0'/1'");
assert.equal(m.pqDerivationPath('falcon512', 'v2'), "m/44'/102007'/0'/0'/0' + m/44'/102007'/0'/0'/1'");
assert.equal(m.pqDerivationPath('mlKem', 'v2'), "m/44'/102005'/0'/0'/0' + m/44'/102005'/0'/0'/1'");
// v1 paths unchanged (legacy)
assert.equal(m.pqDerivationPath('mlDsa44', 'v1'), "m/44'/1237'/0'/0/1");
assert.equal(m.pqDerivationPath('mlKem', 'v1'), "m/44'/1237'/0'/0/7 + m/44'/1237'/0'/0/8");
// algorithm-id helper
assert.equal(m.derivationPathForAlgorithm('ml-dsa-65'), "m/44'/102003'/0'/0'/0'");
assert.equal(m.derivationPathForAlgorithm('ml-dsa-65', 'v1'), "m/44'/1237'/0'/0/2");
});
test('v2: PQ_DERIVATION_SCHEME_VERSION is 2', () => {
assert.equal(m.PQ_DERIVATION_SCHEME_VERSION, 2);
});
test('v2: kind 1 announcement carries derivation_scheme tag', async () => {
const seed = m.mnemonicToSeed(TEST_MNEMONIC);
const pqKeys = m.derivePQKeysFromSeed(seed, 'v2');
const kp = m.deriveSecp256k1FromSeed(seed);
const ev = m.buildKind1Announcement(m.bytesToHex(kp.publicKey), 800000, pqKeys, 'v2');
const tag = ev.tags.find(t => t[0] === 'derivation_scheme');
assert.ok(tag, 'v2 event must have a derivation_scheme tag');
assert.equal(tag[1], '2');
// v1 events (legacy path) omit the tag
const v1Keys = m.derivePQKeysFromSeed(seed, 'v1');
const ev1 = m.buildKind1Announcement(m.bytesToHex(kp.publicKey), 800000, v1Keys, 'v1');
assert.ok(!ev1.tags.some(t => t[0] === 'derivation_scheme'), 'v1 event must NOT have the tag');
});
test('v2: v1 recovery flow — v1 seed re-derives v1 keys that verify against a v1 event', async () => {
// Simulate the recovery path: a v1-era seed produces keys whose signatures
// verify inside a kind 1 announcement built the v1 way.
const seed = m.mnemonicToSeed(TEST_MNEMONIC);
const v1Keys = m.derivePQKeysFromSeed(seed, 'v1');
const kp = m.deriveSecp256k1FromSeed(seed);
const ev = m.buildKind1Announcement(m.bytesToHex(kp.publicKey), 800000, v1Keys, 'v1');
// Each algorithm tag must verify against the re-derived v1 public keys
const algTag = (id) => ev.tags.find(t => t[0] === 'algorithm' && t[1] === id);
const statement = new TextEncoder().encode(ev.content);
const t44 = algTag('ml-dsa-44');
assert.ok(m.verifyMLDSA44(m.base64ToBytes(t44[3]), statement, v1Keys.mlDsa44.publicKey),
'recovered v1 ml-dsa-44 key must verify its own event signature');
const t65 = algTag('ml-dsa-65');
assert.ok(m.verifyMLDSA65(m.base64ToBytes(t65[3]), statement, v1Keys.mlDsa65.publicKey),
'recovered v1 ml-dsa-65 key must verify its own event signature');
const tSlh = algTag('slh-dsa-128s');
assert.ok(m.verifySLHDSA(m.base64ToBytes(tSlh[3]), statement, v1Keys.slhDsa.publicKey),
'recovered v1 slh-dsa-128s key must verify its own event signature');
const tFal = algTag('falcon-512');
assert.ok(m.verifyFalcon(m.base64ToBytes(tFal[3]), statement, v1Keys.falcon512.publicKey),
'recovered v1 falcon-512 key must verify its own event signature');
// KEM has no signature — pubkey presence is the check
const tKem = algTag('ml-kem-768');
assert.equal(tKem[2], m.bytesToBase64(v1Keys.mlKem.publicKey),
'recovered v1 ml-kem-768 pubkey must match the event tag');
});
});
+104 -28
View File
@@ -26,14 +26,6 @@ import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const VECTORS_DIR = __dirname;
const PQ_DERIVATION_PATHS = {
secp256k1: [0],
mlDsa44: [1],
mlDsa65: [2],
slhDsa: [3, 4],
falcon512: [5, 6],
mlKem: [7, 8],
};
const PQ_SEED_LENGTHS = {
mlDsa44: 32,
mlDsa65: 32,
@@ -42,26 +34,43 @@ const PQ_SEED_LENGTHS = {
mlKem: 64,
};
// V1 (legacy): non-hardened children under the NIP-06 account 0 change level.
const V1_PATHS = {
mlDsa44: ["m/44'/1237'/0'/0/1"],
mlDsa65: ["m/44'/1237'/0'/0/2"],
slhDsa: ["m/44'/1237'/0'/0/3", "m/44'/1237'/0'/0/4"],
falcon512: ["m/44'/1237'/0'/0/5", "m/44'/1237'/0'/0/6"],
mlKem: ["m/44'/1237'/0'/0/7", "m/44'/1237'/0'/0/8"],
};
// V2 (default): per-algorithm coin types in the unregistered SLIP-44 102XXX'
// range, all-hardened below the coin type. See plans/v2-hardened-derivation.md.
const V2_PATHS = {
mlDsa44: ["m/44'/102006'/0'/0'/0'"],
mlDsa65: ["m/44'/102003'/0'/0'/0'"],
slhDsa: ["m/44'/102004'/0'/0'/0'", "m/44'/102004'/0'/0'/1'"],
falcon512: ["m/44'/102007'/0'/0'/0'", "m/44'/102007'/0'/0'/1'"],
mlKem: ["m/44'/102005'/0'/0'/0'", "m/44'/102005'/0'/0'/1'"],
};
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
function deriveBIP32Child(bip39Seed, childIndices) {
function deriveBIP32Child(bip39Seed, path) {
const hdKey = HDKey.fromMasterSeed(bip39Seed);
const path = `m/44'/1237'/0'/0/${childIndices.join('/')}`;
const child = hdKey.derive(path);
if (!child.privateKey) throw new Error(`Failed to derive at ${path}`);
return child.privateKey;
}
function derivePQSeedFromBIP32(bip39Seed, childIndices, requiredLength) {
if (childIndices.length === 1) {
const seed = deriveBIP32Child(bip39Seed, childIndices);
return seed;
function derivePQSeedFromBIP32(bip39Seed, paths, requiredLength) {
if (paths.length === 1) {
return deriveBIP32Child(bip39Seed, paths[0]);
} else {
let combined = new Uint8Array(0);
for (const idx of childIndices) {
const child = deriveBIP32Child(bip39Seed, [idx]);
for (const path of paths) {
const child = deriveBIP32Child(bip39Seed, path);
const newCombined = new Uint8Array(combined.length + child.length);
newCombined.set(combined);
newCombined.set(child, combined.length);
@@ -81,18 +90,30 @@ const seed = mnemonicToSeedSync(MNEMONIC, '');
const secpHd = HDKey.fromMasterSeed(seed).derive("m/44'/1237'/0'/0/0");
const secpPub = secpHd.publicKey;
// PQ keys
const mlDsa44Seed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.mlDsa44, PQ_SEED_LENGTHS.mlDsa44);
// PQ keys — v1 (legacy) paths
const mlDsa44Seed = derivePQSeedFromBIP32(seed, V1_PATHS.mlDsa44, PQ_SEED_LENGTHS.mlDsa44);
const mlDsa44Keys = ml_dsa44.keygen(mlDsa44Seed);
const mlDsa65Seed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.mlDsa65, PQ_SEED_LENGTHS.mlDsa65);
const mlDsa65Seed = derivePQSeedFromBIP32(seed, V1_PATHS.mlDsa65, PQ_SEED_LENGTHS.mlDsa65);
const mlDsa65Keys = ml_dsa65.keygen(mlDsa65Seed);
const slhDsaSeed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.slhDsa, PQ_SEED_LENGTHS.slhDsa);
const slhDsaSeed = derivePQSeedFromBIP32(seed, V1_PATHS.slhDsa, PQ_SEED_LENGTHS.slhDsa);
const slhDsaKeys = slh_dsa_sha2_128s.keygen(slhDsaSeed);
const falconSeed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.falcon512, PQ_SEED_LENGTHS.falcon512);
const falconSeed = derivePQSeedFromBIP32(seed, V1_PATHS.falcon512, PQ_SEED_LENGTHS.falcon512);
const falconKeys = falcon512.keygen(falconSeed);
const mlKemSeed = derivePQSeedFromBIP32(seed, PQ_DERIVATION_PATHS.mlKem, PQ_SEED_LENGTHS.mlKem);
const mlKemSeed = derivePQSeedFromBIP32(seed, V1_PATHS.mlKem, PQ_SEED_LENGTHS.mlKem);
const mlKemKeys = ml_kem768.keygen(mlKemSeed);
// PQ keys — v2 (per-algorithm coin types, all hardened)
const v2MlDsa44Seed = derivePQSeedFromBIP32(seed, V2_PATHS.mlDsa44, PQ_SEED_LENGTHS.mlDsa44);
const v2MlDsa44Keys = ml_dsa44.keygen(v2MlDsa44Seed);
const v2MlDsa65Seed = derivePQSeedFromBIP32(seed, V2_PATHS.mlDsa65, PQ_SEED_LENGTHS.mlDsa65);
const v2MlDsa65Keys = ml_dsa65.keygen(v2MlDsa65Seed);
const v2SlhDsaSeed = derivePQSeedFromBIP32(seed, V2_PATHS.slhDsa, PQ_SEED_LENGTHS.slhDsa);
const v2SlhDsaKeys = slh_dsa_sha2_128s.keygen(v2SlhDsaSeed);
const v2FalconSeed = derivePQSeedFromBIP32(seed, V2_PATHS.falcon512, PQ_SEED_LENGTHS.falcon512);
const v2FalconKeys = falcon512.keygen(v2FalconSeed);
const v2MlKemSeed = derivePQSeedFromBIP32(seed, V2_PATHS.mlKem, PQ_SEED_LENGTHS.mlKem);
const v2MlKemKeys = ml_kem768.keygen(v2MlKemSeed);
const vector = {
vectorType: 'nostr-pq-link-seed-to-pubkeys',
vectorVersion: 1,
@@ -129,7 +150,8 @@ const vector = {
notes: [
'The truncation rule is normative: for 48-byte seeds, two BIP32 children are concatenated (64 bytes) and the FIRST 48 bytes are used. For 64-byte seeds, all 64 bytes are used.',
'A future implementer who takes the last 48 bytes, or concatenates in the opposite order, will produce different keys and break seed-phrase recoverability.',
'A second implementation should run its own keygen from the same mnemonic and compare against the publicKeyHex values in this file.'
'A second implementation should run its own keygen from the same mnemonic and compare against the publicKeyHex values in this file.',
'LEGACY: this v1 vector pins the pre-v2 derivation (non-hardened children under m/44\'/1237\'/0\'/0/). It must never change — v1 seed recovery depends on it. New derivations use seed-to-pubkeys.v2.json.'
]
};
@@ -137,8 +159,62 @@ const outPath = join(VECTORS_DIR, 'seed-to-pubkeys.v1.json');
writeFileSync(outPath, JSON.stringify(vector, null, 2) + '\n');
console.log(`Wrote ${outPath}`);
console.log('secp256k1 pubkey:', bytesToHex(secpPub));
console.log('ml-dsa-44 pubkey:', bytesToHex(mlDsa44Keys.publicKey));
console.log('ml-dsa-65 pubkey:', bytesToHex(mlDsa65Keys.publicKey));
console.log('slh-dsa-128s pubkey:', bytesToHex(slhDsaKeys.publicKey));
console.log('falcon-512 pubkey:', bytesToHex(falconKeys.publicKey));
console.log('ml-kem-768 pubkey:', bytesToHex(mlKemKeys.publicKey));
console.log('ml-dsa-44 pubkey (v1):', bytesToHex(mlDsa44Keys.publicKey));
console.log('ml-dsa-65 pubkey (v1):', bytesToHex(mlDsa65Keys.publicKey));
console.log('slh-dsa-128s pubkey (v1):', bytesToHex(slhDsaKeys.publicKey));
console.log('falcon-512 pubkey (v1):', bytesToHex(falconKeys.publicKey));
console.log('ml-kem-768 pubkey (v1):', bytesToHex(mlKemKeys.publicKey));
// ── V2 vector: per-algorithm coin types, all hardened ────────────────────────
const v2Vector = {
vectorType: 'nostr-pq-link-seed-to-pubkeys',
vectorVersion: 2,
derivationScheme: 2,
description: 'V2 hardened derivation: per-algorithm coin types in the unregistered SLIP-44 102XXX range, all-hardened below the coin type. PQ keys are outside the Nostr coin branch (1237\'), so no compromise of the Nostr subtree can reach them (audit F-M3).',
mnemonic: MNEMONIC,
bip39SeedHex: bytesToHex(seed),
coinTypes: {
'ml-dsa-44': 102006,
'ml-dsa-65': 102003,
'slh-dsa-128s': 102004,
'falcon-512': 102007,
'ml-kem-768': 102005,
},
derivedPublicKeys: {
'ml-dsa-44': {
derivationPath: "m/44'/102006'/0'/0'/0'",
publicKeyHex: bytesToHex(v2MlDsa44Keys.publicKey)
},
'ml-dsa-65': {
derivationPath: "m/44'/102003'/0'/0'/0'",
publicKeyHex: bytesToHex(v2MlDsa65Keys.publicKey)
},
'slh-dsa-128s': {
derivationPath: "m/44'/102004'/0'/0'/0' + m/44'/102004'/0'/0'/1' (concatenated, first 48 bytes used)",
publicKeyHex: bytesToHex(v2SlhDsaKeys.publicKey)
},
'falcon-512': {
derivationPath: "m/44'/102007'/0'/0'/0' + m/44'/102007'/0'/0'/1' (concatenated, first 48 bytes used)",
publicKeyHex: bytesToHex(v2FalconKeys.publicKey)
},
'ml-kem-768': {
derivationPath: "m/44'/102005'/0'/0'/0' + m/44'/102005'/0'/0'/1' (concatenated, all 64 bytes used)",
publicKeyHex: bytesToHex(v2MlKemKeys.publicKey)
}
},
notes: [
'Same truncation rule as v1: two hardened children concatenated, first 48 bytes for 48-byte seeds, all 64 for 64-byte seeds.',
'Coin types 102003\u2013102005 match n_signer / the Rust signer; 102006\u2013102007 are this project\u2019s allocations for ML-DSA-44 and Falcon-512.',
'Falcon-512 is per-library: its keygen is rejection-sampling-based with no universal seed interface, so this vector pins @noble/post-quantum\u2019s behavior specifically.',
'The secp256k1 identity key is unchanged: NIP-06 m/44\'/1237\'/0\'/0/0 (see the v1 vector).'
]
};
const v2OutPath = join(VECTORS_DIR, 'seed-to-pubkeys.v2.json');
writeFileSync(v2OutPath, JSON.stringify(v2Vector, null, 2) + '\n');
console.log(`Wrote ${v2OutPath}`);
console.log('ml-dsa-44 pubkey (v2):', bytesToHex(v2MlDsa44Keys.publicKey));
console.log('ml-dsa-65 pubkey (v2):', bytesToHex(v2MlDsa65Keys.publicKey));
console.log('slh-dsa-128s pubkey (v2):', bytesToHex(v2SlhDsaKeys.publicKey));
console.log('falcon-512 pubkey (v2):', bytesToHex(v2FalconKeys.publicKey));
console.log('ml-kem-768 pubkey (v2):', bytesToHex(v2MlKemKeys.publicKey));
+2 -1
View File
@@ -34,6 +34,7 @@
"notes": [
"The truncation rule is normative: for 48-byte seeds, two BIP32 children are concatenated (64 bytes) and the FIRST 48 bytes are used. For 64-byte seeds, all 64 bytes are used.",
"A future implementer who takes the last 48 bytes, or concatenates in the opposite order, will produce different keys and break seed-phrase recoverability.",
"A second implementation should run its own keygen from the same mnemonic and compare against the publicKeyHex values in this file."
"A second implementation should run its own keygen from the same mnemonic and compare against the publicKeyHex values in this file.",
"LEGACY: this v1 vector pins the pre-v2 derivation (non-hardened children under m/44'/1237'/0'/0/). It must never change — v1 seed recovery depends on it. New derivations use seed-to-pubkeys.v2.json."
]
}
+43
View File
@@ -0,0 +1,43 @@
{
"vectorType": "nostr-pq-link-seed-to-pubkeys",
"vectorVersion": 2,
"derivationScheme": 2,
"description": "V2 hardened derivation: per-algorithm coin types in the unregistered SLIP-44 102XXX range, all-hardened below the coin type. PQ keys are outside the Nostr coin branch (1237'), so no compromise of the Nostr subtree can reach them (audit F-M3).",
"mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"bip39SeedHex": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4",
"coinTypes": {
"ml-dsa-44": 102006,
"ml-dsa-65": 102003,
"slh-dsa-128s": 102004,
"falcon-512": 102007,
"ml-kem-768": 102005
},
"derivedPublicKeys": {
"ml-dsa-44": {
"derivationPath": "m/44'/102006'/0'/0'/0'",
"publicKeyHex": "2c9763a5b69e423e6930b0430b1dd13db7ff14696a84bba4fe46ab8a1b2dee581b262caf3f31e2d4e14302b4f57c2f16d3d53f44c98a5e6a033a7e4066c3552a9dfce3fa5b0802599e1fbdb13d4e5e4f14aba08ef93f4b7ba254693952fc39405f74460a52e5b714ea1350ce1e4589534168c23c145e192cff98093398f7930fa747c56cac51a044dc2067527e53b7752eb9114279ca4a97aabb0e9b43ae3d321eb462cfdf2b51d7d93b926df6d02bfa4875c10e95cd2ea08ad1a345602cbc2cb12b22671a677911f72373e3f25e113cd7c34888c022afc79c24f8be57bb4eb3c3ffdb08a96bb80bc07a36ae947eb561178f9bab4c24f49e8188392a20476759a3462323b8a9d04edcd1b88b7734c94b0178215b3908cc55e753c2405ab37d12ca13ec15173b0694a51043dd98093929dad48460aabd3b3e80453bda0a4d0ebe0a5b76654e45900737d87f109997b5e34ef7141004c9537c01a246fc4c04268378cd30935d8e7d2b3847f910821bf9357dc858bb059270822a6f9a4f980b57f8bf4de69b1e55058888e7f6d6524220174b991302f55bfa0f5d979b374fb2c9394aba4e2a1bfcd55fc6495c397482b845b484d68ab1d4b58953f3d79708e0c4647b8deb3fdc89acbc83773d4eaac7b8e47ba30a443e827e180f686b0ae5484e9e780cb529ba3df65128597989cefed5891a01e66f1e4834ef960e819d92a24ee00a84d60878434717503d6f457c465b63302bbd4820df3a37edc66a23124aba228dc12ca6294ca99e1e8669d5a742122ef97ec42e7041e48198b3924f65528d2a766c7032a13ab50094e4cd1b2c5ad06dd1dcb59f70cd0b865a40b1e2198ca413750f6473a001fd908d5ddc640553786847c8dde4f65bef059cc132c8b75a68e2e2d8e4b08f437e1834ae7bb0d7f5e944030749fa73ad786e69cf361d242d1b8ce646ada8bf8e1dd19f87cf513d972f0b590139c1ab20ad44be86b49f5e6c38d5712355ebb484ed2b0f224ed8c8ad7eaa01389d9a5799950edfcb2aab19bf0fdfaaa7759b8deca275b031ebbbaf93e33840fa49c7833fd470917525447db2d0b926a82352cf7960d416d68a4defa51466e0123166fc567348c8aba1ae4d5c0fdc10aa017aa64f88d792ae61690cb4852a79b2d660e1cd405efe3b2f7e5d75edb338e5119a950925b6bdffb057502d241504b00a89dc2700753213912c0a543521674973ebd95aee5c9177962e33813240d44e8f5da85c059166dc1a5c8af10622b30eefd2cd1553ebba63a68f900bce19835cdba115177d22c85d2fd0502c4bb21f1d78bd14f480bbe8548ae02701bdce6f72060b87125faef235acee9ed229935071e4ac4951901f171cfe82f22987269a786fdca03b6ca2c6944b58d2756707a655c225da187d09d8263048f6ea29b248857f9cba4c7ec06698f5a049cff52f6c87a60078464fec9fb36048e5e7e93a4c201d0eecfecbcd9bb2364578ece831d56702854cbb33487fcfcdac49f8bcf4a88d884a890ce3907ec03c8ff11cb73b426ce2ebf6119929151959fededfa0c46ed3649220899b4996064f32f2bc995bf048e197d25ee64cade03a15663cd1f59e6deb11a9269e35c4523816e393be9e547f7879593c4fd253f1cafffa0530457cc3c58270ee2c1ce96ee4607f3026274120a064a7344d6321fdaf6821e1b19e8cef2684ad9e5aed4f34fa28846252c9ac1f7dc4bba3837ed0569a7d242e4a3c9f0956cdaa0087364ea797a75e85e044ead9eaced7cee38678cec7ab4f024c69b71c0858513bd2adeeb283a3e73dda8b2a413e4a5f56fd121275de5960df49b26555e7c651afea746f820b59c85c96a2"
},
"ml-dsa-65": {
"derivationPath": "m/44'/102003'/0'/0'/0'",
"publicKeyHex": "ea4ce61a7606342f76b5fa2462c27c010f5cd3e5467bb65961d68e87288856ee8339a05093eae7f6cd3f1e5b8a8685da980b7a5cc20597bfaf89cf604bdbe1820b3e58d457f684d8484cd4c87deaf2eafa2d654557f9c0c1b1c733772847bc25be39e98fdbe7768039ff6e87c2548a8841b88a6c17b84581f03aa4171fa737786d6639b9354e761703b7a68d6e49e41585f7ec1992e5b9d083b6a7a1d07b65a714bdfa3d7df23b432845264c0c786b9f1ec214f66b54ffa5b65b0d9ede667e40d10136e643aec6bb25cd1cae873d245fde196fb5e2c081e10a69e2dfd3b160adf1cc5a0deefb1a5256d9b6ba15033a89d5fe8b0eef5afc8c1fb1ce6d1c63d177127ead73ba469c30b6ac2502f12aa96f9d930c0287bf793b740b3fc13c0a7d61c170cba75ad7e63f93a6937c369bbdae4d35035583f5478b943e1c21014b2d356449872ee978f0210fa4d4661548d34b2a8ad3f1686fa834f5c431d9e8e55725974cf84acfbcbf2f559dc43f00cf81d53a5c87b7d158162733006721d43fe581ec278331b78a87587011c752b782153ef5d09ccee9e00742d4cf0cc07d343df557790e7674915cc631113ae2fd4921c477c1bf42214d6ab5a42cea1c596f72d17101e0467a8fc54c0a2e19dc5db7d623196ea68f320bda3a8c519ce30666ea0c963de4ee9036beb1a025263aedac8137f5348ceb5762dfc8b689dbfdd889c531920a01e19dc6a40a183bcc4b8dd2100c72746532d9db2c3f668b5e21df428e9b74194464e66f0636516c8c79295f0ec0c08d227bccdb83a92fd4dfcdd552b972e7c3b0530bfe5310603c64e6c2972350762448d448e15279becc2582654a1b398619ce6f2450546b1c602dd1a3f640e878089e43cd6a1594bf1daaf4005aee41ab91b60e9dca9dab461c95d20986085c344a3fc9c3801548484b09e42a507ae44de14efbc471083e7e2992d210c8b022b2d3d840319f59c3554defd027afdf824b955a84385724f1aef727c06d6dda723bf82dff4e7e45265ab56d5d492b2104c6cc363632d905cf4b92e5253e9d10a6496e85959b0e637ee21ca6a8c63716d9ee8e722960113b013a393459079ec53248ea1df90f28bef28f182bdcfb93e94ba455390693eebac77fc670ffb8a0e23585a8b6743a20ebf5555738a23efc24ec681bb3beeeda82e346487c3295f5ea25464f6de5b96f9eebe4539a42392b598365724e786d9a39a5266e4e1a2e1527f5161b3d2f76404b9b52b5d158835f50ecbd6011b172bf55157eb5ae661a75593d6af0adbeaf755244f7d5204e2bc20ad2a2384ea83156df29fcb5cb3ab105fbc4489ddebc395eaf7fed7e71ec07294cb004639c17cc6d8ca5e7a6472208768f82fe25999c551d58552114d8af3dc9b15242dc784654f58517d06d76f99ecddf9c38c5b20d672cc7f6981514ef8f781378c5db2a9d0410428fe97b33708fe946c60aa35e08d85de2d5dbf8659867074960d9b1c345949c1ee3625ed47935007bf496f856c9a40d7ab0ada31d6466730c073e534de7e28292c40237d4db41cffa97836459a585d957dbc7f9983557f4d4894b342a85539343461ab1d86e5d25389959e7ebf3bda57df9a8de8b2096162b5b73388cb4297434355ab36fb884f67c5f3dbd7dcdd75aca4a660219d0e52af7ed2dfa0d8c50bbf3088c947f05a6373dc18321c3e33717780a9e074cea762bda740e520e28ea750014bdc407ff7e581fdcf589f84c26e1b7cdfd76e21fd63b8a2dffba9fe2e3351708f9281f0344a96ef2b10c19709e980fa152f926569bc80cad4a4741d6349ffe52a2162c97e08c47d26a56727f84ab4df26be45318be6286015cb68ad0a26905b321fbe1a889151eed0e8b5a30e7132a4d04c7243804e34addfb7e1e68eb82f3ea647a181e731982de413dd77742d623cf78fdf497ea263452926890f6a4f5d7e9c1b5e07e05544bc21496ef09ecbc32e6852dbd5d5529a32227455d02334c292abdcd9e55b4e1d173c3d3bb44e42205d12465ef56f6ca15c2eac1e18d2afeb4393ab69c55084f37217d078894a44cb43fb2d884a8b44cd39587db1b72f1f4622014945ac1f2d317316a1fe6cae7134b7028fe21055b50a2d7de38281617ba340130f2933ff93c755aac68cc8938ff12efd5055cf646764e4ef4868d6e1cc54e1087798c9bf5c7be5ac8d59983a82eb2aae3157c4f0cb00e33d296a2c1b1165f985a5a104ebcb25845831d796df16e70c70a4b40706240f2ea747c751c3fff24b44a9ba38ab7c98aebba68bf0a128d6251fea943ac86e1ff838c67166a13264ac512f4e708719f5df6ce6cb5622ec21add51a071c7b1b4c67801d487238e1eab8f7806ff5590bd79ce55b4891570a5cda6158c9d4db71ec1db65081525a980b5c19ebfcc72376493f360378994f3e791b59cce41ab980820203b94a70bfe6b011c77b9adf7211a54affa5880f15fc8bbb00f833d85fba1b4d17135e3d70662c588198f9fca09f87c01430ddcad975974a36f5dd76fd0c5b89b7be76b6d4740fde86b2e33e6919af86b68368dae5ad3f4dd0beae887dc6a13ac051095b4cb8504ae617f6fecf6f90c3bd9798d658952a5e71b3904520b22b88d1e40bd51bb02fdf864ef96942dd42a757944092c582eb69f39b75b4bdf9280bf49d0b0a6e41ac2c3194cd0bd9d28b896dc353e15266824b4768df4f7153e0a33e670125a8518073dfcdcbfe0f452d193b870a72c6bc34cafa7e28f18b9d7a1b7"
},
"slh-dsa-128s": {
"derivationPath": "m/44'/102004'/0'/0'/0' + m/44'/102004'/0'/0'/1' (concatenated, first 48 bytes used)",
"publicKeyHex": "193676e5236af19da41c0175581ee7f888b51633a1d125ad8f32a344b60af970"
},
"falcon-512": {
"derivationPath": "m/44'/102007'/0'/0'/0' + m/44'/102007'/0'/0'/1' (concatenated, first 48 bytes used)",
"publicKeyHex": "0958504aa577d76896d84902d64f2e3cf298a7b7c5949dfcacd73dc4e1b34e3fa154d01e57c4ed17eb503e629c0f649dc8500be4807866282573a0cc6a98ea27a28cd113411a076a34350bdf1a263bc1069932a4194c384060af06ab6b79378530ebf378fed2181a1e7142ed9dd54eecf2a0391b9640260d1602e82bcec2080b641120409a66b8cebc2817e34c1fb9cd77c1657a2e5cf1115c6f17844814b702aa742d0cab72631f22aee2fa984b4e818adaba139a2e5f3aca8eb53b9fa7a4fe305239e6412ea436c997efb2436202244fa4461b0c1cda0925a58f7acbe4b5a8958c5eeb89f88bbb88b9cf15c90db59fed0406e719c9be4cd718a3876e1e9a00a9850fbd396d90c6c6224982f8a2d7b6473926755c13cbc9f748c942e4ea5a38a8b94ff5fd8f322aedbce7752264135541c648a47657545fd5d99d934366f37173dc701bb91938ef0689507c758a091f756004f3d19044d42cb2940980a60696153370e74d9b1f902ff6b84e856ffc7d0b6117e08f18569759101c057610659f8cb67d12fc85cf0a24a5121016068f7ba521389009db745d282c2b8dc917a4d194955de5b4b6409d365241e7ad68f970a3ae1e3909b0d426e46625b427a24c153a5fdd0501b05e77244e71578c908eafdacf9a52e16b4d8902c13bc168837e5079ec8103791ca541e74a37b119c786291e141d4167a1a949bd8f6ac7a4bd4d0c6c8d51b04353da62cdd8a98e8f7c04446825439ce80dbe1808776542154cb9497894ed0ab57c35502b2d534a3895038b3b639a8947652d922ded9dc1b2d2e6624c19c8340bc90baad91213c822512001f7d3a906b3d4c4e0e255fb04eeb822feafc595e0e2bbee0eff1a6ddfd991cff22c951333e2e6daa1f4e06455d61761b5105a1ce8f89591d475d41fad09b6f248b96f323fd2b0e18853ee29f0abf9171e4f9a900d574ea76e28aee1a45edf011b8cf009e183c72e3e7b4107a9d0215182709865bc50114088e6521007567684cee58a9504d927be41f0b0aafdf01c9551a12e38f6a203da7ffdd0c3cc14ba49b83ec9ac2d801ed282752fced71c0abf794d5ea83ef5d7f56064a4099197219829439d98197a898fc46cdd8630b29f555ca4510b8e8a78ac2c3b5a62a0aa7f483a7a345ad972b2de6484d01a31f86fc9cf6ee836e27b62aace33b6f0c3145b644f5d2ea665231aed69d6c490222346138d73c0de998f3e15387256ad7b6d1cf2f8eb83cb2b76cf206a41f864dc5d2398c5c9"
},
"ml-kem-768": {
"derivationPath": "m/44'/102005'/0'/0'/0' + m/44'/102005'/0'/0'/1' (concatenated, all 64 bytes used)",
"publicKeyHex": "0980c83da4b081fa8bf69b3e00ddbd47824da54410bfd539fe58783c9b66679c272f32adff0278902a92d2c5a6f5649d5a928797d893b8ba789f67870786aa90a260eed4b7eaa43e6beac4e82c5c14192b44cac0521c4584c02a10740e6f54cd86f3293f10c285c20f33f4af0a87afae853043ca4afc99baaca3a79d483befea212a1b25c12cac8672a238257ba9a876dc6558eb6c397a1694a6e9bcdcf6b121789f8a64a7ced10cea2b5e3ac15bf9807e00ad37b7b1b46dd5c88f21b93482b94ae884744172e2603d15550df77988bac53a5ad22cd357992b2a7795e516a5763a1f093be33a86634cc81f1c33c1f61104629c9c2022040988a44778a52844618241ce0a90790041e461b2cc5212a8220177e08a9bbca3a608663a558287f91095d21321233e9d675599b82867c082096519fea29107a36d6304a183e0c38a590f3c272a06e36e8e62ba6a70b31d3acfb1c52d9205cd5713a96f40a9b0683badd3bcb1e974ade1a68e6a74e1f2097856bc09b75212a503bc97cd90c7ba1a625c4b5829eed65213880f9dcb99a4604eb3a479a0a08f4e28c5b29c9e8773991f2847e310be826bb1fa968b1bb21601c25ec778ae62d1582f94c83fb63749d5206ef89507fa2c331a18e5f11adbabcce14a5b36b39f6c1a3a21c733c9412083e089171ca69a3362dc9c7198b83bf9a26174879f0ed32f2afbcbfa4ac92dac74234a1017197a422220e68834fb2b9ddda915002bc392b69db3985236898a7f602b306c05b26b85f4c9553a7630c1b68890f97bafc96a9e0b06b3f3222cf47f6118894fa469ef8386c14172b2fc19e030574f34c170f33e85849923072cc131508055436fb7c7f88c3e7eeb0c41c02d48d1c759d72c1609240e027d6a6a314784ae924c40257c6bed08508ba907d06cc3db8b619c5594413311ad914895a3946ab31b6f35373a286721d3107fa108da0766af2b2ab2813d030254ea635dd1252153e210ee74c2120722727683d5874dae0b9d6e5bbd10939d543c95d227722903c99402c48a2341d48b0ebd143e5ff0500e910c9f071d5f4a198f0068e241388e4b23fd0582269158c3e0af13d889c2f1afeaf308d6c3cbcfe653345c0384d08a399b6902c550721963e397112ec31f430773c68900aea96b6832709eb918ca3483affb6866488e4fa726b7550f2ed106a9d71bcca0928885947f935255b56e18a54ce18a5257934bc02b3ea531978d61501e435203458f51d1bd58d555c1134cebd6ccd5e19b3545a3545b21aea4919123996be824e11b2f312b43b3f52db7d08d98156fb2dcc8986b78192a97419102290123660346dd6ccd89544f0aa246ddca1cf586266d187e227872cb9c6b503c8546249eff344a3ea9c697031858c88a09201a8194a0ba1a9db2663a936762e3c50293e8a591ebc84a9c128ce2cf8ff941b883bb448075af7868325b906c75b51e5394a6e617bcbaa80a839e81a0489210b7c3c74c260a7d1025b982e192d5b192c0f91e32c079fde60b100501e25564e29368e9591abaa80a7f79cc375489c64980bf9785959c590a50265c32097b5866dda92542198068237ab0d73367f99c846b8d4649b8878520b4bc40252c0719967bfd1a2d08f234788f82ef24b5a6f1f59bf0829d7461754110f0f21088f547beb0"
}
},
"notes": [
"Same truncation rule as v1: two hardened children concatenated, first 48 bytes for 48-byte seeds, all 64 for 64-byte seeds.",
"Coin types 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
View File
@@ -0,0 +1,54 @@
#!/bin/bash
#
# Upload the Nostr Quantum Preparation web app to the TEST endpoint.
#
# Usage: ./upload-test.sh
#
# Uploads the contents of www/ to ubuntu@laantungir.net:html/quantum-prep-test/
# The site will be accessible at https://laantungir.net/quantum-prep-test/
#
# This is the staging deployment for the v2 hardened derivation scheme.
# The production site (https://laantungir.net/quantum-prep/) is NOT touched.
# Once the v2 flow is confirmed working here, run ./upload.sh to promote it.
#
set -e
SERVER="ubuntu@laantungir.net"
REMOTE_DIR="html/quantum-prep-test"
LOCAL_DIR="$(dirname "$0")/www"
echo "🔒 Nostr Quantum Preparation — Upload (TEST endpoint)"
echo "====================================================="
echo "Server: $SERVER"
echo "Remote: $REMOTE_DIR"
echo "Local: $LOCAL_DIR"
echo ""
echo "⚠️ This deploys to the TEST endpoint only."
echo " Production (quantum-prep) is NOT modified."
echo ""
# Ensure the bundle is up to date
echo "📦 Building PQ crypto bundle..."
node "$(dirname "$0")/build-pq-bundle.js"
echo ""
# Create the remote directory if it doesn't exist.
# /var/www/html is root-owned, so creating a NEW top-level directory needs
# sudo; we then chown it to ubuntu so subsequent rsyncs work normally.
echo "📁 Ensuring remote directory exists..."
ssh "$SERVER" "sudo mkdir -p $REMOTE_DIR && sudo chown ubuntu:ubuntu $REMOTE_DIR"
echo ""
# Upload files using rsync
echo "📤 Uploading files..."
rsync -avz --delete \
--exclude='*.map' \
"$LOCAL_DIR/" \
"$SERVER:$REMOTE_DIR/"
echo ""
echo "✅ Upload complete!"
echo "🧪 Test site: https://laantungir.net/quantum-prep-test"
echo ""
echo "To promote to production after confirmation: ./upload.sh"
+16 -5
View File
@@ -488,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>
@@ -505,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>
+31 -3
View File
@@ -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;
+175 -51
View File
@@ -10,8 +10,23 @@
*
* Uses @noble/post-quantum (pure JS, no WASM needed)
*
* BIP32 Derivation Paths (all under m/44'/1237'/0'/0/):
* 0 — secp256k1 (NIP-06 standard, Account #2)
* Derivation schemes (see plans/v2-hardened-derivation.md):
*
* V2 (default) — per-algorithm coin types in the unregistered SLIP-44
* 102XXX' range, all-hardened below coin type. PQ keys are NOT under the
* Nostr coin branch (1237'), so no compromise of the Nostr subtree (even a
* coin-level xpub leak + quantum) can reach them. Coin types 102003'–102005'
* match n_signer/the Rust signer; 102006'–102007' are this project's
* allocations for ML-DSA-44 and Falcon-512.
* ML-DSA-44 m/44'/102006'/0'/0'/0' (32-byte seed)
* ML-DSA-65 m/44'/102003'/0'/0'/0' (32-byte seed)
* SLH-DSA-128s m/44'/102004'/0'/0'/0' + /1' (48-byte seed)
* Falcon-512 m/44'/102007'/0'/0'/0' + /1' (48-byte seed)
* ML-KEM-768 m/44'/102005'/0'/0'/0' + /1' (64-byte seed)
*
* V1 (legacy, retained for recovery only) — all keys under
* m/44'/1237'/0'/0/ with non-hardened leaf children:
* 0 — secp256k1 (NIP-06 standard)
* 1 — ML-DSA-44 (32-byte seed)
* 2 — ML-DSA-65 (32-byte seed)
* 3+4 — SLH-DSA-128s (48-byte seed, two 32-byte children concatenated)
@@ -37,26 +52,83 @@ import { DEFAULT_POLICY, knownAlgorithms, isMandatorySignature, isKem } from './
// ============================================================================
/**
* BIP32 derivation paths for all keys.
* Versioned PQ derivation schemes.
*
* Base path: m/44'/1237'/0'/0/ (NIP-06 account 0, change 0)
* Child indices:
* 0 — secp256k1 (NIP-06 standard)
* 1 — ML-DSA-44
* 2 — ML-DSA-65
* 3, 4 — SLH-DSA-128s (two children, concatenated for 48-byte seed)
* 5, 6 — Falcon-512 (two children, concatenated for 48-byte seed)
* 7, 8 — ML-KEM-768 (two children, concatenated for 64-byte seed)
* V2 (default): per-algorithm coin types (102XXX' range), all-hardened below
* the coin type. Each algorithm gets its own coin branch, so a leak of any
* one branch's extended key compromises exactly one algorithm, and no leak
* within the Nostr coin branch (1237') can reach PQ keys at all.
*
* V1 (legacy): all PQ seeds at non-hardened children 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)
// ============================================================================
+3 -3
View File
@@ -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"
}
+92 -32
View File
@@ -9627,20 +9627,49 @@ function isKem(algorithmId, policy = DEFAULT_POLICY) {
}
// www/js/pq-crypto.mjs
var PQ_DERIVATION_PATHS = {
secp256k1: [0],
// 32 bytes (standard NIP-06)
mlDsa44: [1],
// 32 bytes
mlDsa65: [2],
// 32 bytes
slhDsa: [3, 4],
// 64 bytes concatenated, take first 48
falcon512: [5, 6],
// 64 bytes concatenated, take first 48
mlKem: [7, 8]
// 64 bytes concatenated
var PQ_DERIVATION_SCHEMES = {
v1: {
version: 1,
base: "m/44'/1237'/0'/0",
hardenedLeaves: false,
children: {
mlDsa44: [1],
mlDsa65: [2],
slhDsa: [3, 4],
falcon512: [5, 6],
mlKem: [7, 8]
}
},
v2: {
version: 2,
hardenedLeaves: true,
children: {
mlDsa44: { coin: 102006, indices: [0] },
mlDsa65: { coin: 102003, indices: [0] },
slhDsa: { coin: 102004, indices: [0, 1] },
falcon512: { coin: 102007, indices: [0, 1] },
mlKem: { coin: 102005, indices: [0, 1] }
}
}
};
var PQ_DERIVATION_SCHEME_VERSION = 2;
function resolveScheme(scheme) {
const key = typeof scheme === "number" ? `v${scheme}` : scheme;
const def = PQ_DERIVATION_SCHEMES[key];
if (!def) {
throw new Error(`Unknown derivation scheme: ${scheme}. Supported: 'v1', 'v2'`);
}
return def;
}
function pqDerivationPath(alg, scheme = "v2") {
const def = resolveScheme(scheme);
const child = def.children[alg];
if (!child) throw new Error(`Unknown algorithm: ${alg}`);
if (def.version === 1) {
return child.map((i) => `${def.base}/${i}`).join(" + ");
}
return child.indices.map((i) => `m/44'/${child.coin}'/0'/0'/${i}'`).join(" + ");
}
var PQ_SEED_LENGTHS = {
mlDsa44: 32,
mlDsa65: 32,
@@ -9675,26 +9704,33 @@ function mnemonicToSeed(mnemonic, passphrase = "") {
function isValidMnemonic(mnemonic) {
return validateMnemonic(mnemonic, wordlist);
}
function deriveBIP32Child(bip39Seed, childIndices) {
function deriveBIP32Child(bip39Seed, path) {
const hdKey = HDKey.fromMasterSeed(bip39Seed);
const path = `m/44'/1237'/0'/0/${childIndices.join("/")}`;
const child = hdKey.derive(path);
if (!child.privateKey) {
throw new Error(`Failed to derive private key at path ${path}`);
}
return child.privateKey;
}
function derivePQSeedFromBIP32(bip39Seed, childIndices, requiredLength) {
if (childIndices.length === 1) {
const seed = deriveBIP32Child(bip39Seed, childIndices);
function schemePathsFor(schemeDef, alg) {
const child = schemeDef.children[alg];
if (!child) throw new Error(`Unknown algorithm: ${alg}`);
if (schemeDef.version === 1) {
return child.map((idx) => `${schemeDef.base}/${idx}`);
}
return child.indices.map((i) => `m/44'/${child.coin}'/0'/0'/${i}'`);
}
function derivePQSeedFromBIP32(bip39Seed, paths, requiredLength) {
if (paths.length === 1) {
const seed = deriveBIP32Child(bip39Seed, paths[0]);
if (seed.length !== requiredLength) {
throw new Error(`Seed length mismatch: got ${seed.length}, expected ${requiredLength}`);
}
return seed;
} else {
let combined = new Uint8Array(0);
for (const idx of childIndices) {
const child = deriveBIP32Child(bip39Seed, [idx]);
for (const path of paths) {
const child = deriveBIP32Child(bip39Seed, path);
const newCombined = new Uint8Array(combined.length + child.length);
newCombined.set(combined);
newCombined.set(child, combined.length);
@@ -9718,16 +9754,17 @@ function deriveSecp256k1FromSeed(seed, accountIndex = 0) {
publicKey: child.publicKey
};
}
function derivePQKeysFromSeed(bip39Seed) {
const mlDsa44Seed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlDsa44, PQ_SEED_LENGTHS.mlDsa44);
function derivePQKeysFromSeed(bip39Seed, scheme = "v2") {
const schemeDef = resolveScheme(scheme);
const mlDsa44Seed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, "mlDsa44"), PQ_SEED_LENGTHS.mlDsa44);
const mlDsa44Keys = ml_dsa44.keygen(mlDsa44Seed);
const mlDsa65Seed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlDsa65, PQ_SEED_LENGTHS.mlDsa65);
const mlDsa65Seed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, "mlDsa65"), PQ_SEED_LENGTHS.mlDsa65);
const mlDsa65Keys = ml_dsa65.keygen(mlDsa65Seed);
const slhDsaSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.slhDsa, PQ_SEED_LENGTHS.slhDsa);
const slhDsaSeed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, "slhDsa"), PQ_SEED_LENGTHS.slhDsa);
const slhDsaKeys = slh_dsa_sha2_128s.keygen(slhDsaSeed);
const falconSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.falcon512, PQ_SEED_LENGTHS.falcon512);
const falconSeed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, "falcon512"), PQ_SEED_LENGTHS.falcon512);
const falconKeys = falcon512.keygen(falconSeed);
const mlKemSeed = derivePQSeedFromBIP32(bip39Seed, PQ_DERIVATION_PATHS.mlKem, PQ_SEED_LENGTHS.mlKem);
const mlKemSeed = derivePQSeedFromBIP32(bip39Seed, schemePathsFor(schemeDef, "mlKem"), PQ_SEED_LENGTHS.mlKem);
const mlKemKeys = ml_kem768.keygen(mlKemSeed);
return {
mlDsa44: mlDsa44Keys,
@@ -9910,7 +9947,7 @@ function validateSignerOutput(signedEvent, template, expectedPubkey) {
return signedEvent;
}
var NIP_QR_KIND = 9999;
function buildKind1Announcement(hexPubkey, blockHeight, pqKeys) {
function buildKind1Announcement(hexPubkey, blockHeight, pqKeys, derivationScheme = "v2") {
const npub = hexToNpub(hexPubkey);
const content = `I am signaling that the post-quantum public keys listed in the tags of this event were generated by me and I hold the private keys. I may use these keys in the future as successors to my current Nostr identity.
@@ -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,