Files
n_signer/plans/feather_tinyusb_composite.md

12 KiB
Raw Blame History

Phase 7b — TinyUSB Composite Device on Feather S3 TFT

Status: completed (PoC working on hardware) Owner: firmware/feather_s3_tft Supersedes the runtime portion of Phase 7 (WebUSB-only via TinyUSB while keeping ROM USB Serial/JTAG).

Outcome summary

Implemented and validated end-to-end on real Feather S3 TFT hardware:

  • Single TinyUSB composite USB device (303a:4001) exposing CDC-ACM (/dev/ttyACM0) plus Vendor/WebUSB.
  • Same dispatcher serves both transports; per-request response is sent over the same transport that received it.
  • All Phase 06 behaviors (auth envelope, on-device approval, sign_event signature verification) preserved.
  • Linux udev rule (/etc/udev/rules.d/99-nsigner-webusb.rules) added for browser WebUSB access on the host.

Working hashes/IDs observed during validation:

  • VID:PID 303a:4001, product Feather S3 TFT n_signer
  • Authenticated CDC get_public_key returned the same pubkey as authenticated WebUSB get_public_key for the same caller key (confirming both transports go through the same dispatcher and key store).

See firmware/README.md for runtime usage.

1. Why

The ESP32-S3 has a single USB peripheral. It can be routed to one of two mutually exclusive owners:

  1. The ROM-provided USB Serial/JTAG controller (current firmware path — gives us /dev/ttyACM0 "for free" but cannot expose any other interface, so a browser cannot speak WebUSB to it).
  2. The TinyUSB OTG stack (managed by the espressif/esp_tinyusb component — lets us define arbitrary device descriptors including CDC + Vendor/WebUSB, but takes the peripheral away from the ROM controller).

Phase 7 attempted to run TinyUSB and the ROM USB Serial/JTAG controller simultaneously. That is not a supported configuration on this silicon: enabling TinyUSB causes the /dev/ttyACM0 device node to disappear, breaking every existing host-side script.

Phase 7b commits fully to TinyUSB and replaces the ROM path with a TinyUSB-managed composite device that exposes both CDC-ACM (so existing pyserial tooling keeps working unchanged) and a Vendor/WebUSB interface (so the browser demo works in Chrome / Edge).

2. Goal

A single firmware build produces a USB device that:

  • Enumerates on Linux as /dev/ttyACM0 driven by the kernel cdc_acm driver.
  • Enumerates on Chrome / Edge as a WebUSB-capable vendor interface bound to a registered URL.
  • Routes inbound framed JSON-RPC frames (4-byte big-endian length + JSON body) from either transport into the same dispatcher.
  • Sends each response back on the same transport that received the request.
  • Preserves all current behavior: auth envelope verification, get_public_key, sign_event with on-device approval, TFT status, button handling.

3. Non-goals

  • Console / printf over USB. Boot logs stay on TFT and remain silent on USB to avoid corrupting framed transport bytes (current behavior preserved).
  • Hot-swap between ROM USB Serial/JTAG and TinyUSB at runtime.
  • Automatic entry into the ROM bootloader from firmware. Flashing requires manual bootloader-mode entry.
  • WiFi / network transports.

4. Architecture

4.1 Runtime block diagram

graph LR
    subgraph Host
        PY[pyserial scripts]
        BR[Chrome WebUSB demo]
    end

    subgraph TinyUSB_Composite [TinyUSB Composite USB Device VID:PID 303A:4001]
        IAD[IAD]
        CDC[CDC-ACM ITF 0+1 EP 0x02/0x82/0x83]
        VEN[Vendor ITF 2 EP 0x01/0x81]
    end

    subgraph Firmware
        FRX[frame receiver]
        DSP[dispatcher: auth + sign_event/get_public_key]
        FTX[frame sender]
        TFT[TFT + buttons approval]
    end

    PY -- /dev/ttyACM0 --> CDC
    BR -- WebUSB --> VEN
    CDC --> FRX
    VEN --> FRX
    FRX --> DSP
    DSP --> FTX
    DSP <--> TFT
    FTX --> CDC
    FTX --> VEN

4.2 USB descriptor layout

Item Value
bDeviceClass / Subclass / Protocol MISC / COMMON / IAD (composite)
idVendor / idProduct 0x303A / 0x4001
bcdUSB 0x0210 (required for BOS / WebUSB)
Interface 0 + 1 CDC-ACM, EP 0x02 OUT (bulk), 0x82 IN (bulk), 0x83 IN (notification)
Interface 2 Vendor, EP 0x01 OUT (bulk), 0x81 IN (bulk)
BOS WebUSB platform descriptor + MS OS 2.0 platform descriptor
MS OS 2.0 set Binds Vendor interface (ITF 2) to WINUSB compatible ID and a DeviceInterfaceGUIDs registry property
WebUSB landing URL A short URL to a hosted page that loads the demo (TBD; placeholder example.com/nsigner/feather)
String descriptors [0]=lang 0x0409, [1]=n_signer, [2]=Feather S3 TFT n_signer, [3]=123456, [4]=n_signer CDC, [5]=n_signer WebUSB

4.3 Frame-level protocol (unchanged)

Both transports carry the same framed JSON-RPC bytes:

+--------+--------+--------+--------+----------------------------+
|   length (uint32 big-endian)      |  payload (JSON-RPC bytes)  |
+--------+--------+--------+--------+----------------------------+

Length excludes the 4-byte header. Payload is a single complete JSON-RPC request or response. No streaming, no multi-frame messages.

4.4 Dispatcher routing

Pseudocode for the main loop:

loop {
    if cdc_recv_frame(buf, &len) == OK {
        handle_request(buf, len, response_buf, &resp_len);
        cdc_send_frame(response_buf, resp_len);
        continue;
    }
    if webusb_recv_frame(buf, &len) == OK {
        handle_request(buf, len, response_buf, &resp_len);
        webusb_send_frame(response_buf, resp_len);
        continue;
    }
    vTaskDelay(2 ms);
}

A request received over CDC is responded to over CDC; a request received over WebUSB is responded to over WebUSB. The two transports do not cross-talk.

5. Build configuration changes

In firmware/feather_s3_tft/sdkconfig.defaults:

-CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
+# CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG is intentionally unset.
+# TinyUSB owns the USB peripheral; route boot console to UART so logs do not
+# corrupt framed transport bytes.
+CONFIG_ESP_CONSOLE_UART_DEFAULT=y
+CONFIG_ESP_CONSOLE_SECONDARY_NONE=y

+CONFIG_TINYUSB_CDC_ENABLED=y
+CONFIG_TINYUSB_CDC_COUNT=1
 CONFIG_TINYUSB_VENDOR_COUNT=1

Active sdkconfig will need the same values applied (Phase 6 taught us that defaults alone is not sufficient).

6. Source layout changes

File Change
firmware/feather_s3_tft/main/webusb_transport.c Renamed to usb_transport.c; descriptor table grows from Vendor-only to IAD+CDC+Vendor; adds CDC framer alongside existing Vendor framer
firmware/feather_s3_tft/main/webusb_transport.h Renamed to usb_transport.h; exposes usb_transport_init, cdc_recv_frame, cdc_send_frame, webusb_recv_frame, webusb_send_frame
firmware/feather_s3_tft/main/main.c Drops all usb_serial_jtag_* calls; uses cdc_recv_frame / cdc_send_frame instead; re-enables both transports in the receive loop
firmware/feather_s3_tft/main/CMakeLists.txt Replace webusb_transport.c entry with usb_transport.c
firmware/README.md New "Flashing workflow" section documenting bootloader-mode requirement and the expected lsusb / dmesg enumeration

7. Trade-offs and accepted constraints

Concern Decision
Flashing requires bootloader-mode entry every time Accepted. Document clearly. The ROM bootloader still provides USB Serial/JTAG, so idf.py flash still works whenever the user enters bootloader mode.
Boot console no longer auto-shows on /dev/ttyACM0 Accepted. TFT is the boot diagnostic surface. UART can be wired manually if deeper debugging is needed.
Existing host scripts may need a small startup delay (TinyUSB CDC enumerates slightly slower than ROM USB Serial/JTAG) Existing per-port serial-open hardening in feather_get_public_key.py and feather_sign_event.py already includes a time.sleep(3.0) settle. Tune if needed.
Two transports could in principle race Single-threaded receive loop; only one frame is being processed at a time.
Windows user experience MS OS 2.0 descriptor binds Vendor interface to WinUSB so Chrome WebUSB works without driver install; CDC is bound by the inbox driver.

8. Security posture

No change. Auth envelope is enforced identically on both transports. The transport layer is treated as an untrusted byte pipe. Frame size is bounded by s_frame_buf (4096 bytes). Replay cache, body-hash binding, and on-device approval all remain in place.

9. Validation matrix

Step Method Pass criteria
9.1 Build make firmware-feather No warnings, single binary produced
9.2 Flash Bootloader-mode + idf.py -p /dev/ttyACM0 flash Hash of data verified
9.3 Reset Tap RESET (no BOOT) TFT shows "n_signer" + npub + "usb cdc+webusb"
9.4 Enumeration (Linux) lsusb, dmesg | tail 303a:4001, kernel binds cdc_acm, creates /dev/ttyACM0
9.5 CDC get_public_key ./.venv/bin/python ./examples/feather_get_public_key.py /dev/ttyACM0 Returns expected 64-hex pubkey, exit 0
9.6 CDC sign_event ./.venv/bin/python ./examples/feather_sign_event.py /dev/ttyACM0 Returns signed event, signature verifies
9.7 CDC auth-required reject Send a request with no auth envelope (manual or via test harness) Returns error 2014
9.8 WebUSB connect Open feather_webusb_demo.html in Chrome Pairing dialog shows the device
9.9 WebUSB get_public_key Click "Get public key" in demo Returns same pubkey as 9.5
9.10 Recovery Run 9.5 again after 9.9 Both transports remain healthy

10. Roll-back plan

If composite enumeration fails or breaks /dev/ttyACM0 access in a way that cannot be quickly fixed, revert by:

  1. git checkout HEAD -- firmware/feather_s3_tft/sdkconfig.defaults firmware/feather_s3_tft/main/main.c firmware/feather_s3_tft/main/usb_transport.c firmware/feather_s3_tft/main/usb_transport.h
  2. Re-enable CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y.
  3. Build + bootloader-mode flash.
  4. Confirm /dev/ttyACM0 returns and CDC scripts pass.

The ROM bootloader is in mask ROM and cannot be bricked by firmware, so this rollback is always available as long as the user can press BOOT + RESET.

11. Step-by-step execution checklist

  1. 7b.1 Apply sdkconfig changes (defaults and active sdkconfig).
  2. 7b.2 Rewrite descriptor block in the transport source: add IAD + CDC-ACM interface descriptors, keep Vendor descriptor, fix endpoint numbers, fix string indices.
  3. 7b.3 Add CDC framer (4-byte BE length, mirrors existing vendor framer) using tud_cdc_* API. Expose cdc_recv_frame, cdc_send_frame from the transport module.
  4. 7b.4 Strip usb_serial_jtag_* from main.c. Replace recv_frame_stdin / send_frame_stdout with calls to cdc_recv_frame / cdc_send_frame. Restore the dual-transport selection block.
  5. 7b.5 Re-enable transport init early in app_main(). Update TFT idle text to "usb cdc+webusb".
  6. 7b.6 Build. Flash via bootloader mode. Verify enumeration with lsusb and ls /dev/ttyACM*.
  7. 7b.7 Run all CDC validation steps in §9.5 §9.7.
  8. 7b.8 Run WebUSB validation steps in §9.8 §9.9.
  9. 7b.9 Update firmware/README.md with the "Flashing workflow" and "USB transport" sections.
  10. 7b.10 Bump firmware version string in main.c; update root README.md USB description.

12. Out of scope (future)

  • Add USB DFU runtime trigger so the firmware can reboot itself into bootloader mode on demand.
  • Add a small TUI on the TFT showing per-transport activity counters (rx/tx by source).
  • Move WebUSB landing URL to a project-owned domain.