Connect to the CYD (ESP32-2432S028) board over Web Serial (CH340/CP2102 USB-UART bridge), choose a key index, fetch a pubkey, sign a kind 1 event, and test NIP-04 / NIP-44 encrypt + decrypt RPCs.
+
+
+ Requirements: Chrome 89+, Edge 89+, or Brave. Web Serial is not available in Firefox or Safari.
+ Linux users: Add yourself to the dialout group (sudo usermod -aG dialout $USER) and replug the device.
+ Windows users: Install the WCH-IC CH340 driver if the port does not appear.
+ Note: Close any serial monitor (Arduino IDE, idf.py monitor) before connecting โ only one app can hold the port at a time.
+
+
+
+
+
+
+ Disconnected
+
+
+
+
+
+
+
Public Key
+
+
+
+
+
+
+
+
+
+
Sign Kind 1 Event
+
+
+
+
+
+
+
+
+
+
+
+
NIP-04 Encrypt
+
+
+
+
+
+
+
+
+
+
+
+
NIP-04 Decrypt
+
+
+
+
+
+
+
+
+
+
+
+
NIP-44 Encrypt
+
+
+
+
+
+
+
+
+
+
+
+
NIP-44 Decrypt
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plans/cyd_webserial_signer.md b/plans/cyd_webserial_signer.md
new file mode 100644
index 0000000..3325045
--- /dev/null
+++ b/plans/cyd_webserial_signer.md
@@ -0,0 +1,154 @@
+# 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`](../src/signers/nsigner-webusb.js:1) 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`](../src/signers/nsigner-webusb.js:1) 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`](../examples/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`](../../../n_signer/firmware/cyd_esp32_2432s028/main/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`](../src/signers/nsigner-webusb.js:1) exactly:
+
+```text
+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`](../examples/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`](../src/signers/nsigner-webusb.js:355) 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).
diff --git a/src/VERSION b/src/VERSION
index 04c5555..f8bc4c6 100644
--- a/src/VERSION
+++ b/src/VERSION
@@ -1 +1 @@
-0.1.17
+0.1.18
diff --git a/src/build.js b/src/build.js
index 3519823..d0f7b9e 100644
--- a/src/build.js
+++ b/src/build.js
@@ -230,6 +230,37 @@ if (typeof window !== 'undefined') {
console.warn('โ ๏ธ NSigner driver not found: signers/nsigner-webusb.js');
}
+ // Add NSigner WebSerial driver (CYD ESP32-2432S028)
+ const nsignerSerialDriverPath = path.join(__dirname, 'signers/nsigner-webserial.js');
+ if (fs.existsSync(nsignerSerialDriverPath)) {
+ let nsignerSerialContent = fs.readFileSync(nsignerSerialDriverPath, 'utf8');
+
+ let lines = nsignerSerialContent.split('\n');
+ let contentStartIndex = 0;
+
+ for (let i = 0; i < Math.min(15, lines.length); i++) {
+ const line = lines[i].trim();
+ if (line.startsWith('/**') || line.startsWith('*') ||
+ line.startsWith('/*') || line.startsWith('//')) {
+ contentStartIndex = i + 1;
+ } else if (line && !line.startsWith('*') && !line.startsWith('//')) {
+ break;
+ }
+ }
+
+ if (contentStartIndex > 0) {
+ lines = lines.slice(contentStartIndex);
+ }
+
+ bundle += `// ======================================\n`;
+ bundle += `// NSigner WebSerial Driver (CYD)\n`;
+ bundle += `// ======================================\n\n`;
+ bundle += lines.join('\n');
+ bundle += '\n\n';
+ } else {
+ console.warn('โ ๏ธ NSigner WebSerial driver not found: signers/nsigner-webserial.js');
+ }
+
// Add main library code
// console.log('๐ Adding Main Library...');
bundle += `
@@ -1476,6 +1507,7 @@ class AuthManager {
case 'nsigner':
if (authData.signer) {
authState.nsigner = {
+ transport: String(authData.signer.transport || 'webusb').toLowerCase(),
nostrIndex: Number(authData.signer.nostrIndex ?? 0),
callerSecretKey: authData.signer.callerSecretKey,
callerPubkey: authData.signer.callerPubkey,
@@ -1862,13 +1894,91 @@ class AuthManager {
const nsigner = authState.nsigner;
if (!nsigner) return null;
- if (!window.isSecureContext || !('usb' in navigator) || !window.NSignerWebUSB) {
+ const transport = String(nsigner.transport || 'webusb').toLowerCase();
+ const isWebSerial = transport === 'webserial';
+
+ if (!window.isSecureContext) {
return {
method: 'nsigner',
pubkey: authState.pubkey,
requiresReconnection: true,
connectionData: nsigner,
- message: 'n_signer requires HTTPS/localhost and a WebUSB-capable browser (Chrome/Edge).'
+ message: 'n_signer requires HTTPS or localhost.'
+ };
+ }
+
+ if (isWebSerial) {
+ if (!('serial' in navigator) || !window.NSignerWebSerial) {
+ return {
+ method: 'nsigner',
+ pubkey: authState.pubkey,
+ requiresReconnection: true,
+ connectionData: nsigner,
+ message: 'CYD n_signer requires Web Serial (Chrome 89+, Edge 89+, or Brave).'
+ };
+ }
+
+ const paired = await window.NSignerWebSerial.getPairedDevice({
+ vendorId: nsigner.deviceVid ?? null,
+ productId: nsigner.deviceProductId ?? null
+ });
+
+ if (!paired) {
+ return {
+ method: 'nsigner',
+ pubkey: authState.pubkey,
+ requiresReconnection: true,
+ connectionData: nsigner,
+ message: 'Your CYD n_signer device is not connected. Plug it in and reconnect to continue.'
+ };
+ }
+
+ try {
+ const driver = new window.NSignerWebSerial(paired, {
+ callerSecretKey: nsigner.callerSecretKey,
+ nostrIndex: Number(nsigner.nostrIndex ?? 0)
+ });
+
+ await driver.open();
+ const restoredPubkey = await driver.getPublicKey();
+ if (restoredPubkey !== String(authState.pubkey || '').toLowerCase()) {
+ await driver.close();
+ this.clearAuthState();
+ return null;
+ }
+
+ return {
+ method: 'nsigner',
+ pubkey: authState.pubkey,
+ signer: {
+ driver,
+ transport: 'webserial',
+ nostrIndex: Number(nsigner.nostrIndex ?? 0),
+ callerSecretKey: nsigner.callerSecretKey,
+ callerPubkey: nsigner.callerPubkey,
+ deviceVid: nsigner.deviceVid,
+ deviceProductId: nsigner.deviceProductId,
+ deviceSerial: null
+ }
+ };
+ } catch (_) {
+ return {
+ method: 'nsigner',
+ pubkey: authState.pubkey,
+ requiresReconnection: true,
+ connectionData: nsigner,
+ message: 'Could not reopen your CYD n_signer serial port. Reconnect from the modal.'
+ };
+ }
+ }
+
+ if (!('usb' in navigator) || !window.NSignerWebUSB) {
+ return {
+ method: 'nsigner',
+ pubkey: authState.pubkey,
+ requiresReconnection: true,
+ connectionData: nsigner,
+ message: 'n_signer requires a WebUSB-capable browser (Chrome/Edge).'
};
}
@@ -1907,6 +2017,7 @@ class AuthManager {
pubkey: authState.pubkey,
signer: {
driver,
+ transport: 'webusb',
nostrIndex: Number(nsigner.nostrIndex ?? 0),
callerSecretKey: nsigner.callerSecretKey,
callerPubkey: nsigner.callerPubkey,
diff --git a/src/signers/nsigner-webserial.js b/src/signers/nsigner-webserial.js
new file mode 100644
index 0000000..1c4d831
--- /dev/null
+++ b/src/signers/nsigner-webserial.js
@@ -0,0 +1,488 @@
+class NSignerWebSerial {
+ // CH340 VID:PID 0x1a86:0x7523 (most CYD revisions)
+ // CP2102 VID:PID 0x10c4:0xea60 (some CYD revisions)
+ static FILTERS = [
+ { usbVendorId: 0x1a86, usbProductId: 0x7523 },
+ { usbVendorId: 0x10c4, usbProductId: 0xea60 }
+ ];
+
+ static async requestAndConnect(options = {}) {
+ if (!('serial' in navigator)) {
+ throw new Error('Web Serial API not available in this browser (requires Chrome 89+, Edge 89+, or Brave)');
+ }
+
+ const port = await navigator.serial.requestPort({ filters: NSignerWebSerial.FILTERS });
+ const driver = new NSignerWebSerial(port, options);
+ await driver.open();
+ return driver;
+ }
+
+ static async getPairedDevice(options = {}) {
+ if (!('serial' in navigator)) return null;
+
+ const ports = await navigator.serial.getPorts();
+ if (!ports || ports.length === 0) return null;
+
+ const vendorId = options.vendorId ?? null;
+ const productId = options.productId ?? null;
+
+ const match = ports.find(p => {
+ const info = p.getInfo();
+ if (vendorId !== null && info?.usbVendorId !== vendorId) return false;
+ if (productId !== null && info?.usbProductId !== productId) return false;
+ return true;
+ });
+
+ return match || null;
+ }
+
+ // Identical helpers to NSignerWebUSB โ kept here so the class is self-contained.
+ static randomSecretHex() {
+ const bytes = new Uint8Array(32);
+ crypto.getRandomValues(bytes);
+ return NSignerWebSerial._hex(bytes);
+ }
+
+ static toPubkeyHex(secretHex) {
+ const secret = NSignerWebSerial._hexToBytes(secretHex);
+ const nt = window.NostrTools || {};
+
+ if (nt.schnorr && typeof nt.schnorr.getPublicKey === 'function') {
+ const pub = nt.schnorr.getPublicKey(secret);
+ return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebSerial._hex(pub);
+ }
+
+ if (typeof nt.getPublicKey === 'function') {
+ const pub = nt.getPublicKey(secret);
+ return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebSerial._hex(pub);
+ }
+
+ throw new Error('NostrTools.getPublicKey is unavailable in this bundle');
+ }
+
+ constructor(port, { callerSecretKey, nostrIndex = 0, openStabilizeMs = 1200, signalStrategies = null } = {}) {
+ if (!port) throw new Error('SerialPort is required');
+ if (!callerSecretKey) throw new Error('callerSecretKey is required');
+
+ this._port = port;
+ this.callerSecretKey = callerSecretKey;
+ this.nostrIndex = Number.isFinite(Number(nostrIndex)) ? Number(nostrIndex) : 0;
+
+ this._reader = null;
+ this._readLoopPromise = null;
+ this._ring = new Uint8Array(0);
+ this._pending = new Map(); // id -> { resolve, reject, timer }
+ this._disconnectHandlers = new Set();
+ this._rpcCounter = 0;
+ this._open = false;
+ this._opening = false;
+ this._sawDisconnectDuringOpen = false;
+ this._openStabilizeMs = Math.max(0, Number(openStabilizeMs) || 1200);
+ this._signalStrategies = Array.isArray(signalStrategies) && signalStrategies.length
+ ? signalStrategies
+ : [
+ { dataTerminalReady: false, requestToSend: false, label: 'dtr=0 rts=0' },
+ { dataTerminalReady: true, requestToSend: true, label: 'dtr=1 rts=1' }
+ ];
+
+ // Bound port-level disconnect listener.
+ this._boundPortDisconnect = () => {
+ if (this._opening) this._sawDisconnectDuringOpen = true;
+ this._handleDisconnect();
+ };
+ }
+
+ get isOpen() {
+ return this._open;
+ }
+
+ get vendorId() {
+ return this._port?.getInfo()?.usbVendorId ?? null;
+ }
+
+ get productId() {
+ return this._port?.getInfo()?.usbProductId ?? null;
+ }
+
+ // Web Serial / CH340 / CP2102 do not expose a serial number.
+ get serial() {
+ return null;
+ }
+
+ onDisconnect(cb) {
+ if (typeof cb === 'function') this._disconnectHandlers.add(cb);
+ return () => this._disconnectHandlers.delete(cb);
+ }
+
+ async open() {
+ const originalInfo = this._port?.getInfo?.() || {};
+ let lastError = null;
+
+ for (let attempt = 0; attempt < 2; attempt++) {
+ this._opening = true;
+ this._sawDisconnectDuringOpen = false;
+
+ try {
+ if (attempt > 0) {
+ const replacement = await NSignerWebSerial._findReenumeratedPort(originalInfo);
+ if (replacement) this._port = replacement;
+ }
+
+ await this._port.open({
+ baudRate: 115200,
+ dataBits: 8,
+ parity: 'none',
+ stopBits: 1,
+ flowControl: 'none'
+ });
+
+ this._port.addEventListener('disconnect', this._boundPortDisconnect);
+
+ await this._applySignalStrategies();
+
+ // Wait for potential auto-reset / re-enumeration after open+signals.
+ await new Promise(r => setTimeout(r, this._openStabilizeMs));
+
+ if (this._sawDisconnectDuringOpen || !this._port?.readable || !this._port?.writable) {
+ throw new Error('Serial port dropped during open stabilization');
+ }
+
+ this._open = true;
+ this._readLoopPromise = this._readLoop();
+ return;
+ } catch (err) {
+ lastError = err;
+
+ try { this._port.removeEventListener('disconnect', this._boundPortDisconnect); } catch (_) {}
+ try { await this._port.close(); } catch (_) {}
+
+ // Short wait before retrying in case device just re-enumerated.
+ await new Promise(r => setTimeout(r, 400));
+ } finally {
+ this._opening = false;
+ }
+ }
+
+ throw new Error(`Failed to open n_signer serial port: ${lastError?.message || lastError}`);
+ }
+
+ async close() {
+ this._open = false;
+
+ // Cancel the reader โ this breaks out of the read loop.
+ if (this._reader) {
+ try { await this._reader.cancel(); } catch (_) {}
+ }
+
+ // Reject all in-flight RPCs.
+ for (const [, { reject, timer }] of this._pending) {
+ clearTimeout(timer);
+ reject(new Error('Connection closed'));
+ }
+ this._pending.clear();
+
+ this._port.removeEventListener('disconnect', this._boundPortDisconnect);
+
+ try { await this._port.close(); } catch (_) {}
+ }
+
+ // โโ Public RPC methods (identical surface to NSignerWebUSB) โโโโโโโโโโโโโโโโ
+
+ async getPublicKey() {
+ const params = [{ nostr_index: this.nostrIndex }];
+ const resp = await this._rpcCall('get_public_key', params);
+ if (!resp || typeof resp.result !== 'string') {
+ throw new Error('Invalid get_public_key response');
+ }
+ return resp.result.trim().toLowerCase();
+ }
+
+ async signEvent(unsignedEvent) {
+ const params = [unsignedEvent, { nostr_index: this.nostrIndex }];
+ const resp = await this._rpcCall('sign_event', params);
+ if (!resp || typeof resp.result !== 'object') {
+ throw new Error('Invalid sign_event response');
+ }
+ return resp.result;
+ }
+
+ async nip04Encrypt(peerHex, plaintext) {
+ const resp = await this._rpcCall('nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
+ if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip04_encrypt response');
+ return resp.result;
+ }
+
+ async nip04Decrypt(peerHex, ciphertext) {
+ const resp = await this._rpcCall('nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
+ if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip04_decrypt response');
+ return resp.result;
+ }
+
+ async nip44Encrypt(peerHex, plaintext) {
+ const resp = await this._rpcCall('nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
+ if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip44_encrypt response');
+ return resp.result;
+ }
+
+ async nip44Decrypt(peerHex, ciphertext) {
+ const resp = await this._rpcCall('nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
+ if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip44_decrypt response');
+ return resp.result;
+ }
+
+ // โโ Internal โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ async _rpcCall(method, params) {
+ const id = `nl-${Date.now()}-${++this._rpcCounter}`;
+ const auth = await this._buildAuth(id, method, params);
+ const req = { jsonrpc: '2.0', id, method, params, auth };
+
+ console.info('NSignerWebSerial _rpcCall outbound:', JSON.stringify(req));
+
+ const resp = await this._sendRpc(req);
+
+ if (resp?.error) {
+ const msg = resp.error?.message || JSON.stringify(resp.error);
+ throw new Error(`n_signer ${method} failed: ${msg}`);
+ }
+
+ return resp;
+ }
+
+ _sendRpc(reqObj) {
+ return new Promise((resolve, reject) => {
+ const id = reqObj.id;
+ const timer = setTimeout(() => {
+ this._pending.delete(id);
+ reject(new Error('Timed out waiting for n_signer response'));
+ }, 30000);
+
+ this._pending.set(id, { resolve, reject, timer });
+
+ this._writeFrame(reqObj).catch(err => {
+ this._pending.delete(id);
+ clearTimeout(timer);
+ reject(err);
+ });
+ });
+ }
+
+ async _writeFrame(reqObj) {
+ const body = NSignerWebSerial._utf8(JSON.stringify(reqObj));
+ const frame = new Uint8Array(4 + body.length);
+ frame.set(NSignerWebSerial._be32(body.length), 0);
+ frame.set(body, 4);
+
+ console.info('NSignerWebSerial _writeFrame:', { payloadBytes: body.length, frameBytes: frame.length });
+
+ // Acquire writer, write, release immediately.
+ const w = this._port.writable.getWriter();
+ try {
+ await w.write(frame);
+ } finally {
+ w.releaseLock();
+ }
+ }
+
+ async _readLoop() {
+ let ring = new Uint8Array(0);
+
+ try {
+ this._reader = this._port.readable.getReader();
+
+ while (true) {
+ const { value, done } = await this._reader.read();
+ if (done) break;
+ if (!value || value.length === 0) continue;
+
+ // Append chunk to ring buffer.
+ const next = new Uint8Array(ring.length + value.length);
+ next.set(ring, 0);
+ next.set(value, ring.length);
+ ring = next;
+
+ // Parse as many complete frames as possible.
+ while (ring.length >= 4) {
+ const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3];
+
+ // Invalid length header โ slide one byte (boot-log recovery).
+ if (n <= 0 || n > 1_000_000) {
+ ring = ring.slice(1);
+ continue;
+ }
+
+ // Not enough bytes yet for the full payload.
+ if (ring.length < 4 + n) break;
+
+ const payload = ring.slice(4, 4 + n);
+ ring = ring.slice(4 + n);
+
+ let resp;
+ try {
+ resp = JSON.parse(new TextDecoder().decode(payload));
+ } catch (e) {
+ console.warn('NSignerWebSerial: frame parse error:', e);
+ continue;
+ }
+
+ console.info('NSignerWebSerial _readLoop inbound:', JSON.stringify(resp));
+
+ // Resolve the matching pending RPC.
+ if (resp && resp.id && this._pending.has(resp.id)) {
+ const { resolve, timer } = this._pending.get(resp.id);
+ this._pending.delete(resp.id);
+ clearTimeout(timer);
+ resolve(resp);
+ }
+ }
+ }
+ } catch (err) {
+ if (err && err.name !== 'AbortError') {
+ console.warn('NSignerWebSerial read loop error:', err);
+ }
+ } finally {
+ try { this._reader.releaseLock(); } catch (_) {}
+ this._reader = null;
+ this._handleDisconnect();
+ }
+ }
+
+ _handleDisconnect() {
+ if (!this._open && !this._opening) return; // already closed cleanly
+ this._open = false;
+
+ // Reject all in-flight RPCs.
+ for (const [, { reject, timer }] of this._pending) {
+ clearTimeout(timer);
+ reject(new Error('n_signer device disconnected'));
+ }
+ this._pending.clear();
+
+ for (const cb of this._disconnectHandlers) {
+ try { cb(); } catch (_) {}
+ }
+ }
+
+ async _buildAuth(rpcId, method, params) {
+ const callerPriv = NSignerWebSerial._hexToBytes(this.callerSecretKey);
+ const callerPubX = NSignerWebSerial.toPubkeyHex(this.callerSecretKey);
+
+ const createdAt = Math.floor(Date.now() / 1000);
+ const paramsJson = JSON.stringify(params ?? null);
+ const bodyHash = await NSignerWebSerial._sha256Hex(NSignerWebSerial._utf8(paramsJson));
+
+ const tags = [
+ ['nsigner_rpc', String(rpcId)],
+ ['nsigner_method', String(method)],
+ ['nsigner_body_hash', bodyHash]
+ ];
+
+ const content = 'nostr_login_lite';
+ const nt = window.NostrTools || {};
+
+ // Prefer finalizeEvent() โ stable across nostr-tools bundle shapes.
+ if (typeof nt.finalizeEvent === 'function') {
+ const finalized = nt.finalizeEvent({
+ kind: 27235,
+ created_at: createdAt,
+ tags,
+ content
+ }, callerPriv);
+
+ return {
+ id: String(finalized.id || '').toLowerCase(),
+ pubkey: String(finalized.pubkey || callerPubX).toLowerCase(),
+ created_at: createdAt,
+ kind: 27235,
+ tags,
+ content,
+ sig: String(finalized.sig || '').toLowerCase()
+ };
+ }
+
+ // Fallback: schnorr.sign directly.
+ const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
+ const id = await NSignerWebSerial._sha256Hex(NSignerWebSerial._utf8(ser));
+
+ if (!nt.schnorr || typeof nt.schnorr.sign !== 'function') {
+ throw new Error('NostrTools signer unavailable (need finalizeEvent or schnorr.sign)');
+ }
+
+ const sigBytes = await nt.schnorr.sign(id, callerPriv, new Uint8Array(32));
+ const sigHex = typeof sigBytes === 'string' ? sigBytes : NSignerWebSerial._hex(sigBytes);
+
+ return { id, pubkey: callerPubX, created_at: createdAt, kind: 27235, tags, content, sig: sigHex };
+ }
+
+ async _applySignalStrategies() {
+ if (!this._port?.setSignals) return;
+
+ for (const strategy of this._signalStrategies) {
+ try {
+ await this._port.setSignals({
+ dataTerminalReady: !!strategy.dataTerminalReady,
+ requestToSend: !!strategy.requestToSend
+ });
+ // Small settle gap between strategies.
+ await new Promise(r => setTimeout(r, 120));
+ } catch (_) {
+ // Ignore unsupported setSignals implementations.
+ }
+
+ if (this._sawDisconnectDuringOpen) return;
+ }
+ }
+
+ static async _findReenumeratedPort(matchInfo = {}) {
+ try {
+ if (!navigator.serial?.getPorts) return null;
+ const ports = await navigator.serial.getPorts();
+ if (!ports?.length) return null;
+
+ const vid = matchInfo?.usbVendorId;
+ const pid = matchInfo?.usbProductId;
+
+ return ports.find((p) => {
+ const i = p.getInfo?.() || {};
+ if (vid != null && i.usbVendorId !== vid) return false;
+ if (pid != null && i.usbProductId !== pid) return false;
+ return true;
+ }) || ports[0] || null;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ // โโ Static utilities (mirrors NSignerWebUSB) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ static _utf8(s) {
+ return new TextEncoder().encode(s);
+ }
+
+ static _be32(n) {
+ return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
+ }
+
+ static _hex(bytes) {
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
+ }
+
+ static _hexToBytes(hex) {
+ const v = String(hex || '').trim().toLowerCase();
+ if (!/^[0-9a-f]{64}$/.test(v)) {
+ throw new Error('Secret key must be 64 hex chars');
+ }
+ const out = new Uint8Array(32);
+ for (let i = 0; i < 32; i++) out[i] = parseInt(v.slice(i * 2, i * 2 + 2), 16);
+ return out;
+ }
+
+ static async _sha256Hex(dataBytes) {
+ const h = await crypto.subtle.digest('SHA-256', dataBytes);
+ return NSignerWebSerial._hex(new Uint8Array(h));
+ }
+}
+
+if (typeof window !== 'undefined') {
+ window.NSignerWebSerial = NSignerWebSerial;
+}
diff --git a/src/ui/modal.js b/src/ui/modal.js
index 4f9dfc9..c2f97dc 100644
--- a/src/ui/modal.js
+++ b/src/ui/modal.js
@@ -266,25 +266,27 @@ class Modal {
});
}
- // n_signer USB hardware option
- if (isMethodEnabled('nsigner', true)) {
+ // Unified hardware signer option (Web Serial primary, WebUSB fallback)
+ const nsignerEnabled = isMethodEnabled('nsigner', true) || isMethodEnabled('nsigner_cyd', true);
+ if (nsignerEnabled) {
const secure = typeof window !== 'undefined' ? window.isSecureContext : false;
const hasUsb = typeof navigator !== 'undefined' && ('usb' in navigator);
+ const hasSerial = typeof navigator !== 'undefined' && ('serial' in navigator);
- let description = 'Sign with USB-connected n_signer hardware';
+ let description = 'Use your hardware signer (auto-detect serial/USB)';
let disabled = false;
if (!secure) {
description = 'Requires HTTPS or localhost';
disabled = true;
- } else if (!hasUsb) {
- description = 'Requires Chrome/Edge WebUSB support';
+ } else if (!hasSerial && !hasUsb) {
+ description = 'Requires Web Serial or WebUSB support (Chrome/Edge/Brave)';
disabled = true;
}
options.push({
type: 'nsigner',
- title: 'USB Hardware signer',
+ title: 'USB Hardware Signer',
description,
icon: '๐',
disabled
@@ -403,7 +405,8 @@ class Modal {
this._showConnectScreen();
break;
case 'nsigner':
- this._showNSignerScreen({ autoPrompt: true });
+ case 'nsigner_cyd':
+ this._showHardwareSignerScreen({ autoPrompt: true });
break;
case 'readonly':
this._handleReadonly();
@@ -1384,6 +1387,7 @@ class Modal {
method: 'nsigner',
pubkey,
connectionData: {
+ transport: 'webusb',
nostrIndex: 0,
callerSecretKey: caller.secret,
callerPubkey: caller.pubkey,
@@ -1400,6 +1404,7 @@ class Modal {
pubkey,
signer: {
driver,
+ transport: 'webusb',
nostrIndex: 0,
callerSecretKey: caller.secret,
callerPubkey: caller.pubkey,
@@ -1415,6 +1420,419 @@ class Modal {
}
}
+ _showHardwareSignerScreen({ autoPrompt = false } = {}) {
+ this.modalBody.innerHTML = '';
+
+ const title = document.createElement('h3');
+ title.textContent = 'USB Hardware Signer';
+ title.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);';
+
+ const description = document.createElement('p');
+ description.textContent = 'Connect your hardware signer. We auto-detect Web Serial first, then WebUSB if needed.';
+ description.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;';
+
+ const status = document.createElement('div');
+ status.style.cssText = 'margin-bottom: 12px; font-size: 12px; color: #6b7280;';
+
+ const secure = typeof window !== 'undefined' ? window.isSecureContext : false;
+ const hasUsb = typeof navigator !== 'undefined' && ('usb' in navigator);
+ const hasSerial = typeof navigator !== 'undefined' && ('serial' in navigator);
+ const canSerial = secure && hasSerial && typeof window.NSignerWebSerial === 'function';
+ const canUsb = secure && hasUsb && typeof window.NSignerWebUSB === 'function';
+
+ if (!secure || (!canSerial && !canUsb)) {
+ status.textContent = !secure
+ ? 'Requires HTTPS or localhost.'
+ : 'No supported hardware transport detected (need Web Serial or WebUSB).';
+
+ const backButton = document.createElement('button');
+ backButton.textContent = 'Back';
+ backButton.onclick = () => this._renderLoginOptions();
+ backButton.style.cssText = this._getButtonStyle('secondary');
+
+ this.modalBody.appendChild(title);
+ this.modalBody.appendChild(description);
+ this.modalBody.appendChild(status);
+ this.modalBody.appendChild(backButton);
+ return;
+ }
+
+ const connectButton = document.createElement('button');
+ connectButton.textContent = 'Connect Hardware Signer';
+ connectButton.style.cssText = this._getButtonStyle();
+
+ const accountList = document.createElement('div');
+ accountList.style.cssText = 'margin-top: 12px;';
+
+ const backButton = document.createElement('button');
+ backButton.textContent = 'Back';
+ backButton.onclick = () => this._renderLoginOptions();
+ backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 10px;';
+
+ const shortHex = (v) => `${v.slice(0, 12)}...${v.slice(-8)}`;
+ const prePromptDisplay = this.container.style.display;
+ let hiddenForChooser = false;
+
+ const isChooserCancel = (err) => {
+ const msg = String(err?.message || err || '');
+ return msg.includes('NotFoundError') || msg.includes('No port selected') || msg.includes('No device selected');
+ };
+
+ const restoreModalVisibility = () => {
+ if (hiddenForChooser && this.isVisible) {
+ this.container.style.display = prePromptDisplay || 'block';
+ hiddenForChooser = false;
+ }
+ };
+
+ const renderTransportChoiceScreen = (note) => {
+ restoreModalVisibility();
+ this.modalBody.innerHTML = '';
+
+ const choiceTitle = document.createElement('h3');
+ choiceTitle.textContent = 'USB Hardware Signer';
+ choiceTitle.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);';
+ this.modalBody.appendChild(choiceTitle);
+
+ const choiceDescription = document.createElement('p');
+ choiceDescription.textContent = note || 'Choose which transport your hardware signer uses:';
+ choiceDescription.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;';
+ this.modalBody.appendChild(choiceDescription);
+
+ if (canSerial) {
+ const serialButton = document.createElement('button');
+ serialButton.textContent = 'Try Web Serial Again';
+ serialButton.style.cssText = this._getButtonStyle() + 'margin-bottom: 10px;';
+ serialButton.onclick = () => connectNow({ forceTransport: 'webserial', viaUserGesture: true });
+ this.modalBody.appendChild(serialButton);
+ }
+
+ if (canUsb) {
+ const usbButton = document.createElement('button');
+ usbButton.textContent = 'Try WebUSB Instead';
+ usbButton.style.cssText = this._getButtonStyle() + 'margin-bottom: 10px;';
+ usbButton.onclick = () => connectNow({ forceTransport: 'webusb', viaUserGesture: true });
+ this.modalBody.appendChild(usbButton);
+ }
+
+ const choiceStatus = document.createElement('div');
+ choiceStatus.style.cssText = 'margin: 10px 0; font-size: 12px; color: #6b7280;';
+ choiceStatus.id = 'nl-hwsigner-choice-status';
+ this.modalBody.appendChild(choiceStatus);
+
+ const choiceBack = document.createElement('button');
+ choiceBack.textContent = 'Back';
+ choiceBack.onclick = () => this._renderLoginOptions();
+ choiceBack.style.cssText = this._getButtonStyle('secondary');
+ this.modalBody.appendChild(choiceBack);
+ };
+
+ const renderConnectingScreen = (note) => {
+ restoreModalVisibility();
+ this.modalBody.innerHTML = '';
+
+ const connTitle = document.createElement('h3');
+ connTitle.textContent = 'USB Hardware Signer';
+ connTitle.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);';
+ this.modalBody.appendChild(connTitle);
+
+ const connStatus = document.createElement('p');
+ connStatus.textContent = note || 'Connecting...';
+ connStatus.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;';
+ this.modalBody.appendChild(connStatus);
+ };
+
+ const probeAccounts = async (driver, label) => {
+ const accounts = [];
+ for (let i = 0; i < 6; i++) {
+ try {
+ driver.nostrIndex = i;
+ console.info(`[${label}] probe index`, i);
+ const pubkey = await driver.getPublicKey();
+ if (/^[0-9a-f]{64}$/.test(pubkey) && !accounts.find(a => a.pubkey === pubkey)) {
+ accounts.push({ index: i, pubkey });
+ }
+ } catch (e) {
+ console.warn(`[${label}] account probe failed for index`, i, ':', e?.message || e);
+ }
+ }
+
+ if (!accounts.length) {
+ throw new Error('No account pubkeys returned by signer');
+ }
+
+ return accounts;
+ };
+
+ const renderAccountButtons = (connected) => {
+ this.modalBody.innerHTML = '';
+
+ const transportLabel = connected.transport === 'webserial' ? 'Web Serial' : 'WebUSB';
+
+ const selectionDescription = document.createElement('p');
+ selectionDescription.textContent = `Connected via ${transportLabel}. Select which account to use (${connected.accounts.length} discovered):`;
+ selectionDescription.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
+ this.modalBody.appendChild(selectionDescription);
+
+ const table = document.createElement('table');
+ table.style.cssText = `
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ font-family: var(--nl-font-family, 'Courier New', monospace);
+ font-size: 12px;
+ `;
+
+ const thead = document.createElement('thead');
+ thead.innerHTML = `
+