v0.1.8 - Consolidated USB test page, fixed OTP round-trip, reformatted SD card, added pad checksum logging

This commit is contained in:
Laan Tungir
2026-08-01 05:26:42 -04:00
parent 0ea3e60391
commit e04196f1f1
6 changed files with 109 additions and 563 deletions
-527
View File
@@ -1,527 +0,0 @@
<!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("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: [{ vendorId: 0x303a }] });
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>
+5 -5
View File
@@ -46,10 +46,10 @@ CDC path:
./.venv/bin/python ./examples/feather_sign_event.py /dev/ttyACM0
```
WebUSB path:
WebUSB / Web Serial path (unified):
- Open [`examples/feather_webusb_demo.html`](../examples/feather_webusb_demo.html)
- Connect in Chrome/Edge
- Open [`usb-test.html`](../usb-test.html) in Chrome/Edge
- Connect via Web Serial (works for Teensy 4.1 USB CDC, CYD CH340, Feather, etc.)
- Run `get_public_key`
- Confirm pubkey matches CDC result
@@ -58,9 +58,9 @@ WebUSB path:
The CYD has no native USB; its CH340 bridge exposes a serial port. The browser
transport is **Web Serial** (`navigator.serial`), Chromium-only. A full test
page covering every algorithm and verb lives at
[`examples/cyd_webserial_demo.html`](../examples/cyd_webserial_demo.html):
[`usb-test.html`](../usb-test.html):
- Open [`examples/cyd_webserial_demo.html`](../examples/cyd_webserial_demo.html) in Chrome/Edge
- Open [`usb-test.html`](../usb-test.html) in Chrome/Edge
- Click **Connect Web Serial**, select the CH340 port (`1a86:7523`)
- Exercise each card: `get_public_key` (all 6 algorithms), `sign`/`verify`,
`encapsulate`/`decapsulate`, `derive_shared_secret`, `derive`,
+2 -2
View File
@@ -95,9 +95,9 @@ hardware note below.
The CYD has no native USB; the CH340 bridge exposes a serial port. The browser
transport is **Web Serial** (`navigator.serial`), Chromium-only. A full test
page covering every algorithm and verb lives at
[`examples/cyd_webserial_demo.html`](../../examples/cyd_webserial_demo.html):
[`usb-test.html`](../../usb-test.html):
1. Open [`examples/cyd_webserial_demo.html`](../../examples/cyd_webserial_demo.html) in Chrome/Edge.
1. Open [`usb-test.html`](../../usb-test.html) in Chrome/Edge.
2. Click **Connect Web Serial**, select the CH340 port (`1a86:7523`).
3. On the CYD touchscreen, enter or generate a mnemonic to reach the "ready" state.
4. Exercise each card: `get_public_key` (all 6 algorithms), `sign`/`verify`,
+12 -2
View File
@@ -20,7 +20,7 @@
// each boot. Mirrors CYD_DEBUG_AUTO_GENERATE in
// firmware/cyd_esp32_2432s028/main/main.c. Set to 0 for the normal interactive
// boot flow (show the startup menu, wait for the user to tap Generate/Enter).
#define DEBUG_AUTO_GENERATE 1
#define DEBUG_AUTO_GENERATE 0
#include <lvgl.h>
#include <ST7796_t3.h>
@@ -507,7 +507,17 @@ void setup() {
} else {
Serial.print("Found ");
Serial.print(n_pads);
Serial.println(" pad(s), showing selection screen...");
Serial.println(" pad(s):");
for (int i = 0; i < n_pads; i++) {
Serial.print(" [");
Serial.print(i);
Serial.print("] ");
Serial.print(pad_chksums[i]);
Serial.print(" (");
Serial.print((unsigned long)pad_sizes[i]);
Serial.println(" bytes)");
}
Serial.println("Showing selection screen...");
const char *chksum_ptrs[4];
for (int i = 0; i < n_pads; i++) chksum_ptrs[i] = pad_chksums[i];
char selected[65];
+2 -2
View File
@@ -762,8 +762,8 @@ int socket_name_random(char *out, size_t out_len);
/* Version information (auto-updated by build/version tooling) */
#define NSIGNER_VERSION_MAJOR 0
#define NSIGNER_VERSION_MINOR 1
#define NSIGNER_VERSION_PATCH 7
#define NSIGNER_VERSION "v0.1.7"
#define NSIGNER_VERSION_PATCH 8
#define NSIGNER_VERSION "v0.1.8"
/* NSIGNER_HEADERLESS_DECLS_END */
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>n_signer CYD Web Serial Demo</title>
<title>n_signer USB Test</title>
<script>
/* §12 Dark mode: set class synchronously before paint. */
(function () {
@@ -342,7 +342,7 @@
<div class="divHeaderButtons">
<button id="btnHamburger" title="Menu" aria-label="Open menu"></button>
</div>
<div class="divHeaderText">n_signer CYD Web Serial</div>
<div class="divHeaderText">n_signer USB Test</div>
<div class="divHeaderButtons"></div>
</div>
@@ -350,7 +350,7 @@
<!-- Connection card (full width) -->
<section class="divPostItem full">
<h2>Connection</h2>
<p class="note">Connect to the CYD (CH340 serial) via Web Serial, then exercise every algorithm and verb in the n_signer API. Chrome/Edge/Brave/Opera only.</p>
<p class="note">Connect to the signer (Teensy 4.1 USB CDC, CYD CH340 serial, etc.) via Web Serial, then exercise every algorithm and verb in the n_signer API. Chrome/Edge/Brave/Opera only.</p>
<div class="row">
<button id="connectBtn" class="btn">Connect Web Serial</button>
<button id="disconnectBtn" class="btn secondary" disabled>Disconnect</button>
@@ -464,7 +464,7 @@
<section class="divPostItem section">
<h2>nostr_sign_event</h2>
<label for="nseContent">content</label>
<textarea id="nseContent" class="inpStyle">hello from cyd webserial demo</textarea>
<textarea id="nseContent" class="inpStyle">hello from usb test</textarea>
<label for="nseIdx">nostr_index</label>
<input id="nseIdx" type="number" value="0" min="0" class="inpStyle" />
<div class="row">
@@ -478,7 +478,7 @@
<h2>nostr_mine_event</h2>
<p class="warn">Slow on ESP32 — uses single-threaded PoW. Keep difficulty low.</p>
<label for="nmeContent">content</label>
<textarea id="nmeContent" class="inpStyle">mined by cyd</textarea>
<textarea id="nmeContent" class="inpStyle">mined by usb test</textarea>
<label for="nmeIdx">nostr_index</label>
<input id="nmeIdx" type="number" value="0" min="0" class="inpStyle" />
<label for="nmeDiff">difficulty (leading zero bits)</label>
@@ -530,9 +530,9 @@
<!-- OTP encrypt / decrypt -->
<section class="divPostItem section">
<h2>encrypt / decrypt (otp)</h2>
<p class="note">OTP pad is derived from the mnemonic on the CYD. Offset advances monotonically.</p>
<label for="otpPlain">plaintext (base64)</label>
<textarea id="otpPlain" class="inpStyle">SGVsbG8sIE9UUCB3b3JsZCE=</textarea>
<p class="note">OTP pad is bound from the SD card (Teensy 4.1) or derived from the mnemonic (CYD). Offset advances monotonically.</p>
<label for="otpPlain">plaintext</label>
<textarea id="otpPlain" class="inpStyle">Secret message.</textarea>
<label for="otpCipher">ciphertext (for decrypt, base64)</label>
<textarea id="otpCipher" class="inpStyle" placeholder="filled by encrypt"></textarea>
<label for="otpEnc">encoding</label>
@@ -555,17 +555,17 @@
<div id="divSideNav">
<button id="btnCloseSideNav" title="Close menu" aria-label="Close menu">×</button>
<div id="divSideNavBody">
<h3>n_signer CYD Web Serial Demo</h3>
<p>A browser control panel for the CYD (ESP32-2432S028) signer firmware. Speaks the n_signer JSON-RPC protocol over Web Serial at 115200 baud.</p>
<h3>n_signer USB Test</h3>
<p>A browser control panel for any n_signer hardware signer (Teensy 4.1, CYD ESP32-2432S028, etc.). Speaks the n_signer JSON-RPC protocol over Web Serial at 115200 baud.</p>
<h3>Getting started</h3>
<p>1. Plug the CYD into a USB port (CH340 enumerates as /dev/ttyUSB*).</p>
<p>2. Click <code>Connect Web Serial</code> and pick the CYD port.</p>
<p>1. Plug the signer into a USB port (Teensy 4.1 enumerates as /dev/ttyACM*, CYD CH340 enumerates as /dev/ttyUSB*).</p>
<p>2. Click <code>Connect Web Serial</code> and pick the signer's port.</p>
<p>3. <code>get_info</code> fires automatically and reports firmware version + supported verbs.</p>
<p>4. Enter a mnemonic on the CYD touchscreen to enable the signing verbs.</p>
<p>4. Enter a mnemonic on the signer's touchscreen to enable the signing verbs.</p>
<h3>Auth envelope</h3>
<p>Every request is signed with a demo secp256k1 key (priv = 0x0102…2020). The CYD verifies the kind-27235 auth event and replays-protects by event id.</p>
<p>Every request is signed with a demo secp256k1 key (priv = 0x0102…2020). The signer verifies the kind-27235 auth event and replays-protects by event id.</p>
<h3>Transports</h3>
<p>Frames are 4-byte big-endian length + UTF-8 JSON payload. Same wire format as the host Unix-socket and TCP transports.</p>
@@ -632,7 +632,7 @@
["nsigner_method", method],
["nsigner_body_hash", bodyHash],
];
const content = "cyd-webserial-demo";
const content = "usb-test";
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));
@@ -695,7 +695,7 @@
setTimeout(() => {
if (pendingResolve === resolve) {
pendingResolve = null;
reject(new Error("timeout (30s) — check the CYD screen for an approval prompt"));
reject(new Error("timeout (30s) — check the signer screen for an approval prompt"));
}
}, 65000);
});
@@ -747,7 +747,7 @@
rxBuffer = new Uint8Array(0);
readLoop();
setStatus("Connected", "ok");
log("Connected to CYD via Web Serial @ 115200 baud");
log("Connected to signer via Web Serial @ 115200 baud");
document.querySelectorAll("button[id$='Btn']").forEach(b => {
if (b !== connectBtn && b !== disconnectBtn) b.disabled = false;
});
@@ -860,10 +860,21 @@
callVerb("nostr_mine_event", [event, { nostr_index: idx, difficulty: diff, timeout_sec: timeout }], $("nmeOut"));
});
const nip04Enc = () => {
const nip04Enc = async () => {
const peer = $("nip04Peer").value.trim(), msg = $("nip04Msg").value, idx = Number($("nip04Idx").value || 0);
if (!peer) { $("nip04Out").textContent = "✗ enter peer pubkey"; return; }
callVerb("nostr_nip04_encrypt", [peer, msg, { nostr_index: idx }], $("nip04Out"));
const params = [peer, msg, { nostr_index: idx }];
$("nip04Out").textContent = "→ nostr_nip04_encrypt " + JSON.stringify(params);
try {
const auth = await buildAuth("nostr_nip04_encrypt", params);
const resp = await sendRpc({ id: String(Math.floor(Math.random()*1e9)), method: "nostr_nip04_encrypt", params, auth });
$("nip04Out").textContent += "\n← " + pretty(resp);
if (resp && resp.result) {
$("nip04Cipher").value = resp.result;
}
} catch (e) {
$("nip04Out").textContent += "\n✗ " + e.message;
}
};
const nip04Dec = () => {
const peer = $("nip04Peer").value.trim(), ct = $("nip04Cipher").value, idx = Number($("nip04Idx").value || 0);
@@ -873,10 +884,21 @@
$("nip04EncBtn").addEventListener("click", nip04Enc);
$("nip04DecBtn").addEventListener("click", nip04Dec);
const nip44Enc = () => {
const nip44Enc = async () => {
const peer = $("nip44Peer").value.trim(), msg = $("nip44Msg").value, idx = Number($("nip44Idx").value || 0);
if (!peer) { $("nip44Out").textContent = "✗ enter peer pubkey"; return; }
callVerb("nostr_nip44_encrypt", [peer, msg, { nostr_index: idx }], $("nip44Out"));
const params = [peer, msg, { nostr_index: idx }];
$("nip44Out").textContent = "→ nostr_nip44_encrypt " + JSON.stringify(params);
try {
const auth = await buildAuth("nostr_nip44_encrypt", params);
const resp = await sendRpc({ id: String(Math.floor(Math.random()*1e9)), method: "nostr_nip44_encrypt", params, auth });
$("nip44Out").textContent += "\n← " + pretty(resp);
if (resp && resp.result) {
$("nip44Cipher").value = resp.result;
}
} catch (e) {
$("nip44Out").textContent += "\n✗ " + e.message;
}
};
const nip44Dec = () => {
const peer = $("nip44Peer").value.trim(), ct = $("nip44Cipher").value, idx = Number($("nip44Idx").value || 0);
@@ -886,14 +908,55 @@
$("nip44EncBtn").addEventListener("click", nip44Enc);
$("nip44DecBtn").addEventListener("click", nip44Dec);
$("otpEncBtn").addEventListener("click", () => {
/* OTP encrypt expects base64 plaintext; decrypt returns base64 plaintext.
The UI lets the user type plain text, so we encode/decode transparently. */
function utf8ToB64(str) {
return btoa(unescape(encodeURIComponent(str)));
}
function b64ToUtf8(b64) {
try { return decodeURIComponent(escape(atob(b64))); }
catch (e) { return atob(b64); }
}
$("otpEncBtn").addEventListener("click", async () => {
const pt = $("otpPlain").value, enc = $("otpEnc").value;
callVerb("encrypt", [pt, { algorithm: "otp", encoding: enc }], $("otpOut"));
const ptB64 = utf8ToB64(pt);
const params = [ptB64, { algorithm: "otp", encoding: enc }];
$("otpOut").textContent = "→ encrypt " + JSON.stringify(params);
try {
const auth = await buildAuth("encrypt", params);
const resp = await sendRpc({ id: String(Math.floor(Math.random()*1e9)), method: "encrypt", params, auth });
$("otpOut").textContent += "\n← " + pretty(resp);
if (resp && resp.result) {
/* OTP result is a JSON string: {"ciphertext": ...} */
let r = resp.result;
if (typeof r === "string") { try { r = JSON.parse(r); } catch (_) { /* leave as string */ } }
const ct = (r && r.ciphertext) ? r.ciphertext : (typeof resp.result === "string" ? resp.result : null);
if (ct) { $("otpCipher").value = ct; }
}
} catch (e) {
$("otpOut").textContent += "\n✗ " + e.message;
}
});
$("otpDecBtn").addEventListener("click", () => {
$("otpDecBtn").addEventListener("click", async () => {
const ct = $("otpCipher").value, enc = $("otpEnc").value;
if (!ct) { $("otpOut").textContent = "✗ paste ciphertext first (from encrypt)"; return; }
callVerb("decrypt", [ct, { algorithm: "otp", encoding: enc }], $("otpOut"));
const params = [ct, { algorithm: "otp", encoding: enc }];
$("otpOut").textContent = "→ decrypt " + JSON.stringify(params);
try {
const auth = await buildAuth("decrypt", params);
const resp = await sendRpc({ id: String(Math.floor(Math.random()*1e9)), method: "decrypt", params, auth });
$("otpOut").textContent += "\n← " + pretty(resp);
if (resp && resp.result) {
let r = resp.result;
if (typeof r === "string") { try { r = JSON.parse(r); } catch (_) { /* leave as string */ } }
const ptB64 = (r && r.plaintext) ? r.plaintext : (typeof resp.result === "string" ? resp.result : null);
if (ptB64) {
$("otpOut").textContent += "\nplaintext: " + b64ToUtf8(ptB64);
}
}
} catch (e) {
$("otpOut").textContent += "\n✗ " + e.message;
}
});
if (!("serial" in navigator)) {