Files
n_signer/plans/cyd_signer_port.md

332 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Hardware Signer Port: ESP32-2432S028 ("CYD")
## Goal
Port the existing [`firmware/feather_s3_tft/`](../firmware/feather_s3_tft/) hardware signer to the
**ESP32-2432S028 "Cheap Yellow Display" (CYD)** board, keeping the on-the-wire protocol identical
so the existing [`client/`](../client/) library and the broader n_signer dispatcher stack are
unchanged. The new firmware lives at `firmware/cyd_esp32_2432s028/` and coexists with the feather
firmware — both boards are supported targets from this point forward.
A working prototype/test bed of every CYD peripheral we depend on already exists at
`/home/user/lt/esp32_playground/cyb-esp32-2432s028/`. This plan reuses those drivers wholesale.
## Hardware comparison (what actually changes)
| Concern | feather_s3_tft (existing) | CYD ESP32-2432S028 (new) |
| -------------------- | ----------------------------------------- | -------------------------------------------------------- |
| MCU | ESP32-S3 | ESP32-WROOM-32 (classic, dual-core Xtensa) |
| Native USB | Yes (TinyUSB CDC + Vendor/WebUSB) | **No** — CH340 USB-UART bridge only |
| Host transport | WebUSB (Vendor) + CDC | UART0 framed over CH340; browser uses **Web Serial API** |
| Display | 240×135 ST7789 | 240×320 ILI9341 (~6× more pixel area) |
| Display bus | SPI | SPI (HSPI), DC=IO2, CS=IO15, SCK=IO14, MOSI=IO13 |
| Backlight | GPIO45 | GPIO21 (PWM via LEDC) |
| Input | 3 tactile buttons (D0/D1/D2) | XPT2046 resistive **touchscreen** + BOOT button (IO0) |
| Touch bus | n/a | Bit-banged SPI on IO25/32/33/36/39 |
| Extras available | NeoPixel, battery monitor | SD card, RGB LED (IO4/16/17), LDR (IO34), speaker (IO26) |
| Flash | 4 MB | 4 MB |
| PSRAM | Yes (quad) | No |
| GUI framework | Hand-rolled framebuffer + `font5x7.h` | **LVGL 8.3** (touch + larger screen need it) |
Two implications are load-bearing for the rest of this plan:
1. **Transport.** Frames already pass through a byte stream (length-prefix, JSON-RPC payload) via
[`cdc_send_frame()` / `cdc_recv_frame()`](../firmware/feather_s3_tft/main/usb_transport.c:1).
Swap the TinyUSB backend for UART0 and the dispatcher above it is unchanged. WebUSB is **not
reachable on this hardware** (see "WebUSB note" below); the browser-side replacement is the Web
Serial API.
2. **GUI.** The touchscreen + 240×320 panel makes the hand-rolled text UI a poor fit. The playground
already proves LVGL 8.3 works end-to-end on this board (see `11_lvgl_demo` and especially
`12_lvgl_keyboard`, which is a working LVGL on-screen keyboard — exactly what mnemonic entry
needs). Adopt LVGL for the new firmware.
## WebUSB note (for the record)
WebUSB requires a native USB device with BOS + WebUSB platform descriptors. The CYD's MCU has no
USB peripheral; the USB jacks connect to a CH340 UART bridge. The host sees a generic
`VID:1A86 PID:7523` serial port — no descriptors we control, no Vendor interface, no WebUSB.
The functional equivalent on this hardware is **Web Serial** (`navigator.serial`), Chromium-only
but supported in Chrome / Edge / Brave / Opera. For Firefox/Safari users, the existing
[`plans/nsigner_browser_extension.md`](nsigner_browser_extension.md) path (native messaging host
+ extension) remains the fallback — same wire protocol, different bridge.
Action item for the `nostr_login_lite` integration: feature-detect both transports and prefer
`navigator.usb` when a feather is plugged in, `navigator.serial` when a CYD is plugged in. One
build supports both signers.
## Target directory layout
```
firmware/cyd_esp32_2432s028/
├── CMakeLists.txt
├── partitions.csv # same layout as feather, 4MB flash
├── sdkconfig.defaults # target=esp32, no PSRAM, no TinyUSB
├── version.cmake # mirror feather's GIT_HASH injection
├── dependencies.lock # generated
├── components/
│ └── secp256k1/ # symlink or copy from feather (CMakeLists already vendored)
└── main/
├── CMakeLists.txt
├── idf_component.yml # depends on lvgl/lvgl ^8.3.11
├── lv_conf.h # copied from playground 12_lvgl_keyboard
├── main.c # dispatch loop, ported from feather main.c
├── ili9341.c / ili9341.h # display driver from playground
├── display.c / display.h # thin shim presenting the same API surface feather uses,
│ # implemented in terms of LVGL primitives (so the n_signer
│ # dispatcher's calls like display_clear() still work)
├── touch.c / touch.h # XPT2046 bit-bang driver + calibration helpers
├── ui_lvgl.c / ui.h # LVGL screens (mnemonic entry, approval, idle)
├── input.c / input.h # approval primitives (approve_once / always / deny / timeout)
├── uart_transport.c / uart_transport.h # replaces usb_transport.c
├── nvs_calib.c / nvs_calib.h # touch calibration persistence
├── bech32.c / bech32.h # copied from feather
├── mnemonic.c / mnemonic.h # copied from feather
├── mnemonic_wordlist.h # copied from feather
├── key_derivation.c / .h # copied from feather
└── secure_mem.c / .h # copied from feather
```
Shared files that should not be duplicated (`bech32`, `mnemonic`, `key_derivation`, `secure_mem`)
will be copied for now to keep the two firmware trees independent. A future cleanup task can
extract them into a shared component under `firmware/common/` once both boards are stable.
## Architecture overview
```mermaid
flowchart TB
subgraph Host
Browser[Browser / nostr_login_lite] --> WS[navigator.serial]
Native[Native nsigner / Qubes RPC] --> Tty[/dev/ttyUSB0]
WS --> Tty
end
subgraph CYD_Firmware
Tty --> UART[uart_transport.c]
UART --> Disp[dispatcher in main.c]
Disp --> Sign[sign_event / get_public_key / nip04 / nip44]
Disp --> Approve[approval flow]
Approve --> UI[ui_lvgl.c - approval screen]
UI --> Touch[XPT2046 touch.c]
UI --> LCD[ILI9341 ili9341.c]
Sign --> Keys[key_derivation.c + secure_mem.c]
end
```
Same diagram as feather, with UART replacing TinyUSB and LVGL replacing the hand-rolled
framebuffer. The `dispatcher` / `sign_event` / `approval` boxes are byte-for-byte the same code.
## Detailed implementation phases
### Phase 1 — project skeleton (no signer logic yet)
Goal: get a blinking-LED-equivalent firmware that compiles, flashes, and shows "n_signer" on the
LCD. No transport, no signing, no LVGL yet.
1. Create `firmware/cyd_esp32_2432s028/` with `CMakeLists.txt`, `partitions.csv` (copied from
feather, both bootloader and app slots fit in 4 MB without OTA), and `sdkconfig.defaults`:
```
CONFIG_IDF_TARGET="esp32"
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384
CONFIG_FREERTOS_HZ=1000
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
# UART0 stays the console + transport. Keep it on stock pins (IO1 TX, IO3 RX).
```
2. Vendor the display driver from `/home/user/lt/esp32_playground/cyb-esp32-2432s028/02_hello_tft/main/ili9341.c` and `.h` into `main/`.
3. Add `main/CMakeLists.txt` registering only `main.c` and `ili9341.c`, with `REQUIRES esp_driver_gpio esp_driver_spi`.
4. Write a stub `main.c` that calls `ili9341_init()`, fills black, draws `"n_signer cyd"` at
(10,10), and idles. Exit criterion: flash via `idf.py -p /dev/ttyUSB0 flash` and see text on
the LCD.
### Phase 2 — peripherals and LVGL
Goal: LVGL on the device with a touch-driven hello-world.
1. Vendor the XPT2046 bit-bang driver from `12_lvgl_keyboard/main/main.c` (extract into
`touch.c` / `touch.h` with `touch_init()`, `touch_read_raw()`, `touch_read_calibrated()`,
`touch_run_calibration()`).
2. Vendor `lv_conf.h` from `12_lvgl_keyboard/main/lv_conf.h`.
3. Add `main/idf_component.yml`:
```yaml
dependencies:
idf: { version: ">=5.0.0" }
lvgl/lvgl: "^8.3.11"
```
4. In `main.c`, port the LVGL bring-up sequence verbatim from `12_lvgl_keyboard`:
`lv_init` → `lv_disp_drv` with `lvgl_flush_cb` calling `ili9341_blit_rgb565` → `lv_indev_drv`
with `lvgl_touch_read_cb` calling `touch_read_calibrated` → 2 ms periodic `esp_timer` for
`lv_tick_inc(2)` → loop `lv_timer_handler()` + `vTaskDelay(5)`.
5. **Touch calibration persistence.** Add `nvs_calib.c`:
- `nvs_calib_load(touch_calib_t *out)` returns `ESP_ERR_NVS_NOT_FOUND` on first boot.
- `nvs_calib_save(const touch_calib_t *in)`.
- On first boot, run the 4-corner calibration UI from `12_lvgl_keyboard`, save to NVS, never
prompt again unless the user holds BOOT (IO0) during reset to force re-calibration.
6. Build a throwaway LVGL screen with `lv_label` "Touch me" and a button that flips a label —
verifies touch + flush end to end.
### Phase 3 — UART transport
Goal: byte-for-byte protocol parity with feather, over UART instead of USB.
1. Read the existing frame protocol in [`firmware/feather_s3_tft/main/usb_transport.c`](../firmware/feather_s3_tft/main/usb_transport.c) carefully — note the 4-byte big-endian length prefix, the max frame size (`USB_MAX_FRAME = 4096`), and the ring buffer semantics.
2. Implement `uart_transport.c` exposing identical signatures:
```c
int uart_transport_init(void);
int cdc_recv_frame(uint8_t *payload, size_t payload_max, size_t *out_len);
int cdc_send_frame(const uint8_t *payload, size_t len);
bool cdc_is_connected(void); /* always true for UART once init runs */
```
Keep the names `cdc_send_frame` / `cdc_recv_frame` so [`main.c`](../firmware/feather_s3_tft/main/main.c:152-158) needs no changes.
3. Use `driver/uart.h`, `UART_NUM_0`, 115200 baud, 8N1, hardware flow control disabled. RX into a
FreeRTOS-queue-backed event task, then drain into the same length-prefix ring buffer logic the
feather uses.
4. **Console contention.** UART0 is also where ESP_LOGI prints. Two options:
- Route `esp_log` to UART1 on IO22/IO27 (the CN1 connector) so devs can still see logs without
disturbing the protocol byte stream. Set `CONFIG_ESP_CONSOLE_UART_CUSTOM=y`,
`CONFIG_ESP_CONSOLE_UART_NUM=1`, `CONFIG_ESP_CONSOLE_UART_TX_GPIO=22`.
- Or silence logs in release builds (`CONFIG_LOG_DEFAULT_LEVEL_NONE=y`).
Recommendation: route logs to UART1. The CN1 connector is exactly the broken-out pin that
developers will solder a header to.
5. Smoke-test with a small Python script that opens `/dev/ttyUSB0`, sends a `{"jsonrpc":"2.0","method":"ping"}` frame, and prints the reply.
### Phase 4 — port shared crypto + dispatcher
Goal: signer logic running, with a stub UI that auto-approves everything.
1. Copy these files verbatim from `firmware/feather_s3_tft/main/` to `firmware/cyd_esp32_2432s028/main/`: `bech32.c/.h`, `mnemonic.c/.h`, `mnemonic_wordlist.h`, `key_derivation.c/.h`, `secure_mem.c/.h`.
2. Reference the `nostr_core_lib` sources from `CMakeLists.txt` the same way feather does (relative paths to `../../../resources/nostr_core_lib/`).
3. Copy [`firmware/feather_s3_tft/main/main.c`](../firmware/feather_s3_tft/main/main.c) as a starting point. Replace the includes of `buttons.h`, `ui.h`, `display.h` with the new headers (added in Phase 5). For now, stub:
```c
static approval_decision_t wait_for_approval(uint32_t timeout_ms) {
(void)timeout_ms;
return APPROVAL_ONCE; /* PHASE 4 STUB - REMOVE BEFORE MERGE */
}
```
4. Hard-code a known test mnemonic into `s_mnemonic` so we can exercise `get_public_key` and
`sign_event` without an entry UI yet.
5. Exit criterion: from the host, `get_public_key` returns the expected npub for the hard-coded
mnemonic, and `sign_event` returns a valid Schnorr signature.
### Phase 5 — LVGL signer UI
Goal: real user-facing flows on the LCD/touch.
Screens and state machine:
```mermaid
stateDiagram-v2
[*] --> Splash
Splash --> MainMenu : 1s
MainMenu --> Generate : tap Generate
MainMenu --> EnterMnemonic : tap Enter
MainMenu --> View : tap View (only if has_phrase)
Generate --> ConfirmMnemonic
EnterMnemonic --> ConfirmMnemonic
ConfirmMnemonic --> Idle : confirmed
View --> Idle : back
Idle --> Approval : RPC sign_event request
Approval --> Idle : decision sent
```
Screen designs (240×320, portrait):
- **Splash.** Centered "N_SIGNER", red, large font. 1 s timeout → MainMenu.
- **MainMenu.** Three large buttons (full-width minus padding, ~60 px tall): "Generate new", "Enter existing", "View current" (disabled until a phrase exists). Footer status bar with firmware version and "ready" / "no mnemonic".
- **Generate.** Show 12-word mnemonic with each word numbered, in a scrollable list. Bottom: "I wrote it down" button (large, primary). Pressing it advances to ConfirmMnemonic.
- **ConfirmMnemonic.** Pick the *N*th word from a 4-choice multiple-select to confirm the user actually wrote it down. Two random positions tested. Wrong answer → back to Generate.
- **EnterMnemonic.** Top: word count chip ("3 / 12"). Middle: list of entered words. Bottom: `lv_keyboard` in lowercase mode. User taps letters → an `lv_textarea` above the keyboard shows the prefix → autocomplete matches are shown as 3 chips above the textarea (tap to commit). This is exactly the [`12_lvgl_keyboard`](../../esp32_playground/cyb-esp32-2432s028/12_lvgl_keyboard) layout adapted for BIP-39 wordlist autocomplete.
- **Idle.** Top: "nostr:" + truncated npub (first 11, ellipsis, last 6). Middle: short mnemonic preview (first 2 words, ellipsis, last 1). Bottom: "v0.0.1 ready". This is the steady state where the firmware waits for RPC.
- **Approval.** Red header "Approve sign request?". Body: caller pubkey (truncated), event kind, content preview (3 lines, ellipsized). Bottom: three large buttons side-by-side: green "Once", amber "Always", red "Deny". A 30 s countdown bar at the very bottom — when it empties, deny is auto-selected.
Implementation steps:
1. Create `ui_lvgl.c` with `ui_show_splash()`, `ui_run_until_working(char out_mnemonic[256])`, `ui_show_idle(const char *npub, const char *mnemonic_short)`, `ui_show_approval(const char *caller, int kind, const char *content_preview, approval_decision_t *out, uint32_t timeout_ms)`.
2. The approval call is **synchronous from the dispatcher's point of view** — `main.c` calls it and blocks waiting for a decision. Internally it builds the LVGL screen, registers button callbacks that store the decision into a static, posts to a `xSemaphoreCreateBinary`, returns, and tears the screen down.
3. The mnemonic-entry flow uses a custom `lv_keyboard` event filter: only accept letters present in any BIP-39 word that matches the current prefix. After 3 unambiguous letters, auto-commit the word.
4. Replace the Phase 4 stub `wait_for_approval` with a real call into `ui_show_approval`.
### Phase 6 — build + flash plumbing
1. `scripts/flash_cyd.sh`:
```bash
#!/usr/bin/env bash
set -e
PORT="${PORT:-/dev/ttyUSB0}"
cd firmware/cyd_esp32_2432s028
idf.py -p "$PORT" -b 460800 flash monitor
```
2. Top-level [`Makefile`](../Makefile) gets new targets paralleling the existing feather ones:
```
cyd-build:
cd firmware/cyd_esp32_2432s028 && idf.py build
cyd-flash:
./flash_cyd.sh
cyd-monitor:
cd firmware/cyd_esp32_2432s028 && idf.py -p $${PORT:-/dev/ttyUSB0} monitor
cyd-clean:
cd firmware/cyd_esp32_2432s028 && idf.py fullclean
```
3. Document driver requirements (CH340 kernel module, `dialout` group membership) in `firmware/README.md`.
### Phase 7 — host side (Web Serial)
1. Add a `transport_serial.ts` (or `.js`) to `nostr_login_lite` and to [`examples/`](../examples/) mirroring the existing WebUSB transport but using `navigator.serial`. Same length-prefixed frame protocol; the only differences are connection setup and read/write APIs.
2. Add a transport-selection shim:
```js
async function connectSigner() {
// Prefer the feather (WebUSB) if available.
try {
const dev = await navigator.usb.requestDevice({ filters: [{ vendorId: 0x303A }] });
return new WebUsbTransport(dev);
} catch (_) {}
const port = await navigator.serial.requestDevice({
filters: [{ usbVendorId: 0x1A86, usbProductId: 0x7523 }], // CH340
});
await port.open({ baudRate: 115200 });
return new WebSerialTransport(port);
}
```
3. Update [`examples/feather_webusb_demo.html`](../examples/feather_webusb_demo.html) or add a new
`examples/cyd_webserial_demo.html` that exercises `get_public_key` and `sign_event` end-to-end.
### Phase 8 — testing and docs
1. Manual test matrix on real hardware:
- First-boot calibration runs.
- Generate → confirm → idle → sign_event from host → approval prompt → signature returned.
- Enter existing mnemonic via on-screen keyboard.
- Approval timeout fires correctly (set timeout to 5 s for the test).
- "Always" approval persists for the session.
- Power cycle, re-enter mnemonic, verify same npub (proves key derivation parity with feather).
2. Cross-board parity test: same mnemonic on a feather and a CYD, both sign the same event,
signatures verify against the same npub.
3. Add a `tests/test_uart_transport.c` host-side framing test (we can't unit-test the device-side
UART, but the framing helpers are pure functions and testable).
4. Update [`firmware/README.md`](../firmware/README.md) with a "Supported boards" table and per-board flashing instructions.
## Deferred decisions
- **LVGL GUI designer.** EEZ Studio (GPL-3, mature) vs the official LVGL Editor (MIT, in beta) vs hand-written. Decision punted until the firmware is working — by then we'll know whether the screens are stable enough to be worth round-tripping through a designer.
- **Shared `firmware/common/` component.** The duplicated `bech32` / `mnemonic` / `key_derivation` / `secure_mem` files are a future refactor target once both boards are stable.
- **OTA updates.** Both boards currently use single-app partition tables. Out of scope for this port.
- **Locked-down release config.** Disabling JTAG, enabling flash encryption, secure boot — these apply equally to both boards and should be a separate hardening pass.
## Risks and mitigations
- **UART0 console contention.** Mitigated by routing logs to UART1 (Phase 3, step 4). If a dev forgets to reroute, the host will see log spam interleaved with frames and the framing layer will reject them — fail-loud, not silent corruption.
- **LVGL RAM footprint.** Classic ESP32 has 512 KB internal SRAM, no PSRAM on the CYD. LVGL 8.3 with a 240×320 single buffer at 12-row stripes is ~5.6 KB; full double-buffer would be ~150 KB. Use the partial-buffer mode from playground 11 (already proven). Watch heap with `esp_get_free_heap_size()` in `idf.py monitor` once the signer is wired in — secp256k1 verification can spike usage.
- **Touchscreen drift.** Resistive panels drift with temperature and age. The "hold BOOT during reset to recalibrate" escape hatch (Phase 2, step 5) means a stuck calibration never bricks the device.
- **CH340 driver on macOS / Windows.** Macs need a kext or DriverKit driver, Windows 10+ ships one. Document this in `firmware/README.md`; not a code problem.
- **Web Serial browser support.** Chromium-only. Firefox/Safari users fall back to the native-bridge path that the desktop signer already uses ([`plans/nsigner_browser_extension.md`](nsigner_browser_extension.md)).
## Acceptance criteria
The port is "done" when all of the following are true:
1. `make cyd-build && make cyd-flash` produces a working device from a clean checkout.
2. From the same mnemonic, a CYD and a feather produce the same npub and identical Schnorr signatures for the same event payload.
3. The Web Serial example page in `examples/` can connect to a CYD and successfully sign an event end-to-end, including the on-device approval prompt.
4. The hand-written feather firmware is unchanged by this work (no regressions on the existing board).