Files
nostr_login_lite/plans/cyd_webserial_signer.md

14 KiB
Raw Permalink Blame History

CYD Web Serial signer integration

Add support for the new n_signer hardware board (CYD: ESP32-2432S028, the resistive-touch ILI9341 board) to nostr_login_lite, alongside the existing Feather WebUSB signer.

Background

The existing NSignerWebUSB talks to the Feather S3 board over WebUSB because the Feather has a native USB peripheral, enumerates with VID 0x303a, and exposes a vendor-class interface (class 0xFF) plus a control transfer (0x22, value=1) that switches the device into WebUSB mode.

The new CYD board does not have native USB. The ESP32 talks to the host through a CH340 (or, on some revisions, a CP2102) USB-to-UART bridge chip, so the host enumerates a plain CDC serial device. WebUSB cannot reach it; the Web Serial API (navigator.serial) can.

On the firmware side the protocol is unchanged: both Feather and CYD speak the same 4-byte big-endian length-prefixed JSON-RPC framing — only the underlying byte stream is different (USB bulk endpoint vs. UART byte stream). All RPC semantics (auth envelope kind 27235, nsigner_rpc / nsigner_method / nsigner_body_hash tags, get_public_key, sign_event, nip04_encrypt, nip04_decrypt, nip44_encrypt, nip44_decrypt) are identical.

Goals

  1. Add NSignerWebSerial to src/signers/ with the same public API as NSignerWebUSB so the rest of nostr_login_lite (modal, login flow, build pipeline) is agnostic to which board is connected.
  2. Add a cyd_webserial_demo.html example mirroring feather_webusb_demo.html, so a user can manually verify get_public_key, sign_event, NIP-04 and NIP-44 roundtrips against the CYD without booting the full SDK.
  3. Update the login modal so the user can pick "Feather (WebUSB)" or "CYD (Serial)" at connect time.
  4. Bundle the new signer into nostr_login_lite.js via build.js.
  5. Document browser support, CH340 driver setup, and DTR/RTS behavior.

Non-goals

  • Touching the firmware: the CYD firmware in n_signer/firmware/cyd_esp32_2432s028/ already exposes the correct protocol on UART0 at 115200 8N1 with no flow control.
  • Replacing the existing NSignerWebUSB. Both transports coexist; users with Feather hardware continue to use WebUSB.
  • Supporting Firefox/Safari. Web Serial (and WebUSB) are Chromium-only. This is a known limitation, not a regression.

Transport mapping

Concern WebUSB (Feather) Web Serial (CYD)
Device picker navigator.usb.requestDevice({filters:[{vendorId:0x303a}]}) navigator.serial.requestPort({filters:[{usbVendorId:0x1a86, usbProductId:0x7523}, {usbVendorId:0x10c4, usbProductId:0xea60}]})
Open dev.open(); selectConfiguration(1); claimInterface; selectAlternateInterface; controlTransferOut(req=0x22, value=1) port.open({baudRate:115200, dataBits:8, parity:"none", stopBits:1, flowControl:"none"})
DTR/RTS n/a port.setSignals({dataTerminalReady:false, requestToSend:false}) immediately after open to avoid pulsing EN/IO0 (which would reset the ESP32 or put it in download mode)
Already-paired discovery navigator.usb.getDevices() navigator.serial.getPorts()
Write dev.transferOut(EP_OUT, frame) writer = port.writable.getWriter(); await writer.write(frame); writer.releaseLock()
Read dev.transferIn(EP_IN, 512) returning DataView reader = port.readable.getReader(); { value, done } = await reader.read(); reader.releaseLock() (or hold the reader for the lifetime of the connection — see below)
Disconnect event navigator.usb.addEventListener('disconnect', ...) port.addEventListener('disconnect', ...) plus the reader stream throwing on unplug
Close releaseInterface(); device.close() await reader.cancel(); await port.close()

Framing protocol is identical: [4-byte big-endian length][JSON body]. The CYD firmware in uart_transport.c already tolerates leading non-frame bytes (early boot logs) by sliding the parser one byte at a time when the length header is invalid, so the client doesn't need to coordinate boot timing.

Design

NSignerWebSerial class

File: src/signers/nsigner-webserial.js.

Public API mirrors NSignerWebUSB exactly:

static FILTERS                              // [{usbVendorId:0x1a86, usbProductId:0x7523}, {usbVendorId:0x10c4, usbProductId:0xea60}]
static async requestAndConnect(options)     // picker -> open -> return driver
static async getPairedDevice(options)       // navigator.serial.getPorts() -> first matching
static randomSecretHex()                    // unchanged from WebUSB version
static toPubkeyHex(secretHex)               // unchanged
constructor(port, { callerSecretKey, nostrIndex })
get isOpen, vendorId, productId, serial
onDisconnect(cb)
async open()
async close()
async getPublicKey()
async signEvent(unsignedEvent)
async nip04Encrypt(peerHex, plaintext)
async nip04Decrypt(peerHex, ciphertext)
async nip44Encrypt(peerHex, plaintext)
async nip44Decrypt(peerHex, ciphertext)

Internal differences from the WebUSB class:

  1. Constructor takes a SerialPort (from navigator.serial) rather than a USBDevice.
  2. vendorId / productId / serial come from port.getInfo() (usbVendorId, usbProductId). serial is generally unavailable on Web Serial — keep the field for API parity but expect null.
  3. Single long-lived reader. Web Serial's reader, unlike WebUSB's transferIn calls, owns the readable stream for as long as it's locked. The cleanest pattern is:
    • On open(), start a background _readLoop() task that acquires the reader once, accumulates bytes into a ring buffer, parses frames, and resolves the pending RPC promise keyed by req.id.
    • _sendRpc() registers {id, resolve, reject, timer} in a Map, writes the frame via the writer, and awaits the registered promise.
    • On close(), call reader.cancel() to break out of the loop, then port.close().
  4. DTR/RTS handling. Right after port.open(), call port.setSignals({dataTerminalReady: false, requestToSend: false}). The CH340's RTS/DTR are wired to the ESP32 EN/IO0 reset/boot pins through a small transistor network on the CYD; the auto-reset-into-bootloader sequence triggers when esptool toggles them in a specific pattern. We never want either asserted during normal operation. Test empirically — if it turns out the CYD revision in use ignores them, we leave the call as a defensive no-op.
  5. Reconnect detection. The disconnect event on the port fires when the user unplugs the cable; our loop's reader.read() will resolve {done:true} shortly after. Both paths should call the registered _disconnectHandlers.
  6. Frame parser. Identical to the WebUSB ring-buffer parser, including the "slide one byte and retry" recovery when the length header is invalid (e.g., during the CYD bootloader's early-boot stdout noise).
  7. Auth envelope construction. Reuse the kind-27235 builder verbatim. Extract _buildAuth, _be32, _hex, _hexToBytes, _sha256Hex, _utf8 into a shared helper module (e.g. src/signers/_auth.js) so both classes import the same code rather than duplicating it. Optional polish — not required for the first cut.

Standalone demo: examples/cyd_webserial_demo.html

Copy feather_webusb_demo.html. Replace only:

  • Title and intro text ("n_signer CYD Web Serial Demo").
  • connect() function: swap WebUSB calls for Web Serial as per the transport mapping above.
  • The sendRpc() loop: use the long-lived reader pattern.
  • A small "Disconnect" button that calls port.close() cleanly.

Keep all the auth-envelope code, kind 1 signer, NIP-04/44 sections unchanged.

Modal / login flow

In src/ui/modal.js, where the WebUSB option is rendered, add a sibling "CYD (Serial)" option. On click, instantiate NSignerWebSerial instead of NSignerWebUSB. The downstream code already uses the common interface so no further plumbing should be needed.

Build pipeline

In build.js, add src/signers/nsigner-webserial.js (and the shared _auth.js if extracted) to the concatenation list, exposing window.NSignerWebSerial so non-bundled consumers can use it directly the same way window.NSignerWebUSB is exposed today.

DTR/RTS / CH340 caveats

  • The CH340 datasheet specifies DTR and RTS are open-collector outputs. On the CYD, these tie through transistors to the ESP32 EN (reset) and IO0 (boot mode) pins respectively. esptool relies on this circuit for one-click flashing.
  • Linux's cdc-acm driver (well, the ch341 kernel driver) toggles DTR/RTS on open(2) by default, which is why naively running cat /dev/ttyUSB0 resets the board. Web Serial does NOT do this automatically as far as Chromium's implementation goes — but to be safe we explicitly set both to false after open.
  • If the board resets on connect anyway, mitigations:
    • Add a 10 µF capacitor between EN and GND (a common hardware fix; out of scope for this plan).
    • In software, after port.open(), immediately call setSignals({dataTerminalReady:false, requestToSend:false}), then wait ~200 ms before doing anything else, then drain and discard any pending bytes (since the firmware will spew boot logs).
    • If the user does see a reset, the client should be resilient: a reset means port.readable will close, the disconnect event fires, but the OS-level serial port may still exist. The client should NOT try to reopen automatically; surface "device reset, please reconnect" to the user.

Browser support and udev

  • Web Serial: Chrome 89+, Edge 89+, Brave, Opera. Not in Firefox or Safari.
  • Linux: the user's account must be in dialout (Debian/Ubuntu) or uucp (Arch) for unprivileged access. ChromeOS exposes serial ports via permission prompts. macOS and Windows: no extra setup beyond the CH340/CP2102 driver.
  • macOS CH340 driver: WCH-IC ships a driver for older macOS; macOS 11+ has an in-tree driver but it sometimes conflicts with old kexts. Document this in the README.
  • Windows CH340 driver: WCH-IC's official driver is required on Windows 10/11 (the in-box usbser driver does not auto-bind to all CH340 PID variants).

Validation

  1. Open cyd_webserial_demo.html in Chrome, click Connect, pick the CYD port.
  2. Verify "Connected" status appears; auto-fetch returns the device's pubkey (64-hex).
  3. Sign a kind 1 event — on the CYD's touchscreen the approval prompt should appear; tap "Approve" — the demo logs the signed event.
  4. Tap "Always" once for nip04_encrypt, run an encrypt/decrypt roundtrip, then for nip44. Confirm second invocation of the same method skips the approval prompt due to Always caching on-device.
  5. Tap "Deny" on one — confirm the client receives a deny error.
  6. Unplug the cable — confirm the modal/SDK fires onDisconnect.

Risks / open questions

  • CH340 vs CP2102 hardware revisions. The two known VID/PID pairs cover most CYDs; if a third variant appears (e.g., FTDI FT232R, 0x0403:0x6001), add a third filter entry. Web Serial allows multiple filters in one requestPort call so the picker shows any matching device.
  • SerialPort.getInfo() on some Chrome versions returns null vendor/product IDs on Linux when the kernel driver doesn't expose them through /sys/. The fallback is to show all ports in the picker (no filter) — slightly worse UX but functional.
  • No serial number is exposed by CH340/CP2102 chips on CYDs, so the "remember this device" UX from NSignerWebUSB.getPairedDevice will only be able to match on VID/PID, not serial. If multiple CYDs are connected, the user picks each time.
  • Concurrent access. Only one tab can open a given serial port at a time. If the user has the Arduino IDE serial monitor or idf.py monitor running on the same port, Web Serial's port.open() will throw NetworkError. Document this.

Phasing

Phase Deliverable
1 cyd_webserial_demo.html — standalone, no SDK. Lets us validate the transport works end-to-end against the CYD firmware.
2 src/signers/nsigner-webserial.js — class with full API parity.
3 Modal integration in src/ui/modal.js; build pipeline update in build.js.
4 Documentation: README updates (browser support, driver setup, udev rule note), plus a short user-facing note in the n_signer-side firmware/README.md linking to the new demo.

Phase 1 is the recommended first step because the firmware is already done — landing a working demo verifies the transport and unblocks Phase 2 with a known-good wire format to imitate.

Acceptance criteria

  • cyd_webserial_demo.html performs a full get_public_key / sign_event / nip04 roundtrip / nip44 roundtrip against a CYD device.
  • NSignerWebSerial passes the same smoke tests as NSignerWebUSB, with the same public surface (so any existing call site that takes either driver works without changes).
  • Login modal renders both transport options and produces an equivalent session for either path.
  • The Feather WebUSB path continues to work unchanged.

Question raised by the user

Is our new board ready to work with nostr_login_lite once Phase 1–3 land?

Yes. The CYD firmware in n_signer/firmware/cyd_esp32_2432s028/ already implements the full JSON-RPC surface (get_public_key, sign_event, nip04_encrypt, nip04_decrypt, nip44_encrypt, nip44_decrypt) over UART0, with the same auth envelope and approval UI as the Feather. Once NSignerWebSerial exists and the modal offers it, nostr_login_lite will treat the CYD as a fully-supported signer. The only remaining gating items are the optional polish in this plan (touch calibration persistence and the CYD-side flash helper script — both are out of scope here and tracked on the n_signer firmware side).