153 lines
7.0 KiB
Markdown
153 lines
7.0 KiB
Markdown
# 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.
|