Files
signer/plans/pq_seeded_derivation_plan.md

153 lines
7.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](../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`](../src/pq_crypto.rs)): `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.