# 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'/'/0/0` (existing) | NIP-06, unchanged | | secp256k1 | bitcoin | `m/44'/0'/'/0/0` etc. | BIP-44, future | | ed25519 | ssh | `m/44'/102001'/'/0'/0'` | SLIP-0010 ed25519 (all hardened) | | x25519 | age | `m/44'/102002'/'/0'/0'` | SLIP-0010 x25519 (all hardened) | | ML-DSA-65 | pq-sig | `m/44'/102003'/'/0'/0'` → seed → PQClean keygen | PQ-SIG coin type | | SLH-DSA-128s | pq-sig | `m/44'/102004'/'/0'/0'` → seed → PQClean keygen | PQ-HASH-SIG coin type | | ML-KEM-768 | pq-kem | `m/44'/102005'/'/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 `` 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": "", "key_id": "" } } ``` 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 |