10 KiB
V2 Hardened Derivation Scheme — Design Doc
Status
Proposed. Addresses audit finding F-M3 (Medium) — 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}'orm/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-level1237'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); 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() 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 checks PQ signatures against pubkeys in the kind 1 event tags and never derives from a seed. Therefore:
- Existing v1 events remain fully verifiable forever. No verifier changes required for old events.
- A v1 user's seed still recovers their v1 keys. V1 derivation code is retained and exposed as a legacy option.
- 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:
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()andderivePQSeedFromBIP32()to take the scheme instead of the hardcoded v1 base. -
derivePQKeysFromSeed(seed, scheme = 'v2')— default v2;'v1'still works for recovery. -
buildKind1Announcement()gains aderivationSchemeparameter (default 2) and emits thederivation_schemetag. -
PQ_KEY_INFOderivation 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.jsafter 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; documentderivation_schemetag; document v1 legacy/recovery; document the102XXX'coin-type registry (102003'–102005' per n_signer, 102006'–102007' new); flag Falcon as per-library.audits/GLM5.2/findings.mdF-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_schemefail-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) |