Files
n_signer/firmware/README.md

11 KiB
Raw Permalink Blame History

Feather S3 TFT Firmware

This directory contains the ESP-IDF firmware for the Feather S3 TFT signer target.

USB transport mode

Firmware now runs in TinyUSB composite mode (single USB peripheral owned by TinyUSB), exposing:

  • CDC-ACM interface for Python/serial tooling (/dev/ttyACM*)
  • Vendor interface for WebUSB browser transport

Expected runtime USB identity:

  • VID:PID 303a:4001
  • Product string: Feather S3 TFT n_signer

Flashing workflow (important)

Because TinyUSB owns USB at runtime, normal app mode is not the ROM USB-Serial/JTAG flashing interface.

Use this workflow for flashing:

  1. Enter bootloader mode: hold BOOT, tap RESET, release BOOT.
  2. Confirm bootloader USB device appears (typically /dev/ttyACM0).
  3. Flash:
cd firmware/feather_s3_tft
idf.py -p /dev/ttyACM0 flash
  1. Exit bootloader and run app: ensure BOOT is released, then tap RESET once.
  2. Confirm app USB identity:
lsusb | grep -i 303a
# expect: 303a:4001 n_signer Feather S3 TFT n_signer

Quick validation

CDC path:

./.venv/bin/python ./examples/feather_get_public_key.py /dev/ttyACM0
./.venv/bin/python ./examples/feather_sign_event.py /dev/ttyACM0

WebUSB path:

CYD (ESP32-2432S028) validation — Web Serial

The CYD has no native USB; its CH340 bridge exposes a serial port. The browser transport is Web Serial (navigator.serial), Chromium-only. A full test page covering every algorithm and verb lives at examples/cyd_webserial_demo.html:

  • Open examples/cyd_webserial_demo.html in Chrome/Edge
  • Click Connect Web Serial, select the CH340 port (1a86:7523)
  • Exercise each card: get_public_key (all 6 algorithms), sign/verify, encapsulate/decapsulate, derive_shared_secret, derive, nostr_get_public_key, nostr_sign_event, nostr_mine_event, nostr_nip04/nostr_nip44 encrypt+decrypt, and encrypt/decrypt (otp)
  • Each request shows the raw JSON-RPC request and response

The CYD firmware (v0.0.2+) speaks the same algorithm-based API as the host (README.md §4). The OTP pad is derived from the mnemonic seed (no USB pad on this board); the offset advances monotonically and is reported in every encrypt/decrypt response.

Linux WebUSB host setup (one-time)

Chrome and Edge need permission to open the device on Linux. Install a udev rule for the firmware VID:PID and reload rules:

sudo tee /etc/udev/rules.d/99-nsigner-webusb.rules >/dev/null <<'EOF'
SUBSYSTEM=="usb", ATTR{idVendor}=="303a", ATTR{idProduct}=="4001", MODE="0666", GROUP="plugdev", TAG+="uaccess"
EOF
sudo udevadm control --reload-rules
sudo udevadm trigger

After installing the rule, unplug and replug the device once, then reload the demo page.

Troubleshooting

  • If lsusb shows 303a:1001, the board is still in ROM bootloader mode. Tap RESET with BOOT released.
  • If CDC command hangs, verify app-mode VID:PID is 303a:4001, not 303a:1001.
  • If no /dev/ttyACM* appears, unplug/replug after reset and re-check lsusb.
  • If WebUSB connect throws SecurityError: Access denied, install the Linux udev rule above and replug.

CYD ESP32-2432S028 (CH340) serial-reset hardware note

For the CYD board (firmware/cyd_esp32_2432s028), opening /dev/ttyUSB0 can reset the ESP32 because CH340 DTR/RTS control lines are wired into the ESP32 auto-reset circuit.

Symptoms:

  • Device returns to startup/menu when a host app opens serial
  • Browser/CLI connect appears to "knock" signer out of ready screen

Field-proven mitigation:

  • Add a 10uF capacitor between EN and GND on the CYD board
  • A practical solder point is across the board reset path (e.g. reset button EN/GND pads)
  • For polarized electrolytic caps: negative leg to GND, positive to EN

Expected result after this hardware mod:

  • Serial open no longer triggers unwanted reset in normal host workflows
  • Signer remains in ready state across browser/CLI connect events

Notes:

  • Typical working range is ~4.7uF to 22uF; 10uF is recommended
  • Keep leads short for best stability
  • Auto-reset behavior for flashing may still work, but if flashing ever becomes unreliable, enter bootloader manually

Post-quantum crypto support (Phase 7)

Both firmware targets (feather_s3_tft and cyd_esp32_2432s028) now include the three NIST-standardized post-quantum algorithms alongside the existing secp256k1 (Nostr) and new ed25519/x25519 classical algorithms:

Algorithm Standard Purpose Pub key Priv key Sig/Ct
secp256k1 Nostr (existing) 32 B 32 B 64 B
ed25519 RFC 8032 SSH signatures 32 B 32 B 64 B
x25519 RFC 7748 Key agreement (age) 32 B 32 B
ML-DSA-65 FIPS 204 PQ signatures 1952 B 4032 B 3309 B
SLH-DSA-128s FIPS 205 PQ hash-based sigs 32 B 64 B 7856 B
ML-KEM-768 FIPS 203 PQ key encapsulation 1184 B 2400 B 1088 B

mbedtls backend (vs OpenSSL on host)

The host build uses OpenSSL EVP for SHA-256, SHA-512, SHA3-256, SHA3-512, SHAKE-128, and SHAKE-256. On ESP32, OpenSSL is not available. Instead, the firmware uses a crypto backend abstraction (resources/pqclean/common/crypto_backend.h) with two implementations:

The mbedtls backend uses:

  • mbedtls_sha256() for SHA-256 (ESP32 hardware accelerated where available)
  • mbedtls_sha512() for SHA-512 (ESP32 hardware accelerated where available)
  • A self-contained Keccak-f[1600] implementation (FIPS 202, public domain) for SHA3-256, SHA3-512, SHAKE-128, and SHAKE-256. This is vendored directly in crypto_backend_mbedtls.c because ESP-IDF v5.x mbedtls does not expose SHAKE (and SHA3 is only available when CONFIG_MBEDTLS_SHA3_C is set) through the mbedtls_md API. Carrying the Keccak core avoids any mbedtls config dependency for the PQ algorithms.

ed25519 / x25519 via PSA crypto

ESP-IDF v5.x mbedtls removed the low-level mbedtls_ed25519_* functions. The firmware uses the PSA Crypto API for ed25519 sign/verify/key-derivation and x25519 key derivation + ECDH. Enable PSA in sdkconfig.defaults:

CONFIG_MBEDTLS_PSA_CRYPTO_C=y
CONFIG_MBEDTLS_ECP_DP_CURVE25519_ENABLED=y

No SHA3/SHAKE menuconfig requirement

Because SHA3/SHAKE are provided by the vendored Keccak core (not mbedtls), you do not need to enable CONFIG_MBEDTLS_SHA3_C or any SHAKE config. The PQ algorithms build and run with the default mbedtls configuration.

PQClean component

The PQClean algorithm code is compiled as an ESP-IDF component at components/pqclean/. The component's CMakeLists.txt references the shared source files in resources/pqclean/ via relative paths, so there is a single source of truth for both host and firmware builds.

The component includes:

  • ML-DSA-65: sign.c, poly.c, ntt.c
  • SLH-DSA-128s: sign.c, fors.c, wots.c, hash.c, thash.c, address.c, utils.c
  • ML-KEM-768: kem.c, indcpa.c, poly.c, ntt.c, cbd.c, reduce.c, symmetric.c, verify.c
  • Common: fips202.c, sha2.c, crypto_backend_mbedtls.c
  • Firmware DRBG: pq_drbg_firmware.c, randombytes_mbedtls.c

Flash usage estimates

Algorithm Code size (approx)
ML-DSA-65 ~150 KB
SLH-DSA-128s ~80 KB
ML-KEM-768 ~120 KB
Total PQ code ~350 KB

The ESP32-S3 (Feather S3 TFT) has 8 MB flash and the ESP32 (CYD) has 4 MB flash. The PQ code fits comfortably in both, but partition sizes may need adjustment if the total app image exceeds the default partition.

RAM usage notes

PQ key buffers are large compared to classical ECC keys:

Buffer Size
ML-DSA-65 private key 4032 bytes
ML-DSA-65 public key 1952 bytes
ML-DSA-65 signature 3309 bytes
SLH-DSA-128s signature 7856 bytes
ML-KEM-768 private key 2400 bytes
ML-KEM-768 public key 1184 bytes
ML-KEM-768 ciphertext 1088 bytes

The ESP32 has ~320 KB available heap (after WiFi/BT are disabled). These buffers must not be stack-allocated — the default task stack is 8 KB. Use malloc() or static buffers. The firmware derives PQ keys on demand (not all at startup) to keep peak RAM usage low.

SLH-DSA-128s signing latency warning

SLH-DSA-128s (SPHINCS+-128s) is a hash-based signature scheme with a deep hypertree structure (7 layers of WOTS+ + Merkle trees). On the ESP32-S3 (240 MHz dual-core), expect:

  • Key generation: 530 seconds
  • Signing: 530 seconds
  • Verification: 0.52 seconds

This is inherent to the algorithm — it trades computation for minimal trust assumptions (only SHA-256). The firmware logs a warning before SLH-DSA-128s keygen/signing. Users should choose whether to use SLH-DSA-128s per-role based on their latency tolerance. ML-DSA-65 is much faster (~100 ms for signing on ESP32-S3) and is the recommended PQ signature algorithm for interactive use.

Derivation paths

All algorithms derive from the mnemonic using BIP-32/HMAC-SHA512 with SLIP-0010 all-hardened derivation for ed25519/x25519/PQ:

Algorithm Path Notes
secp256k1 (Nostr) m/44'/1237'/<n>'/0/0 NIP-06, existing
ed25519 (SSH) m/44'/102001'/<n>'/0'/0' SLIP-0010
x25519 (age) m/44'/102002'/<n>'/0'/0' SLIP-0010
ML-DSA-65 m/44'/102003'/<n>'/0'/0' seed → PQClean keygen
SLH-DSA-128s m/44'/102004'/<n>'/0'/0' seed → PQClean keygen
ML-KEM-768 m/44'/102005'/<n>'/0'/0' seed → PQClean keygen

The PQ derivation produces a 32-byte seed that feeds a deterministic SHAKE-256 DRBG (pq_drbg_firmware.c), which replaces PQClean's randombytes() during keygen. This gives deterministic, mnemonic-recoverable PQ keys — same mnemonic, same key pair.

Firmware API

The firmware exposes PQ operations via pq_crypto_firmware.h:

/* Key generation (deterministic from mnemonic-derived seed) */
int fw_pq_ml_dsa_65_keygen(const uint8_t seed[32], uint8_t *pk, uint8_t *sk);
int fw_pq_slh_dsa_128s_keygen(const uint8_t seed[32], uint8_t *pk, uint8_t *sk);
int fw_pq_ml_kem_768_keygen(const uint8_t seed[32], uint8_t *pk, uint8_t *sk);

/* Signing / verification */
int fw_pq_ml_dsa_65_sign(uint8_t *sig, size_t *siglen, ...);
int fw_pq_slh_dsa_128s_sign(uint8_t *sig, size_t *siglen, ...);

/* KEM encaps / decaps */
int fw_pq_ml_kem_768_encaps(uint8_t *ct, uint8_t *ss, const uint8_t *pk);
int fw_pq_ml_kem_768_decaps(uint8_t *ss, const uint8_t *ct, const uint8_t *sk);

Key derivation from the mnemonic seed is via key_derivation.h:

int derive_ed25519_key(const uint8_t seed[64], uint32_t index, ...);
int derive_x25519_key(const uint8_t seed[64], uint32_t index, ...);
int derive_ml_dsa_65_key(const uint8_t seed[64], uint32_t index, ...);
int derive_slh_dsa_128s_key(const uint8_t seed[64], uint32_t index, ...);
int derive_ml_kem_768_key(const uint8_t seed[64], uint32_t index, ...);