Compare commits

..
17 Commits
Author SHA1 Message Date
Laan Tungir 5c45a4f3dc Fixed n_signer webserial/webusb sign-in failure: add nostr_ prefix to RPC method names (get_public_key, sign_event, nip04/nip44 encrypt/decrypt) required by upgraded n_signer firmware 2026-07-21 19:30:02 -04:00
Laan Tungir 2221e81c9b Migrated n_signer verb names to nostr_ prefix (sign_event→nostr_sign_event, get_public_key→nostr_get_public_key, nip04_*→nostr_nip04_*, nip44_*→nostr_nip44_*) 2026-07-20 18:04:15 -04:00
Laan Tungir 2ca4baad63 Bump version for unfiltered hardware signer USB/serial chooser and reconnect matching updates 2026-06-09 15:25:39 -04:00
Laan Tungir c4ecc8aca2 Remove USB/serial device filters and relax reconnect matching 2026-06-09 15:23:28 -04:00
Laan Tungir 43c8d0d3a3 Harden webserial session stability for hardware signer reconnect and probing 2026-05-28 09:23:09 -04:00
Laan Tungir b1218fa514 Improve nsigner multi-tab behavior with delegated restore state and clearer driver availability handling 2026-05-27 15:17:36 -04:00
Laan Tungir a20f17ea32 Unify hardware signer UX with serial-first auto-detect and WebUSB fallback; add transport-aware nsigner persistence/restore 2026-05-27 07:25:28 -04:00
Laan Tungir bdc9513e52 Add nsigner account probe diagnostics and fix modal visibility during chooser flow 2026-05-11 07:51:09 -04:00
Laan Tungir 1fd3862324 Add nsigner account chooser, modal sign-out action, and methods all/empty fallback 2026-05-10 18:25:58 -04:00
Laan Tungir 7f1dc00f49 USB hardware signer 2026-05-10 10:33:46 -04:00
Laan Tungir 973025602c USB hardware signer 2026-05-10 10:30:35 -04:00
Laan Tungir 7af85a45a7 Fix NIP-46 signing across pages by auto-reconnecting BunkerSigner 2026-04-04 09:40:23 -04:00
Your Name 71347ea1bf small change 2026-01-28 13:16:03 -04:00
Your Name 58d9b4386e Remove more logs 2025-11-14 14:40:05 -04:00
Your Name cb4f4b2a3c Remove more logs 2025-11-14 14:31:40 -04:00
Your Name 05a5306f86 Reorganize directories 2025-11-14 13:59:08 -04:00
Your Name 98b87de736 Comment out debug prints 2025-11-14 13:45:29 -04:00
17 changed files with 6843 additions and 293 deletions
+2
View File
@@ -22,7 +22,9 @@ await window.NOSTR_LOGIN_LITE.init({
extension: true, // Browser extensions (Alby, nos2x, etc.)
local: true, // Manual key entry & generation
readonly: true, // Read-only mode (no signing)
seedphrase: true,
connect: true, // NIP-46 remote signers
nsigner: true, // USB Hardware signer (n_signer via WebUSB)
otp: false // OTP/DM authentication (not implemented yet)
},
File diff suppressed because it is too large Load Diff
@@ -11146,12 +11146,12 @@ zoo`.split("\n");
}
async getPublicKey() {
if (!this.cachedPubKey) {
this.cachedPubKey = await this.sendRequest("get_public_key", []);
this.cachedPubKey = await this.sendRequest("nostr_get_public_key", []);
}
return this.cachedPubKey;
}
async signEvent(event) {
let resp = await this.sendRequest("sign_event", [JSON.stringify(event)]);
let resp = await this.sendRequest("nostr_sign_event", [JSON.stringify(event)]);
let signed = JSON.parse(resp);
if (verifyEvent(signed)) {
return signed;
@@ -11160,16 +11160,16 @@ zoo`.split("\n");
}
}
async nip04Encrypt(thirdPartyPubkey, plaintext) {
return await this.sendRequest("nip04_encrypt", [thirdPartyPubkey, plaintext]);
return await this.sendRequest("nostr_nip04_encrypt", [thirdPartyPubkey, plaintext]);
}
async nip04Decrypt(thirdPartyPubkey, ciphertext) {
return await this.sendRequest("nip04_decrypt", [thirdPartyPubkey, ciphertext]);
return await this.sendRequest("nostr_nip04_decrypt", [thirdPartyPubkey, ciphertext]);
}
async nip44Encrypt(thirdPartyPubkey, plaintext) {
return await this.sendRequest("nip44_encrypt", [thirdPartyPubkey, plaintext]);
return await this.sendRequest("nostr_nip44_encrypt", [thirdPartyPubkey, plaintext]);
}
async nip44Decrypt(thirdPartyPubkey, ciphertext) {
return await this.sendRequest("nip44_decrypt", [thirdPartyPubkey, ciphertext]);
return await this.sendRequest("nostr_nip44_decrypt", [thirdPartyPubkey, ciphertext]);
}
};
async function createAccount(bunker, params, username, domain, email, localSecretKey = generateSecretKey()) {
+4 -1
View File
@@ -1,3 +1,6 @@
#!/bin/bash
rsync -avz --chmod=644 --progress lite/{nostr-lite.js,nostr.bundle.js} ubuntu@laantungir.net:WWW/nostr-login-lite/
rsync -avz --chmod=644 --progress \
build/{nostr-lite.js,nostr.bundle.js} \
examples/{nsigner.html,feather_webusb_demo.html,cyd_webserial_demo.html} \
ubuntu@laantungir.net:html/nostr-login-lite/
+735
View File
@@ -0,0 +1,735 @@
<!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 ─────────────────────────────────────────────────────────────────
// Keep chooser unfiltered so any serial-capable device can be selected.
const SERIAL_FILTERS = [];
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 = SERIAL_FILTERS.length
? await navigator.serial.requestPort({ filters: SERIAL_FILTERS })
: await navigator.serial.requestPort();
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("nostr_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("nostr_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("nostr_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("nostr_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("nostr_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("nostr_nip44_decrypt", params);
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
} catch (e) {
nip44DecOutEl.textContent = String(e);
}
});
</script>
</body>
</html>
+527
View File
@@ -0,0 +1,527 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>n_signer Feather WebUSB 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; }
.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; }
</style>
</head>
<body>
<div class="wrap">
<h1>n_signer Feather WebUSB Demo</h1>
<p class="note">Connect to the board, choose a key index, fetch a pubkey, sign a kind 1 event, and test NIP-04 / NIP-44 encrypt + decrypt RPCs.</p>
<div class="card">
<div class="row">
<button id="connectBtn">Connect WebUSB</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 feather webusb 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";
const logEl = document.getElementById("log");
const connStatusEl = document.getElementById("connStatus");
const connectBtn = document.getElementById("connectBtn");
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");
let dev = null;
let iface = null;
let ownPubkey = "";
const EP_OUT = 1;
const EP_IN = 1;
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 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);
}
}
async function buildAuth(method, params) {
// Demo caller key only (not secret in browser demo).
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", "1"],
["nsigner_method", method],
["nsigner_body_hash", bodyHash],
];
const content = "webusb-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,
};
}
async function sendRpc(reqObj) {
const body = utf8(JSON.stringify(reqObj));
const frame = new Uint8Array(4 + body.length);
frame.set(be32(body.length), 0);
frame.set(body, 4);
await dev.transferOut(EP_OUT, frame);
const deadline = Date.now() + 10000;
let ring = new Uint8Array(0);
while (Date.now() < deadline) {
const r = await dev.transferIn(EP_IN, 512);
if (!r.data || r.data.byteLength === 0) continue;
const chunk = new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength);
const next = new Uint8Array(ring.length + chunk.length);
next.set(ring, 0);
next.set(chunk, ring.length);
ring = next;
while (ring.length >= 4) {
const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3];
if (n <= 0 || n > 1_000_000) {
ring = ring.slice(1);
continue;
}
if (ring.length < 4 + n) break;
const payload = ring.slice(4, 4 + n);
ring = ring.slice(4 + n);
const txt = new TextDecoder().decode(payload);
return JSON.parse(txt);
}
}
throw new Error("Timed out waiting for framed response");
}
async function rpcCall(method, params, id = "web-1") {
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;
}
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)}`);
}
async function fetchOwnPubkeyAndFillPeers() {
const params = [getIndexOptions()];
const resp = await rpcCall("nostr_get_public_key", params, "web-own-pubkey");
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;
}
}
}
async function connect() {
dev = await navigator.usb.requestDevice({ filters: [{}] });
await dev.open();
if (dev.configuration === null) {
await dev.selectConfiguration(1);
}
const intf = dev.configuration.interfaces.find(i =>
i.alternates.some(a => a.interfaceClass === 0xff)
);
if (!intf) throw new Error("No vendor WebUSB interface found");
iface = intf.interfaceNumber;
await dev.claimInterface(iface);
const alt = intf.alternates.find(a => a.interfaceClass === 0xff);
await dev.selectAlternateInterface(iface, alt.alternateSetting);
await dev.controlTransferOut({
requestType: "class",
recipient: "interface",
request: 0x22,
value: 1,
index: iface,
});
pubkeyBtn.disabled = false;
signKind1Btn.disabled = false;
nip04EncBtn.disabled = false;
nip04DecBtn.disabled = false;
nip44EncBtn.disabled = false;
nip44DecBtn.disabled = false;
setStatus(`Connected (iface ${iface})`, "ok");
log("Connected. Interface", String(iface));
try {
await fetchOwnPubkeyAndFillPeers();
log("Default peer pubkeys set to selected signer pubkey");
} catch (e) {
log("Auto pubkey fetch failed:", String(e));
}
}
connectBtn.addEventListener("click", async () => {
try {
await connect();
} catch (e) {
setStatus("Connect failed", "err");
log("Connect failed:", 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("nostr_sign_event", params, "web-sign-kind1");
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("nostr_nip04_encrypt", params, "web-nip04-enc");
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("nostr_nip04_decrypt", params, "web-nip04-dec");
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("nostr_nip44_encrypt", params, "web-nip44-enc");
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("nostr_nip44_decrypt", params, "web-nip44-dec");
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
} catch (e) {
nip44DecOutEl.textContent = String(e);
}
});
</script>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>n_signer WebUSB Test</title>
</head>
<body>
<h2>n_signer WebUSB Harness</h2>
<div id="status"></div>
<div id="results"></div>
<button id="launch">Launch Login Modal</button>
<button id="sign" disabled>Sign kind-1</button>
<button id="nip44" disabled>NIP-44 self encrypt/decrypt</button>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script>
const status = document.getElementById('status');
const results = document.getElementById('results');
const signBtn = document.getElementById('sign');
const nip44Btn = document.getElementById('nip44');
function setStatus(msg) {
status.textContent = msg;
}
function show(obj) {
results.innerHTML = `<pre>${typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2)}</pre>`;
}
(async () => {
setStatus('Initializing...');
await window.NOSTR_LOGIN_LITE.init({
theme: 'default',
methods: {
// extension: true,
// local: true,
// readonly: true,
// connect: true,
// nsigner: true,
// otp: false
},
floatingTab: { enabled: false }
});
if (!window.isSecureContext) {
setStatus('Not a secure context. Use HTTPS or localhost for WebUSB.');
} else if (!('usb' in navigator)) {
setStatus('WebUSB not available in this browser. Use Chrome/Edge.');
} else {
setStatus('Ready. Use Launch Login Modal and select USB Hardware signer.');
}
})();
document.getElementById('launch').addEventListener('click', () => {
window.NOSTR_LOGIN_LITE.launch('login');
});
window.addEventListener('nlMethodSelected', (event) => {
setStatus(`Authenticated with: ${event.detail.method}`);
const enabled = event.detail.method !== 'readonly';
signBtn.disabled = !enabled;
nip44Btn.disabled = !enabled;
});
window.addEventListener('nlAuthRestored', (event) => {
setStatus(`Restored auth: ${event.detail.method}`);
const enabled = event.detail.method !== 'readonly';
signBtn.disabled = !enabled;
nip44Btn.disabled = !enabled;
});
window.addEventListener('nlReconnectionRequired', (event) => {
setStatus(event.detail?.message || 'Reconnection required');
});
signBtn.addEventListener('click', async () => {
try {
const event = {
kind: 1,
content: 'hello from nsigner harness',
tags: [],
created_at: Math.floor(Date.now() / 1000)
};
const signed = await window.nostr.signEvent(event);
show(signed);
} catch (err) {
show(`signEvent failed: ${err.message}`);
}
});
nip44Btn.addEventListener('click', async () => {
try {
const pubkey = await window.nostr.getPublicKey();
const plaintext = 'self-test ' + Date.now();
const cipher = await window.nostr.nip44.encrypt(pubkey, plaintext);
const dec = await window.nostr.nip44.decrypt(pubkey, cipher);
show({ pubkey, plaintext, cipher, decrypted: dec, ok: dec === plaintext });
} catch (err) {
show(`nip44 failed: ${err.message}`);
}
});
</script>
</body>
</html>
+6 -6
View File
@@ -55,14 +55,14 @@ new_tag="v$new_version"
echo -e "${GREEN}📈 Incrementing version: $current_version$new_version${NC}"
# Step 2.5: Save version to lite/VERSION file
echo -e "${YELLOW}💾 Saving version to lite/VERSION...${NC}"
echo "$new_version" > lite/VERSION
# Step 2.5: Save version to src/VERSION file
echo -e "${YELLOW}💾 Saving version to src/VERSION...${NC}"
echo "$new_version" > src/VERSION
echo -e "Version saved: ${GREEN}$new_version${NC}"
# Step 2.5: Run build.js
echo -e "${YELLOW}🔧 Running build process...${NC}"
cd lite
cd src
node build.js
cd ..
echo -e "${GREEN}✅ Build completed${NC}"
@@ -100,8 +100,8 @@ git push --tags
echo -e "${GREEN}🎉 Successfully completed:${NC}"
echo -e " • Version incremented to: ${GREEN}$new_version${NC}"
echo -e " • VERSION file updated: ${GREEN}lite/VERSION${NC}"
echo -e " • Build completed: ${GREEN}lite/nostr-lite.js${NC}"
echo -e " • VERSION file updated: ${GREEN}src/VERSION${NC}"
echo -e " • Build completed: ${GREEN}build/nostr-lite.js${NC}"
echo -e " • Git tag created: ${GREEN}$new_tag${NC}"
echo -e " • Changes pushed to remote${NC}"
echo -e "\n${GREEN}✨ Process complete!${NC}"
-1
View File
@@ -1 +0,0 @@
0.1.8
+154
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).
+351
View File
@@ -0,0 +1,351 @@
# n_signer WebUSB Integration
Add WebUSB support for the [`n_signer`](../../n_signer/README.md:1) hardware signer (Feather S3 firmware) to [`nostr_login_lite`](../src/build.js:1) as a new login method in the modal. Any host page that consumes `window.nostr` automatically gets hardware signing — without per-page edits.
## 1. Why WebUSB is the only thing to add
`n_signer` is a multi-transport signer (one core dispatcher, many wire formats), but from inside a browser tab the picture is simple:
| n_signer transport | Reachable from this lib today? | Action |
|---|---|---|
| **WebUSB** (Feather S3, VID:PID `303a:4001`, framed JSON-RPC) | ✅ Yes | **This plan.** New code. |
| **NIP-46 bunker** (deferred upstream — [`plans/nip46_bunker_mode.md`](../../n_signer/plans/nip46_bunker_mode.md:1)) | ✅ When upstream ships | Free — uses existing `connect` tile. No code. |
| **Browser extension** (deferred upstream — [`plans/nsigner_browser_extension.md`](../../n_signer/plans/nsigner_browser_extension.md:1)) | ✅ When upstream ships | Free — uses existing `extension` tile. No code. |
| AF_UNIX, qrexec, stdio, raw TCP, CDC serial | ❌ Browser physics | Not in scope. |
So the entire integration is: **add a WebUSB tile.** The other paths land for free as `n_signer` ships them, through the modal options the lib already has.
## 2. What's already in place on both sides
### 2.1 `n_signer` (Feather S3 firmware)
- Composite USB device, **VID:PID `303a:4001`**, exposes a Vendor / WebUSB interface ([`firmware/README.md`](../../n_signer/firmware/README.md:7)).
- Wire format: **4-byte big-endian length prefix + JSON body**, JSON-RPC 2.0 shape ([`README.md`](../../n_signer/README.md:172)).
- Verbs we need: `get_public_key`, `sign_event`, `nip04_encrypt` / `nip04_decrypt`, `nip44_encrypt` / `nip44_decrypt` ([`README.md`](../../n_signer/README.md:183)).
- Selector: `{ nostr_index: N }`.
- Every WebUSB request must carry an **auth envelope**: a kind-27235 Nostr event signed by the *caller's* keypair, with required tags `nsigner_rpc`, `nsigner_method`, `nsigner_body_hash` (full spec: [`plans/caller_token_identity.md`](../../n_signer/plans/caller_token_identity.md:55)). Reference builder: [`feather_webusb_demo.html`](../../n_signer/examples/feather_webusb_demo.html:279).
### 2.2 `nostr_login_lite` (this repo)
The lib already has the right shape for "yet another signing method":
- Modal tiles render in [`src/ui/modal.js`](../src/ui/modal.js:201) and dispatch in `_handleOptionClick()` at [`src/ui/modal.js`](../src/ui/modal.js:335).
- The `WindowNostr` facade at [`src/build.js`](../src/build.js:1937) already switches on `this.authState.method` for all six NIP-07 methods. Switch locations:
| NIP-07 method | Switch line |
|---|---|
| `getPublicKey()` | [`src/build.js`](../src/build.js:2022) |
| `signEvent()` | [`src/build.js`](../src/build.js:2050) |
| `nip04.encrypt` | [`src/build.js`](../src/build.js:2102) |
| `nip04.decrypt` | [`src/build.js`](../src/build.js:2144) |
| `nip44.encrypt` | [`src/build.js`](../src/build.js:2190) |
| `nip44.decrypt` | [`src/build.js`](../src/build.js:2232) |
- `AuthManager` persistence is keyed on `authData.method`: persist switch at [`src/build.js`](../src/build.js:1410), restore switch at [`src/build.js`](../src/build.js:1488).
We add tile + matching `case` arms; no surface change.
## 3. Architecture
```mermaid
flowchart LR
subgraph Page[Host page using window.nostr]
UI[App calls window.nostr.signEvent]
end
subgraph Lite[nostr_login_lite]
Modal[Modal: pick n_signer USB]
Facade[WindowNostr facade]
Auth[AuthManager persistence]
end
subgraph Driver[NSignerWebUSB driver]
USB[WebUSB transport: 4-byte BE length + JSON]
RPC[JSON-RPC client]
AuthEnv[kind-27235 auth envelope signer]
end
Device[Feather S3 n_signer hardware]
UI --> Facade
Modal -- _setAuthMethod nsigner --> Facade
Facade -- getPublicKey/signEvent/nip04/nip44 --> Driver
Driver --> USB
USB --> Device
RPC --> USB
AuthEnv --> RPC
Modal -- saves caller key + index --> Auth
Auth -- restores on reload --> Facade
```
The **driver** is pure WebUSB + framing + JSON-RPC. The **facade** holds session state (`authState.signer.driver`, `nostrIndex`, caller keypair).
## 4. Driver module — `src/signers/nsigner-webusb.js`
New file. Distilled from [`feather_webusb_demo.html`](../../n_signer/examples/feather_webusb_demo.html:197) into an ES module / class.
### 4.1 Public surface
```js
class NSignerWebUSB {
static FILTERS = [{ vendorId: 0x303a, productId: 0x4001 }];
static async requestAndConnect(opts) // navigator.usb.requestDevice + open + claim
static async getPairedDevice(opts) // navigator.usb.getDevices() match for auto-restore
constructor(usbDevice, { callerSecretKey, nostrIndex = 0 })
async open() // selectConfiguration, claim vendor iface, controlTransferOut(0x22, 1)
async close() // release + close, disconnect listener cleanup
isOpen
vendorId / productId / serial
async getPublicKey() // -> hex x-only
async signEvent(unsignedEvent) // -> signed event with id/pubkey/sig
async nip04Encrypt(peerHex, plaintext)
async nip04Decrypt(peerHex, ciphertext)
async nip44Encrypt(peerHex, plaintext)
async nip44Decrypt(peerHex, ciphertext)
onDisconnect(cb) // forwarded from navigator.usb.ondisconnect
}
```
### 4.2 Internals (proven in the demo, just refactored)
- `_sendRpc(req)` — frame as `4-byte BE length || JSON`, `transferOut(EP_OUT=1, …)`, then `transferIn(EP_IN=1, 512)` until a full frame is reassembled. Same ring-buffer logic as [`feather_webusb_demo.html`](../../n_signer/examples/feather_webusb_demo.html:310).
- `_buildAuth(method, params)` — kind 27235 with required tags `nsigner_rpc`, `nsigner_method`, `nsigner_body_hash`, signed by `callerSecretKey` using `window.NostrTools.schnorr` (already in the bundle via `nostr-tools`). Demo reference: [`feather_webusb_demo.html`](../../n_signer/examples/feather_webusb_demo.html:279).
- All RPC params append `{ nostr_index }`. Configurable per call so future UI can switch identities without reconnect.
- Single in-flight request mutex — the firmware dispatches one at a time and the wire is one bulk endpoint pair.
The driver does **no UI** and does **no persistence**. Both stay in `nostr_login_lite`.
## 5. `nostr_login_lite` changes
### 5.1 Modal — new tile
In [`src/ui/modal.js`](../src/ui/modal.js:201) `_renderLoginOptions()`, gated by `this.options?.methods?.nsigner !== false`:
```js
options.push({
type: 'nsigner',
title: 'USB Hardware signer',
description: 'Sign with USB-connected n_signer hardware',
icon: '🔐'
});
```
Add a `case 'nsigner': this._showNSignerScreen()` in the dispatch switch at [`src/ui/modal.js`](../src/ui/modal.js:339).
Feature-detect at modal render:
- `!('usb' in navigator)` → tile shows disabled with a "Chrome/Edge required" hint.
- `!window.isSecureContext` → tile shows disabled with a "Requires HTTPS or localhost" hint (see §11 for why).
Mirrors how `extension` handles a missing `window.nostr`.
### 5.2 Modal — connect screen `_showNSignerScreen()`
UI elements:
- **"Connect Device"** button → calls `NSignerWebUSB.requestAndConnect()`.
- Numeric input for **Nostr key index** (`nostr_index`, default `0`). Persisted with the auth state.
- Status line that reads back the resolved pubkey once `getPublicKey` returns, for human verification against the device's TFT.
- A persistent "look at the device" hint while requests are in-flight (the on-device TFT may be prompting for approval per [`plans/feather_signer_ui.md`](../../n_signer/plans/feather_signer_ui.md:1)).
- **"Use this device"** → `_setAuthMethod('nsigner', { pubkey, signer: { driver, nostrIndex, callerPubkey } })`.
The screen also generates (or loads) the **caller keypair** for the auth envelope (per [`plans/caller_token_identity.md`](../../n_signer/plans/caller_token_identity.md:13)). Per-app, per-installation, generated once and persisted (§5.4). The first request triggers an on-device approval prompt — expected behavior; surface a "approve on device" hint while waiting.
### 5.3 `WindowNostr` facade — new branch
In [`src/build.js`](../src/build.js:1937) add a `case 'nsigner'` arm to each switch:
| Method | Switch location | Action |
|---|---|---|
| `getPublicKey()` | [`src/build.js`](../src/build.js:2022) | Return cached `authState.pubkey`. Verified at login; no round-trip per call. |
| `signEvent(event)` | [`src/build.js`](../src/build.js:2050) | `await authState.signer.driver.signEvent(event)`. |
| `nip04.encrypt` | [`src/build.js`](../src/build.js:2102) | `driver.nip04Encrypt(peer, text)`. |
| `nip04.decrypt` | [`src/build.js`](../src/build.js:2144) | `driver.nip04Decrypt(peer, ct)`. |
| `nip44.encrypt` | [`src/build.js`](../src/build.js:2190) | `driver.nip44Encrypt(peer, text)`. |
| `nip44.decrypt` | [`src/build.js`](../src/build.js:2232) | `driver.nip44Decrypt(peer, ct)`. |
The facade keeps a single `NSignerWebUSB` instance for the session. If the device is unplugged mid-session, `onDisconnect` fires, the facade clears the live driver, any pending call rejects with a recognizable error, and the modal can offer a "Reconnect device" path using `_attemptNSignerRestore()` (§5.4).
### 5.4 Persistence — `AuthManager`
Hardware signing has no master secret to encrypt. Persist:
```jsonc
{
"method": "nsigner",
"pubkey": "<hex of the device-derived nostr pubkey>",
"nostrIndex": 0,
"callerSecretKey": "<hex>",
"callerPubkey": "<hex>",
"deviceVid": 12346,
"deviceProductId": 16385,
"deviceSerial": "<usb serial if available>",
"timestamp": 1730000000000
}
```
Storage location follows existing `isolateSession` rules in [`src/build.js`](../src/build.js:1408). `callerSecretKey` is the only secret here and **is intentionally not** a Nostr identity — it is the per-app caller token (the identity the device approves once). Encrypting it with the same scheme used for `local` keys is a small non-blocking improvement.
Add a new branch in the persist switch at [`src/build.js`](../src/build.js:1410) (next to `'extension'`, `'local'`, `'nip46'`) and the matching restore branch at [`src/build.js`](../src/build.js:1488).
On page reload, [`src/build.js`](../src/build.js:1115) gets a new `_attemptNSignerRestore()`:
1. `navigator.usb.getDevices()` — already-permitted devices return without a prompt (per WebUSB spec).
2. Match `vid==0x303a && pid==0x4001`. If multiple, match `serialNumber` if stored.
3. If found, `open()`, then `getPublicKey()` and verify it matches stored `pubkey`. Mismatch → treat as logout (the user re-paired the device with a different mnemonic).
4. If no device is currently plugged in, dispatch `nlReconnectionRequired` (analogous to the NIP-46 path) so the host page can show a "Plug in your signer" prompt instead of silently failing.
### 5.5 Logout
`NOSTR_LOGIN_LITE.logout()` already dispatches `nlLogout`. Add a listener in the facade to call `driver.close()` and zeroize the cached caller key.
## 6. Open questions
1. **Caller-key generation.** The demo at [`feather_webusb_demo.html`](../../n_signer/examples/feather_webusb_demo.html:281) hard-codes `[1..32]` as the caller key. We must generate a fresh random caller key per browser profile (matches §1.2 of [`plans/caller_token_identity.md`](../../n_signer/plans/caller_token_identity.md:13)). Confirm.
2. **Methods config gating.** Add a `methods.nsigner: false` opt-out in `init()`, mirroring existing `methods.{extension,local,seedphrase,connect,readonly,otp}` flags. Update [`README.md`](../README.md:21) accordingly.
3. **Index switching at runtime.** Do we want a UI to switch `nostr_index` after login (effectively switching identities without re-pairing)? Easy to add via `NOSTR_LOGIN_LITE.setNSignerIndex(n)`. Defer to v1.1?
4. **Optional encryption of `callerSecretKey` at rest.** It's not a Nostr identity, but encrypting it with the same scheme as `local` keys is cheap and reduces casual exfiltration.
## 7. Risks & mitigations
| Risk | Mitigation |
|---|---|
| WebUSB unsupported in Firefox/Safari | Feature-detect `'usb' in navigator` at modal render; show the tile in a disabled state with a "Chrome/Edge required" tooltip. Mirrors how `extension` handles missing `window.nostr`. |
| Linux udev rule needed | Show the udev install snippet from [`firmware/README.md`](../../n_signer/firmware/README.md:56) inline in the connect screen if `requestDevice` errors with `SecurityError`. |
| Concurrent calls collide on the device | Driver mutex serializes RPCs. Tests cover overlapping `signEvent` from multiple async callers in the same page. |
| Device unplug mid-flow | `usb.ondisconnect` → reject pending, dispatch `nlReconnectionRequired`, keep `authState` so reconnect resumes seamlessly. |
| Approval prompts on the TFT block requests | Surface a "Approve on device" hint and a soft 30 s timeout that doesn't reject — keeps spinning until the device responds. |
| Bundle size growth | Driver is small (~58 KB). Reuses `nostr-tools` schnorr already in the bundle. No new deps. |
| User pairs a different mnemonic on the same physical device | Auto-restore step §5.4.3 detects the pubkey mismatch and forces re-login instead of silently signing with the wrong identity. |
| Caller-key leak from `localStorage` | Document that `callerSecretKey` is per-app, not a Nostr identity. Optionally encrypt at rest with the same scheme as `local`. |
## 8. Test plan
### 8.1 Unit (driver, no device)
Mock `USBDevice`. Verify:
- 4-byte BE length framing (out and in).
- Ring-buffer reassembly across split chunks of arbitrary boundaries.
- Auth envelope produces a kind-27235 event whose `nsigner_body_hash` matches `SHA-256(JCS(params))`.
- Auth envelope verifies with `nostr-tools` `verifyEvent()` round-trip.
- Mutex serializes overlapping `signEvent` calls.
- `onDisconnect` rejects in-flight calls with a recognizable error.
### 8.2 Manual (with real Feather S3 device)
- **Pair flow.** Open [`examples/sign.html`](../examples/sign.html), pick the n_signer tile, complete pairing, sign a kind-1 event.
- **Auto-restore.** Reload the page; the device should re-attach via `navigator.usb.getDevices()` without a permission prompt; pubkey verifies; signing works without re-pairing.
- **Unplug → replug.** While paired, unplug the device; `nlReconnectionRequired` should fire. Replug; "Reconnect device" button completes without re-entering the modal.
- **Wrong mnemonic.** Re-pair the device with a different mnemonic, reload host page; auto-restore should detect the pubkey mismatch and force logout.
- **NIP-04 / NIP-44 round-trip.** Encrypt to self, decrypt, verify equality.
- **Cross-method interaction.** Logout from `nsigner`; switch to `local`; back to `nsigner`. No leaked state in `localStorage`.
### 8.3 Negative
- Wrong index → device returns error; UI surfaces it.
- Device locked (firmware-side session locked) → `unauthorized` error surfaced.
- USB error mid-frame → driver rejects pending; mutex releases; next call works after reconnect.
- WebUSB denied at OS level (no udev rule) → friendly Linux hint shown.
### 8.4 Cross-browser
- Chrome, Edge: full support.
- Firefox, Safari: tile shows disabled state with hint; existing methods (`extension`, `local`, `connect`) remain unaffected.
## 9. Phased delivery
```mermaid
flowchart TD
P1[Phase 1: WebUSB driver module + harness] --> P2[Phase 2: Modal tile + facade branches]
P2 --> P3[Phase 3: Persistence + auto-restore]
P3 --> P4[Phase 4: Disconnect/reconnect UX + Linux udev hint]
P4 --> P5[Phase 5: Docs + example page]
```
- **Phase 1 — Driver.** Land [`src/signers/nsigner-webusb.js`](../src/signers/nsigner-webusb.js) plus a standalone harness page mirroring the upstream demo. No changes to the lib's public surface yet. Unit tests (§8.1) green.
- **Phase 2 — Modal tile + facade.** New tile, `_setAuthMethod('nsigner', …)`, six facade `case` arms. Manual smoke test from [`examples/sign.html`](../examples/sign.html).
- **Phase 3 — Persistence + auto-restore.** `AuthManager` `case 'nsigner'` (persist + restore), `_attemptNSignerRestore()` on init.
- **Phase 4 — Disconnect/reconnect.** `nlReconnectionRequired` parity with NIP-46. Friendly Linux udev snippet on first `SecurityError`.
- **Phase 5 — Docs.** Update [`README.md`](../README.md:21) `methods.nsigner`, add [`examples/nsigner.html`](../examples/nsigner.html), brief section in [`login_logic.md`](../login_logic.md:1) and a one-line note that `n_signer` users running NIP-46 bunker mode or the future browser extension use the existing `connect` / `extension` tiles respectively.
## 10. What we explicitly do not change
- The host page's per-page code — they all reach the new path through `window.nostr`.
- The `WindowNostr` facade's public surface — only new `case` arms, no removed/renamed methods.
- Existing methods (`extension`, `local`, `seedphrase`, `connect`, `readonly`, `otp`) — completely untouched.
- The build pipeline — only [`src/build.js`](../src/build.js:1) and the new [`src/signers/nsigner-webusb.js`](../src/signers/nsigner-webusb.js) feed the bundler.
## 11. Development workflow & deployment
### 11.1 WebUSB requires a secure context
`navigator.usb` is gated to **secure contexts only** ([WebUSB spec](https://wicg.github.io/webusb/)). The host page that loads `nostr-lite.js` must be served from one of:
-`https://anything.com` — production case.
-`http://localhost` or `http://127.0.0.1` — browsers treat these as secure for WebUSB.
-`file:///path/to/page.html` — Chrome treats `file://` as secure for WebUSB; viable for local examples / harness pages.
-`https://laantungir.net/...` — confirmed HTTPS, so the deployed copy at `https://laantungir.net/nostr-login-lite/` is WebUSB-eligible.
- ❌ Plain `http://` non-localhost origins — `requestDevice()` is undefined or throws. Not a concern in this project since the server is HTTPS.
Both `localhost` for dev and `https://laantungir.net` for staging/prod are eligible. No constraint blocks the integration.
### 11.2 Existing deploy pipeline
[`deploy.sh`](../deploy.sh:1) already pushes built artifacts to the server:
```bash
rsync -avz --chmod=644 --progress build/{nostr-lite.js,nostr.bundle.js} \
ubuntu@laantungir.net:html/nostr-login-lite/
```
[`increment_build_push.sh`](../increment_build_push.sh:1) handles version bump → `node src/build.js` → git commit/tag/push. The two scripts are independent; `deploy.sh` is the rsync step.
### 11.3 Recommended dev workflow for this feature
```mermaid
flowchart LR
A[Local edit src/build.js + signers/nsigner-webusb.js] --> B[node src/build.js]
B --> C{Test target?}
C -->|Local| D[Open examples/nsigner.html via http://localhost or file://]
C -->|Server| E[deploy.sh rsync to laantungir.net]
E --> F[https://laantungir.net/... must be HTTPS for WebUSB]
D --> G[Plug in Feather S3, run pair flow]
F --> G
G --> H[increment_build_push.sh on merge]
```
Concretely:
1. **Phase 1 / 2 dev iteration on `localhost`.** Run a tiny local static server in the workspace root:
```bash
python3 -m http.server 8080
# then open http://localhost:8080/examples/nsigner.html
```
`localhost` qualifies as a secure context, so `navigator.usb` works. No HTTPS / certs needed during development.
2. **Add an example page.** New file [`examples/nsigner.html`](../examples/nsigner.html:1), modeled on [`examples/sign.html`](../examples/sign.html:1) but with `methods: { nsigner: true }` and prominent secure-context detection. This becomes both the local-dev harness and the published test page at `https://laantungir.net/nostr-login-lite/nsigner.html`.
3. **Update [`deploy.sh`](../deploy.sh:1) to also push the new example page**:
```bash
rsync -avz --chmod=644 --progress \
build/{nostr-lite.js,nostr.bundle.js} \
examples/nsigner.html \
ubuntu@laantungir.net:html/nostr-login-lite/
```
4. **Server-side validation loop.** After local sign-off:
```bash
./deploy.sh
# then open https://laantungir.net/nostr-login-lite/nsigner.html in Chrome
# plug in Feather S3, run the pair flow end-to-end against the deployed bundle
```
This catches any same-origin / CDN / cache issue that wouldn't surface on `localhost`.
---
**Bottom line:** add a thin `NSignerWebUSB` driver, a new `nsigner` method tile (titled "USB Hardware signer"), and six `case 'nsigner'` branches across the `WindowNostr` facade and `AuthManager`. Develop against `http://localhost` (a WebUSB-eligible secure context), deploy through the existing [`deploy.sh`](../deploy.sh:1) once the server origin is confirmed HTTPS. Every existing host page that consumes `window.nostr` becomes USB-hardware-sign-capable with no per-page edits. Other `n_signer` transports (NIP-46 bunker, browser extension) land for free through the lib's existing `connect` and `extension` tiles when upstream ships them.
View File
+1
View File
@@ -0,0 +1 @@
0.1.22
+469 -99
View File
@@ -24,7 +24,7 @@ const path = require('path');
function createNostrLoginLiteBundle() {
// console.log('🔧 Creating NOSTR_LOGIN_LITE bundle for two-file architecture...');
const outputPath = path.join(__dirname, 'nostr-lite.js');
const outputPath = path.join(__dirname, '../build/nostr-lite.js');
// Remove old bundle
try {
@@ -56,10 +56,10 @@ if (typeof window !== 'undefined') {
throw new Error('Missing dependency: nostr.bundle.js');
}
console.log('NOSTR_LOGIN_LITE: Dependencies verified ✓');
console.log('NOSTR_LOGIN_LITE: NostrTools available with keys:', Object.keys(window.NostrTools));
console.log('NOSTR_LOGIN_LITE: NIP-06 available:', !!window.NostrTools.nip06);
console.log('NOSTR_LOGIN_LITE: NIP-46 available:', !!window.NostrTools.nip46);
// console.log('NOSTR_LOGIN_LITE: Dependencies verified ✓');
// console.log('NOSTR_LOGIN_LITE: NostrTools available with keys:', Object.keys(window.NostrTools));
// console.log('NOSTR_LOGIN_LITE: NIP-06 available:', !!window.NostrTools.nip06);
// console.log('NOSTR_LOGIN_LITE: NIP-46 available:', !!window.NostrTools.nip46);
}
// ======================================
@@ -109,7 +109,7 @@ if (typeof window !== 'undefined') {
bundle += ` style.id = 'nl-theme-css';\n`;
bundle += ` style.textContent = themeCss;\n`;
bundle += ` document.head.appendChild(style);\n`;
bundle += ` console.log('NOSTR_LOGIN_LITE: ' + themeName + ' theme CSS injected');\n`;
bundle += ` // console.log('NOSTR_LOGIN_LITE: ' + themeName + ' theme CSS injected');\n`;
bundle += ` }\n`;
bundle += `}\n\n`;
@@ -199,6 +199,68 @@ if (typeof window !== 'undefined') {
console.warn('⚠️ Modal UI not found: ui/modal.js');
}
// Add NSigner WebUSB driver
const nsignerDriverPath = path.join(__dirname, 'signers/nsigner-webusb.js');
if (fs.existsSync(nsignerDriverPath)) {
let nsignerContent = fs.readFileSync(nsignerDriverPath, 'utf8');
let lines = nsignerContent.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 WebUSB Driver\n`;
bundle += `// ======================================\n\n`;
bundle += lines.join('\n');
bundle += '\n\n';
} else {
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 += `
@@ -887,7 +949,7 @@ class NostrLite {
}
async init(options = {}) {
console.log('NOSTR_LOGIN_LITE: Initializing with options:', options);
// console.log('NOSTR_LOGIN_LITE: Initializing with options:', options);
this.options = {
theme: 'default',
@@ -899,6 +961,7 @@ class NostrLite {
seedphrase: false,
readonly: true,
connect: false,
nsigner: true,
otp: false
},
floatingTab: {
@@ -935,12 +998,12 @@ class NostrLite {
// Create modal during init (matching original git architecture)
this.modal = new Modal(this.options);
console.log('NOSTR_LOGIN_LITE: Modal created during init');
// console.log('NOSTR_LOGIN_LITE: Modal created during init');
// Initialize floating tab if enabled
if (this.options.floatingTab.enabled) {
this.floatingTab = new FloatingTab(this.modal, this.options.floatingTab);
console.log('NOSTR_LOGIN_LITE: Floating tab initialized');
// console.log('NOSTR_LOGIN_LITE: Floating tab initialized');
}
// Attempt to restore authentication state if persistence is enabled (AFTER facade is ready)
@@ -952,7 +1015,7 @@ class NostrLite {
}
this.initialized = true;
console.log('NOSTR_LOGIN_LITE: Initialization complete');
// console.log('NOSTR_LOGIN_LITE: Initialization complete');
return this;
}
@@ -1087,7 +1150,7 @@ class NostrLite {
}
launch(startScreen = 'login') {
console.log('NOSTR_LOGIN_LITE: Launching with screen:', startScreen);
// console.log('NOSTR_LOGIN_LITE: Launching with screen:', startScreen);
if (this.modal) {
this.modal.open({ startScreen });
@@ -1099,18 +1162,14 @@ class NostrLite {
// Attempt to restore authentication state
async _attemptAuthRestore() {
try {
console.log('🔍 NOSTR_LOGIN_LITE: === _attemptAuthRestore START ===');
console.log('🔍 NOSTR_LOGIN_LITE: hasExtension:', this.hasExtension);
console.log('🔍 NOSTR_LOGIN_LITE: facadeInstalled:', this.facadeInstalled);
console.log('🔍 NOSTR_LOGIN_LITE: window.nostr:', window.nostr?.constructor?.name);
if (this.hasExtension) {
// EXTENSION MODE: Use custom extension persistence logic
console.log('🔍 NOSTR_LOGIN_LITE: Extension mode - using extension-specific restore');
const restoredAuth = await this._attemptExtensionRestore();
if (restoredAuth) {
console.log('🔍 NOSTR_LOGIN_LITE: ✅ Extension auth restored successfully!');
return restoredAuth;
} else {
console.log('🔍 NOSTR_LOGIN_LITE: ❌ Extension auth could not be restored');
@@ -1122,14 +1181,11 @@ class NostrLite {
const restoredAuth = await window.nostr.restoreAuthState();
if (restoredAuth) {
console.log('🔍 NOSTR_LOGIN_LITE: ✅ Facade auth restored successfully!');
console.log('🔍 NOSTR_LOGIN_LITE: Method:', restoredAuth.method);
console.log('🔍 NOSTR_LOGIN_LITE: Pubkey:', restoredAuth.pubkey);
// CRITICAL FIX: Activate facade resilience system for non-extension methods
// Extensions like nos2x can override our facade after page refresh
if (restoredAuth.method === 'local' || restoredAuth.method === 'nip46') {
console.log('🔍 NOSTR_LOGIN_LITE: 🛡️ Activating facade resilience system for page refresh');
if (restoredAuth.method === 'local' || restoredAuth.method === 'nip46' || restoredAuth.method === 'nsigner') {
this._activateResilienceProtection(restoredAuth.method);
}
@@ -1261,7 +1317,7 @@ class NostrLite {
// Show prompt for NIP-46 reconnection
_showReconnectionPrompt(authData) {
console.log('NOSTR_LOGIN_LITE: Showing reconnection prompt for NIP-46');
// Dispatch event that UI can listen to
if (typeof window !== 'undefined') {
@@ -1270,7 +1326,9 @@ class NostrLite {
method: authData.method,
pubkey: authData.pubkey,
connectionData: authData.connectionData,
message: 'Your NIP-46 session has expired. Please reconnect to continue.'
message: authData.message || (authData.method === 'nsigner'
? 'Your n_signer device is not connected. Plug it in and reconnect to continue.'
: 'Your NIP-46 session has expired. Please reconnect to continue.')
}
}));
}
@@ -1393,10 +1451,10 @@ class AuthManager {
// Configure storage type based on isolateSession option
if (options.isolateSession) {
this.storage = sessionStorage;
console.log('🔐 AuthManager: Using sessionStorage for per-window isolation');
// console.log('🔐 AuthManager: Using sessionStorage for per-window isolation');
} else {
this.storage = localStorage;
console.log('🔐 AuthManager: Using localStorage for cross-window persistence');
// console.log('🔐 AuthManager: Using localStorage for cross-window persistence');
}
console.warn('🔐 SECURITY: Private keys stored unencrypted in browser storage');
@@ -1406,8 +1464,7 @@ class AuthManager {
// Save authentication state using unified plaintext approach
async saveAuthState(authData) {
try {
console.log('🔐 AuthManager: Saving auth state with plaintext storage');
console.warn('🔐 SECURITY: Private key will be stored unencrypted for maximum usability');
const authState = {
method: authData.method,
@@ -1423,33 +1480,47 @@ class AuthManager {
hasGetPublicKey: typeof authData.extension?.getPublicKey === 'function',
hasSignEvent: typeof authData.extension?.signEvent === 'function'
};
console.log('🔐 AuthManager: Extension method - storing verification data only');
break;
case 'local':
// UNIFIED PLAINTEXT: Store secret key directly for maximum compatibility
if (authData.secret) {
authState.secret = authData.secret;
console.log('🔐 AuthManager: Local method - storing secret key in plaintext');
console.warn('🔐 SECURITY: Secret key stored unencrypted for developer convenience');
}
break;
case 'nip46':
// For NIP-46, store connection parameters (no secrets)
// For NIP-46, store connection parameters including secret to allow auto-reconnect
if (authData.signer) {
authState.nip46 = {
remotePubkey: authData.signer.remotePubkey,
relays: authData.signer.relays,
// Don't store secret - user will need to reconnect
secret: authData.signer.secret,
localSecretKey: authData.signer.localSecretKey // We need to store this too
};
}
break;
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,
deviceVid: authData.signer.deviceVid,
deviceProductId: authData.signer.deviceProductId,
deviceSerial: authData.signer.deviceSerial || null
};
console.log('🔐 AuthManager: NIP-46 method - storing connection parameters');
}
break;
case 'readonly':
// Read-only mode has no secrets to store
console.log('🔐 AuthManager: Read-only method - storing basic auth state');
// console.log('🔐 AuthManager: Read-only method - storing basic auth state');
break;
default:
@@ -1458,7 +1529,7 @@ class AuthManager {
this.storage.setItem(this.storageKey, JSON.stringify(authState));
this.currentAuthState = authState;
console.log('🔐 AuthManager: Auth state saved successfully for method:', authData.method);
// console.log('🔐 AuthManager: Auth state saved successfully for method:', authData.method);
} catch (error) {
console.error('🔐 AuthManager: Failed to save auth state:', error);
@@ -1469,11 +1540,10 @@ class AuthManager {
// Restore authentication state on page load
async restoreAuthState() {
try {
console.log('🔍 AuthManager: === restoreAuthState START ===');
console.log('🔍 AuthManager: storageKey:', this.storageKey);
const stored = this.storage.getItem(this.storageKey);
console.log('🔍 AuthManager: Storage raw value:', stored);
if (!stored) {
console.log('🔍 AuthManager: ❌ No stored auth state found');
@@ -1481,10 +1551,7 @@ class AuthManager {
}
const authState = JSON.parse(stored);
console.log('🔍 AuthManager: ✅ Parsed stored auth state:', authState);
console.log('🔍 AuthManager: Method:', authState.method);
console.log('🔍 AuthManager: Timestamp:', authState.timestamp);
console.log('🔍 AuthManager: Age (ms):', Date.now() - authState.timestamp);
// Check if stored state is too old (24 hours for most methods, 1 hour for extensions)
const maxAge = authState.method === 'extension' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000;
@@ -1496,27 +1563,31 @@ class AuthManager {
return null;
}
console.log('🔍 AuthManager: ✅ Auth state not expired, attempting restore for method:', authState.method);
let result;
switch (authState.method) {
case 'extension':
console.log('🔍 AuthManager: Calling _restoreExtensionAuth...');
result = await this._restoreExtensionAuth(authState);
break;
case 'local':
console.log('🔍 AuthManager: Calling _restoreLocalAuth...');
result = await this._restoreLocalAuth(authState);
break;
case 'nip46':
console.log('🔍 AuthManager: Calling _restoreNip46Auth...');
result = await this._restoreNip46Auth(authState);
break;
case 'nsigner':
result = await this._restoreNSignerAuth(authState);
break;
case 'readonly':
console.log('🔍 AuthManager: Calling _restoreReadonlyAuth...');
result = await this._restoreReadonlyAuth(authState);
break;
@@ -1525,8 +1596,6 @@ class AuthManager {
return null;
}
console.log('🔍 AuthManager: Restore method result:', result);
console.log('🔍 AuthManager: === restoreAuthState END ===');
return result;
} catch (error) {
@@ -1552,19 +1621,14 @@ class AuthManager {
return null;
}
console.log('🔍 AuthManager: ✅ Extension found:', extension.constructor?.name);
try {
// Verify extension still works and has same pubkey
const currentPubkey = await extension.getPublicKey();
if (currentPubkey !== authState.pubkey) {
console.log('🔍 AuthManager: ❌ Extension pubkey changed, not restoring');
console.log('🔍 AuthManager: Expected:', authState.pubkey);
console.log('🔍 AuthManager: Got:', currentPubkey);
return null;
}
console.log('🔍 AuthManager: ✅ Extension auth restored successfully');
return {
method: 'extension',
pubkey: authState.pubkey,
@@ -1572,16 +1636,14 @@ class AuthManager {
};
} catch (error) {
console.log('🔍 AuthManager: ❌ Extension verification failed:', error);
return null;
}
}
// Smart extension waiting system - polls multiple locations for extensions
async _waitForExtension(authState, maxWaitMs = 3000) {
console.log('🔍 AuthManager: === _waitForExtension START ===');
console.log('🔍 AuthManager: maxWaitMs:', maxWaitMs);
console.log('🔍 AuthManager: Looking for extension with constructor:', authState.extensionVerification?.constructor);
const startTime = Date.now();
const pollInterval = 100; // Check every 100ms
@@ -1599,13 +1661,13 @@ class AuthManager {
];
while (Date.now() - startTime < maxWaitMs) {
console.log('🔍 AuthManager: Polling for extensions... (elapsed:', Date.now() - startTime, 'ms)');
// If our facade is currently installed and blocking, temporarily remove it
let facadeRemoved = false;
let originalNostr = null;
if (window.nostr?.constructor?.name === 'WindowNostr') {
console.log('🔍 AuthManager: Temporarily removing our facade to check for real extensions');
originalNostr = window.nostr;
window.nostr = window.nostr.existingNostr || undefined;
facadeRemoved = true;
@@ -1616,21 +1678,21 @@ class AuthManager {
for (const location of extensionLocations) {
try {
const extension = location.getter();
console.log('🔍 AuthManager: Checking', location.path, ':', !!extension, extension?.constructor?.name);
if (this._isValidExtensionForRestore(extension, authState)) {
console.log('🔍 AuthManager: ✅ Found matching extension at', location.path);
// Restore facade if we removed it
if (facadeRemoved && originalNostr) {
console.log('🔍 AuthManager: Restoring facade after finding extension');
window.nostr = originalNostr;
}
return extension;
}
} catch (error) {
console.log('🔍 AuthManager: Error checking', location.path, ':', error.message);
}
}
@@ -1691,31 +1753,27 @@ class AuthManager {
}
}
console.log('🔍 AuthManager: ✅ Extension validation passed for:', constructorName);
return true;
}
async _restoreLocalAuth(authState) {
console.log('🔐 AuthManager: === _restoreLocalAuth (Unified Plaintext) ===');
// Check for legacy encrypted format first
if (authState.encrypted) {
console.log('🔐 AuthManager: Detected LEGACY encrypted format - migrating to plaintext');
console.warn('🔐 SECURITY: Converting from encrypted to plaintext storage for compatibility');
// Try to decrypt legacy format
const sessionPassword = sessionStorage.getItem('nostr_session_key');
if (!sessionPassword) {
console.log('🔐 AuthManager: Legacy session password not found - user must re-login');
return null;
}
try {
console.warn('🔐 AuthManager: Legacy encryption system no longer supported - user must re-login');
this.clearAuthState(); // Clear legacy format
return null;
} catch (error) {
console.error('🔐 AuthManager: Legacy decryption failed:', error);
this.clearAuthState(); // Clear corrupted legacy format
return null;
}
@@ -1723,12 +1781,11 @@ class AuthManager {
// NEW UNIFIED PLAINTEXT FORMAT
if (!authState.secret) {
console.log('🔐 AuthManager: No secret found in plaintext format');
return null;
}
console.log('🔐 AuthManager: ✅ Local auth restored from plaintext storage');
console.warn('🔐 SECURITY: Secret key was stored unencrypted');
return {
method: 'local',
@@ -1739,14 +1796,92 @@ class AuthManager {
async _restoreNip46Auth(authState) {
if (!authState.nip46) {
console.log('🔐 AuthManager: No NIP-46 data found');
return null;
}
// For NIP-46, we can't automatically restore the connection
// because it requires the user to re-authenticate with the remote signer
// Instead, we return the connection parameters so the UI can prompt for reconnection
console.log('🔐 AuthManager: NIP-46 connection data found, requires user reconnection');
// If we have the local secret key and remote pubkey, we can auto-reconnect
if (authState.nip46.localSecretKey && authState.nip46.remotePubkey) {
try {
if (!window.NostrTools?.nip46) {
throw new Error('nostr-tools NIP-46 module not available');
}
const localSecretKey = window.NostrTools.nip19.decode(authState.nip46.localSecretKey).data;
// Reconstruct bunker pointer
const bunkerPointer = {
pubkey: authState.nip46.remotePubkey,
relays: authState.nip46.relays || [],
secret: authState.nip46.secret
};
// Create a SimplePool with a patched subscribe that filters out stale cached
// NIP-46 response events. Relays like primal.net store and replay previous
// "unauthorized" responses before we've even sent our request.
const sessionStart = Math.floor(Date.now() / 1000);
const gate = { open: false };
const signerRef = { current: null }; // forward ref so the closure can access signer after it's created
const pool = new window.NostrTools.SimplePool();
const origSubscribe = pool.subscribe.bind(pool);
pool.subscribe = (relays, filter, subParams) => {
const origOnevent = subParams.onevent;
subParams.onevent = async (event) => {
if (!gate.open) return; // drop events arriving before we open the gate
// Also skip if we can decrypt and see it's a response to a request id
// that we haven't sent yet (i.e. its id doesn't match any pending listener)
try {
const s = signerRef.current;
if (!s) return; // signer not yet created, drop
const decrypted = JSON.parse(window.NostrTools.nip44.decrypt(event.content, s.conversationKey));
if (decrypted.id && !s.listeners[decrypted.id]) {
// No pending listener for this id — it's stale, drop it
return;
}
} catch (_) {
// Can't decrypt — not for us, drop it
return;
}
return origOnevent(event);
};
return origSubscribe(relays, { ...filter, since: sessionStart }, subParams);
};
// Use nostr-tools BunkerSigner factory method, passing our patched pool
const signer = window.NostrTools.nip46.BunkerSigner.fromBunker(localSecretKey, bunkerPointer, {
pool,
onauth: (url) => {
window.open(url, '_blank', 'width=600,height=800');
}
});
signerRef.current = signer; // allow the onevent closure to access the signer
// Open the gate to allow events through
gate.open = true;
// We don't need to call connect() again if we already have the pubkey and secret,
// but we do need to ensure the signer is ready to sign.
// The BunkerSigner will automatically subscribe to the relays when we call signEvent.
return {
method: 'nip46',
pubkey: authState.pubkey,
signer: {
method: 'nip46',
remotePubkey: authState.nip46.remotePubkey,
bunkerSigner: signer,
secret: authState.nip46.secret,
relays: authState.nip46.relays
}
};
} catch (error) {
console.error('🔍 AuthManager: Failed to auto-reconnect NIP-46:', error);
// Fall back to requiring reconnection
}
}
// For NIP-46, if we can't auto-reconnect, we return the connection parameters
// so the UI can prompt for reconnection
return {
method: 'nip46',
pubkey: authState.pubkey,
@@ -1755,8 +1890,199 @@ class AuthManager {
};
}
async _restoreNSignerAuth(authState) {
const nsigner = authState.nsigner;
if (!nsigner) return null;
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 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 (err) {
const msg = String(err?.message || err || '');
const likelyBusy = /in use|already open|networkerror|failed to open/i.test(msg);
if (likelyBusy) {
return {
method: 'nsigner',
pubkey: authState.pubkey,
signer: {
driver: null,
delegatedOnly: true,
transport: 'webserial',
nostrIndex: Number(nsigner.nostrIndex ?? 0),
callerSecretKey: nsigner.callerSecretKey,
callerPubkey: nsigner.callerPubkey,
deviceVid: nsigner.deviceVid,
deviceProductId: nsigner.deviceProductId,
deviceSerial: null
},
message: 'CYD n_signer appears to be connected in another tab. Use that tab or delegate signing through your SharedWorker.'
};
}
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).'
};
}
const paired = await window.NSignerWebUSB.getPairedDevice({
vendorId: nsigner.deviceVid ?? null,
productId: nsigner.deviceProductId ?? null,
serialNumber: nsigner.deviceSerial || undefined
});
if (!paired) {
return {
method: 'nsigner',
pubkey: authState.pubkey,
requiresReconnection: true,
connectionData: nsigner,
message: 'Your n_signer device is not connected. Plug it in and reconnect to continue.'
};
}
try {
const driver = new window.NSignerWebUSB(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: 'webusb',
nostrIndex: Number(nsigner.nostrIndex ?? 0),
callerSecretKey: nsigner.callerSecretKey,
callerPubkey: nsigner.callerPubkey,
deviceVid: nsigner.deviceVid,
deviceProductId: nsigner.deviceProductId,
deviceSerial: nsigner.deviceSerial || null
}
};
} catch (err) {
const msg = String(err?.message || err || '');
const likelyBusy = /in use|already open|networkerror|unable to claim|claiminterface/i.test(msg);
if (likelyBusy) {
return {
method: 'nsigner',
pubkey: authState.pubkey,
signer: {
driver: null,
delegatedOnly: true,
transport: 'webusb',
nostrIndex: Number(nsigner.nostrIndex ?? 0),
callerSecretKey: nsigner.callerSecretKey,
callerPubkey: nsigner.callerPubkey,
deviceVid: nsigner.deviceVid,
deviceProductId: nsigner.deviceProductId,
deviceSerial: nsigner.deviceSerial || null
},
message: 'n_signer appears to be connected in another tab. Use that tab or delegate signing through your SharedWorker.'
};
}
return {
method: 'nsigner',
pubkey: authState.pubkey,
requiresReconnection: true,
connectionData: nsigner,
message: 'Could not reopen your n_signer device. Reconnect from the modal.'
};
}
}
async _restoreReadonlyAuth(authState) {
console.log('🔐 AuthManager: Read-only auth restored successfully');
return {
method: 'readonly',
pubkey: authState.pubkey
@@ -1768,7 +2094,7 @@ class AuthManager {
this.storage.removeItem(this.storageKey);
sessionStorage.removeItem('nostr_session_key'); // Clear legacy session key
this.currentAuthState = null;
console.log('🔐 AuthManager: Auth state cleared from unified storage');
}
// Check if we have valid stored auth
@@ -1811,8 +2137,8 @@ function getGlobalAuthManager() {
// **UNIFIED GLOBAL FUNCTION**: Set authentication state (works for all methods)
function setAuthState(authData, options = {}) {
try {
console.log('🌐 setAuthState: Setting global auth state for method:', authData.method);
console.warn('🔐 SECURITY: Using unified plaintext storage for maximum compatibility');
// Store in memory
globalAuthState = authData;
@@ -1821,7 +2147,7 @@ function setAuthState(authData, options = {}) {
const authManager = new AuthManager(options);
authManager.saveAuthState(authData);
console.log('🌐 setAuthState: Auth state saved successfully');
} catch (error) {
console.error('🌐 setAuthState: Failed to save auth state:', error);
throw error;
@@ -1844,13 +2170,13 @@ function getAuthState() {
}
if (!stored) {
console.log('🌐 getAuthState: No auth state found in storage');
// console.log('🌐 getAuthState: No auth state found in storage');
globalAuthState = null;
return null;
}
const authState = JSON.parse(stored);
console.log('🌐 getAuthState: Retrieved auth state:', authState.method);
// console.log('🌐 getAuthState: Retrieved auth state:', authState.method);
// Update in-memory cache
globalAuthState = authState;
@@ -1866,7 +2192,7 @@ function getAuthState() {
// **UNIFIED GLOBAL FUNCTION**: Clear authentication state (works for all methods)
function clearAuthState() {
try {
console.log('🌐 clearAuthState: Clearing global auth state');
// console.log('🌐 clearAuthState: Clearing global auth state');
// Clear in-memory state
globalAuthState = null;
@@ -1877,7 +2203,7 @@ function clearAuthState() {
sessionStorage.removeItem(storageKey);
sessionStorage.removeItem('nostr_session_key'); // Clear legacy session key
console.log('🌐 clearAuthState: Auth state cleared from all storage locations');
// console.log('🌐 clearAuthState: Auth state cleared from all storage locations');
} catch (error) {
console.error('🌐 clearAuthState: Failed to clear auth state:', error);
}
@@ -1951,8 +2277,13 @@ class WindowNostr {
}
});
window.addEventListener('nlLogout', () => {
window.addEventListener('nlLogout', async () => {
console.log('🔍 WindowNostr: nlLogout event received');
if (this.authState?.method === 'nsigner' && this.authState?.signer?.driver?.close) {
try { await this.authState.signer.driver.close(); } catch (_) {}
}
this.authState = null;
this.authenticatedExtension = null;
@@ -1979,6 +2310,9 @@ class WindowNostr {
case 'nip46':
return this.authState.pubkey;
case 'nsigner':
return this.authState.pubkey;
case 'readonly':
throw new Error('Read-only mode - cannot get public key');
@@ -2026,6 +2360,11 @@ class WindowNostr {
}
return await this.authState.signer.bunkerSigner.signEvent(event);
}
case 'nsigner': {
const driver = this._getNSignerDriver('signEvent');
return await driver.signEvent(event);
}
default:
throw new Error('Unsupported auth method: ' + this.authState.method);
@@ -2075,6 +2414,11 @@ class WindowNostr {
}
return await this.authState.signer.bunkerSigner.nip04Encrypt(pubkey, plaintext);
}
case 'nsigner': {
const driver = this._getNSignerDriver('nip04.encrypt');
return await driver.nip04Encrypt(pubkey, plaintext);
}
default:
throw new Error('Unsupported auth method: ' + this.authState.method);
@@ -2117,6 +2461,11 @@ class WindowNostr {
}
return await this.authState.signer.bunkerSigner.nip04Decrypt(pubkey, ciphertext);
}
case 'nsigner': {
const driver = this._getNSignerDriver('nip04.decrypt');
return await driver.nip04Decrypt(pubkey, ciphertext);
}
default:
throw new Error('Unsupported auth method: ' + this.authState.method);
@@ -2163,6 +2512,11 @@ class WindowNostr {
}
return await this.authState.signer.bunkerSigner.nip44Encrypt(pubkey, plaintext);
}
case 'nsigner': {
const driver = this._getNSignerDriver('nip44.encrypt');
return await driver.nip44Encrypt(pubkey, plaintext);
}
default:
throw new Error('Unsupported auth method: ' + this.authState.method);
@@ -2205,6 +2559,11 @@ class WindowNostr {
}
return await this.authState.signer.bunkerSigner.nip44Decrypt(pubkey, ciphertext);
}
case 'nsigner': {
const driver = this._getNSignerDriver('nip44.decrypt');
return await driver.nip44Decrypt(pubkey, ciphertext);
}
default:
throw new Error('Unsupported auth method: ' + this.authState.method);
@@ -2213,6 +2572,17 @@ class WindowNostr {
};
}
_getNSignerDriver(opName = 'operation') {
const driver = this.authState?.signer?.driver;
if (driver) return driver;
if (this.authState?.signer?.delegatedOnly) {
throw new Error('n_signer driver not available in this tab for ' + opName + '; signer is active in another tab');
}
throw new Error('n_signer driver not available');
}
_hexToUint8Array(hex) {
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string length');
@@ -2260,9 +2630,9 @@ if (typeof window !== 'undefined') {
_instance: nostrLite
};
console.log('NOSTR_LOGIN_LITE: Library loaded and ready');
console.log('NOSTR_LOGIN_LITE: Use window.NOSTR_LOGIN_LITE.init(options) to initialize');
console.log('NOSTR_LOGIN_LITE: Detected', nostrLite.extensionBridge.getExtensionCount(), 'browser extensions');
// console.log('NOSTR_LOGIN_LITE: Library loaded and ready');
// console.log('NOSTR_LOGIN_LITE: Use window.NOSTR_LOGIN_LITE.init(options) to initialize');
// console.log('NOSTR_LOGIN_LITE: Detected', nostrLite.extensionBridge.getExtensionCount(), 'browser extensions');
console.warn('🔐 SECURITY: Unified plaintext storage enabled for maximum developer usability');
} else {
// Node.js environment
+489
View File
@@ -0,0 +1,489 @@
class NSignerWebSerial {
// Keep Web Serial chooser unfiltered so users can select any serial port.
static FILTERS = [];
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 = NSignerWebSerial.FILTERS.length
? await navigator.serial.requestPort({ filters: NSignerWebSerial.FILTERS })
: await navigator.serial.requestPort();
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('nostr_get_public_key', params);
if (!resp || typeof resp.result !== 'string') {
throw new Error('Invalid nostr_get_public_key response');
}
return resp.result.trim().toLowerCase();
}
async signEvent(unsignedEvent) {
const params = [unsignedEvent, { nostr_index: this.nostrIndex }];
const resp = await this._rpcCall('nostr_sign_event', params);
if (!resp || typeof resp.result !== 'object') {
throw new Error('Invalid nostr_sign_event response');
}
return resp.result;
}
async nip04Encrypt(peerHex, plaintext) {
const resp = await this._rpcCall('nostr_nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_encrypt response');
return resp.result;
}
async nip04Decrypt(peerHex, ciphertext) {
const resp = await this._rpcCall('nostr_nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_decrypt response');
return resp.result;
}
async nip44Encrypt(peerHex, plaintext) {
const resp = await this._rpcCall('nostr_nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip44_encrypt response');
return resp.result;
}
async nip44Decrypt(peerHex, ciphertext) {
const resp = await this._rpcCall('nostr_nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_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;
// Prefer exact VID/PID match when available, but do not require it.
const exact = 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;
});
return exact || 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;
}
+358
View File
@@ -0,0 +1,358 @@
class NSignerWebUSB {
// Keep WebUSB chooser unfiltered so users can select any USB device.
// WebUSB requires a non-empty filters array; {} matches all devices.
static FILTERS = [{}];
static async requestAndConnect(options = {}) {
if (!('usb' in navigator)) {
throw new Error('WebUSB not available in this browser');
}
const device = await navigator.usb.requestDevice({ filters: NSignerWebUSB.FILTERS });
const driver = new NSignerWebUSB(device, options);
await driver.open();
return driver;
}
static async getPairedDevice(options = {}) {
if (!('usb' in navigator)) return null;
const devices = await navigator.usb.getDevices();
if (!devices || devices.length === 0) return null;
const vendorId = options.vendorId ?? null;
const productId = options.productId ?? null;
const serial = options.serialNumber || null;
return devices.find((d) => {
if (vendorId !== null && vendorId !== undefined && d.vendorId !== vendorId) {
return false;
}
if (productId !== null && productId !== undefined && d.productId !== productId) {
return false;
}
if (serial && d.serialNumber) return d.serialNumber === serial;
return true;
}) || null;
}
static randomSecretHex() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return NSignerWebUSB._hex(bytes);
}
static toPubkeyHex(secretHex) {
const secret = NSignerWebUSB._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() : NSignerWebUSB._hex(pub);
}
if (typeof nt.getPublicKey === 'function') {
const pub = nt.getPublicKey(secret);
return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebUSB._hex(pub);
}
throw new Error('NostrTools.getPublicKey is unavailable in this bundle');
}
constructor(usbDevice, { callerSecretKey, nostrIndex = 0 } = {}) {
if (!usbDevice) throw new Error('USB device is required');
if (!callerSecretKey) throw new Error('callerSecretKey is required');
this.device = usbDevice;
this.callerSecretKey = callerSecretKey;
this.nostrIndex = Number.isFinite(Number(nostrIndex)) ? Number(nostrIndex) : 0;
this.iface = null;
this.epIn = 1;
this.epOut = 1;
this._busy = false;
this._disconnectHandlers = new Set();
this._rpcCounter = 0;
this._boundDisconnect = (event) => {
if (event.device === this.device) {
for (const cb of this._disconnectHandlers) {
try { cb(event); } catch (_) {}
}
}
};
if (navigator.usb?.addEventListener) {
navigator.usb.addEventListener('disconnect', this._boundDisconnect);
}
}
get isOpen() {
return !!this.device?.opened;
}
get vendorId() {
return this.device?.vendorId;
}
get productId() {
return this.device?.productId;
}
get serial() {
return this.device?.serialNumber || null;
}
onDisconnect(cb) {
if (typeof cb === 'function') this._disconnectHandlers.add(cb);
return () => this._disconnectHandlers.delete(cb);
}
async open() {
if (!this.device.opened) await this.device.open();
if (this.device.configuration === null) await this.device.selectConfiguration(1);
const intf = this.device.configuration.interfaces.find(i =>
i.alternates.some(a => a.interfaceClass === 0xff)
);
if (!intf) throw new Error('No vendor WebUSB interface found');
this.iface = intf.interfaceNumber;
await this.device.claimInterface(this.iface);
const alt = intf.alternates.find(a => a.interfaceClass === 0xff);
if (alt) await this.device.selectAlternateInterface(this.iface, alt.alternateSetting);
await this.device.controlTransferOut({
requestType: 'class',
recipient: 'interface',
request: 0x22,
value: 1,
index: this.iface
});
}
async close() {
try {
if (this.device?.opened && this.iface !== null) {
await this.device.releaseInterface(this.iface);
}
} catch (_) {}
try {
if (this.device?.opened) await this.device.close();
} catch (_) {}
this.iface = null;
if (navigator.usb?.removeEventListener && this._boundDisconnect) {
navigator.usb.removeEventListener('disconnect', this._boundDisconnect);
}
}
async getPublicKey() {
const params = [{ nostr_index: this.nostrIndex }];
const resp = await this._rpcCall('nostr_get_public_key', params);
if (!resp || typeof resp.result !== 'string') {
throw new Error('Invalid nostr_get_public_key response');
}
return resp.result.trim().toLowerCase();
}
async signEvent(unsignedEvent) {
const params = [unsignedEvent, { nostr_index: this.nostrIndex }];
const resp = await this._rpcCall('nostr_sign_event', params);
if (!resp || typeof resp.result !== 'object') {
throw new Error('Invalid nostr_sign_event response');
}
return resp.result;
}
async nip04Encrypt(peerHex, plaintext) {
const resp = await this._rpcCall('nostr_nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_encrypt response');
return resp.result;
}
async nip04Decrypt(peerHex, ciphertext) {
const resp = await this._rpcCall('nostr_nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_decrypt response');
return resp.result;
}
async nip44Encrypt(peerHex, plaintext) {
const resp = await this._rpcCall('nostr_nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip44_encrypt response');
return resp.result;
}
async nip44Decrypt(peerHex, ciphertext) {
const resp = await this._rpcCall('nostr_nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip44_decrypt response');
return resp.result;
}
async _rpcCall(method, params) {
if (this._busy) {
throw new Error('n_signer device is busy; wait for previous request');
}
this._busy = true;
try {
const id = `nl-${Date.now()}-${++this._rpcCounter}`;
const auth = await this._buildAuth(id, method, params);
const req = { jsonrpc: '2.0', id, method, params, auth };
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;
} finally {
this._busy = false;
}
}
async _buildAuth(rpcId, method, params) {
const callerPriv = NSignerWebUSB._hexToBytes(this.callerSecretKey);
const callerPubX = NSignerWebUSB.toPubkeyHex(this.callerSecretKey);
const createdAt = Math.floor(Date.now() / 1000);
const paramsJson = JSON.stringify(params ?? null);
const bodyHash = await NSignerWebUSB._sha256Hex(NSignerWebUSB._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() because it is 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 path for bundles exposing schnorr directly.
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
const id = await NSignerWebUSB._sha256Hex(NSignerWebUSB._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 : NSignerWebUSB._hex(sigBytes);
return {
id,
pubkey: callerPubX,
created_at: createdAt,
kind: 27235,
tags,
content,
sig: sigHex
};
}
async _sendRpc(reqObj) {
const reqJson = JSON.stringify(reqObj);
console.info('NSigner _sendRpc outbound JSON:', reqJson);
const body = NSignerWebUSB._utf8(reqJson);
const frame = new Uint8Array(4 + body.length);
frame.set(NSignerWebUSB._be32(body.length), 0);
frame.set(body, 4);
console.info('NSigner _sendRpc transferOut:', {
endpoint: this.epOut,
payloadBytes: body.length,
frameBytes: frame.length
});
await this.device.transferOut(this.epOut, frame);
const deadline = Date.now() + 30000;
let ring = new Uint8Array(0);
while (Date.now() < deadline) {
const r = await this.device.transferIn(this.epIn, 512);
if (!r || !r.data || r.data.byteLength === 0) continue;
const chunk = new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength);
const next = new Uint8Array(ring.length + chunk.length);
next.set(ring, 0);
next.set(chunk, ring.length);
ring = next;
while (ring.length >= 4) {
const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3];
if (n <= 0 || n > 1_000_000) {
ring = ring.slice(1);
continue;
}
if (ring.length < 4 + n) break;
const payload = ring.slice(4, 4 + n);
ring = ring.slice(4 + n);
const txt = new TextDecoder().decode(payload);
return JSON.parse(txt);
}
}
throw new Error('Timed out waiting for n_signer response');
}
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 NSignerWebUSB._hex(new Uint8Array(h));
}
}
if (typeof window !== 'undefined') {
window.NSignerWebUSB = NSignerWebUSB;
}
File diff suppressed because it is too large Load Diff