16 KiB
Plan: Teensy 4.1 Signer Implementation
Remaining correctness and performance work is tracked in
teensy41_signer_remaining_fixes.md.
Goal: turn the Teensy 4.1 + ST7796S display + XPT2046 touch into a working
n_signer hardware signer — same verb set and wire protocol as the CYD firmware,
same mnemonic-in-working-memory-only security model, with the aesthetics from
~/lt/aesthetics (black/white/red, monospaced,
menu-first).
Phase 0 (bring-up) is complete — see teensy41_bringup.md.
This plan covers Phases 1-7: crypto → keys → display/touch → UI → transport →
dispatch → integration.
Design principles (from the user)
- Mnemonic is working-memory only. The seed phrase is never written to
flash or SD. Power off → it's gone. The CYD already does this
(
secure_memzero(s_mnemonic)aftermnemonic_to_seed); we keep that. - Generate random OR enter your own. Startup menu offers both paths.
- Aesthetics (from
~/lt/aesthetics/WEB.md):- Black background (
0x0000), white foreground (0xFFFF), red accent (0xF800) for active/pressed/selection/warnings only. - Muted grey (
0x4208≈#444) for disabled/secondary text and borders. - Monospaced font (the ST7796_t3 library's built-in glcd font).
- Menu-first interaction: every screen has an obvious next action.
- Single-key/tap primary actions; fast cancellation (an "X" / back button).
- Rounded corners (6px radius) on buttons, 2px white borders, no gradients/shadows.
- State visibility: show npub, version, and connection status on idle.
- Black background (
Architecture
flowchart TD
USB[Host USB CDC] --> Frame[transport.cpp<br/>4-byte length prefix + JSON-RPC]
Frame --> Auth[auth envelope verify<br/>secp256k1 schnorr]
Auth -->|ok| Disp[dispatch.cpp<br/>handle_request]
Auth -->|fail| Err[auth error JSON]
Disp -->|sign/get_pubkey/etc| KD[key_derivation.cpp<br/>secp256k1/ed25519/x25519/PQ]
Disp -->|encrypt/decrypt| OTP[otp_pad.cpp<br/>HKDF-derived pad from seed]
KD --> UI[ui.cpp<br/>approval prompt<br/>ST7796S + XPT2046]
UI -->|approve| Exec[execute verb]
UI -->|deny| Deny[deny JSON]
Exec --> Resp[structured JSON result]
Resp --> Frame
Startup[Boot] --> Menu[startup menu<br/>generate or enter mnemonic]
Menu --> Seed[mnemonic_to_seed<br/>PBKDF2-HMAC-SHA512]
Seed --> Zero[secure_memzero mnemonic]
Zero --> Idle[idle screen<br/>show npub]
Idle --> Frame
File layout
firmware/teensy41/
├── README.md (updated — calibrated values, status)
├── WIRING.md (done — pin assignments)
├── teensy41_signer.ino (Arduino entry — setup() + loop())
├── src/
│ ├── display.cpp (ST7796S wrapper: init, fill, text, rect, blit)
│ ├── display.h
│ ├── touch.cpp (XPT2046: raw read, calibrated screen coords)
│ ├── touch.h (TouchCal struct + calibrated constants)
│ ├── ui.cpp (UI screens: startup, mnemonic, idle, approval)
│ ├── ui.h
│ ├── ui_primitives.cpp (buttons, keyboard, text input, scrollable list)
│ ├── ui_primitives.h
│ ├── key_derivation.cpp (BIP-39 → seed → secp256k1/ed25519/x25519/PQ keys)
│ ├── key_derivation.h
│ ├── mnemonic.cpp (BIP-39 wordlist + generate + validate + to_seed)
│ ├── mnemonic.h
│ ├── mnemonic_wordlist.h (the 2048 BIP-39 words — port from CYD)
│ ├── bech32.cpp (npub encoding — port from CYD)
│ ├── bech32.h
│ ├── pq_crypto.cpp (PQClean wrappers: ml-dsa-65, slh-dsa-128s, ml-kem-768)
│ ├── pq_crypto.h
│ ├── otp_pad.cpp (HKDF-derived pad from seed — port from CYD)
│ ├── otp_pad.h
│ ├── transport.cpp (USB CDC + 4-byte length-prefix framing)
│ ├── transport.h
│ ├── dispatch.cpp (verb dispatch — port from CYD handle_request)
│ ├── dispatch.h
│ ├── auth_envelope.cpp (secp256k1 schnorr auth verify — port from src/)
│ ├── auth_envelope.h
│ ├── secure_mem.cpp (zeroize helpers)
│ ├── secure_mem.h
│ ├── cJSON.c (vendored cJSON — port from CYD)
│ ├── cJSON.h
│ └── font5x7.h (optional: custom font — or use ST7796_t3 built-in)
├── lib/
│ ├── secp256k1/ (libsecp256k1, built for ARM Cortex-M7)
│ ├── pqclean/ (PQClean ML-DSA-65, SLH-DSA-128s, ML-KEM-768)
│ └── nostr_core_lib/ (nip004, nip044, nostr_common, utils — portable C)
└── documents/ (datasheets — already present)
Aesthetics: color constants
// RGB565 values matching ~/lt/aesthetics/WEB.md
#define UI_COLOR_BG 0x0000 // black — primary background
#define UI_COLOR_FG 0xFFFF // white — primary foreground / text
#define UI_COLOR_ACCENT 0xF800 // red — active/pressed/selection/warning
#define UI_COLOR_MUTED 0x4208 // grey (~#444) — disabled/secondary/borders
#define UI_COLOR_DISABLED 0x2104 // darker grey (~#222) — disabled text
Phase 1: Crypto stack
Port the crypto libraries for ARM Cortex-M7 (600 MHz, no FPU issues, plenty of RAM). The Teensy has 1 MB SRAM + 16 MB PSRAM, so memory is not a constraint.
- secp256k1 — build
libsecp256k1for ARM Cortex-M7 withUSE_NUM_NONE USE_FIELD_INV_BUILTIN USE_SCALAR_INV_BUILTIN(no ASM). Verify schnorr sign/verify + ECDSA against known test vectors. Source: the kb2040_hidden_signer already has a vendored secp256k1 atfirmware/kb2040_hidden_signer/src/secp256k1/— reuse that. - SHA-256 / SHA-512 / HMAC — use the Teensy's built-in
HashLibor port a portable C impl. Needed for PBKDF2-HMAC-SHA512 (mnemonic_to_seed), SHA-256 (event IDs), HMAC (auth envelope). - ed25519 / x25519 — use a portable ed25519 (e.g. the ref10 impl or
micro-ecc+ portable ed25519). The CYD uses mbedtls/PSA; the Teensy doesn't have PSA, so we need a portable impl. - PQClean — compile
resources/pqclean/(ML-DSA-65, SLH-DSA-128s, ML-KEM-768) for Cortex-M7. The Keccak core is portable C. Verify keygen + sign + verify against the host's test vectors. - nostr_core_lib — compile
resources/nostr_core_lib/(nip004, nip044, nostr_common, utils) for Cortex-M7. Portable C, should compile as-is.
Exit criterion: all crypto operations produce the same outputs as the host n_signer for the same inputs (cross-check with test vectors).
Phase 2: Key derivation + mnemonic
- BIP-39 wordlist — port
firmware/cyd_esp32_2432s028/main/mnemonic_wordlist.h(2048 words). It's a static array, compiles as-is. - mnemonic.cpp — port
firmware/cyd_esp32_2432s028/main/mnemonic.c:generate_mnemonic_12()— 16 random bytes → 12 words + checksum. Random source: Teensy's built-inTRNG(true random number generator).validate_mnemonic()— checksum validation.mnemonic_to_seed()— PBKDF2-HMAC-SHA512, 2048 iterations, 64-byte seed. Replace mbedtls/PSA calls with the portable SHA-512 from Phase 1.
- key_derivation.cpp — port
firmware/cyd_esp32_2432s028/main/key_derivation.c:- seed → secp256k1 privkey (BIP-32 derivation, NIP-06).
- seed → ed25519 / x25519 (SLIP-0010).
- seed → PQ keypairs (ML-DSA-65, SLH-DSA-128s, ML-KEM-768).
- Replace mbedtls/PSA calls with portable crypto from Phase 1.
- bech32.cpp — port
firmware/cyd_esp32_2432s028/main/bech32.cfor npub encoding.
Exit criterion: same mnemonic → same npub, same secp256k1/ed25519/x25519/PQ public keys as the CYD and the host n_signer.
Phase 3: Display + touch drivers
- display.cpp — thin wrapper around
ST7796_t3:display_init()—tft.init(320, 480)+setRotation(1)(landscape 480×320, per the bring-up lessons).display_fill(color)— full-screen fill.display_fill_rect(x, y, w, h, color).display_draw_text(x, y, text, color, bg)— uses ST7796_t3's text rendering. Monospaced,setTextSize(1)or(2)for readability.display_draw_rect(x, y, w, h, color)— for borders.display_clear()— fill black.- Constants:
SCREEN_W=480,SCREEN_H=320(NOTtft.width()/tft.height()— those are broken, see bring-up lessons).
- touch.cpp — port the XPT2046 driver from the touch_cal sketch:
touch_init()— pinMode T_CS=9, T_IRQ=2 (NOT pin 10!).touch_read_raw()— hardware SPI at 2 MHz, 7-sample average, pressure threshold 80.touch_read_screen()— calibrated screen coords using the constants fromfirmware/teensy41/README.md:x_min=207, x_max=1909, y_min=168, y_max=1798, invert_x=1, invert_y=1, with axis swap (raw Y → screen X, raw X → screen Y).touch_tapped()— non-blocking: returns true if a tap completed since last call.
Exit criterion: can fill the screen, draw text, and read calibrated touch coords across the full 480×320 area.
Phase 4: UI screens (LVGL 8.3)
Port the CYD's LVGL screens from
firmware/cyd_esp32_2432s028/main/ui.c.
The CYD already uses the aesthetics color palette (black bg, white text, red
pressed/focused, grey disabled) — see style_screen(), style_button(),
style_key_matrix() in that file. We port those styles + screens, adapting
for the 480×320 landscape (the CYD is 320×240 portrait).
-
LVGL display + touch driver:
- Install LVGL via
arduino-cli lib install lvgl. - Write
lvgl_flush_cb()— alv_disp_drv_tflush callback that converts LVGL'slv_color_tpixels to RGB565 and pushes them to the ST7796S viatft.fillRect/tft.drawPixels. Template: the CYD'slvgl_flush_cb(swapili9341_blit_rgb565for ST7796_t3 calls). - Write
lvgl_touch_read_cb()— alv_indev_drv_tcallback that calls our calibratedtouch_read_screen()and feeds the coords to LVGL. Template: the CYD'slvgl_touch_read_cb. - LVGL tick: a timer interrupt or
lv_tick_inc()call inloop(). lv_conf.h: configure for 480×320, 16-bit color, the draw buffer size (useDMAMEMfor the buffer to keep it in PSRAM).
- Install LVGL via
-
Port the CYD screens (from
firmware/cyd_esp32_2432s028/main/ui.c):ui_startup_menu()→ port the startup menu (Generate / Enter buttons). Adapt button positions for 480×320 landscape.ui_show_mnemonic()→ port the 12-word display grid. 3×4 or 4×3 layout for the wider screen.ui_enter_mnemonic()→ port the on-screen keyboard + BIP-39 suggestion logic. This is the most complex screen — the CYD's key matrix + prefix matching + suggestion row port directly, just reflow for 480×320.ui_show_idle()→ port the idle screen (npub + version). Wider screen gives more room for the npub.ui_approve()→ port the approval prompt (verb + summary + Approve/Deny buttons + 30s countdown).
-
Aesthetics enforcement: the CYD's
style_screen(),style_button(), andstyle_key_matrix()already use the correct palette:- bg =
0x000000(black) - text =
0xFFFFFF(white) - pressed/focused =
0xFF0000(red) - disabled =
0x505050/0x303030(grey) Port these style functions as-is. They match~/lt/aesthetics/WEB.mdexactly.
- bg =
Exit criterion: can walk through generate → show mnemonic → idle, and enter → idle, and approval → approve/deny, all via touch, with the black/white/red aesthetics.
Phase 5: USB CDC transport
- transport.cpp — port
firmware/cyd_esp32_2432s028/main/uart_transport.cfor USB CDC:transport_init()—Serial.begin(115200)(USB CDC, already verified in HelloSerial).transport_read_frame(buf, max_len)— read 4-byte big-endian length prefix, then that many bytes of JSON-RPC payload. Returns the payload length or -1 on error/timeout.transport_write_frame(buf, len)— write 4-byte big-endian length prefix- payload.
- Non-blocking read with a timeout so the UI can still process touch events while waiting for a request.
Exit criterion: can round-trip a JSON-RPC request/response over USB CDC with a Python or C test client.
Phase 6: Verb dispatch
- dispatch.cpp — port
handle_request()fromfirmware/cyd_esp32_2432s028/main/main.c(lines 1297+). This is the bulk of the logic:- Parse the JSON-RPC request (cJSON).
- Verify the auth envelope (secp256k1 schnorr) if required.
- Dispatch by verb:
sign,get_public_key,derive,encrypt,decrypt,encapsulate,decapsulate,derive_shared_secret,verify,get_info. - For signing verbs: call
ui_approve()and only proceed if approved. - Build the structured JSON result.
- Replace all mbedtls/PSA calls with portable crypto from Phase 1.
- auth_envelope.cpp — port
src/auth_envelope.cfor the schnorr auth verify. - otp_pad.cpp — port the HKDF-derived pad from the CYD (the Teensy's 1 TB SDXC pad is a future enhancement; for now, use the same HKDF-from-seed pad as the CYD).
Exit criterion: every verb produces the same result as the CYD for the same mnemonic + inputs.
Phase 7: Integration + end-to-end test
- teensy41_signer.ino — the Arduino entry:
void setup() { display_init(); touch_init(); // Boot screen: "n_signer" logo, version. // Startup menu: generate or enter mnemonic. // Apply mnemonic → derive seed + keys → zeroize mnemonic. // Idle screen: show npub. // Transport init: USB CDC ready. } void loop() { // Read framed request (non-blocking, with touch polling). // Dispatch verb. // Write framed response. // Return to idle screen. } - End-to-end smoke test:
- Load a known mnemonic, verify the npub matches the CYD/host.
- Exercise every verb over USB CDC with a Python test client.
- Verify approval prompts appear for signing verbs and can be approved/denied via touch.
- Power-cycle → mnemonic is gone, must re-enter.
- Cross-board parity: same mnemonic on Teensy 4.1 and CYD → same npub, same public keys, same signatures.
Exit criterion: the Teensy 4.1 is a fully functional n_signer hardware signer, interchangeable with the CYD.
Decisions (resolved 2026-07-26)
- UI framework: LVGL 8.3. Port the CYD's LVGL screens from
firmware/cyd_esp32_2432s028/main/ui.c. The Teensy has 1 MB SRAM + 16 MB PSRAM — plenty for LVGL. We need to write an LVGL display driver for ST7796_t3: alv_disp_drv_tflush callback that pushes pixel data to the display viatft.fillRect/tft.drawPixels, and alv_indev_drv_ttouch callback that calls our calibratedtouch_read_screen(). The CYD'slvgl_flush_cbandlvgl_touch_read_cbinfirmware/cyd_esp32_2432s028/main/ui.care the templates — swap theili9341_blit_rgb565call for ST7796_t3 calls, and thetouch_read_screencall for our calibrated touch driver. LVGL is installed via Arduino Library Manager (arduino-cli lib install lvgl). Phase 4 is updated to reflect LVGL instead of hand-rolled primitives. - ed25519/x25519 impl: investigate whether the Teensy's mbedtls Arduino library has ed25519/x25519. If not, use a portable impl (orlp/ed25519 + micro-ecc for x25519).
- OTP pad: for the initial port, use the HKDF-from-seed pad (same as CYD). The 1 TB SDXC pad is a separate enhancement after the 1 TB card arrives.
- cJSON: vendor the same cJSON the CYD uses (it's a single .c/.h pair, public domain, compiles anywhere).