Unify hardware signer UX with serial-first auto-detect and WebUSB fallback; add transport-aware nsigner persistence/restore

This commit is contained in:
Laan Tungir
2026-05-27 07:25:28 -04:00
parent bdc9513e52
commit a20f17ea32
8 changed files with 3418 additions and 22 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,5 +2,5 @@
rsync -avz --chmod=644 --progress \
build/{nostr-lite.js,nostr.bundle.js} \
examples/{nsigner.html,feather_webusb_demo.html} \
examples/{nsigner.html,feather_webusb_demo.html,cyd_webserial_demo.html} \
ubuntu@laantungir.net:html/nostr-login-lite/

View File

@@ -0,0 +1,737 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>n_signer CYD Web Serial Demo</title>
<style>
:root {
--bg: #0b0f14;
--panel: #121821;
--panel-2: #182231;
--text: #e6edf3;
--muted: #9fb0c3;
--accent: #58a6ff;
--good: #3fb950;
--bad: #f85149;
--border: #263448;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.35;
}
.wrap {
max-width: 980px;
margin: 20px auto;
padding: 0 14px 24px;
}
h1 { margin: 0 0 8px; font-size: 1.45rem; }
p.note { margin: 0 0 14px; color: var(--muted); }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
margin-top: 12px;
}
.card {
background: linear-gradient(180deg, var(--panel), var(--panel-2));
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
}
.card h2 {
font-size: 1rem;
margin: 0 0 10px;
}
label {
font-size: 0.86rem;
color: var(--muted);
display: block;
margin: 6px 0 4px;
}
input, textarea, button {
font: inherit;
border-radius: 8px;
border: 1px solid var(--border);
}
input, textarea {
width: 100%;
background: #0c131d;
color: var(--text);
padding: 8px 10px;
}
textarea { min-height: 84px; resize: vertical; }
input[type="number"] { max-width: 130px; }
button {
background: #1f6feb;
color: white;
padding: 8px 12px;
cursor: pointer;
border: 0;
}
button[disabled] {
opacity: 0.55;
cursor: not-allowed;
}
.secondary { background: #334155; }
.danger { background: #7f1d1d; }
.status {
padding: 6px 10px;
border-radius: 999px;
background: #2a3648;
color: var(--muted);
font-size: 0.85rem;
border: 1px solid var(--border);
}
.status.ok { color: var(--good); border-color: #2f5a3a; }
.status.err { color: var(--bad); border-color: #6a3131; }
pre {
margin: 8px 0 0;
background: #0a1018;
border: 1px solid #1b2636;
color: #d7e2ee;
padding: 10px;
border-radius: 8px;
overflow: auto;
max-height: 220px;
font-size: 12px;
}
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.info-box {
background: #0d1f35;
border: 1px solid #1e3a5f;
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 14px;
font-size: 0.84rem;
color: var(--muted);
line-height: 1.5;
}
.info-box strong { color: var(--accent); }
</style>
</head>
<body>
<div class="wrap">
<h1>n_signer CYD Web Serial Demo</h1>
<p class="note">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.</p>
<div class="info-box">
<strong>Requirements:</strong> Chrome 89+, Edge 89+, or Brave. Web Serial is not available in Firefox or Safari.<br>
<strong>Linux users:</strong> Add yourself to the <code>dialout</code> group (<code>sudo usermod -aG dialout $USER</code>) and replug the device.<br>
<strong>Windows users:</strong> Install the WCH-IC CH340 driver if the port does not appear.<br>
<strong>Note:</strong> Close any serial monitor (Arduino IDE, idf.py monitor) before connecting — only one app can hold the port at a time.
</div>
<div class="card">
<div class="row">
<button id="connectBtn">Connect CYD (Serial)</button>
<button id="disconnectBtn" class="danger" disabled>Disconnect</button>
<span id="connStatus" class="status">Disconnected</span>
</div>
<pre id="log" class="mono"></pre>
</div>
<div class="grid">
<section class="card">
<h2>Public Key</h2>
<label for="keyIndex">Key index (nostr_index)</label>
<input id="keyIndex" type="text" inputmode="numeric" pattern="[0-9]*" value="0" />
<div class="row" style="margin-top:10px">
<button id="pubkeyBtn" disabled>Get Public Key</button>
</div>
<pre id="pubkeyOut" class="mono"></pre>
</section>
<section class="card">
<h2>Sign Kind 1 Event</h2>
<label for="kind1Content">Content</label>
<textarea id="kind1Content">hello from cyd webserial demo</textarea>
<label for="kind1Tags">Tags JSON (array)</label>
<input id="kind1Tags" value="[]" />
<div class="row" style="margin-top:10px">
<button id="signKind1Btn" disabled>Create + Sign kind 1</button>
</div>
<pre id="signOut" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-04 Encrypt</h2>
<label for="nip04Peer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip04Peer" placeholder="e.g. 64 hex chars" />
<label for="nip04Msg">Plaintext</label>
<textarea id="nip04Msg">hello via nip04</textarea>
<div class="row" style="margin-top:10px">
<button id="nip04EncBtn" disabled>Encrypt (nip04_encrypt)</button>
</div>
<pre id="nip04Out" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-04 Decrypt</h2>
<label for="nip04DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip04DecPeer" placeholder="e.g. 64 hex chars" />
<label for="nip04Cipher">Ciphertext</label>
<textarea id="nip04Cipher" placeholder="ciphertext?iv=..."></textarea>
<div class="row" style="margin-top:10px">
<button id="nip04DecBtn" disabled>Decrypt (nip04_decrypt)</button>
</div>
<pre id="nip04DecOut" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-44 Encrypt</h2>
<label for="nip44Peer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip44Peer" placeholder="e.g. 64 hex chars" />
<label for="nip44Msg">Plaintext</label>
<textarea id="nip44Msg">hello via nip44</textarea>
<div class="row" style="margin-top:10px">
<button id="nip44EncBtn" disabled>Encrypt (nip44_encrypt)</button>
</div>
<pre id="nip44Out" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-44 Decrypt</h2>
<label for="nip44DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip44DecPeer" placeholder="e.g. 64 hex chars" />
<label for="nip44Cipher">Ciphertext</label>
<textarea id="nip44Cipher" placeholder="base64 payload"></textarea>
<div class="row" style="margin-top:10px">
<button id="nip44DecBtn" disabled>Decrypt (nip44_decrypt)</button>
</div>
<pre id="nip44DecOut" class="mono"></pre>
</section>
</div>
</div>
<script type="module">
import { schnorr } from "https://esm.sh/@noble/curves@1.5.0/secp256k1?bundle";
// ── DOM refs ──────────────────────────────────────────────────────────────
const logEl = document.getElementById("log");
const connStatusEl = document.getElementById("connStatus");
const connectBtn = document.getElementById("connectBtn");
const disconnectBtn = document.getElementById("disconnectBtn");
const pubkeyBtn = document.getElementById("pubkeyBtn");
const signKind1Btn = document.getElementById("signKind1Btn");
const nip04EncBtn = document.getElementById("nip04EncBtn");
const nip04DecBtn = document.getElementById("nip04DecBtn");
const nip44EncBtn = document.getElementById("nip44EncBtn");
const nip44DecBtn = document.getElementById("nip44DecBtn");
const keyIndexEl = document.getElementById("keyIndex");
const pubkeyOutEl = document.getElementById("pubkeyOut");
const kind1ContentEl = document.getElementById("kind1Content");
const kind1TagsEl = document.getElementById("kind1Tags");
const signOutEl = document.getElementById("signOut");
const nip04PeerEl = document.getElementById("nip04Peer");
const nip04MsgEl = document.getElementById("nip04Msg");
const nip04OutEl = document.getElementById("nip04Out");
const nip04DecPeerEl = document.getElementById("nip04DecPeer");
const nip04CipherEl = document.getElementById("nip04Cipher");
const nip04DecOutEl = document.getElementById("nip04DecOut");
const nip44PeerEl = document.getElementById("nip44Peer");
const nip44MsgEl = document.getElementById("nip44Msg");
const nip44OutEl = document.getElementById("nip44Out");
const nip44DecPeerEl = document.getElementById("nip44DecPeer");
const nip44CipherEl = document.getElementById("nip44Cipher");
const nip44DecOutEl = document.getElementById("nip44DecOut");
// ── State ─────────────────────────────────────────────────────────────────
// CH340 VID:PID 0x1a86:0x7523 (most CYD revisions)
// CP2102 VID:PID 0x10c4:0xea60 (some CYD revisions)
const SERIAL_FILTERS = [
{ usbVendorId: 0x1a86, usbProductId: 0x7523 },
{ usbVendorId: 0x10c4, usbProductId: 0xea60 }
];
let port = null; // SerialPort
let writer = null; // WritableStreamDefaultWriter
let reader = null; // ReadableStreamDefaultReader
let ownPubkey = "";
let rpcCounter = 0;
// Pending RPC promises keyed by request id
const pending = new Map(); // id -> { resolve, reject, timer }
// ── Helpers ───────────────────────────────────────────────────────────────
function log(...args) {
logEl.textContent += args.join(" ") + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
function setStatus(text, mode = "") {
connStatusEl.textContent = text;
connStatusEl.className = `status ${mode}`.trim();
}
function setConnected(connected) {
connectBtn.disabled = connected;
disconnectBtn.disabled = !connected;
pubkeyBtn.disabled = !connected;
signKind1Btn.disabled = !connected;
nip04EncBtn.disabled = !connected;
nip04DecBtn.disabled = !connected;
nip44EncBtn.disabled = !connected;
nip44DecBtn.disabled = !connected;
}
function hex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
}
function utf8(s) {
return new TextEncoder().encode(s);
}
function be32(n) {
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
}
async function sha256Hex(dataBytes) {
const h = await crypto.subtle.digest("SHA-256", dataBytes);
return hex(new Uint8Array(h));
}
function getIndexOptions() {
const raw = Number.parseInt(String(keyIndexEl.value ?? "0"), 10);
const index = Number.isFinite(raw) && raw >= 0 ? raw : 0;
return { nostr_index: index };
}
function pretty(value) {
try { return JSON.stringify(value, null, 2); }
catch { return String(value); }
}
function requirePeerHex(peer) {
const v = String(peer || "").trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(v)) {
throw new Error("Peer pubkey must be exactly 64 hex chars (x-only pubkey)");
}
return v;
}
function requireStringResult(resp, what) {
if (resp && typeof resp.result === "string") return resp.result;
throw new Error(`${what} failed: ${pretty(resp)}`);
}
// ── Auth envelope ─────────────────────────────────────────────────────────
async function buildAuth(method, params) {
// Demo caller key — fixed, non-secret (demo only).
const callerPriv = Uint8Array.from({ length: 32 }, (_, i) => i + 1);
const callerPubX = hex(schnorr.getPublicKey(callerPriv));
const createdAt = Math.floor(Date.now() / 1000);
const paramsJson = JSON.stringify(params);
const bodyHash = await sha256Hex(utf8(paramsJson));
const tags = [
["nsigner_rpc", String(++rpcCounter)],
["nsigner_method", method],
["nsigner_body_hash", bodyHash],
];
const content = "cyd-webserial-demo";
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
const id = await sha256Hex(utf8(ser));
const sigBytes = await schnorr.sign(id, callerPriv, new Uint8Array(32));
const sigHex = typeof sigBytes === "string" ? sigBytes : hex(sigBytes);
return { id, pubkey: callerPubX, created_at: createdAt, kind: 27235, tags, content, sig: sigHex };
}
// ── Frame writer ──────────────────────────────────────────────────────────
async function writeFrame(reqObj) {
const body = utf8(JSON.stringify(reqObj));
const frame = new Uint8Array(4 + body.length);
frame.set(be32(body.length), 0);
frame.set(body, 4);
// Acquire writer, write, release immediately so other callers can use it.
const w = port.writable.getWriter();
try {
await w.write(frame);
} finally {
w.releaseLock();
}
}
// ── Background read loop ──────────────────────────────────────────────────
// Holds the reader for the lifetime of the connection.
// Parses length-prefixed frames and resolves pending RPC promises.
async function readLoop() {
let ring = new Uint8Array(0);
try {
reader = port.readable.getReader();
while (true) {
const { value, done } = await 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) {
log("⚠ Frame parse error:", String(e));
continue;
}
// Resolve the matching pending RPC.
if (resp && resp.id && pending.has(resp.id)) {
const { resolve, timer } = pending.get(resp.id);
pending.delete(resp.id);
clearTimeout(timer);
resolve(resp);
} else {
log("← unsolicited frame:", JSON.stringify(resp));
}
}
}
} catch (err) {
// reader.cancel() throws a non-error on clean close; ignore it.
if (err && err.name !== "AbortError") {
log("Read loop error:", String(err));
}
} finally {
try { reader.releaseLock(); } catch (_) {}
reader = null;
handleDisconnect("Device disconnected");
}
}
// ── RPC call ──────────────────────────────────────────────────────────────
function sendRpc(reqObj) {
return new Promise((resolve, reject) => {
const id = reqObj.id;
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error("Timed out waiting for n_signer response"));
}, 30000);
pending.set(id, { resolve, reject, timer });
writeFrame(reqObj).catch(err => {
pending.delete(id);
clearTimeout(timer);
reject(err);
});
});
}
async function rpcCall(method, params) {
const id = `cyd-${Date.now()}-${++rpcCounter}`;
const auth = await buildAuth(method, params);
const req = { jsonrpc: "2.0", id, method, params, auth };
log("→", method, JSON.stringify(params));
const resp = await sendRpc(req);
log("←", method, JSON.stringify(resp));
return resp;
}
// ── Connect / disconnect ──────────────────────────────────────────────────
async function applySignalStrategies(serialPort) {
const strategies = [
{ dataTerminalReady: false, requestToSend: false, label: "dtr=0 rts=0" },
{ dataTerminalReady: true, requestToSend: true, label: "dtr=1 rts=1" }
];
if (!serialPort?.setSignals) return;
for (const s of strategies) {
try {
await serialPort.setSignals({
dataTerminalReady: !!s.dataTerminalReady,
requestToSend: !!s.requestToSend
});
await new Promise(r => setTimeout(r, 120));
} catch (e) {
log("⚠ setSignals strategy failed:", s.label, String(e));
}
}
}
async function findReenumeratedPort(matchInfo = {}) {
try {
const ports = await navigator.serial.getPorts();
if (!ports || !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;
}
}
async function connect() {
if (!("serial" in navigator)) {
throw new Error("Web Serial API not available — use Chrome 89+, Edge 89+, or Brave");
}
const initiallySelected = await navigator.serial.requestPort({ filters: SERIAL_FILTERS });
const selectedInfo = initiallySelected.getInfo?.() || {};
let lastError = null;
for (let attempt = 0; attempt < 2; attempt++) {
let droppedDuringOpen = false;
try {
port = attempt === 0 ? initiallySelected : (await findReenumeratedPort(selectedInfo));
if (!port) throw new Error("Serial port not available after re-enumeration");
const onPortDisconnect = () => {
droppedDuringOpen = true;
handleDisconnect("Port disconnect event");
};
port.addEventListener("disconnect", onPortDisconnect);
await port.open({
baudRate: 115200,
dataBits: 8,
parity: "none",
stopBits: 1,
flowControl: "none"
});
await applySignalStrategies(port);
// Stabilize after open/signals: some CYD revisions briefly reset and re-enumerate.
await new Promise(r => setTimeout(r, 1200));
if (droppedDuringOpen || !port?.readable || !port?.writable) {
throw new Error("Serial port dropped during open stabilization");
}
// Start background read loop (does not block).
readLoop();
const info = port.getInfo();
const vid = info?.usbVendorId != null ? `0x${info.usbVendorId.toString(16).padStart(4,"0")}` : "?";
const pid = info?.usbProductId != null ? `0x${info.usbProductId.toString(16).padStart(4,"0")}` : "?";
setConnected(true);
setStatus(`Connected (VID:${vid} PID:${pid})`, "ok");
log(`Connected. VID:${vid} PID:${pid}`);
try {
await fetchOwnPubkeyAndFillPeers();
log("Default peer pubkeys set to selected signer pubkey");
} catch (e) {
log("Auto pubkey fetch failed:", String(e));
}
return;
} catch (e) {
lastError = e;
try { if (port) await port.close(); } catch (_) {}
await new Promise(r => setTimeout(r, 400));
}
}
throw new Error(`Failed to open CYD serial port: ${String(lastError?.message || lastError)}`);
}
async function disconnect() {
// Cancel the reader to break out of readLoop.
if (reader) {
try { await reader.cancel(); } catch (_) {}
}
// Cancel all pending RPCs.
for (const [id, { reject, timer }] of pending) {
clearTimeout(timer);
reject(new Error("Connection closed"));
}
pending.clear();
try {
if (port) await port.close();
} catch (_) {}
port = null;
setConnected(false);
setStatus("Disconnected");
log("Disconnected.");
}
function handleDisconnect(reason) {
if (!port) return; // already cleaned up
log("⚠", reason);
// Reject all pending RPCs.
for (const [id, { reject, timer }] of pending) {
clearTimeout(timer);
reject(new Error("Device disconnected"));
}
pending.clear();
port = null;
setConnected(false);
setStatus("Disconnected — device reset or unplugged", "err");
}
// ── Pubkey helper ─────────────────────────────────────────────────────────
async function fetchOwnPubkeyAndFillPeers() {
const params = [getIndexOptions()];
const resp = await rpcCall("get_public_key", params);
if (resp && typeof resp.result === "string") {
ownPubkey = resp.result.trim().toLowerCase();
pubkeyOutEl.textContent = pretty(resp);
if (/^[0-9a-f]{64}$/.test(ownPubkey)) {
nip04PeerEl.value = ownPubkey;
nip04DecPeerEl.value = ownPubkey;
nip44PeerEl.value = ownPubkey;
nip44DecPeerEl.value = ownPubkey;
}
}
}
// ── Button handlers ───────────────────────────────────────────────────────
connectBtn.addEventListener("click", async () => {
try {
await connect();
} catch (e) {
setStatus("Connect failed", "err");
log("Connect failed:", String(e));
}
});
disconnectBtn.addEventListener("click", async () => {
try {
await disconnect();
} catch (e) {
log("Disconnect error:", String(e));
}
});
pubkeyBtn.addEventListener("click", async () => {
try {
await fetchOwnPubkeyAndFillPeers();
} catch (e) {
pubkeyOutEl.textContent = String(e);
}
});
signKind1Btn.addEventListener("click", async () => {
try {
let tags = [];
try {
tags = JSON.parse(kind1TagsEl.value || "[]");
if (!Array.isArray(tags)) throw new Error("tags must be an array");
} catch (e) {
throw new Error(`Invalid tags JSON: ${String(e)}`);
}
const unsignedEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags,
content: String(kind1ContentEl.value || ""),
};
const params = [unsignedEvent, getIndexOptions()];
const resp = await rpcCall("sign_event", params);
signOutEl.textContent = pretty(resp?.result ?? resp);
} catch (e) {
signOutEl.textContent = String(e);
}
});
nip04EncBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip04PeerEl.value);
const msg = String(nip04MsgEl.value || "");
const params = [peer, msg, getIndexOptions()];
const resp = await rpcCall("nip04_encrypt", params);
nip04OutEl.textContent = pretty(resp?.result ?? resp);
if (resp && typeof resp.result === "string") {
nip04DecPeerEl.value = peer;
nip04CipherEl.value = resp.result;
}
} catch (e) {
nip04OutEl.textContent = String(e);
}
});
nip04DecBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip04DecPeerEl.value);
const ciphertext = String(nip04CipherEl.value || "");
const params = [peer, ciphertext, getIndexOptions()];
const resp = await rpcCall("nip04_decrypt", params);
nip04DecOutEl.textContent = requireStringResult(resp, "NIP-04 decrypt");
} catch (e) {
nip04DecOutEl.textContent = String(e);
}
});
nip44EncBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip44PeerEl.value);
const msg = String(nip44MsgEl.value || "");
const params = [peer, msg, getIndexOptions()];
const resp = await rpcCall("nip44_encrypt", params);
nip44OutEl.textContent = pretty(resp?.result ?? resp);
if (resp && typeof resp.result === "string") {
nip44DecPeerEl.value = peer;
nip44CipherEl.value = resp.result;
}
} catch (e) {
nip44OutEl.textContent = String(e);
}
});
nip44DecBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip44DecPeerEl.value);
const ciphertext = String(nip44CipherEl.value || "");
const params = [peer, ciphertext, getIndexOptions()];
const resp = await rpcCall("nip44_decrypt", params);
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
} catch (e) {
nip44DecOutEl.textContent = String(e);
}
});
</script>
</body>
</html>

View File

@@ -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 13 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).

View File

@@ -1 +1 @@
0.1.17
0.1.18

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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 = `
<tr style="background: #f3f4f6;">
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">#</th>
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">Use</th>
</tr>
`;
table.appendChild(thead);
const tbody = document.createElement('tbody');
connected.accounts.forEach((acct) => {
const row = document.createElement('tr');
row.style.cssText = 'border: 1px solid #d1d5db;';
const indexCell = document.createElement('td');
indexCell.textContent = acct.index;
indexCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;';
const actionCell = document.createElement('td');
actionCell.style.cssText = 'padding: 8px; border: 1px solid #d1d5db;';
const selectButton = document.createElement('button');
selectButton.textContent = shortHex(acct.pubkey);
selectButton.style.cssText = `
width: 100%;
padding: 8px 12px;
font-size: 11px;
background: var(--nl-secondary-color);
color: var(--nl-primary-color);
border: 1px solid var(--nl-primary-color);
border-radius: 4px;
cursor: pointer;
font-family: 'Courier New', monospace;
text-align: center;
`;
selectButton.onmouseover = () => { selectButton.style.borderColor = 'var(--nl-accent-color)'; };
selectButton.onmouseout = () => { selectButton.style.borderColor = 'var(--nl-primary-color)'; };
selectButton.onclick = () => {
connected.driver.nostrIndex = acct.index;
this._setAuthMethod('nsigner', {
pubkey: acct.pubkey,
signer: {
driver: connected.driver,
transport: connected.transport,
nostrIndex: acct.index,
callerSecretKey: connected.callerSecretKey,
callerPubkey: connected.callerPubkey,
deviceVid: connected.deviceVid,
deviceProductId: connected.deviceProductId,
deviceSerial: connected.deviceSerial
}
});
};
actionCell.appendChild(selectButton);
row.appendChild(indexCell);
row.appendChild(actionCell);
tbody.appendChild(row);
});
table.appendChild(tbody);
this.modalBody.appendChild(table);
const selectionBackButton = document.createElement('button');
selectionBackButton.textContent = 'Back';
selectionBackButton.onclick = () => this._renderLoginOptions();
selectionBackButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(selectionBackButton);
};
const connectNow = async ({ forceTransport = null, viaUserGesture = false } = {}) => {
try {
const caller = this._getOrCreateNSignerCallerIdentity();
const startIndex = 0;
const connectSerialFromPort = async (port) => {
const driver = new window.NSignerWebSerial(port, {
callerSecretKey: caller.secret,
nostrIndex: startIndex
});
await driver.open();
return driver;
};
const connectUsbFromDevice = async (device) => {
const driver = new window.NSignerWebUSB(device, {
callerSecretKey: caller.secret,
nostrIndex: startIndex
});
await driver.open();
return driver;
};
let transport = null;
let driver = null;
// Try silent paired-device reconnect first when this isn't a transport-forced retry.
if (!forceTransport) {
if (canSerial) {
try {
const pairedSerial = await window.NSignerWebSerial.getPairedDevice({});
if (pairedSerial) {
renderConnectingScreen('Reconnecting to paired Web Serial device...');
transport = 'webserial';
driver = await connectSerialFromPort(pairedSerial);
}
} catch (e) {
console.warn('Paired Web Serial reconnect failed:', e);
}
}
if (!driver && canUsb) {
try {
const pairedUsb = await window.NSignerWebUSB.getPairedDevice({
vendorId: 0x303a,
productId: 0x4001
});
if (pairedUsb) {
renderConnectingScreen('Reconnecting to paired WebUSB device...');
transport = 'webusb';
driver = await connectUsbFromDevice(pairedUsb);
}
} catch (e) {
console.warn('Paired WebUSB reconnect failed:', e);
}
}
}
// Need a new chooser path.
if (!driver) {
// Determine which transport to prompt. Default to Web Serial when available.
const target = forceTransport
|| (canSerial ? 'webserial' : (canUsb ? 'webusb' : null));
if (!target) {
throw new Error('No supported hardware transport available');
}
if (target === 'webserial') {
renderConnectingScreen('Waiting for Web Serial port selection...');
try {
driver = await window.NSignerWebSerial.requestAndConnect({
callerSecretKey: caller.secret,
nostrIndex: startIndex
});
transport = 'webserial';
} catch (serialErr) {
if (isChooserCancel(serialErr)) {
renderTransportChoiceScreen(
canUsb
? 'No serial port selected. Choose how to connect:'
: 'No serial port selected.'
);
return;
}
throw serialErr;
}
} else if (target === 'webusb') {
renderConnectingScreen('Waiting for WebUSB device selection...');
try {
const selectedDevice = await navigator.usb.requestDevice({ filters: [{ vendorId: 0x303a }] });
transport = 'webusb';
driver = await connectUsbFromDevice(selectedDevice);
} catch (usbErr) {
if (isChooserCancel(usbErr)) {
renderTransportChoiceScreen(
canSerial
? 'No USB device selected. Choose how to connect:'
: 'No USB device selected.'
);
return;
}
throw usbErr;
}
}
}
renderConnectingScreen('Connected. Reading account pubkeys...');
const accounts = await probeAccounts(driver, transport);
const serialInfo = transport === 'webserial' ? (driver._port?.getInfo?.() || {}) : null;
const connected = {
transport,
accounts,
callerSecretKey: caller.secret,
callerPubkey: caller.pubkey,
driver,
deviceVid: transport === 'webserial' ? (serialInfo.usbVendorId ?? null) : driver.vendorId,
deviceProductId: transport === 'webserial' ? (serialInfo.usbProductId ?? null) : driver.productId,
deviceSerial: transport === 'webserial' ? null : (driver.serial || null)
};
driver.onDisconnect(() => {
window.dispatchEvent(new CustomEvent('nlReconnectionRequired', {
detail: {
method: 'nsigner',
pubkey: accounts[0].pubkey,
connectionData: {
transport,
nostrIndex: accounts[0].index,
callerSecretKey: caller.secret,
callerPubkey: caller.pubkey,
deviceVid: connected.deviceVid,
deviceProductId: connected.deviceProductId,
deviceSerial: connected.deviceSerial
},
message: transport === 'webserial'
? 'Your hardware signer (Web Serial) was disconnected. Reconnect to continue.'
: 'Your hardware signer (WebUSB) was disconnected. Reconnect to continue.'
}
}));
});
restoreModalVisibility();
renderAccountButtons(connected);
} catch (error) {
const msg = String(error?.message || error);
let displayMsg;
if (msg.includes('NetworkError')) {
displayMsg = 'Device/port is already in use — close any serial monitor and try again.';
} else if (msg.includes('SecurityError')) {
displayMsg = 'Access denied. Check browser permissions and Linux udev/dialout setup.';
} else if (msg.includes('NotFoundError')) {
displayMsg = 'No device selected.';
} else {
displayMsg = `Connect failed: ${msg}`;
}
// Always re-surface a usable UI on failure.
renderTransportChoiceScreen(displayMsg);
}
};
connectButton.onclick = () => connectNow();
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(connectButton);
this.modalBody.appendChild(status);
this.modalBody.appendChild(accountList);
this.modalBody.appendChild(backButton);
if (autoPrompt) {
hiddenForChooser = true;
this.container.style.display = 'none';
connectNow();
return;
}
}
_showNSignerScreen({ autoPrompt = false } = {}) {
this.modalBody.innerHTML = '';
@@ -1531,6 +1949,7 @@ class Modal {
pubkey: acct.pubkey,
signer: {
driver: connected.driver,
transport: 'webusb',
nostrIndex: acct.index,
callerSecretKey: connected.callerSecretKey,
callerPubkey: connected.callerPubkey,
@@ -1616,6 +2035,7 @@ class Modal {
method: 'nsigner',
pubkey: accounts[0].pubkey,
connectionData: {
transport: 'webusb',
nostrIndex: accounts[0].index,
callerSecretKey: caller.secret,
callerPubkey: caller.pubkey,
@@ -1672,6 +2092,252 @@ class Modal {
}
}
_showCydSerialScreen({ autoPrompt = false } = {}) {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'CYD Serial 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 = 'Select your CYD (ESP32-2432S028) serial port when prompted, then choose one of the discovered accounts.';
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 hasSerial = typeof navigator !== 'undefined' && ('serial' in navigator);
if (!secure || !hasSerial) {
status.textContent = !secure
? 'Requires HTTPS or localhost for Web Serial.'
: 'Web Serial unavailable. Use Chrome 89+, Edge 89+, or Brave.';
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 = 'Choose Serial Port';
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 renderAccountButtons = (connected) => {
this.modalBody.innerHTML = '';
const selectionDescription = document.createElement('p');
selectionDescription.textContent = `Select which account to use (${connected.accounts.length} accounts discovered from CYD signer):`;
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 = `
<tr style="background: #f3f4f6;">
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">#</th>
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">Use</th>
</tr>
`;
table.appendChild(thead);
const tbody = document.createElement('tbody');
connected.accounts.forEach((acct) => {
const row = document.createElement('tr');
row.style.cssText = 'border: 1px solid #d1d5db;';
const indexCell = document.createElement('td');
indexCell.textContent = acct.index;
indexCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;';
const actionCell = document.createElement('td');
actionCell.style.cssText = 'padding: 8px; border: 1px solid #d1d5db;';
const selectButton = document.createElement('button');
selectButton.textContent = shortHex(acct.pubkey);
selectButton.style.cssText = `
width: 100%;
padding: 8px 12px;
font-size: 11px;
background: var(--nl-secondary-color);
color: var(--nl-primary-color);
border: 1px solid var(--nl-primary-color);
border-radius: 4px;
cursor: pointer;
font-family: 'Courier New', monospace;
text-align: center;
`;
selectButton.onmouseover = () => { selectButton.style.borderColor = 'var(--nl-accent-color)'; };
selectButton.onmouseout = () => { selectButton.style.borderColor = 'var(--nl-primary-color)'; };
selectButton.onclick = () => {
connected.driver.nostrIndex = acct.index;
this._setAuthMethod('nsigner', {
pubkey: acct.pubkey,
signer: {
driver: connected.driver,
transport: 'webserial',
nostrIndex: acct.index,
callerSecretKey: connected.callerSecretKey,
callerPubkey: connected.callerPubkey,
deviceVid: connected.deviceVid,
deviceProductId: connected.deviceProductId,
deviceSerial: null
}
});
};
actionCell.appendChild(selectButton);
row.appendChild(indexCell);
row.appendChild(actionCell);
tbody.appendChild(row);
});
table.appendChild(tbody);
this.modalBody.appendChild(table);
const selectionBackButton = document.createElement('button');
selectionBackButton.textContent = 'Back';
selectionBackButton.onclick = () => this._renderLoginOptions();
selectionBackButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(selectionBackButton);
};
const connectNow = async () => {
try {
const startIndex = 0;
status.textContent = 'Connecting...';
accountList.innerHTML = '';
const caller = this._getOrCreateNSignerCallerIdentity();
if (!window.NSignerWebSerial || typeof window.NSignerWebSerial !== 'function') {
throw new Error('NSignerWebSerial driver missing');
}
const driver = await window.NSignerWebSerial.requestAndConnect({
callerSecretKey: caller.secret,
nostrIndex: startIndex
});
status.textContent = 'Connected. Reading account pubkeys...';
const accounts = [];
for (let i = startIndex; i < startIndex + 6; i++) {
try {
driver.nostrIndex = i;
console.info('NSignerWebSerial probe index', i);
const pubkey = await driver.getPublicKey();
console.info('NSignerWebSerial probe index', i, 'pubkey=', pubkey);
if (/^[0-9a-f]{64}$/.test(pubkey) && !accounts.find(a => a.pubkey === pubkey)) {
accounts.push({ index: i, pubkey });
}
} catch (e) {
console.warn('NSignerWebSerial account probe failed for index', i, ':', e?.message || e);
}
}
console.info('NSignerWebSerial probe complete, accounts found:', accounts.length, accounts);
if (!accounts.length) {
throw new Error('No account pubkeys returned by CYD signer');
}
const info = driver._port?.getInfo?.() || {};
const connected = {
accounts,
callerSecretKey: caller.secret,
callerPubkey: caller.pubkey,
driver,
deviceVid: info.usbVendorId ?? null,
deviceProductId: info.usbProductId ?? null
};
driver.onDisconnect(() => {
window.dispatchEvent(new CustomEvent('nlReconnectionRequired', {
detail: {
method: 'nsigner',
pubkey: accounts[0].pubkey,
connectionData: {
transport: 'webserial',
nostrIndex: accounts[0].index,
callerSecretKey: caller.secret,
callerPubkey: caller.pubkey,
deviceVid: connected.deviceVid,
deviceProductId: connected.deviceProductId,
deviceSerial: null
},
message: 'Your CYD n_signer device was disconnected. Reconnect to continue.'
}
}));
});
if (hiddenForChooser && this.isVisible) {
this.container.style.display = prePromptDisplay || 'block';
hiddenForChooser = false;
}
renderAccountButtons(connected);
} catch (error) {
const msg = String(error?.message || error);
if (msg.includes('NotFoundError') || msg.includes('No port selected')) {
status.textContent = 'No serial port selected.';
} else if (msg.includes('NetworkError')) {
status.textContent = 'Port already in use — close any serial monitor and try again.';
} else if (msg.includes('SecurityError')) {
status.textContent = 'Access denied. On Linux, add yourself to the dialout group and replug.';
} else {
status.textContent = `Connect failed: ${msg}`;
}
}
if (hiddenForChooser && this.isVisible) {
this.container.style.display = prePromptDisplay || 'block';
hiddenForChooser = false;
}
};
connectButton.onclick = connectNow;
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(connectButton);
this.modalBody.appendChild(status);
this.modalBody.appendChild(accountList);
this.modalBody.appendChild(backButton);
if (autoPrompt) {
hiddenForChooser = true;
this.container.style.display = 'none';
connectNow();
return;
}
}
_showConnectScreen() {
this.modalBody.innerHTML = '';