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:
Laan Tungir
2026-07-16 15:14:57 -04:00
parent 6fd7b8ce1f
commit c5f1a70658
66 changed files with 19091 additions and 257 deletions

View File

@@ -127,6 +127,25 @@ Methods are NIP-46 style verbs.
- `nip44_encrypt`
- `nip44_decrypt`
### 4.2b Algorithm-based verbs (new)
In addition to the role-based verbs above, the signer supports algorithm-based verbs where the caller specifies `algorithm` and `index` directly:
- `sign` — sign arbitrary bytes (params: `[message_hex, {algorithm, index, scheme?}]`)
- `verify` — verify a signature (params: `[message_hex, signature_hex, {algorithm, index, scheme?}]`)
- `encapsulate` — KEM encapsulation (params: `[peer_pubkey_hex, {algorithm}]`)
- `decapsulate` — KEM decapsulation (params: `[ciphertext_hex, {algorithm, index}]`)
- `derive_shared_secret` — ECDH key agreement (params: `[peer_pubkey_hex, {algorithm, index}]`)
- `get_public_key` with `algorithm` parameter — returns structured JSON
Algorithm names: `secp256k1`, `ed25519`, `ml-dsa-65`, `slh-dsa-128s`, `x25519`, `ml-kem-768`
For secp256k1 `sign`/`verify`, the optional `scheme` parameter selects `"schnorr"` (default, BIP-340) or `"ecdsa"`.
Old verb aliases (`sign_data`, `ssh_sign`, `verify_signature`, `kem_encapsulate`, `kem_decapsulate`) map to the new verbs when used with the `algorithm` parameter. Without `algorithm`, they fall through to the role-based path.
See [README.md §4c](../README.md) for full details.
### 4.3 Selector options
The last param may include selector options:
@@ -484,7 +503,228 @@ Decrypt response:
---
## 11. Compatibility notes
## 11. Post-Quantum and Multi-Algorithm Support
n_signer supports six cryptographic algorithms, all derived deterministically
from the same BIP-39 mnemonic via distinct derivation paths:
| Algorithm | Purpose | Curve string | Purpose string | Derivation path |
|---|---|---|---|---|
| `secp256k1` | Nostr (sign_event, NIP-04/44) | `secp256k1` | `nostr` | `m/44'/1237'/<n>'/0/0` (NIP-06) |
| `ed25519` | SSH signing, general signatures | `ed25519` | `ssh` | `m/44'/102001'/<n>'/0'/0'` (SLIP-0010) |
| `x25519` | Key agreement (age, ECDH) | `x25519` | `age` | `m/44'/102002'/<n>'/0'/0'` (SLIP-0010) |
| `ml-dsa-65` | Post-quantum signatures (FIPS 204) | `ml-dsa-65` | `pq-sig` | `m/44'/102003'/<n>'/0'/0'` → seed → PQClean keygen |
| `slh-dsa-128s` | Post-quantum hash-based signatures (FIPS 205) | `slh-dsa-128s` | `pq-sig` | `m/44'/102004'/<n>'/0'/0'` → seed → PQClean keygen |
| `ml-kem-768` | Post-quantum key encapsulation (FIPS 203) | `ml-kem-768` | `pq-kem` | `m/44'/102005'/<n>'/0'/0'` → seed → PQClean keygen |
The `102XXX` coin types are unregistered in SLIP-44 and reserved by n_signer
for PQ/SSH/age algorithm families. All non-secp256k1 paths use SLIP-0010
all-hardened derivation.
### 11.1 Algorithm key sizes
| Algorithm | Pub key | Priv key | Signature | Ciphertext | Shared secret |
|---|---|---|---|---|---|
| secp256k1 | 32 bytes | 32 bytes | 64 bytes | — | — |
| ed25519 | 32 bytes | 32 bytes | 64 bytes | — | — |
| x25519 | 32 bytes | 32 bytes | — | — | 32 bytes |
| ML-DSA-65 | 1952 bytes | 4032 bytes | 3309 bytes | — | — |
| SLH-DSA-128s | 32 bytes | 64 bytes | 7856 bytes | — | — |
| ML-KEM-768 | 1184 bytes | 2400 bytes | — | 1088 bytes | 32 bytes |
PQ public keys and signatures are much larger than classical ones. Clients
must allocate buffers accordingly (ML-DSA-65 pubkey hex = 3904 chars;
SLH-DSA-128s signature hex = 15712 chars; ML-KEM-768 pubkey hex = 2368 chars).
### 11.2 New verbs
| Verb | Purpose | Allowed (purpose, curve) | Description |
|---|---|---|---|
| `sign_data` | pq-sig, ssh | (pq-sig, ml-dsa-65), (pq-sig, slh-dsa-128s), (ssh, ed25519) | Sign arbitrary bytes (not a Nostr event) |
| `verify_signature` | pq-sig, ssh | same as `sign_data` | Verify a signature against the role's public key |
| `ssh_sign` | ssh | (ssh, ed25519) | Sign an SSH authentication challenge (ed25519) |
| `kem_encapsulate` | pq-kem | (pq-kem, ml-kem-768) | Encapsulate: generate ciphertext + shared secret from a peer's ML-KEM public key |
| `kem_decapsulate` | pq-kem | (pq-kem, ml-kem-768) | Decapsulate: recover shared secret from ciphertext using the role's ML-KEM private key |
The existing Nostr verbs (`sign_event`, `nip44_*`, `nip04_*`, `mine_event`)
remain restricted to `purpose=nostr + curve=secp256k1`.
### 11.3 Structured `get_public_key` response format
`get_public_key` is a universal verb — it works for all six algorithms.
**For secp256k1 (backward compatibility):** the result is a plain hex string
(the existing format). Existing Nostr clients are unaffected.
```json
{ "id": "1", "result": "<64-char hex pubkey>" }
```
**For secp256k1 with `format: "structured"` option:** new clients can request
the structured format for consistency:
Request:
```json
{
"id": "1",
"method": "get_public_key",
"params": [{ "role": "main", "format": "structured" }]
}
```
Response:
```json
{
"id": "1",
"result": "{\"algorithm\":\"secp256k1\",\"public_key\":\"<hex>\",\"key_id\":\"<16 hex>\"}"
}
```
**For all other algorithms (ed25519, x25519, ML-DSA-65, SLH-DSA-128s,
ML-KEM-768):** the result is always a structured JSON object serialized as a
string:
```json
{
"id": "1",
"result": {
"algorithm": "ml-dsa-65",
"public_key": "<hex-encoded public key>",
"key_id": "<first 16 hex chars of public key>"
}
}
```
The `key_id` is the first 16 hex characters of the public key — a short
display identifier similar to an SSH key fingerprint. The `result` field is a
JSON string (the object serialized), so clients must parse it twice: once for
the JSON-RPC envelope, once for the result object.
### 11.4 Example: `sign_data` (ML-DSA-65)
Request:
```json
{
"id": "10",
"method": "sign_data",
"params": ["68656c6c6f", { "role": "pq_sig" }]
}
```
Response:
```json
{
"id": "10",
"result": "{\"signature\":\"<hex>\",\"algorithm\":\"ml-dsa-65\"}"
}
```
The first param is the message bytes as hex. The signature is hex-encoded
(3309 bytes = 6618 hex chars for ML-DSA-65).
### 11.5 Example: `verify_signature` (ed25519)
Request:
```json
{
"id": "11",
"method": "verify_signature",
"params": ["<msg_hex>", "<sig_hex>", { "role": "ssh_main" }]
}
```
Response:
```json
{ "id": "11", "result": "{\"valid\":true}" }
```
The signature is verified against the role's derived public key.
### 11.6 Example: `ssh_sign` (ed25519)
Request:
```json
{
"id": "12",
"method": "ssh_sign",
"params": ["<session_id_hex>", { "role": "ssh_main" }]
}
```
Response:
```json
{
"id": "12",
"result": "{\"signature\":\"<hex>\",\"algorithm\":\"ed25519\"}"
}
```
The first param is the SSH session ID (or challenge) as hex. The signature is
a raw ed25519 signature (64 bytes = 128 hex chars).
### 11.7 Example: `kem_encapsulate` (ML-KEM-768)
Request:
```json
{
"id": "13",
"method": "kem_encapsulate",
"params": ["<peer_pubkey_hex>", { "role": "kem_main" }]
}
```
Response:
```json
{
"id": "13",
"result": "{\"ciphertext\":\"<hex>\",\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}"
}
```
The first param is the peer's ML-KEM-768 public key as hex (1184 bytes = 2368
hex chars). The response contains the ciphertext (1088 bytes = 2176 hex chars)
and the shared secret (32 bytes = 64 hex chars). The encapsulating party keeps
the shared secret; the ciphertext is sent to the decapsulating party.
### 11.8 Example: `kem_decapsulate` (ML-KEM-768)
Request:
```json
{
"id": "14",
"method": "kem_decapsulate",
"params": ["<ciphertext_hex>", { "role": "kem_main" }]
}
```
Response:
```json
{
"id": "14",
"result": "{\"shared_secret\":\"<hex>\",\"algorithm\":\"ml-kem-768\"}"
}
```
The first param is the ciphertext from `kem_encapsulate` (1088 bytes = 2176
hex chars). The decapsulated shared secret will match the encapsulating
party's shared secret.
### 11.9 Example clients
See the `examples/` directory for working C clients demonstrating the new
verbs:
- [`examples/pq_sign_example.c`](../examples/pq_sign_example.c) — ML-DSA-65
`get_public_key` + `sign_data`
- [`examples/pq_kem_example.c`](../examples/pq_kem_example.c) — ML-KEM-768
`get_public_key` + `kem_encapsulate` + `kem_decapsulate` (verifies shared
secrets match)
- [`examples/ssh_sign_example.c`](../examples/ssh_sign_example.c) — ed25519
`get_public_key` + `ssh_sign`
---
## 12. Compatibility notes
- If you are writing an autonomous agent client, pin to explicit socket name and explicit role selector.
- Keep method support feature-detected (`method_not_found` fallback).

View File

@@ -215,12 +215,18 @@ The signer enforces a strict `(verb, purpose, curve)` matrix:
| Verb | Required purpose | Required curve |
|---|---|---|
| `sign_event` | `nostr` | `secp256k1` |
| `get_public_key` | `nostr` | `secp256k1` |
| `mine_event` | `nostr` | `secp256k1` |
| `nip04_encrypt` / `nip04_decrypt` | `nostr` | `secp256k1` |
| `nip44_encrypt` / `nip44_decrypt` | `nostr` | `secp256k1` |
| `get_public_key` | any | any (must match role's declared curve) |
| `sign_data` | `ssh` or `pq-sig` | `ed25519`, `ml-dsa-65`, or `slh-dsa-128s` |
| `verify_signature` | `ssh` or `pq-sig` | `ed25519`, `ml-dsa-65`, or `slh-dsa-128s` |
| `ssh_sign` | `ssh` | `ed25519` |
| `kem_encapsulate` | `pq-kem` | `ml-kem-768` |
| `kem_decapsulate` | `pq-kem` | `ml-kem-768` |
| Any other verb | rejected | rejected |
A pre-approval to use a Bitcoin-purposed key for `sign_event` does **not** override the enforcement matrix. The approval grants access to the key; enforcement still gates the verb. **Fail-closed**: unknown verbs are rejected, never passed through.
A pre-approval to use a Bitcoin-purposed key for `sign_event` does **not** override the enforcement matrix. The approval grants access to the key; enforcement still gates the verb. **Fail-closed**: unknown verbs and unlisted `(verb, purpose, curve)` combinations are rejected, never passed through.
This is the layer that prevents (for example) a `bitcoin/secp256k1` key from being used to sign a Nostr event even if some pre-approval entry mistakenly named it. The key's *purpose* is part of its identity; you cannot reuse it across domains.
@@ -508,7 +514,86 @@ If any of these statements becomes false in code, that is a security bug worth f
---
## 16. References
---
## 16. Post-Quantum Cryptography
`n_signer` supports three post-quantum algorithms alongside the classical secp256k1, ed25519, and x25519:
- **ML-DSA-65** (FIPS 204) — lattice-based post-quantum digital signatures
- **SLH-DSA-128s** (FIPS 205) — hash-based post-quantum signatures with minimal trust assumptions
- **ML-KEM-768** (FIPS 203) — lattice-based post-quantum key encapsulation mechanism
These are additional options, not replacements for secp256k1. Nostr continues to use secp256k1 exclusively. PQ algorithms are opt-in per role via `purpose="pq-sig"` or `purpose="pq-kem"`. See [`README.md`](../README.md) §4b for the full crypto palette.
### 16.1 PQ threat model
The primary PQ threat is **harvest-now-decrypt-later**: an adversary records encrypted traffic or key agreement exchanges today, stores them, and decrypts them once a sufficiently large quantum computer becomes available. ML-KEM-768 addresses this for key agreement — a session key encapsulated with ML-KEM-768 cannot be recovered by a future quantum adversary.
For signatures, the future risk is **quantum forgery**: a quantum computer could forge classical signatures (ECDSA, Ed25519) given the public key, undermining authentication retroactively. ML-DSA-65 and SLH-DSA-128s address this by providing signatures that resist quantum forgery. The urgency is lower than for key agreement (signatures are forged when needed, not retroactively decrypted), but forward-looking deployments may want PQ signature keys now.
`n_signer` does not claim to defend against all quantum threats. It provides the PQ primitives; the protocol layer (SSH, TLS, Nostr) must adopt them for the protection to be meaningful.
### 16.2 Deterministic PQ key derivation
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.
`n_signer` uses a **non-standard** approach to derive PQ keys deterministically from the mnemonic:
1. Derive a 32-byte seed from the mnemonic using BIP-32/SLIP-0010 HMAC-SHA512 at a PQ-specific derivation path (e.g. `m/44'/102003'/<n>'/0'/0'` for ML-DSA-65).
2. Feed that seed into a SHAKE-256 DRBG (NIST SP 800-90A style).
3. Replace PQClean's `randombytes()` callback with this DRBG so keygen is deterministic.
4. The PQ algorithm expands the DRBG output into the full key pair.
**Security argument:**
- The 32-byte seed from BIP-32 derivation carries full 256 bits of entropy (assuming the mnemonic has full entropy).
- SHAKE-256 is a NIST-approved XOF; using it as a DRBG seeded with 256 bits of entropy is sufficient for all three PQ algorithms.
- Each role uses a distinct derivation path (distinct coin types 102003/102004/102005), so compromising one role's PQ key does not compromise others.
**This is non-standard.** There is no NIST or IETF specification for deriving PQ keys from a BIP-39 mnemonic. The approach preserves `n_signer`'s core crash-equals-wipe model: PQ keys are re-derived from the mnemonic on every startup, same as secp256k1. The alternative (random PQ keys with no mnemonic recovery) would break the model.
**Risk:** If a weakness is found in using DRBG output as PQ keygen randomness, all PQ keys derived this way could be affected. Mitigation: per-role distinct derivation paths limit blast radius. The classical algorithms (secp256k1, ed25519, x25519) are unaffected — they do not use the DRBG.
Implementation: [`src/pq_drbg.c`](../src/pq_drbg.c), [`src/pq_crypto.c`](../src/pq_crypto.c).
### 16.3 PQ algorithm maturity
ML-DSA, SLH-DSA, and ML-KEM are FIPS-standardized (FIPS 203, 204, 205) and have undergone extensive NIST scrutiny. However, they are newer than classical algorithms and have less deployment history. They are provided as **additional options**, not replacements. The enforcement matrix (§5.2) ensures PQ keys cannot be used for Nostr operations and vice versa.
### 16.4 SLH-DSA-128s signing latency
SLH-DSA-128s signing on ESP32 can take **530 seconds**. This is a UX consideration, not a security issue. The hash-based signature scheme is intentionally compute-bound (that is its security foundation). On the Feather/CYD firmware, the approval prompt should show a "signing..." indicator during the operation.
The user should choose whether to use SLH-DSA-128s per role. For interactive use where latency matters, ML-DSA-65 is faster. SLH-DSA-128s is appropriate for low-frequency, high-assurance signing where minimal trust assumptions (hash-based, no number-theoretic hardness assumption) are desired.
### 16.5 Key sizes and memory
PQ private keys are large compared to classical keys:
| Algorithm | Private key | Public key | Signature / Ciphertext |
|---|---|---|---|
| secp256k1 | 32 bytes | 32 bytes | 64 bytes (sig) |
| ed25519 | 32 bytes | 32 bytes | 64 bytes (sig) |
| ML-DSA-65 | 4032 bytes | 1952 bytes | 3309 bytes (sig) |
| SLH-DSA-128s | 64 bytes | 32 bytes | 7856 bytes (sig) |
| ML-KEM-768 | 2400 bytes | 1184 bytes | 1088 bytes (ciphertext) |
With `ROLE_TABLE_MAX_ENTRIES` at 256, a full table of ML-DSA-65 keys would use ~1 MB of `mlock`'d memory (4032 × 256 ≈ 1.03 MB for private keys alone). This is acceptable on host. On ESP32 with 512 KB SRAM, this would not fit — on-demand derivation (deriving a PQ key only when a request targets that role) is the recommended pattern. The existing [`crypto_derive_one`](../src/key_store.c) path already supports this.
### 16.6 No hybrid signatures yet
Hybrid signatures (e.g., ed25519 + ML-DSA combined into one signature object) are **future work**. No standard exists for hybrid SSH signatures yet. `n_signer` provides the individual primitives (`sign_data` for ed25519, ML-DSA-65, and SLH-DSA-128s); a hybrid format can be assembled by the client once standards solidify.
### 16.7 PQ key persistence
**PQ keys are NOT persisted.** They are re-derived from the mnemonic on every startup, same as secp256k1. There is no PQ key file, no PQ key database, no PQ key cache on disk. Crash-equals-wipe (§10) applies unchanged: if the process dies, all PQ keys are gone and must be re-derived from the mnemonic on next startup.
This is a deliberate design choice. The deterministic derivation approach (§16.2) makes it possible to recover PQ keys from the mnemonic alone, so persistence would add risk (key material on disk) without adding capability.
---
## 17. References
- [`README.md`](../README.md) — authoritative behavior spec.
- [`plans/nsigner.md`](../plans/nsigner.md) — root design plan and decisions log.
@@ -517,4 +602,5 @@ If any of these statements becomes false in code, that is a security bug worth f
- [`documents/QUBES_OS.md`](QUBES_OS.md) — Qubes RPC integration.
- [`documents/FIPS_DEPLOYMENT.md`](FIPS_DEPLOYMENT.md) — FIPS-mode deployment notes.
- [`plans/seed_phrase_uses.md`](../plans/seed_phrase_uses.md) — what one mnemonic can become.
- [`src/policy.c`](../src/policy.c), [`src/server.c`](../src/server.c), [`src/dispatcher.c`](../src/dispatcher.c), [`src/role_table.c`](../src/role_table.c), [`src/selector.c`](../src/selector.c), [`src/enforcement.c`](../src/enforcement.c) — the security-related code.
- [`plans/post_quantum_crypto.md`](../plans/post_quantum_crypto.md) — post-quantum and multi-algorithm crypto expansion plan.
- [`src/policy.c`](../src/policy.c), [`src/server.c`](../src/server.c), [`src/dispatcher.c`](../src/dispatcher.c), [`src/role_table.c`](../src/role_table.c), [`src/selector.c`](../src/selector.c), [`src/enforcement.c`](../src/enforcement.c), [`src/pq_crypto.c`](../src/pq_crypto.c), [`src/pq_drbg.c`](../src/pq_drbg.c) — the security-related code.