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:
- Enter bootloader mode: hold BOOT, tap RESET, release BOOT.
- Confirm bootloader USB device appears (typically
/dev/ttyACM0). - Flash:
cd firmware/feather_s3_tft
idf.py -p /dev/ttyACM0 flash
- Exit bootloader and run app: ensure BOOT is released, then tap RESET once.
- 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:
- Open
examples/feather_webusb_demo.html - Connect in Chrome/Edge
- Run
get_public_key - Confirm pubkey matches CDC result
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
lsusbshows303a: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, not303a:1001. - If no
/dev/ttyACM*appears, unplug/replug after reset and re-checklsusb. - 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:
resources/pqclean/common/crypto_backend_openssl.c— host build (OpenSSL EVP)resources/pqclean/common/crypto_backend_mbedtls.c— ESP32 firmware (mbedtls)
The mbedtls backend uses:
mbedtls_sha256_ret()for SHA-256 (ESP32-S3 hardware accelerated)mbedtls_sha512_ret()for SHA-512 (ESP32-S3 hardware accelerated)mbedtls_mdAPI for SHA3-256, SHA3-512, SHAKE-128, SHAKE-256
Enabling SHA3/SHAKE in menuconfig
mbedtls does not enable SHA3/SHAKE by default. You must enable them in menuconfig before building:
Component config → mbedTLS → Hash functions → SHA-3
Component config → mbedTLS → Hash functions → SHAKE
Or add to sdkconfig.defaults:
CONFIG_MBEDTLS_SHA3_C=y
CONFIG_MBEDTLS_SHAKE_C=y
If mbedtls does not have SHAKE support, the firmware build will fail at link
time with unresolved mbedtls_md_info_from_type(MBEDTLS_MD_SHAKE128). In that
case, either enable the config options above or patch mbedtls with a
Keccak/SHAKE contribution.
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: 5–30 seconds
- Signing: 5–30 seconds
- Verification: 0.5–2 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, ...);