Files
signer/plans/pq_seeded_derivation_plan.md

7.0 KiB
Raw Permalink Blame History

PQ Seeded Derivation Migration Plan (signer)

Status

Implemented (signer). Companion to the v2 hardened-derivation design in nostr_quantum_preparation/plans/v2-hardened-derivation.md. That project is the only one with users; it keeps a v1→v2 migration path. signer (and n_signer) have no users, so we are free to align to the FIPS seeded interface directly.

Implementation notes (divergences from the original proposal, all consistent with its intent):

  • derive_pq_seed(mnemonic, coin, indices) was realized as derive_pq_seed_from_path(mnemonic, path, seed_len) — the sibling child is derived by incrementing the last path level, so callers pass a single path.
  • PQ private keys are stored in seed form (ML-DSA-65 32 B, ML-KEM-768 64 B, SLH-DSA-128s 64 B sk serialization); CryptoAlg::sizes() reflects this.
  • SLH-DSA-128s uses the SHA2 parameter set (slh_dsa::Sha2_128s), matching the web app's slh_dsa_sha2_128s.
  • The v2 conformance test (tests/pq_conformance.rs) reproduces seed-to-pubkeys.v2.json exactly for all three algorithms.
  • The dispatcher's encapsulate/decapsulate verbs and the sign-verb private-key truncation bug were fixed as part of this work.
  • n_signer (C) still needs the same migration — filed separately there.

Background

signer is the Rust port of n_signer and inherited two PQ derivation choices:

  1. SHAKE-256 DRBG pipeline (src/pq_drbg.rs): the BIP-44-derived 32-byte seed feeds a SHAKE-256 DRBG that stands in for PQClean's randombytes() callback during keygen. This was an API artifact of the C PQClean integration, not a cryptographic choice.
  2. SLIP-0010 derivation for PQ paths (src/pq_crypto.rs:90): derive_seed_from_mnemonic() branches on the path prefix — BIP-32 for m/44'/1237', SLIP-0010 for everything else (ed25519, x25519, PQ).

The nostr_quantum_preparation web app (the project with actual users) has standardized v2 on:

  • Per-algorithm coin types in the unregistered SLIP-44 102XXX' range (102003' ML-DSA-65, 102004' SLH-DSA-128s, 102005' ML-KEM-768 — matching signer's existing allocations — plus new 102006' ML-DSA-44 and 102007' Falcon-512).
  • FIPS seeded keygen: BIP32 child bytes (exact length: 32 B ML-DSA, 48 B SLH-DSA-128s, 64 B ML-KEM) → keygen(seed). No DRBG.
  • BIP32 (not SLIP-0010) for the PQ paths, via @scure/bip32 HDKey.

Problem

Two divergences prevent cross-project key parity (same mnemonic + same path → same PQ keys):

Divergence signer today nostr_quantum_preparation v2
Seed expansion SHAKE-256 DRBG → RNG-fed keygen exact-length seed → seeded keygen
Derivation function for PQ paths SLIP-0010 BIP32

SLIP-0010 and BIP32 produce different master keys (different HMAC keys) and different child derivation, so even identical paths yield unrelated seeds. The DRBG pipeline additionally means signer can never reproduce FIPS-seeded keys regardless of derivation function.

Neither divergence is a security weakness — both are deterministic expansions of secret material — but parity matters operationally: a user should be able to derive the same PQ identity in the web app and on signer hardware from one mnemonic.

Solution

Migrate signer's PQ keygen to the FIPS seeded interface and PQ path derivation to BIP32, keeping ed25519/x25519 on SLIP-0010 (correct for those curves).

Target pipeline

mnemonic → BIP39 seed → BIP32 master → m/44'/<coin>'/0'/0'/<n>' → child bytes
  → concatenate/truncate to algorithm seed length → seeded keygen
Algorithm Coin type Path Seed len RustCrypto API
ML-DSA-65 102003' m/44'/102003'/0'/0'/0' 32 B ml_dsa::SigningKey::from_seed(&[u8; 32])
SLH-DSA-128s 102004' m/44'/102004'/0'/0'/0' + /1' 48 B slh_dsa seeded keygen (verify exact API at rc version in use)
ML-KEM-768 102005' m/44'/102005'/0'/0'/0' + /1' 64 B ml_kem::DecapsulationKey::from_seed(&[u8; 64])
ML-DSA-44 102006' m/44'/102006'/0'/0'/0' 32 B future — add with ml-dsa crate
Falcon-512 102007' m/44'/102007'/0'/0'/0' + /1' 48 B future — no stable RustCrypto crate; rejection sampling makes cross-library determinism impossible anyway

48/64-byte seeds come from two hardened children concatenated (first 48 of 64 used where 48 B is required) — matching the web app's construction exactly.

Falcon caveat

Falcon keygen is rejection-sampling-based with no universally implemented seed interface. Even with identical seeds, implementations disagree. The web app pins noble's behavior in test vectors and flags Falcon as per-library in its NIP proposal; signer should do the same when Falcon support lands, and should not promise parity for it.

Changes

1. src/pq_crypto.rs

  • derive_seed_from_mnemonic(): route PQ coin types (102003'102007') through BIP-32 (same branch as m/44'/1237'), keeping SLIP-0010 only for ed25519/x25519 (102001'/102002').
  • Add derive_pq_seed(mnemonic, coin_type, indices) -> Vec<u8> implementing the concatenate/truncate-to-length construction.
  • Replace DRBG-fed keygen call sites with the seeded APIs above.

2. src/pq_drbg.rs

  • Keep the module (it is a faithful port and may serve future PQClean-style integrations) but remove it from the PQ keygen path. Mark as not-used-for- derivation in the module doc.

3. src/alg_cache.rs, src/role_table.rs, src/tui.rs, src/main.rs

  • No path changes needed for 102003'102005' (already correct).
  • Add MlDsa44 (102006') to CryptoAlg, path formatting, purpose mapping (PqSig), and TUI presets when the ml-dsa crate's ML-DSA-44 variant is wired in. Falcon (102007') waits on a viable crate.

4. Tests

  • Cross-implementation conformance: reproduce the web app's seed-to-pubkeys.v2.json vector (same fixed mnemonic) for ML-DSA-65, SLH-DSA-128s, and ML-KEM-768. This is the acceptance test for parity.
  • Regression: DRBG removal does not change ed25519/x25519 derivation.
  • Unit: 48/64-byte seed construction matches the two-children concatenation.

5. Docs

  • README.md / documents/ equivalent: document the seeded pipeline, the BIP32-for-PQ decision, the coin-type registry (102003'102005' existing, 102006'102007' reserved), and the Falcon caveat.
  • Note for n_signer (C): same migration applies; file it there separately.

What we are explicitly NOT doing

  • Not changing secp256k1 NIP-06 derivation (m/44'/1237'/n'/0/0, BIP-32).
  • Not changing ed25519/x25519 SLIP-0010 derivation (correct for those curves).
  • Not preserving DRBG-derived PQ keys (no users; clean break is the point).
  • Not implementing Falcon now (no stable crate; determinism caveat).

Acceptance criteria

  1. cargo test passes with the seeded pipeline.
  2. The web app's v2 vector reproduces exactly for ML-DSA-65, SLH-DSA-128s, ML-KEM-768 (same mnemonic → same pubkeys in Rust and JS).
  3. ed25519/x25519 pubkeys unchanged from pre-migration for the same mnemonic.