14 KiB
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
- Add
NSignerWebSerialtosrc/signers/with the same public API asNSignerWebUSBso the rest ofnostr_login_lite(modal, login flow, build pipeline) is agnostic to which board is connected. - Add a
cyd_webserial_demo.htmlexample mirroringfeather_webusb_demo.html, so a user can manually verifyget_public_key,sign_event, NIP-04 and NIP-44 roundtrips against the CYD without booting the full SDK. - Update the login modal so the user can pick "Feather (WebUSB)" or "CYD (Serial)" at connect time.
- Bundle the new signer into
nostr_login_lite.jsviabuild.js. - 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:
- Constructor takes a
SerialPort(fromnavigator.serial) rather than aUSBDevice. vendorId/productId/serialcome fromport.getInfo()(usbVendorId,usbProductId).serialis generally unavailable on Web Serial — keep the field for API parity but expectnull.- Single long-lived reader. Web Serial's reader, unlike WebUSB's
transferIncalls, 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 byreq.id. _sendRpc()registers{id, resolve, reject, timer}in aMap, writes the frame via the writer, and awaits the registered promise.- On
close(), callreader.cancel()to break out of the loop, thenport.close().
- On
- DTR/RTS handling. Right after
port.open(), callport.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. - Reconnect detection. The
disconnectevent on the port fires when the user unplugs the cable; our loop'sreader.read()will resolve{done:true}shortly after. Both paths should call the registered_disconnectHandlers. - 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).
- Auth envelope construction. Reuse the kind-27235 builder verbatim. Extract
_buildAuth,_be32,_hex,_hexToBytes,_sha256Hex,_utf8into 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-acmdriver (well, thech341kernel driver) toggles DTR/RTS onopen(2)by default, which is why naively runningcat /dev/ttyUSB0resets the board. Web Serial does NOT do this automatically as far as Chromium's implementation goes — but to be safe we explicitly set both tofalseafter 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 callsetSignals({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.readablewill 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) oruucp(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
usbserdriver does not auto-bind to all CH340 PID variants).
Validation
- Open
cyd_webserial_demo.htmlin Chrome, click Connect, pick the CYD port. - Verify "Connected" status appears; auto-fetch returns the device's
pubkey(64-hex). - Sign a kind 1 event — on the CYD's touchscreen the approval prompt should appear; tap "Approve" — the demo logs the signed event.
- 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
Alwayscaching on-device. - Tap "Deny" on one — confirm the client receives a deny error.
- 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 onerequestPortcall so the picker shows any matching device. SerialPort.getInfo()on some Chrome versions returnsnullvendor/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.getPairedDevicewill 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 monitorrunning on the same port, Web Serial'sport.open()will throwNetworkError. 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.htmlperforms a full get_public_key / sign_event / nip04 roundtrip / nip44 roundtrip against a CYD device.NSignerWebSerialpasses the same smoke tests asNSignerWebUSB, 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_liteonce 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).