170 lines
10 KiB
Markdown
170 lines
10 KiB
Markdown
# V2 Hardened Derivation Scheme — Design Doc
|
||
|
||
## Status
|
||
|
||
Proposed. Addresses audit finding [F-M3 (Medium)](../audits/GLM5.2/findings.md) — 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`](../../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()`](../www/js/pq-crypto.mjs) 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`](../www/js/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:
|
||
|
||
```js
|
||
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()`](../www/js/pq-crypto.mjs) 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) |
|