Files
n_signer/plans/post_quantum_crypto.md

29 KiB

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 (secp256k1, ed25519, x25519) and five purposes in role_purpose_t (nostr, bitcoin, ssh, age, fips). However, only PURPOSE_NOSTR + CURVE_SECP256K1 is actually implemented:

  • crypto_derive_all and crypto_derive_one skip any role that isn't PURPOSE_NOSTR + CURVE_SECP256K1 + SELECTOR_NOSTR_INDEX.
  • enforce_verb_role only allows nostr verbs on PURPOSE_NOSTR + CURVE_SECP256K1; everything else returns ENFORCE_ERR_UNKNOWN_VERB or a mismatch.
  • derived_key_t 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 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:

/* 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 with a variable-length design:

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. 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 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:

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:

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 / role_curve_to_str:

  • "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 with new verb→purpose→curve rules:

/* 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.

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:

{
  "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:

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:

/* 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). 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:

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

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 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