v0.0.47 - Added post-quantum cryptography (ML-DSA-65, SLH-DSA-128s, ML-KEM-768) and standard ECC (ed25519, x25519) support with algorithm-based API
This commit is contained in:
387
plans/algorithm_based_api.md
Normal file
387
plans/algorithm_based_api.md
Normal file
@@ -0,0 +1,387 @@
|
||||
# Proposal: Algorithm-Based API + New Policy Model
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The current API keys off **roles** that bundle three concepts together:
|
||||
- **Algorithm** (curve: secp256k1, ed25519, ml-dsa-65, etc.)
|
||||
- **Purpose** (nostr, ssh, age, pq-sig, pq-kem)
|
||||
- **Derivation index** (nostr_index or role_path)
|
||||
|
||||
The caller must know the role name and the role's configuration determines the algorithm. This is unintuitive — a caller that wants to "sign something with ed25519" shouldn't need to know that the operator named the role "ssh_main" and configured it with purpose=ssh.
|
||||
|
||||
The purpose field is really a **policy/enforcement constraint**, not something the caller should care about. The caller knows what operation they want (sign, verify, encapsulate) and what algorithm they want to use. The signer's job is to check whether that combination is allowed and whether the caller is approved.
|
||||
|
||||
## 2. Proposed API shape
|
||||
|
||||
### 2.1 Core principle: verb + algorithm, not verb + role
|
||||
|
||||
The caller specifies:
|
||||
- **What** they want to do (the verb)
|
||||
- **Which algorithm** they want to use
|
||||
- **Which key** (by derivation index)
|
||||
|
||||
The signer determines:
|
||||
- Whether the verb+algorithm combination is valid (enforcement)
|
||||
- Whether the caller is approved (policy)
|
||||
- Derives the key on demand from the mnemonic
|
||||
|
||||
### 2.2 Verbs
|
||||
|
||||
Simplify to operation-based verbs. The algorithm is a parameter, not implicit in the verb name.
|
||||
|
||||
| Verb | Description | Algorithm parameter | Key parameter |
|
||||
|---|---|---|---|
|
||||
| `get_public_key` | Get public key for a derived key | `algorithm` | `index` |
|
||||
| `sign` | Sign arbitrary bytes | `algorithm` | `index` |
|
||||
| `verify` | Verify a signature | `algorithm` | `index` (or `public_key`) |
|
||||
| `encapsulate` | KEM encapsulation | `algorithm` | `public_key` (peer's) |
|
||||
| `decapsulate` | KEM decapsulation | `algorithm` | `index` |
|
||||
| `derive_shared_secret` | ECDH key agreement (x25519) | `algorithm` | `index` + `peer_public_key` |
|
||||
| `sign_event` | Sign a Nostr event (secp256k1 only) | — (always secp256k1) | `index` |
|
||||
| `nip44_encrypt` | NIP-44 encrypt (secp256k1 only) | — | `index` + `peer_pubkey` |
|
||||
| `nip44_decrypt` | NIP-44 decrypt (secp256k1 only) | — | `index` + `peer_pubkey` |
|
||||
| `nip04_encrypt` | NIP-04 encrypt (secp256k1 only) | — | `index` + `peer_pubkey` |
|
||||
| `nip04_decrypt` | NIP-04 decrypt (secp256k1 only) | — | `index` + `peer_pubkey` |
|
||||
| `mine_event` | Mine + sign Nostr event (secp256k1 only) | — | `index` |
|
||||
|
||||
The Nostr-specific verbs (`sign_event`, `nip44_*`, `nip04_*`, `mine_event`) are inherently secp256k1 — that's a protocol requirement of Nostr, not a policy choice. They don't take an `algorithm` parameter. The generic verbs (`sign`, `verify`, `encapsulate`, `decapsulate`, `derive_shared_secret`) take an `algorithm` parameter.
|
||||
|
||||
### 2.3 Request format
|
||||
|
||||
**Generic sign (any signature algorithm):**
|
||||
```json
|
||||
{
|
||||
"id": "1",
|
||||
"method": "sign",
|
||||
"params": [
|
||||
"68656c6c6f",
|
||||
{
|
||||
"algorithm": "ml-dsa-65",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- First param: message bytes as hex
|
||||
- Second param: options with `algorithm` and `index`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "1",
|
||||
"result": {
|
||||
"signature": "<hex>",
|
||||
"algorithm": "ml-dsa-65",
|
||||
"key_id": "a1b2c3d4e5f6a1b2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Get public key (any algorithm):**
|
||||
```json
|
||||
{
|
||||
"id": "2",
|
||||
"method": "get_public_key",
|
||||
"params": [
|
||||
{
|
||||
"algorithm": "ed25519",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "2",
|
||||
"result": {
|
||||
"algorithm": "ed25519",
|
||||
"public_key": "<hex>",
|
||||
"key_id": "a1b2c3d4e5f6a1b2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**KEM encapsulate:**
|
||||
```json
|
||||
{
|
||||
"id": "3",
|
||||
"method": "encapsulate",
|
||||
"params": [
|
||||
"<peer_pubkey_hex>",
|
||||
{
|
||||
"algorithm": "ml-kem-768"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**KEM decapsulate:**
|
||||
```json
|
||||
{
|
||||
"id": "4",
|
||||
"method": "decapsulate",
|
||||
"params": [
|
||||
"<ciphertext_hex>",
|
||||
{
|
||||
"algorithm": "ml-kem-768",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**ECDH shared secret (x25519):**
|
||||
```json
|
||||
{
|
||||
"id": "5",
|
||||
"method": "derive_shared_secret",
|
||||
"params": [
|
||||
"<peer_pubkey_hex>",
|
||||
{
|
||||
"algorithm": "x25519",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Nostr sign_event (unchanged, always secp256k1):**
|
||||
```json
|
||||
{
|
||||
"id": "6",
|
||||
"method": "sign_event",
|
||||
"params": [
|
||||
"<event_json>",
|
||||
{
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Algorithm names
|
||||
|
||||
| String | Algorithm | Key type |
|
||||
|---|---|---|
|
||||
| `secp256k1` | secp256k1 (Schnorr) | Signature |
|
||||
| `ed25519` | ed25519 | Signature |
|
||||
| `ml-dsa-65` | ML-DSA-65 (FIPS 204) | Signature |
|
||||
| `slh-dsa-128s` | SLH-DSA-128s (FIPS 205) | Signature |
|
||||
| `x25519` | X25519 (ECDH) | Key agreement |
|
||||
| `ml-kem-768` | ML-KEM-768 (FIPS 203) | KEM |
|
||||
|
||||
### 2.5 Key identification
|
||||
|
||||
Keys are identified by **algorithm + index** instead of role names. The index is the BIP-44 account field in the derivation path:
|
||||
|
||||
| Algorithm | Derivation path for index N |
|
||||
|---|---|
|
||||
| secp256k1 | `m/44'/1237'/N'/0/0` (NIP-06, unchanged) |
|
||||
| ed25519 | `m/44'/102001'/N'/0'/0'` |
|
||||
| x25519 | `m/44'/102002'/N'/0'/0'` |
|
||||
| ml-dsa-65 | `m/44'/102003'/N'/0'/0'` |
|
||||
| slh-dsa-128s | `m/44'/102004'/N'/0'/0'` |
|
||||
| ml-kem-768 | `m/44'/102005'/N'/0'/0'` |
|
||||
|
||||
The `key_id` (first 16 hex chars of the public key) provides a short human-readable identifier for display at the approval prompt.
|
||||
|
||||
### 2.6 Backward compatibility
|
||||
|
||||
The existing role-based API continues to work for Nostr clients:
|
||||
- `sign_event` with `{role: "main"}` or `{nostr_index: 0}` — unchanged
|
||||
- `nip44_encrypt`, `nip04_decrypt`, etc. — unchanged
|
||||
- `get_public_key` with `{role: "main"}` — returns plain hex (secp256k1 backward compat)
|
||||
|
||||
The new algorithm-based API is additive — new verbs (`sign`, `verify`, `encapsulate`, `decapsulate`, `derive_shared_secret`) use the algorithm parameter. Old verbs keep their role-based selectors.
|
||||
|
||||
## 3. Proposed policy model
|
||||
|
||||
### 3.1 Current model (role-based)
|
||||
|
||||
From [`plans/deny_by_default_approvals.md`](plans/deny_by_default_approvals.md):
|
||||
- Roles are pre-configured at startup with purpose+curve+index
|
||||
- Policy entries approve callers for specific roles
|
||||
- `--preapprove caller=uid:1000,role=main,verb=sign_event`
|
||||
- Prompt `[a]` approves the caller for the role
|
||||
|
||||
### 3.2 New model (algorithm-based)
|
||||
|
||||
Policy entries approve callers for **algorithm + index ranges + verbs**:
|
||||
|
||||
**Policy entry structure:**
|
||||
```
|
||||
caller: <caller_id>
|
||||
algorithms: [ed25519, ml-dsa-65] # which algorithms this caller can use
|
||||
index_range: 0-4 # which derivation indices (or * for any)
|
||||
verbs: [sign, verify, get_public_key] # which verbs
|
||||
prompt: first_per_boot # prompt behavior
|
||||
```
|
||||
|
||||
**`--preapprove` CLI flag:**
|
||||
```bash
|
||||
nsigner --preapprove caller=uid:1000,algorithm=ed25519,index=0-4,verb=sign
|
||||
nsigner --preapprove caller=uid:1000,algorithm=ml-dsa-65,index=0,verb=sign,verify
|
||||
nsigner --preapprove caller=pubkey:abc...,algorithm=ml-kem-768,index=0,verb=decapsulate
|
||||
```
|
||||
|
||||
**Prompt display:**
|
||||
When a caller requests `sign` with `algorithm=ml-dsa-65, index=0`, the prompt shows:
|
||||
```
|
||||
Caller: uid:1000
|
||||
Action: sign with ML-DSA-65
|
||||
Key: index 0 (key_id: a1b2c3d4e5f6a1b2)
|
||||
[a] approve [d] deny
|
||||
```
|
||||
|
||||
The operator approves the caller for that algorithm+index+verb combination. Subsequent requests with the same combination are auto-approved (if `prompt: first_per_boot`).
|
||||
|
||||
### 3.3 Nostr verbs policy
|
||||
|
||||
Nostr verbs (`sign_event`, `nip44_*`, `nip04_*`, `mine_event`) are always secp256k1. They can be policy-keyed as either:
|
||||
- **Algorithm-based**: `algorithm=secp256k1, verb=sign_event, index=0` (new style)
|
||||
- **Role-based**: `role=main, verb=sign_event` (old style, backward compat)
|
||||
|
||||
Both resolve to the same key. The signer accepts both selector forms.
|
||||
|
||||
### 3.4 Wildcards
|
||||
|
||||
| Pattern | Meaning |
|
||||
|---|---|
|
||||
| `algorithm=*` | Any algorithm |
|
||||
| `index=*` | Any index |
|
||||
| `index=0-4` | Indices 0 through 4 inclusive |
|
||||
| `verb=*` | Any verb valid for the algorithm |
|
||||
|
||||
Wildcards make policy more compact but less precise. The default is no wildcards (explicit per-algorithm, per-index, per-verb).
|
||||
|
||||
### 3.5 Enforcement matrix
|
||||
|
||||
The signer checks verb+algorithm validity (regardless of policy):
|
||||
|
||||
| Verb | Valid algorithms | Notes |
|
||||
|---|---|---|
|
||||
| `sign` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | Generic signing |
|
||||
| `verify` | secp256k1, ed25519, ml-dsa-65, slh-dsa-128s | Generic verification |
|
||||
| `encapsulate` | ml-kem-768 | KEM encapsulation |
|
||||
| `decapsulate` | ml-kem-768 | KEM decapsulation |
|
||||
| `derive_shared_secret` | x25519 | ECDH key agreement |
|
||||
| `get_public_key` | all | Any algorithm |
|
||||
| `sign_event` | secp256k1 (implicit) | Nostr protocol requirement |
|
||||
| `nip44_encrypt` | secp256k1 (implicit) | Nostr protocol requirement |
|
||||
| `nip44_decrypt` | secp256k1 (implicit) | Nostr protocol requirement |
|
||||
| `nip04_encrypt` | secp256k1 (implicit) | Nostr protocol requirement |
|
||||
| `nip04_decrypt` | secp256k1 (implicit) | Nostr protocol requirement |
|
||||
| `mine_event` | secp256k1 (implicit) | Nostr protocol requirement |
|
||||
|
||||
If a caller requests `sign` with `algorithm=x25519`, the signer rejects with `algorithm_not_supported_for_verb` — x25519 is a key agreement algorithm, not a signature algorithm.
|
||||
|
||||
### 3.6 No purpose field
|
||||
|
||||
The `purpose` field is **removed entirely**. There is no `purpose` in the API, no purpose in enforcement, no purpose in policy. The role_purpose_t enum and all purpose-related code is removed from the codebase.
|
||||
|
||||
Enforcement is purely verb+algorithm based — the signer checks whether the requested verb is valid for the requested algorithm. No "purpose mismatch" errors, no purpose in policy entries, no purpose in role configuration.
|
||||
|
||||
Roles (if kept for backward compat with existing Nostr clients) are keyed on algorithm+index only, not purpose+curve.
|
||||
|
||||
## 4. Migration path
|
||||
|
||||
### Phase A: Add algorithm-based verbs (additive, no breaking changes)
|
||||
|
||||
1. Add new verbs: `sign`, `verify`, `encapsulate`, `decapsulate`, `derive_shared_secret`
|
||||
2. These accept `{algorithm, index}` in the options
|
||||
3. Old verbs (`sign_data`, `ssh_sign`, `kem_encapsulate`, `kem_decapsulate`) become aliases that map to the new verbs
|
||||
4. Old role-based selectors continue to work on all verbs
|
||||
5. Policy model extended to support algorithm-based entries alongside role-based entries
|
||||
|
||||
### Phase B: Deprecate old verb names (optional, future)
|
||||
|
||||
1. `sign_data` → `sign`
|
||||
2. `ssh_sign` → `sign` (with `algorithm=ed25519`)
|
||||
3. `kem_encapsulate` → `encapsulate`
|
||||
4. `kem_decapsulate` → `decapsulate`
|
||||
5. `verify_signature` → `verify`
|
||||
6. Old names still work but are documented as deprecated
|
||||
|
||||
### Phase C: Remove purpose from enforcement (optional, future)
|
||||
|
||||
1. Purpose becomes pure metadata
|
||||
2. Enforcement only checks verb+algorithm validity
|
||||
3. Roles become optional (algorithm+index is the primary key selector)
|
||||
|
||||
## 5. Example: full session with new API
|
||||
|
||||
### Operator starts n_signer:
|
||||
```bash
|
||||
nsigner --preapprove caller=uid:1000,algorithm=ed25519,index=0,verb=sign,verify,get_public_key \
|
||||
--preapprove caller=uid:1000,algorithm=ml-dsa-65,index=0,verb=sign,verify,get_public_key \
|
||||
--preapprove caller=uid:1000,algorithm=ml-kem-768,index=0,verb=decapsulate,get_public_key
|
||||
```
|
||||
|
||||
### Client gets an ed25519 public key:
|
||||
```json
|
||||
{"id":"1","method":"get_public_key","params":[{"algorithm":"ed25519","index":0}]}
|
||||
→ {"id":"1","result":{"algorithm":"ed25519","public_key":"...","key_id":"a1b2..."}}
|
||||
```
|
||||
|
||||
### Client signs a message with ed25519:
|
||||
```json
|
||||
{"id":"2","method":"sign","params":["68656c6c6f",{"algorithm":"ed25519","index":0}]}
|
||||
→ {"id":"2","result":{"signature":"...","algorithm":"ed25519","key_id":"a1b2..."}}
|
||||
```
|
||||
|
||||
### Client signs a message with ML-DSA-65:
|
||||
```json
|
||||
{"id":"3","method":"sign","params":["68656c6c6f",{"algorithm":"ml-dsa-65","index":0}]}
|
||||
→ {"id":"3","result":{"signature":"...","algorithm":"ml-dsa-65","key_id":"c3d4..."}}
|
||||
```
|
||||
|
||||
### Client encapsulates with ML-KEM-768 (using peer's public key):
|
||||
```json
|
||||
{"id":"4","method":"encapsulate","params":["<peer_pubkey_hex>",{"algorithm":"ml-kem-768"}]}
|
||||
→ {"id":"4","result":{"ciphertext":"...","shared_secret":"...","algorithm":"ml-kem-768"}}
|
||||
```
|
||||
|
||||
### Client decapsulates (signer side, using its ML-KEM private key):
|
||||
```json
|
||||
{"id":"5","method":"decapsulate","params":["<ciphertext_hex>",{"algorithm":"ml-kem-768","index":0}]}
|
||||
→ {"id":"5","result":{"shared_secret":"...","algorithm":"ml-kem-768"}}
|
||||
```
|
||||
|
||||
### Client signs a Nostr event (backward compatible):
|
||||
```json
|
||||
{"id":"6","method":"sign_event","params":["<event_json>",{"index":0}]}
|
||||
→ {"id":"6","result":"<signed_event_json>"}
|
||||
```
|
||||
|
||||
## 6. Comparison: old vs new
|
||||
|
||||
| Aspect | Old (role-based) | New (algorithm-based) |
|
||||
|---|---|---|
|
||||
| Caller specifies | Role name | Algorithm + index |
|
||||
| Algorithm determined by | Role's configured curve | Explicit `algorithm` parameter |
|
||||
| Purpose | Enforcement constraint | Optional metadata |
|
||||
| Policy unit | Role | Algorithm + index + verb |
|
||||
| Nostr verbs | Role-based selector | Index-based (algorithm implicit) |
|
||||
| Generic verbs | `sign_data` (role-based) | `sign` (algorithm-based) |
|
||||
| Key derivation | Role's index/path | Algorithm's derivation path + index |
|
||||
|
||||
## 7. Decisions
|
||||
|
||||
1. **secp256k1 `sign` — offer both Schnorr and ECDSA.** The `sign` verb accepts an optional `scheme` parameter for secp256k1: `"scheme": "schnorr"` (default, BIP-340) or `"scheme": "ecdsa"`. Other algorithms have one signature scheme each and ignore this parameter.
|
||||
|
||||
2. **Index is per-algorithm.** Each algorithm has its own derivation path prefix (coin type), so "index 0" for ed25519 and "index 0" for ml-dsa-65 are different keys. This is the only sensible model — different algorithms need different key material.
|
||||
|
||||
3. **Key selector is `index` only.** No `role_path` support for the new verbs. The `index` parameter maps to the account field in the standard BIP-44 derivation path. This is simpler, covers all normal use cases, and is easy to policy-gate. Arbitrary derivation paths can be a future advanced feature if needed.
|
||||
|
||||
4. **`verify` uses the signer's own derived public key.** The `verify` verb takes `algorithm` + `index` (same as `sign`) and verifies a signature against the signer's own derived public key. This is useful for round-trip testing (client signs, then verifies). The signer does NOT verify arbitrary external signatures with a `public_key` parameter — that's a client-side operation that doesn't need the signer.
|
||||
|
||||
5. **Remove the `purpose` field entirely.** No `purpose` in the API, no purpose in enforcement, no purpose in policy. Enforcement is purely verb+algorithm based:
|
||||
- `sign` works with any signature algorithm (secp256k1, ed25519, ml-dsa-65, slh-dsa-128s)
|
||||
- `encapsulate`/`decapsulate` work with ml-kem-768
|
||||
- `derive_shared_secret` works with x25519
|
||||
- `sign_event`/`nip44_*`/`nip04_*`/`mine_event` always use secp256k1 (Nostr protocol requirement, not a purpose check)
|
||||
- `get_public_key` works with any algorithm
|
||||
|
||||
The `role_purpose_t` enum and all purpose-related code is removed. Roles (if kept for backward compat) are keyed on algorithm+index only.
|
||||
567
plans/post_quantum_crypto.md
Normal file
567
plans/post_quantum_crypto.md
Normal file
@@ -0,0 +1,567 @@
|
||||
# Plan: Post-Quantum Cryptography + Standard ECC Expansion for n_signer
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Expand n_signer's crypto palette from the current single algorithm (secp256k1 for Nostr) to a complete offering:
|
||||
|
||||
**Standard ECC (classical):**
|
||||
- `ed25519` — SSH signing keys (works with current OpenSSH), general-purpose signatures
|
||||
- `x25519` — key agreement (age encryption, WireGuard-style identities, SSH KEX classical leg)
|
||||
|
||||
**Post-quantum (NIST FIPS standardized):**
|
||||
- `ML-DSA-65` (FIPS 204, lattice-based signatures) — post-quantum digital signatures
|
||||
- `SLH-DSA-128s` (FIPS 205, hash-based signatures) — post-quantum signatures with minimal trust assumptions
|
||||
- `ML-KEM-768` (FIPS 203, lattice-based KEM) — post-quantum key encapsulation for encryption
|
||||
|
||||
All six algorithms are always compiled in on all targets (host x86_64 static binary and ESP32 firmware). Size is acceptable on both; signing speed for SLH-DSA-128s on ESP32 may be slow (5-30s) but the user accepts this and will choose whether to use it per-role.
|
||||
|
||||
## 2. Background and context
|
||||
|
||||
### 2.1 Current state
|
||||
|
||||
n_signer models three curves in [`role_curve_t`](src/role_table.c:89) (`secp256k1`, `ed25519`, `x25519`) and five purposes in [`role_purpose_t`](src/role_table.c:79) (`nostr`, `bitcoin`, `ssh`, `age`, `fips`). However, only `PURPOSE_NOSTR + CURVE_SECP256K1` is actually implemented:
|
||||
|
||||
- [`crypto_derive_all`](src/key_store.c:484) and [`crypto_derive_one`](src/key_store.c:548) skip any role that isn't `PURPOSE_NOSTR + CURVE_SECP256K1 + SELECTOR_NOSTR_INDEX`.
|
||||
- [`enforce_verb_role`](src/enforcement.c:468) only allows nostr verbs on `PURPOSE_NOSTR + CURVE_SECP256K1`; everything else returns `ENFORCE_ERR_UNKNOWN_VERB` or a mismatch.
|
||||
- [`derived_key_t`](src/key_store.c:303) uses fixed 32-byte arrays for private/public keys — insufficient for PQ keys (ML-DSA-65 priv is 4032 bytes, pub 1952 bytes).
|
||||
|
||||
### 2.2 PQ key derivation from mnemonic
|
||||
|
||||
PQ private keys are not scalars — they are complex mathematical structures (polynomial matrices for lattice schemes, hypertree seeds for hash-based schemes). You cannot use a 32-byte BIP-32 output directly as a PQ private key.
|
||||
|
||||
However, all three PQ algorithms internally use a seed to generate the full key pair via keygen. The approach:
|
||||
|
||||
1. Derive a 32-byte seed from the mnemonic using BIP-32/HMAC (same as secp256k1 today, using a PQ-specific derivation path)
|
||||
2. Feed that seed into a deterministic PRNG (SHA-256-DRBG or SHAKE-256)
|
||||
3. Replace PQClean's `randombytes()` callback with this PRNG so keygen is deterministic
|
||||
4. The algorithm expands the seed into the full key pair
|
||||
|
||||
This gives **deterministic, mnemonic-recoverable PQ keys** — same mnemonic, same role, same key pair every time. The crash-equals-wipe model from [`README.md`](README.md) is preserved because we re-derive from the mnemonic on every startup. No persistence needed.
|
||||
|
||||
This is the same technique used by PQM4 (the MCU post-quantum benchmark project) for deterministic test vectors.
|
||||
|
||||
### 2.3 SSH post-quantum landscape
|
||||
|
||||
Per OpenSSH's PQ page (openssh.com/pq.html):
|
||||
- **KEX (key agreement)**: PQ supported since OpenSSH 9.0, default since 10.0 (`mlkem768x25519-sha256`). This is session encryption, handled by the SSH protocol — n_signer is not involved.
|
||||
- **Signature/authentication keys**: "OpenSSH will add support for post-quantum signature algorithms in the future." Not yet implemented. SSH signing keys are still classical (ed25519, RSA, ECDSA).
|
||||
|
||||
Implication: ed25519 is still required for SSH signing keys today. ML-DSA-65 is forward-looking — we build the primitive before the ecosystem supports it. When OpenSSH adds PQ signing keys, n_signer will already have ML-DSA-65 ready.
|
||||
|
||||
### 2.4 Library choice: PQClean
|
||||
|
||||
**PQClean** (public domain / CC0) is the reference implementation source for all three algorithms. Reasons:
|
||||
|
||||
- **ESP32-compatible**: Self-contained C, no build system dependency, proven on Cortex-M4 via PQM4. No CMake, no provider loading, no OpenSSL 3.x requirement.
|
||||
- **Minimal footprint**: Vendor only the three algorithm folders. No infrastructure code from liboqs.
|
||||
- **Public domain**: No license complexity for a security-critical project.
|
||||
- **`randombytes()` hook**: Each algorithm calls a `randombytes()` function that we can replace with our mnemonic-seeded PRNG for deterministic keygen.
|
||||
|
||||
liboqs was considered but rejected: designed for servers, CMake-heavy, pulls in infrastructure code not needed on MCU, no MCU deployments. oqs-provider was rejected: requires OpenSSL 3.x provider loading, not viable on ESP32.
|
||||
|
||||
For ed25519 and x25519: use existing OpenSSL (already linked) or libsecp256k1's sibling curve25519 code. OpenSSL's EVP_PKEY API already supports both. On ESP32, use the ESP-IDF mbedtls component which has ed25519/x25519 support.
|
||||
|
||||
## 3. Design
|
||||
|
||||
### 3.1 Algorithm registry
|
||||
|
||||
Introduce a new `src/pq_crypto.c` / `pq_crypto.h` (headerless-decls style matching the project convention) that provides a unified interface over all six algorithms. The core abstraction:
|
||||
|
||||
```c
|
||||
/* Algorithm identifiers */
|
||||
typedef enum {
|
||||
CRYPTO_ALG_SECP256K1 = 0, /* existing, Nostr */
|
||||
CRYPTO_ALG_ED25519, /* new, SSH signatures */
|
||||
CRYPTO_ALG_X25519, /* new, key agreement */
|
||||
CRYPTO_ALG_ML_DSA_65, /* new, PQ signatures */
|
||||
CRYPTO_ALG_SLH_DSA_128S, /* new, PQ signatures */
|
||||
CRYPTO_ALG_ML_KEM_768, /* new, PQ KEM */
|
||||
CRYPTO_ALG_UNKNOWN
|
||||
} crypto_alg_t;
|
||||
|
||||
/* Key sizes for each algorithm (compile-time constants) */
|
||||
typedef struct {
|
||||
size_t priv_key_len;
|
||||
size_t pub_key_len;
|
||||
size_t sig_len; /* 0 for KEM */
|
||||
size_t ciphertext_len; /* 0 for signatures */
|
||||
size_t shared_secret_len; /* 0 for signatures */
|
||||
} crypto_alg_sizes_t;
|
||||
|
||||
const crypto_alg_sizes_t *crypto_alg_get_sizes(crypto_alg_t alg);
|
||||
|
||||
/* Map role_curve_t + role_purpose_t to crypto_alg_t */
|
||||
crypto_alg_t crypto_alg_from_role(role_curve_t curve, role_purpose_t purpose);
|
||||
```
|
||||
|
||||
### 3.2 Variable-length key storage
|
||||
|
||||
Replace the fixed 32-byte [`derived_key_t`](src/key_store.c:303) with a variable-length design:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
secure_buf_t private_key; /* mlock'd, variable size per algorithm */
|
||||
secure_buf_t public_key; /* mlock'd, variable size per algorithm */
|
||||
char pubkey_hex[8192]; /* hex-encoded public key (PQ pubkeys are large) */
|
||||
char key_id[128]; /* human-readable key identifier (bech32/hex/algorithm-specific) */
|
||||
crypto_alg_t alg; /* which algorithm this key was derived for */
|
||||
int valid;
|
||||
} derived_key_t;
|
||||
```
|
||||
|
||||
The `secure_buf_t` already supports variable sizes via [`secure_buf_alloc`](src/secure_mem.c). The change is allocating different sizes per role based on the algorithm.
|
||||
|
||||
### 3.3 Derivation paths
|
||||
|
||||
Each algorithm gets a distinct BIP-32 derivation path from the mnemonic. All paths follow the standard 5-level BIP-44 structure for consistency: `m/purpose'/coin_type'/account'/change/index`. This ensures no two algorithms derive from the same path (avoiding key reuse across algorithms) and maintains compatibility with standard HD wallet tooling.
|
||||
|
||||
| Algorithm | Purpose | Derivation path | Notes |
|
||||
|---|---|---|---|
|
||||
| secp256k1 | nostr | `m/44'/1237'/<n>'/0/0` (existing) | NIP-06, unchanged |
|
||||
| secp256k1 | bitcoin | `m/44'/0'/<account>'/0/0` etc. | BIP-44, future |
|
||||
| ed25519 | ssh | `m/44'/102001'/<n>'/0'/0'` | SLIP-0010 ed25519 (all hardened) |
|
||||
| x25519 | age | `m/44'/102002'/<n>'/0'/0'` | SLIP-0010 x25519 (all hardened) |
|
||||
| ML-DSA-65 | pq-sig | `m/44'/102003'/<n>'/0'/0'` → seed → PQClean keygen | PQ-SIG coin type |
|
||||
| SLH-DSA-128s | pq-sig | `m/44'/102004'/<n>'/0'/0'` → seed → PQClean keygen | PQ-HASH-SIG coin type |
|
||||
| ML-KEM-768 | pq-kem | `m/44'/102005'/<n>'/0'/0'` → seed → PQClean keygen | PQ-KEM coin type |
|
||||
|
||||
**Path structure notes:**
|
||||
|
||||
- **Purpose (level 0):** Always `44'` (BIP-44). This is the registered BIP-44 purpose for HD wallets. Using a different purpose number (e.g., 204 for PQ-SIG) at level 0 would be non-standard; instead we use distinct **coin_type** values at level 1 to separate algorithm families.
|
||||
- **Coin type (level 1):** Distinct coin type per algorithm family: `1237` (Nostr, existing NIP-06), `102001` (SSH/ed25519), `102002` (age/x25519), `102003` (PQ-SIG/ML-DSA), `102004` (PQ-HASH-SIG/SLH-DSA), `102005` (PQ-KEM/ML-KEM). The 102XXX range was chosen because it is unregistered in [SLIP-44](https://github.com/satoshilabs/slips/blob/master/slip-0044.md) and unlikely to be claimed by real cryptocurrencies, avoiding future path collisions.
|
||||
- **Account (level 2):** The `<n>` index — this is the `nostr_index` or equivalent role index. Hardened (`'`).
|
||||
- **Change (level 3):** `0` for secp256k1 (standard BIP-44 external chain). `0'` (hardened) for ed25519/x25519/PQ because SLIP-0010 requires all-hardened derivation.
|
||||
- **Index (level 4):** `0` for secp256k1 (standard BIP-44 first address). `0'` (hardened) for ed25519/x25519/PQ, same SLIP-0010 reason.
|
||||
|
||||
**Why all-hardened for ed25519/x25519/PQ:** SLIP-0010 (the standard for ed25519/x25519 HD derivation) requires that every derivation step be hardened — non-hardened derivation is not possible with ed25519 because the public key cannot be used to derive child public keys (the math doesn't work the same as secp256k1). For PQ, we follow the same convention since we're using the path output as a seed, and hardened derivation provides stronger isolation between derived keys.
|
||||
|
||||
The path produces a 32-byte seed (512-bit extended key, of which 256 bits are the private key material). For ECC algorithms, this seed IS the private key (or is processed via SLIP-0010's derivation). For PQ algorithms, this seed feeds the deterministic PRNG (SHA-256-DRBG) that replaces PQClean's `randombytes()` during keygen.
|
||||
|
||||
### 3.4 New purpose values
|
||||
|
||||
Extend [`role_purpose_t`](src/role_table.c:79):
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
PURPOSE_NOSTR = 0,
|
||||
PURPOSE_BITCOIN,
|
||||
PURPOSE_SSH,
|
||||
PURPOSE_AGE,
|
||||
PURPOSE_FIPS,
|
||||
PURPOSE_PQ_SIG, /* new — post-quantum signatures (ML-DSA, SLH-DSA) */
|
||||
PURPOSE_PQ_KEM, /* new — post-quantum key encapsulation (ML-KEM) */
|
||||
PURPOSE_UNKNOWN
|
||||
} role_purpose_t;
|
||||
```
|
||||
|
||||
### 3.5 New curve values
|
||||
|
||||
Extend [`role_curve_t`](src/role_table.c:89):
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
CURVE_SECP256K1 = 0,
|
||||
CURVE_ED25519,
|
||||
CURVE_X25519,
|
||||
CURVE_ML_DSA_65, /* new */
|
||||
CURVE_SLH_DSA_128S, /* new */
|
||||
CURVE_ML_KEM_768, /* new */
|
||||
CURVE_UNKNOWN
|
||||
} role_curve_t;
|
||||
```
|
||||
|
||||
String mappings in [`role_curve_from_str`](src/role_table.c:597) / [`role_curve_to_str`](src/role_table.c:631):
|
||||
- `"ml-dsa-65"` ↔ `CURVE_ML_DSA_65`
|
||||
- `"slh-dsa-128s"` ↔ `CURVE_SLH_DSA_128S`
|
||||
- `"ml-kem-768"` ↔ `CURVE_ML_KEM_768`
|
||||
|
||||
### 3.6 New verbs
|
||||
|
||||
Add verbs for the new crypto operations:
|
||||
|
||||
| Verb | Purpose | Curve | Description |
|
||||
|---|---|---|---|
|
||||
| `get_public_key` | all | all | Already exists; extend to return algorithm-appropriate pubkey format |
|
||||
| `sign_data` | pq-sig, ssh | ml-dsa-65, slh-dsa-128s, ed25519 | Sign arbitrary bytes (not a Nostr event) |
|
||||
| `verify_signature` | pq-sig, ssh | ml-dsa-65, slh-dsa-128s, ed25519 | Verify a signature (optional — signer can verify, or client verifies) |
|
||||
| `kem_encapsulate` | pq-kem | ml-kem-768 | Generate ciphertext + shared secret from peer's ML-KEM public key |
|
||||
| `kem_decapsulate` | pq-kem | ml-kem-768 | Decapsulate ciphertext to recover shared secret |
|
||||
| `ssh_sign` | ssh | ed25519 | Sign SSH authentication challenge (ed25519-specific format) |
|
||||
|
||||
The existing nostr verbs (`sign_event`, `nip44_encrypt`, etc.) remain unchanged and still require `PURPOSE_NOSTR + CURVE_SECP256K1`.
|
||||
|
||||
### 3.7 Enforcement rules
|
||||
|
||||
Extend [`enforce_verb_role`](src/enforcement.c:468) with new verb→purpose→curve rules:
|
||||
|
||||
```c
|
||||
/* Nostr verbs: unchanged — PURPOSE_NOSTR + CURVE_SECP256K1 only */
|
||||
|
||||
/* sign_data: allowed on PQ-SIG or SSH purposes */
|
||||
/* PURPOSE_PQ_SIG + CURVE_ML_DSA_65 → OK */
|
||||
/* PURPOSE_PQ_SIG + CURVE_SLH_DSA_128S → OK */
|
||||
/* PURPOSE_SSH + CURVE_ED25519 → OK */
|
||||
|
||||
/* kem_encapsulate / kem_decapsulate: */
|
||||
/* PURPOSE_PQ_KEM + CURVE_ML_KEM_768 → OK */
|
||||
|
||||
/* ssh_sign: */
|
||||
/* PURPOSE_SSH + CURVE_ED25519 → OK */
|
||||
```
|
||||
|
||||
Fail-closed for any unlisted combination, preserving the existing security model from [`plans/deny_by_default_approvals.md`](plans/deny_by_default_approvals.md).
|
||||
|
||||
### 3.8 Public key output format
|
||||
|
||||
The `get_public_key` verb currently returns a 64-hex-char secp256k1 pubkey. For PQ keys, the public keys are much larger. The response format changes to include algorithm metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"result": {
|
||||
"algorithm": "ml-dsa-65",
|
||||
"public_key": "<hex-encoded, up to ~4KB>",
|
||||
"key_id": "<short identifier for display>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For backward compatibility, secp256k1/nostr roles continue to return the plain hex string (the existing format) unless the client requests the structured format via an options flag.
|
||||
|
||||
### 3.9 PQClean integration
|
||||
|
||||
Vendor PQClean into `resources/pqclean/` with only the three algorithm folders:
|
||||
|
||||
```
|
||||
resources/pqclean/
|
||||
├── crypto_sign/
|
||||
│ ├── ml-dsa-65/
|
||||
│ │ ├── api.h
|
||||
│ │ ├── sign.c
|
||||
│ │ ├── params.h
|
||||
│ │ └── ... (algorithm-specific files)
|
||||
│ └── slh-dsa-128s/
|
||||
│ ├── api.h
|
||||
│ ├── sign.c
|
||||
│ ├── params.h
|
||||
│ └── ...
|
||||
├── crypto_kem/
|
||||
│ └── ml-kem-768/
|
||||
│ ├── api.h
|
||||
│ ├── kem.c
|
||||
│ ├── params.h
|
||||
│ └── ...
|
||||
├── common/ /* shared utilities (fips202, sha2, randombytes hook) */
|
||||
│ ├── fips202.c
|
||||
│ ├── sha2.c
|
||||
│ └── randombytes.c /* we replace this with our PRNG */
|
||||
└── pqclean.h /* umbrella include */
|
||||
```
|
||||
|
||||
Each PQClean algorithm exposes a simple API:
|
||||
```c
|
||||
int crypto_sign_keypair(unsigned char *pk, unsigned char *sk); /* uses randombytes() */
|
||||
int crypto_sign(unsigned char *sig, size_t *siglen,
|
||||
const unsigned char *m, size_t mlen,
|
||||
const unsigned char *sk);
|
||||
int crypto_sign_open(unsigned char *m, size_t *mlen,
|
||||
const unsigned char *sm, size_t smlen,
|
||||
const unsigned char *pk);
|
||||
|
||||
int crypto_kem_keypair(unsigned char *pk, unsigned char *sk);
|
||||
int crypto_kem_enc(unsigned char *ct, unsigned char *ss, const unsigned char *pk);
|
||||
int crypto_kem_dec(unsigned char *ss, const unsigned char *ct, const unsigned char *sk);
|
||||
```
|
||||
|
||||
### 3.10 Deterministic PRNG for PQ keygen
|
||||
|
||||
New file `src/pq_drbg.c`:
|
||||
|
||||
```c
|
||||
/* SHA-256-DRBG seeded from mnemonic-derived seed.
|
||||
* Replaces PQClean's randombytes() for deterministic keygen. */
|
||||
|
||||
void pq_drbg_init(const unsigned char *seed, size_t seed_len);
|
||||
int pq_drbg_randombytes(unsigned char *buf, size_t len);
|
||||
void pq_drbg_zeroize(void);
|
||||
```
|
||||
|
||||
PQClean's `randombytes.c` is replaced with a shim that calls `pq_drbg_randombytes()`. During keygen, the DRBG is seeded from the mnemonic-derived path seed. After keygen, the DRBG is zeroized.
|
||||
|
||||
For signing operations, PQClean's sign functions do not call `randombytes()` (ML-DSA and SLH-DSA are deterministic signatures), so the DRBG is only needed during keygen.
|
||||
|
||||
### 3.11 ed25519 / x25519 implementation
|
||||
|
||||
**Host (x86_64 static binary):** Use OpenSSL's EVP_PKEY API (already linked via `-lssl -lcrypto` in [`Makefile`](Makefile:3)). Functions:
|
||||
- `EVP_PKEY_keygen_init` / `EVP_PKEY_keygen` for key generation
|
||||
- `EVP_DigestSign` / `EVP_DigestVerify` for ed25519 signing
|
||||
- `EVP_PKEY_derive` for x25519 key agreement
|
||||
|
||||
**ESP32 firmware:** Use ESP-IDF's mbedtls component (`mbedtls_ed25519_*`, `mbedtls_ecdh_*` with X25519 curve). Already available in the ESP-IDF dependency tree.
|
||||
|
||||
The abstraction in `src/pq_crypto.c` wraps both backends behind the same interface so the dispatcher and key_store are backend-agnostic.
|
||||
|
||||
### 3.12 Build system changes
|
||||
|
||||
#### Makefile (host)
|
||||
|
||||
Add PQClean source files to [`SOURCES`](Makefile:13):
|
||||
|
||||
```makefile
|
||||
PQCLEAN_DIR := resources/pqclean
|
||||
PQCLEAN_SOURCES := \
|
||||
$(PQCLEAN_DIR)/crypto_sign/ml-dsa-65/sign.c \
|
||||
$(PQCLEAN_DIR)/crypto_sign/ml-dsa-65/... \
|
||||
$(PQCLEAN_DIR)/crypto_sign/slh-dsa-128s/sign.c \
|
||||
$(PQCLEAN_DIR)/crypto_sign/slh-dsa-128s/... \
|
||||
$(PQCLEAN_DIR)/crypto_kem/ml-kem-768/kem.c \
|
||||
$(PQCLEAN_DIR)/crypto_kem/ml-kem-768/... \
|
||||
$(PQCLEAN_DIR)/common/fips202.c \
|
||||
$(PQCLEAN_DIR)/common/sha2.c \
|
||||
src/pq_crypto.c \
|
||||
src/pq_drbg.c
|
||||
|
||||
SOURCES += $(PQCLEAN_SOURCES)
|
||||
CFLAGS += -I$(PQCLEAN_DIR) -I$(PQCLEAN_DIR)/crypto_sign/ml-dsa-65 ...
|
||||
```
|
||||
|
||||
#### Dockerfile.alpine-musl
|
||||
|
||||
No new system packages needed — PQClean is vendored C source compiled by the existing gcc/musl toolchain. The build already has `-lssl -lcrypto` for ed25519/x25519.
|
||||
|
||||
#### ESP32 firmware
|
||||
|
||||
Add PQClean sources to the firmware CMakeLists.txt. ESP32-S3 has hardware SHA-2 acceleration which benefits SLH-DSA-128s. The PQClean SHA-2 implementation can be replaced with ESP-IDF's hardware-accelerated `mbedtls_sha256` for a significant speedup.
|
||||
|
||||
### 3.13 Architecture diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
M[BIP-39 Mnemonic] --> DERIVE[crypto_derive_all]
|
||||
DERIVE --> |m/44'/1237'/n'| SECP[secp256k1 keypair]
|
||||
DERIVE --> |m/44'/1238'/n'| ED25519[ed25519 keypair]
|
||||
DERIVE --> |m/44'/1239'/n'| X25519[x25519 keypair]
|
||||
DERIVE --> |m/44'/204'/n' seed| DRBG[SHA-256-DRBG]
|
||||
DRBG --> |randombytes hook| MLDSA[ML-DSA-65 keypair]
|
||||
DERIVE --> |m/44'/205'/n' seed| DRBG2[SHA-256-DRBG]
|
||||
DRBG2 --> |randombytes hook| SLHDSA[SLH-DSA-128s keypair]
|
||||
DERIVE --> |m/44'/206'/n' seed| DRBG3[SHA-256-DRBG]
|
||||
DRBG3 --> |randombytes hook| MLKEM[ML-KEM-768 keypair]
|
||||
|
||||
SECP --> KS[Key Store - variable length]
|
||||
ED25519 --> KS
|
||||
X25519 --> KS
|
||||
MLDSA --> KS
|
||||
SLHDSA --> KS
|
||||
MLKEM --> KS
|
||||
|
||||
KS --> DISP[Dispatcher]
|
||||
DISP --> |sign_event| SECP
|
||||
DISP --> |sign_data| ED25519
|
||||
DISP --> |sign_data| MLDSA
|
||||
DISP --> |sign_data| SLHDSA
|
||||
DISP --> |kem_encaps/decaps| MLKEM
|
||||
DISP --> |ssh_sign| ED25519
|
||||
```
|
||||
|
||||
## 4. Implementation phases
|
||||
|
||||
### Phase 1 — Foundation: variable-length key store + algorithm registry
|
||||
|
||||
**Goal:** Refactor the key store to support variable-length keys and introduce the algorithm abstraction. No new crypto yet — secp256k1 continues to work through the new abstraction.
|
||||
|
||||
**Files:**
|
||||
- `src/pq_crypto.c` / `pq_crypto.h` (new) — algorithm registry, size tables, `crypto_alg_from_role()`
|
||||
- `src/key_store.c` (modify) — replace fixed 32-byte `derived_key_t` with variable-length `secure_buf_t` for both priv and pub; route secp256k1 through the new abstraction
|
||||
- `src/role_table.c` (modify) — add new purpose/curve enum values and string mappings
|
||||
- `tests/test_key_store.c` (new or extend) — verify secp256k1 still works after refactor
|
||||
|
||||
**Exit criteria:**
|
||||
- `make test` passes (all existing tests)
|
||||
- secp256k1 key derivation and signing works through the new abstraction
|
||||
- `derived_key_t` uses `secure_buf_t` for both private and public keys
|
||||
- New enum values exist but are not yet wired to crypto
|
||||
|
||||
### Phase 2 — ed25519 + x25519 (standard ECC)
|
||||
|
||||
**Goal:** Implement ed25519 signing and x25519 key agreement using OpenSSL on host.
|
||||
|
||||
**Files:**
|
||||
- `src/pq_crypto.c` (extend) — add ed25519 keygen, sign, verify; x25519 keygen, derive
|
||||
- `src/key_store.c` (extend) — derive ed25519/x25519 keys from mnemonic via SLIP-0010 paths
|
||||
- `src/enforcement.c` (extend) — add `sign_data` and `ssh_sign` verbs for `PURPOSE_SSH + CURVE_ED25519`
|
||||
- `src/dispatcher.c` (extend) — handle new verbs
|
||||
- `tests/test_pq_crypto.c` (new) — ed25519 sign/verify roundtrip, x25519 ECDH roundtrip
|
||||
|
||||
**Exit criteria:**
|
||||
- ed25519 key derived from mnemonic, signs data, signature verifies
|
||||
- x25519 key derived from mnemonic, ECDH shared secret matches between two derived keys
|
||||
- `sign_data` verb works for ed25519
|
||||
- Enforcement rejects ed25519 on nostr verbs and vice versa
|
||||
|
||||
### Phase 3 — PQClean vendoring + ML-DSA-65
|
||||
|
||||
**Goal:** Vendor PQClean, implement deterministic keygen via DRBG, get ML-DSA-65 working.
|
||||
|
||||
**Files:**
|
||||
- `resources/pqclean/` (new) — vendored PQClean ML-DSA-65 + common files
|
||||
- `src/pq_drbg.c` / `pq_drbg.h` (new) — SHA-256-DRBG seeded from mnemonic
|
||||
- `resources/pqclean/common/randombytes.c` (replace) — shim calling `pq_drbg_randombytes()`
|
||||
- `src/pq_crypto.c` (extend) — ML-DSA-65 keygen, sign, verify
|
||||
- `src/key_store.c` (extend) — derive ML-DSA-65 keys via DRBG-seeded keygen
|
||||
- `src/enforcement.c` (extend) — `sign_data` for `PURPOSE_PQ_SIG + CURVE_ML_DSA_65`
|
||||
- `src/dispatcher.c` (extend) — handle ML-DSA-65 signing
|
||||
- `Makefile` (modify) — add PQClean sources
|
||||
- `Dockerfile.alpine-musl` (verify) — static build still works with PQClean
|
||||
- `tests/test_pq_crypto.c` (extend) — ML-DSA-65 keygen determinism, sign/verify roundtrip, known-answer test
|
||||
|
||||
**Exit criteria:**
|
||||
- ML-DSA-65 keypair derived deterministically from mnemonic (same mnemonic + role = same keypair)
|
||||
- Signature verifies with PQClean's verify function
|
||||
- Static binary builds with PQClean linked
|
||||
- Known-answer test passes (test vector from NIST or PQClean)
|
||||
|
||||
### Phase 4 — SLH-DSA-128s
|
||||
|
||||
**Goal:** Add hash-based PQ signatures.
|
||||
|
||||
**Files:**
|
||||
- `resources/pqclean/crypto_sign/slh-dsa-128s/` (new) — vendored PQClean SLH-DSA-128s
|
||||
- `src/pq_crypto.c` (extend) — SLH-DSA-128s keygen, sign, verify
|
||||
- `src/key_store.c` (extend) — derive SLH-DSA-128s keys
|
||||
- `src/enforcement.c` (extend) — `sign_data` for `PURPOSE_PQ_SIG + CURVE_SLH_DSA_128S`
|
||||
- `src/dispatcher.c` (extend) — handle SLH-DSA-128s signing
|
||||
- `Makefile` (modify) — add SLH-DSA-128s sources
|
||||
- `tests/test_pq_crypto.c` (extend) — SLH-DSA-128s keygen determinism, sign/verify roundtrip
|
||||
|
||||
**Exit criteria:**
|
||||
- SLH-DSA-128s keypair derived deterministically from mnemonic
|
||||
- Signature verifies
|
||||
- Known-answer test passes
|
||||
- Document the signing latency on ESP32 (measure and record, no mitigation required)
|
||||
|
||||
### Phase 5 — ML-KEM-768
|
||||
|
||||
**Goal:** Add PQ key encapsulation.
|
||||
|
||||
**Files:**
|
||||
- `resources/pqclean/crypto_kem/ml-kem-768/` (new) — vendored PQClean ML-KEM-768
|
||||
- `src/pq_crypto.c` (extend) — ML-KEM-768 keygen, encaps, decaps
|
||||
- `src/key_store.c` (extend) — derive ML-KEM-768 keys
|
||||
- `src/enforcement.c` (extend) — `kem_encapsulate` / `kem_decapsulate` for `PURPOSE_PQ_KEM + CURVE_ML_KEM_768`
|
||||
- `src/dispatcher.c` (extend) — handle KEM verbs
|
||||
- `Makefile` (modify) — add ML-KEM-768 sources
|
||||
- `tests/test_pq_crypto.c` (extend) — ML-KEM-768 keygen determinism, encaps/decaps roundtrip, shared secret matches
|
||||
|
||||
**Exit criteria:**
|
||||
- ML-KEM-768 keypair derived deterministically from mnemonic
|
||||
- Encapsulate with public key produces ciphertext + shared secret
|
||||
- Decapsulate with private key + ciphertext recovers same shared secret
|
||||
- Known-answer test passes
|
||||
|
||||
### Phase 6 — Public key output format + client API
|
||||
|
||||
**Goal:** Update the `get_public_key` response to handle large PQ public keys and algorithm metadata.
|
||||
|
||||
**Files:**
|
||||
- `src/dispatcher.c` (modify) — structured `get_public_key` response for non-secp256k1 algorithms
|
||||
- `client/` (modify) — client library handles new response format
|
||||
- `documents/CLIENT_IMPLEMENTATION.md` (modify) — document new verbs and response formats
|
||||
- `examples/` (new) — example clients for PQ signing and KEM
|
||||
|
||||
**Exit criteria:**
|
||||
- `get_public_key` returns algorithm-appropriate format for all 6 algorithms
|
||||
- secp256k1 backward compatibility preserved (plain hex string by default)
|
||||
- Client examples demonstrate PQ signing and KEM usage
|
||||
|
||||
### Phase 7 — ESP32 firmware port
|
||||
|
||||
**Goal:** Get PQ algorithms working on ESP32-S3 firmware.
|
||||
|
||||
**Files:**
|
||||
- `firmware/feather_s3_tft/main/` (modify) — add PQClean sources to firmware build, replace SHA-2 with hardware-accelerated mbedtls
|
||||
- `firmware/feather_s3_tft/CMakeLists.txt` (modify) — add PQClean source files
|
||||
- `firmware/cyd_esp32_2432s028/CMakeLists.txt` (modify) — same
|
||||
|
||||
**Exit criteria:**
|
||||
- ESP32-S3 firmware builds with all 6 algorithms
|
||||
- ML-DSA-65 signing works on device (measure latency)
|
||||
- SLH-DSA-128s signing works on device (measure and document latency)
|
||||
- ML-KEM-768 encaps/decaps works on device
|
||||
- ed25519/x25519 work on device via mbedtls
|
||||
- Flash usage measured and documented (confirm it fits)
|
||||
|
||||
### Phase 8 — Documentation
|
||||
|
||||
**Files:**
|
||||
- `README.md` (modify) — add PQ algorithms to the crypto palette section, document new verbs
|
||||
- `documents/SECURITY.md` (modify) — PQ threat model, deterministic derivation explanation, SLH-DSA latency note
|
||||
- `plans/seed_phrase_uses.md` (modify) — add PQ purpose domains to the catalog
|
||||
- `documents/CLIENT_IMPLEMENTATION.md` (modify) — full verb reference for new operations
|
||||
|
||||
## 5. Threat model considerations
|
||||
|
||||
### 5.1 Deterministic PQ key derivation
|
||||
|
||||
The mnemonic-seeded DRBG approach is non-standard. There is no NIST or IETF specification for deriving PQ keys from a BIP-39 mnemonic. The security argument:
|
||||
|
||||
- The 32-byte seed from BIP-32 derivation has full 256 bits of entropy (assuming the mnemonic has full entropy).
|
||||
- SHA-256-DRBG is a NIST-approved DRBG (SP 800-90A). Seeding it with 256 bits of entropy is sufficient for all three PQ algorithms.
|
||||
- The alternative (random PQ keys, no mnemonic recovery) breaks n_signer's core model. The deterministic approach is the right tradeoff for this project.
|
||||
|
||||
**Risk:** If a weakness is found in using DRBG output as PQ keygen randomness, all PQ keys derived this way could be affected. Mitigation: the DRBG is seeded per-role with distinct derivation paths, so compromising one role's PQ key does not compromise others.
|
||||
|
||||
### 5.2 PQ algorithm maturity
|
||||
|
||||
ML-DSA, SLH-DSA, and ML-KEM are FIPS-standardized (FIPS 203, 204, 205). They have undergone extensive NIST scrutiny. However, they are newer than classical algorithms. The plan does not replace secp256k1 for Nostr — PQ algorithms are additional options, not replacements.
|
||||
|
||||
### 5.3 SLH-DSA-128s signing latency on ESP32
|
||||
|
||||
SLH-DSA-128s signing on ESP32 could take 5-30 seconds. This is not a security issue but a UX consideration. The approval prompt flow on the feather/CYD should show a "signing..." indicator during the operation. The user has accepted this and will choose whether to use SLH-DSA-128s per-role.
|
||||
|
||||
### 5.4 Key size and memory
|
||||
|
||||
PQ private keys are large (ML-DSA-65: 4032 bytes, ML-KEM-768: 2400 bytes). With `ROLE_TABLE_MAX_ENTRIES` at 256, a full table of PQ keys would use ~1MB of mlock'd memory. This is acceptable on host. On ESP32 with 512KB SRAM, the role table should be smaller or PQ roles should be derived on-demand rather than all at startup. The existing on-demand derivation in [`crypto_derive_one`](src/key_store.c:548) already supports this pattern.
|
||||
|
||||
## 6. What is explicitly out of scope
|
||||
|
||||
1. **Hybrid signatures** (e.g., ed25519 + ML-DSA combined signature). No standard exists yet for SSH. Will be a follow-up when OpenSSH defines the format.
|
||||
2. **PQ SSH signing key format**. OpenSSH does not support PQ signing keys yet. We build the primitive; the SSH wire format is future work.
|
||||
3. **Bitcoin purpose implementation**. `PURPOSE_BITCOIN` remains modeled but not implemented. Separate plan.
|
||||
4. **FIPS purpose with PQ algorithms**. The FIPS mesh transport purpose is orthogonal to the crypto algorithm. Separate concern.
|
||||
5. **PQ key persistence**. Breaks the crash-equals-wipe model. Explicitly rejected.
|
||||
6. **NIP-44/NIP-04 with PQ keys**. Nostr encryption uses secp256k1 ECDH. PQ KEM is a separate encryption path, not a Nostr NIP.
|
||||
7. **liboqs or oqs-provider**. Rejected in favor of PQClean for ESP32 compatibility.
|
||||
|
||||
## 7. Open questions
|
||||
|
||||
1. **PQ public key encoding for `get_public_key`**: Should PQ public keys be returned as raw hex, base64, or a structured format? Plan defaults to hex for consistency with secp256k1, but PQ pubkeys are large (ML-DSA-65 pub is 1952 bytes = 3904 hex chars). Base64 would be more compact. Decision can be made at implementation time.
|
||||
|
||||
2. **Derivation path purpose codes**: The plan proposes `44'/204'`, `44'/205'`, `44'/206'` for PQ-SIG, PQ-HASH-SIG, PQ-KEM. These are unregistered BIP-44 purpose codes. If there is a future standard for PQ derivation paths, these may need to change. Acceptable for now since the derivation is internal to n_signer.
|
||||
|
||||
3. **ESP32 role table size for PQ**: Should the firmware limit the number of PQ roles to control memory usage, or rely on on-demand derivation? Plan defaults to on-demand derivation (already supported) with no hard limit change.
|
||||
|
||||
4. **Verify-on-signer for `sign_data`**: Should the signer verify its own PQ signatures before returning them to the client? Adds latency but catches implementation bugs. Plan defaults to no (client verifies), matching the existing `sign_event` behavior.
|
||||
|
||||
## 8. Files touched summary
|
||||
|
||||
| File | Action | Phase |
|
||||
|---|---|---|
|
||||
| `src/pq_crypto.c` / `pq_crypto.h` | New | 1-5 |
|
||||
| `src/pq_drbg.c` / `pq_drbg.h` | New | 3 |
|
||||
| `src/key_store.c` | Modify (variable-length keys, new derivations) | 1-5 |
|
||||
| `src/role_table.c` | Modify (new enum values, string mappings) | 1 |
|
||||
| `src/enforcement.c` | Modify (new verb→purpose→curve rules) | 2-5 |
|
||||
| `src/dispatcher.c` | Modify (new verb handlers, structured pubkey response) | 2-6 |
|
||||
| `src/main.c` | Modify (headerless decls for new types) | 1-5 |
|
||||
| `resources/pqclean/` | New (vendored PQClean) | 3-5 |
|
||||
| `Makefile` | Modify (PQClean sources, includes) | 3-5 |
|
||||
| `Dockerfile.alpine-musl` | Verify (static build with PQClean) | 3 |
|
||||
| `tests/test_pq_crypto.c` | New | 2-5 |
|
||||
| `firmware/feather_s3_tft/` | Modify (PQClean in firmware build) | 7 |
|
||||
| `firmware/cyd_esp32_2432s028/` | Modify (same) | 7 |
|
||||
| `README.md` | Modify (crypto palette, new verbs) | 8 |
|
||||
| `documents/SECURITY.md` | Modify (PQ threat model) | 8 |
|
||||
| `documents/CLIENT_IMPLEMENTATION.md` | Modify (new verb docs) | 6, 8 |
|
||||
| `plans/seed_phrase_uses.md` | Modify (PQ purpose domains) | 8 |
|
||||
| `examples/` | New (PQ signing + KEM examples) | 6 |
|
||||
@@ -92,15 +92,57 @@ This prevents "same seed means same trust domain" mistakes by separating identit
|
||||
- Per-service deterministic auth keys (HMAC or asymmetric challenge keys depending on service model).
|
||||
- Strongly policy-scoped to avoid accidental cross-service linkage.
|
||||
|
||||
### 3.7 Post-quantum signature identities (`purpose="pq-sig"`)
|
||||
|
||||
- Candidate curves: `ml-dsa-65`, `slh-dsa-128s`
|
||||
- Derivation paths:
|
||||
- ML-DSA-65: `m/44'/102003'/<n>'/0'/0'` → seed → SHAKE-256 DRBG → PQClean keygen
|
||||
- SLH-DSA-128s: `m/44'/102004'/<n>'/0'/0'` → seed → SHAKE-256 DRBG → PQClean keygen
|
||||
- Uses:
|
||||
- PQ-aware protocol signatures
|
||||
- future SSH PQ signing keys (when OpenSSH adds support)
|
||||
- general-purpose PQ signatures for forward-looking applications
|
||||
- Note: OpenSSH does not yet support PQ signing keys. These are forward-looking — the primitives are ready for when the ecosystem adopts them. See [`plans/post_quantum_crypto.md`](post_quantum_crypto.md) §2.3 for the SSH PQ landscape.
|
||||
- Note: SLH-DSA-128s signing on ESP32 can take 5–30 seconds. Choose per-role whether the latency tradeoff is acceptable.
|
||||
- Verbs: `sign_data`, `verify_signature` (see [`README.md`](../README.md) §5).
|
||||
|
||||
### 3.8 Post-quantum key encapsulation (`purpose="pq-kem"`)
|
||||
|
||||
- Candidate curves: `ml-kem-768`
|
||||
- Derivation path: `m/44'/102005'/<n>'/0'/0'` → seed → SHAKE-256 DRBG → PQClean keygen
|
||||
- Uses:
|
||||
- PQ key agreement for encryption (encapsulate a shared secret against a peer's ML-KEM-768 public key)
|
||||
- hybrid PQ+classical key exchange (client combines ML-KEM-768 with x25519)
|
||||
- future PQ TLS session key establishment
|
||||
- Note: ML-KEM-768 addresses the harvest-now-decrypt-later threat for key agreement. See [`documents/SECURITY.md`](../documents/SECURITY.md) §16.1 for the PQ threat model.
|
||||
- Verbs: `kem_encapsulate`, `kem_decapsulate` (see [`README.md`](../README.md) §5).
|
||||
|
||||
## 4. Curve and derivation notes
|
||||
|
||||
`n_signer` currently models curve metadata from day one:
|
||||
`n_signer` models these curves:
|
||||
|
||||
- `secp256k1`
|
||||
- `ed25519`
|
||||
- `x25519`
|
||||
- `secp256k1` — Nostr, Bitcoin
|
||||
- `ed25519` — SSH signatures
|
||||
- `x25519` — key agreement (age)
|
||||
- `ml-dsa-65` — PQ signatures (FIPS 204)
|
||||
- `slh-dsa-128s` — PQ hash-based signatures (FIPS 205)
|
||||
- `ml-kem-768` — PQ key encapsulation (FIPS 203)
|
||||
|
||||
Not every purpose should be valid on every curve. Enforcement should be explicit in policy/runtime validation.
|
||||
Derivation paths use BIP-44 structure with distinct coin types per algorithm family:
|
||||
|
||||
| Algorithm | Coin type | Path | Derivation style |
|
||||
|---|---|---|---|
|
||||
| secp256k1 (nostr) | 1237 | `m/44'/1237'/<n>'/0/0` | BIP-32 (NIP-06) |
|
||||
| secp256k1 (bitcoin) | 0 | `m/44'/0'/<account>'/0/0` etc. | BIP-44 |
|
||||
| ed25519 (ssh) | 102001 | `m/44'/102001'/<n>'/0'/0'` | SLIP-0010 (all hardened) |
|
||||
| x25519 (age) | 102002 | `m/44'/102002'/<n>'/0'/0'` | SLIP-0010 (all hardened) |
|
||||
| ML-DSA-65 (pq-sig) | 102003 | `m/44'/102003'/<n>'/0'/0'` | SLIP-0010 → seed → DRBG → PQClean keygen |
|
||||
| SLH-DSA-128s (pq-sig) | 102004 | `m/44'/102004'/<n>'/0'/0'` | SLIP-0010 → seed → DRBG → PQClean keygen |
|
||||
| ML-KEM-768 (pq-kem) | 102005 | `m/44'/102005'/<n>'/0'/0'` | SLIP-0010 → seed → DRBG → PQClean keygen |
|
||||
|
||||
The 102XXX coin type range is unregistered in SLIP-44 and chosen to avoid collisions with real cryptocurrencies. All non-secp256k1 paths are fully hardened per SLIP-0010. PQ keys use a seed→DRBG→PQClean keygen approach because PQ private keys are not scalars — see [`documents/SECURITY.md`](../documents/SECURITY.md) §16.2 for the security argument.
|
||||
|
||||
Not every purpose should be valid on every curve. Enforcement is explicit in the `(verb, purpose, curve)` matrix — see [`README.md`](../README.md) §6.
|
||||
|
||||
## 5. Operational caveats
|
||||
|
||||
|
||||
Reference in New Issue
Block a user