Files
nostr_quantum_preparation/plans/v2-hardened-derivation.md
T

10 KiB
Raw Blame History

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}' 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); 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:

  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:

    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() 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)