v0.1.0 - CYD firmware v0.0.2: algorithm-based API upgrade (all verbs, all algorithms), vendored Keccak/SHAKE + PSA ed25519/x25519 for IDF v5.4, Web Serial test page, CYD docs, Teensy 4.1 port plan (1TB SDXC OTP pad), brainstorming READMEs for BLE/IR/NFC/FPGA signer concepts

This commit is contained in:
Laan Tungir
2026-07-21 13:16:04 -04:00
parent ca18e1e42d
commit 64fbd5c874
23 changed files with 4271 additions and 488 deletions

View File

@@ -0,0 +1,535 @@
<!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, select, button { font: inherit; border-radius: 8px; border: 1px solid var(--border); }
input, textarea, select { width: 100%; background: #0c131d; color: var(--text); padding: 8px 10px; }
textarea { min-height: 64px; 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: 200px; font-size: 12px; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.warn { color: #d29922; font-size: 0.8rem; }
</style>
</head>
<body>
<div class="wrap">
<h1>n_signer CYD Web Serial Demo</h1>
<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>
<div class="card">
<div class="row">
<button id="connectBtn">Connect Web Serial</button>
<button id="disconnectBtn" class="secondary" disabled>Disconnect</button>
<span id="connStatus" class="status">Disconnected</span>
</div>
<pre id="log" class="mono"></pre>
</div>
<div class="grid">
<!-- Get Public Key (algorithm-based) -->
<section class="card">
<h2>Get Public Key (algorithm)</h2>
<label for="gpkAlg">Algorithm</label>
<select id="gpkAlg">
<option>secp256k1</option><option>ed25519</option><option>x25519</option>
<option>ml-dsa-65</option><option>slh-dsa-128s</option><option>ml-kem-768</option>
</select>
<label for="gpkIdx">Index</label>
<input id="gpkIdx" type="number" value="0" min="0" />
<div class="row" style="margin-top:10px">
<button id="gpkBtn" disabled>get_public_key</button>
</div>
<pre id="gpkOut" class="mono"></pre>
</section>
<!-- Nostr Get Public Key -->
<section class="card">
<h2>nostr_get_public_key</h2>
<label for="ngpkIdx">nostr_index</label>
<input id="ngpkIdx" type="number" value="0" min="0" />
<label for="ngpkFmt">format</label>
<select id="ngpkFmt"><option>bare</option><option>structured</option></select>
<div class="row" style="margin-top:10px">
<button id="ngpkBtn" disabled>nostr_get_public_key</button>
</div>
<pre id="ngpkOut" class="mono"></pre>
</section>
<!-- Sign / Verify -->
<section class="card">
<h2>sign / verify</h2>
<label for="signAlg">Algorithm</label>
<select id="signAlg">
<option>secp256k1</option><option>ed25519</option><option>ml-dsa-65</option><option>slh-dsa-128s</option>
</select>
<label for="signIdx">Index</label>
<input id="signIdx" type="number" value="0" min="0" />
<label for="signScheme">scheme (secp256k1 only)</label>
<select id="signScheme"><option>schnorr</option><option>ecdsa</option></select>
<label for="signMsg">message (hex)</label>
<input id="signMsg" value="68656c6c6f" />
<div class="row" style="margin-top:10px">
<button id="signBtn" disabled>sign</button>
<button id="verifyBtn" disabled>verify</button>
</div>
<pre id="signOut" class="mono"></pre>
</section>
<!-- KEM encapsulate / decapsulate -->
<section class="card">
<h2>encapsulate / decapsulate (ml-kem-768)</h2>
<label for="kemPeer">peer pubkey hex (1184 bytes / 2368 hex) — leave empty to use self pubkey</label>
<textarea id="kemPeer" placeholder="auto: uses ml-kem-768 get_public_key"></textarea>
<label for="kemIdx">index (for decapsulate)</label>
<input id="kemIdx" type="number" value="0" min="0" />
<label for="kemCt">ciphertext hex (for decapsulate)</label>
<textarea id="kemCt" placeholder="filled by encapsulate"></textarea>
<div class="row" style="margin-top:10px">
<button id="encapBtn" disabled>encapsulate</button>
<button id="decapBtn" disabled>decapsulate</button>
</div>
<pre id="kemOut" class="mono"></pre>
</section>
<!-- derive_shared_secret (x25519) -->
<section class="card">
<h2>derive_shared_secret (x25519)</h2>
<label for="x25519Peer">peer pubkey hex (32 bytes / 64 hex)</label>
<input id="x25519Peer" placeholder="64 hex chars" />
<label for="x25519Idx">index</label>
<input id="x25519Idx" type="number" value="0" min="0" />
<div class="row" style="margin-top:10px">
<button id="x25519Btn" disabled>derive_shared_secret</button>
</div>
<pre id="x25519Out" class="mono"></pre>
</section>
<!-- derive (HMAC) -->
<section class="card">
<h2>derive (secp256k1 HMAC-SHA256)</h2>
<label for="deriveData">data (UTF-8 string)</label>
<input id="deriveData" value="hello-derive" />
<label for="deriveIdx">index (required)</label>
<input id="deriveIdx" type="number" value="0" min="0" />
<div class="row" style="margin-top:10px">
<button id="deriveBtn" disabled>derive</button>
</div>
<pre id="deriveOut" class="mono"></pre>
</section>
<!-- Nostr Sign Event -->
<section class="card">
<h2>nostr_sign_event</h2>
<label for="nseContent">content</label>
<textarea id="nseContent">hello from cyd webserial demo</textarea>
<label for="nseIdx">nostr_index</label>
<input id="nseIdx" type="number" value="0" min="0" />
<div class="row" style="margin-top:10px">
<button id="nseBtn" disabled>nostr_sign_event</button>
</div>
<pre id="nseOut" class="mono"></pre>
</section>
<!-- Nostr Mine Event -->
<section class="card">
<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">mined by cyd</textarea>
<label for="nmeIdx">nostr_index</label>
<input id="nmeIdx" type="number" value="0" min="0" />
<label for="nmeDiff">difficulty (leading zero bits)</label>
<input id="nmeDiff" type="number" value="4" min="1" max="16" />
<label for="nmeTimeout">timeout (sec)</label>
<input id="nmeTimeout" type="number" value="30" min="1" max="60" />
<div class="row" style="margin-top:10px">
<button id="nmeBtn" disabled>nostr_mine_event</button>
</div>
<pre id="nmeOut" class="mono"></pre>
</section>
<!-- NIP-04 -->
<section class="card">
<h2>nostr_nip04_encrypt / decrypt</h2>
<label for="nip04Peer">peer pubkey hex (32-byte x-only)</label>
<input id="nip04Peer" placeholder="64 hex chars" />
<label for="nip04Msg">plaintext</label>
<textarea id="nip04Msg">hello via nip04</textarea>
<label for="nip04Cipher">ciphertext (for decrypt)</label>
<textarea id="nip04Cipher" placeholder="ciphertext?iv=..."></textarea>
<label for="nip04Idx">nostr_index</label>
<input id="nip04Idx" type="number" value="0" min="0" />
<div class="row" style="margin-top:10px">
<button id="nip04EncBtn" disabled>encrypt</button>
<button id="nip04DecBtn" disabled>decrypt</button>
</div>
<pre id="nip04Out" class="mono"></pre>
</section>
<!-- NIP-44 -->
<section class="card">
<h2>nostr_nip44_encrypt / decrypt</h2>
<label for="nip44Peer">peer pubkey hex (32-byte x-only)</label>
<input id="nip44Peer" placeholder="64 hex chars" />
<label for="nip44Msg">plaintext</label>
<textarea id="nip44Msg">hello via nip44</textarea>
<label for="nip44Cipher">ciphertext (for decrypt)</label>
<textarea id="nip44Cipher" placeholder="base64 payload"></textarea>
<label for="nip44Idx">nostr_index</label>
<input id="nip44Idx" type="number" value="0" min="0" />
<div class="row" style="margin-top:10px">
<button id="nip44EncBtn" disabled>encrypt</button>
<button id="nip44DecBtn" disabled>decrypt</button>
</div>
<pre id="nip44Out" class="mono"></pre>
</section>
<!-- OTP encrypt / decrypt -->
<section class="card">
<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">SGVsbG8sIE9UUCB3b3JsZCE=</textarea>
<label for="otpCipher">ciphertext (for decrypt, base64)</label>
<textarea id="otpCipher" placeholder="filled by encrypt"></textarea>
<label for="otpEnc">encoding</label>
<select id="otpEnc"><option>ascii</option><option>binary</option></select>
<div class="row" style="margin-top:10px">
<button id="otpEncBtn" disabled>encrypt</button>
<button id="otpDecBtn" disabled>decrypt</button>
</div>
<pre id="otpOut" 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 disconnectBtn = document.getElementById("disconnectBtn");
let port = null;
let reader = null;
let writer = null;
let readLoopRunning = false;
let rxBuffer = new Uint8Array(0);
let pendingResolve = null;
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 pretty(value) {
try { return JSON.stringify(value, null, 2); } catch { return String(value); }
}
async function buildAuth(method, params) {
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 = "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 };
}
/* ---- Web Serial transport ---- */
async function readLoop() {
readLoopRunning = true;
while (readLoopRunning && reader) {
try {
const { value, done } = await reader.read();
if (done) break;
if (value) {
const next = new Uint8Array(rxBuffer.length + value.length);
next.set(rxBuffer, 0);
next.set(value, rxBuffer.length);
rxBuffer = next;
tryDeliver();
}
} catch (e) {
log("read error:", e.message);
break;
}
}
readLoopRunning = false;
}
function tryDeliver() {
if (pendingResolve === null) return;
while (rxBuffer.length >= 4) {
const len = (rxBuffer[0] << 24) | (rxBuffer[1] << 16) | (rxBuffer[2] << 8) | rxBuffer[3];
if (len <= 0 || len > 16384) {
/* resync: drop one byte */
rxBuffer = rxBuffer.slice(1);
continue;
}
if (rxBuffer.length < 4 + len) return;
const payload = rxBuffer.slice(4, 4 + len);
rxBuffer = rxBuffer.slice(4 + len);
const text = new TextDecoder().decode(payload);
let parsed;
try { parsed = JSON.parse(text); } catch { parsed = text; }
const r = pendingResolve;
pendingResolve = null;
r(parsed);
if (pendingResolve === null) return;
}
}
async function sendRpc(reqObj) {
if (!writer) throw new Error("not connected");
const body = utf8(JSON.stringify(reqObj));
const frame = new Uint8Array(4 + body.length);
frame.set(be32(body.length), 0);
frame.set(body, 4);
await writer.write(frame);
const resp = await new Promise((resolve, reject) => {
pendingResolve = resolve;
setTimeout(() => {
if (pendingResolve === resolve) {
pendingResolve = null;
reject(new Error("timeout (30s) — check the CYD screen for an approval prompt"));
}
}, 65000);
});
return resp;
}
async function callVerb(method, params, outEl) {
outEl.textContent = "→ " + method + " " + JSON.stringify(params);
try {
const auth = await buildAuth(method, params);
const req = { id: String(Math.floor(Math.random() * 1e9)), method, params, auth };
const resp = await sendRpc(req);
outEl.textContent += "\n← " + pretty(resp);
} catch (e) {
outEl.textContent += "\n✗ " + e.message;
}
}
/* ---- Connect / Disconnect ---- */
connectBtn.addEventListener("click", async () => {
try {
port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
reader = port.readable.getReader();
writer = port.writable.getWriter();
rxBuffer = new Uint8Array(0);
readLoop();
setStatus("Connected", "ok");
log("Connected to CYD via Web Serial @ 115200 baud");
document.querySelectorAll("button[id$='Btn']").forEach(b => {
if (b !== connectBtn && b !== disconnectBtn) b.disabled = false;
});
disconnectBtn.disabled = false;
connectBtn.disabled = true;
} catch (e) {
setStatus("Error", "err");
log("connect failed:", e.message);
}
});
disconnectBtn.addEventListener("click", async () => {
readLoopRunning = false;
try { if (reader) await reader.cancel(); } catch {}
try { if (writer) await writer.releaseLock(); } catch {}
try { if (port) await port.close(); } catch {}
reader = null; writer = null; port = null;
setStatus("Disconnected");
document.querySelectorAll("button[id$='Btn']").forEach(b => {
if (b !== connectBtn) b.disabled = true;
});
connectBtn.disabled = false;
disconnectBtn.disabled = true;
log("Disconnected");
});
/* ---- Verb wiring ---- */
const $ = id => document.getElementById(id);
$("gpkBtn").addEventListener("click", () => {
const alg = $("gpkAlg").value, idx = Number($("gpkIdx").value || 0);
callVerb("get_public_key", [{ algorithm: alg, index: idx }], $("gpkOut"));
});
$("ngpkBtn").addEventListener("click", () => {
const idx = Number($("ngpkIdx").value || 0), fmt = $("ngpkFmt").value;
const opts = { nostr_index: idx };
if (fmt === "structured") opts.format = "structured";
callVerb("nostr_get_public_key", [opts], $("ngpkOut"));
});
$("signBtn").addEventListener("click", () => {
const alg = $("signAlg").value, idx = Number($("signIdx").value || 0);
const scheme = $("signScheme").value, msg = $("signMsg").value;
const opts = { algorithm: alg, index: idx };
if (alg === "secp256k1") opts.scheme = scheme;
callVerb("sign", [msg, opts], $("signOut"));
});
$("verifyBtn").addEventListener("click", async () => {
const alg = $("signAlg").value, idx = Number($("signIdx").value || 0);
const scheme = $("signScheme").value, msg = $("signMsg").value;
const opts = { algorithm: alg, index: idx };
if (alg === "secp256k1") opts.scheme = scheme;
/* parse the last sign result to get the signature */
const outText = $("signOut").textContent;
const m = outText.match(/"signature"\s*:\s*"([0-9a-f]+)"/);
if (!m) { $("signOut").textContent += "\n✗ no signature found — run sign first"; return; }
callVerb("verify", [msg, m[1], opts], $("signOut"));
});
$("encapBtn").addEventListener("click", async () => {
let peer = $("kemPeer").value.trim();
if (!peer) {
/* fetch self ml-kem-768 pubkey first */
const opts = [{ algorithm: "ml-kem-768", index: Number($("kemIdx").value || 0) }];
const auth = await buildAuth("get_public_key", opts);
const resp = await sendRpc({ id: String(Math.floor(Math.random()*1e9)), method: "get_public_key", params: opts, auth });
peer = resp.result && JSON.parse(resp.result).public_key;
if (!peer) { $("kemOut").textContent = "✗ could not fetch self pubkey"; return; }
$("kemPeer").value = peer;
}
callVerb("encapsulate", [peer, { algorithm: "ml-kem-768" }], $("kemOut"));
});
$("decapBtn").addEventListener("click", () => {
const ct = $("kemCt").value.trim();
const idx = Number($("kemIdx").value || 0);
if (!ct) { $("kemOut").textContent = "✗ paste a ciphertext first (from encapsulate)"; return; }
callVerb("decapsulate", [ct, { algorithm: "ml-kem-768", index: idx }], $("kemOut"));
});
$("x25519Btn").addEventListener("click", () => {
const peer = $("x25519Peer").value.trim();
const idx = Number($("x25519Idx").value || 0);
if (!peer) { $("x25519Out").textContent = "✗ enter peer pubkey"; return; }
callVerb("derive_shared_secret", [peer, { algorithm: "x25519", index: idx }], $("x25519Out"));
});
$("deriveBtn").addEventListener("click", () => {
const data = $("deriveData").value;
const idx = Number($("deriveIdx").value || 0);
callVerb("derive", [data, { algorithm: "secp256k1", index: idx }], $("deriveOut"));
});
$("nseBtn").addEventListener("click", () => {
const content = $("nseContent").value;
const idx = Number($("nseIdx").value || 0);
const event = { kind: 1, created_at: Math.floor(Date.now()/1000), tags: [], content };
callVerb("nostr_sign_event", [event, { nostr_index: idx }], $("nseOut"));
});
$("nmeBtn").addEventListener("click", () => {
const content = $("nmeContent").value;
const idx = Number($("nmeIdx").value || 0);
const diff = Number($("nmeDiff").value || 4);
const timeout = Number($("nmeTimeout").value || 30);
const event = { kind: 1, created_at: Math.floor(Date.now()/1000), tags: [], content };
callVerb("nostr_mine_event", [event, { nostr_index: idx, difficulty: diff, timeout_sec: timeout }], $("nmeOut"));
});
const nip04Enc = () => {
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 nip04Dec = () => {
const peer = $("nip04Peer").value.trim(), ct = $("nip04Cipher").value, idx = Number($("nip04Idx").value || 0);
if (!peer || !ct) { $("nip04Out").textContent = "✗ enter peer pubkey + ciphertext"; return; }
callVerb("nostr_nip04_decrypt", [peer, ct, { nostr_index: idx }], $("nip04Out"));
};
$("nip04EncBtn").addEventListener("click", nip04Enc);
$("nip04DecBtn").addEventListener("click", nip04Dec);
const nip44Enc = () => {
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 nip44Dec = () => {
const peer = $("nip44Peer").value.trim(), ct = $("nip44Cipher").value, idx = Number($("nip44Idx").value || 0);
if (!peer || !ct) { $("nip44Out").textContent = "✗ enter peer pubkey + ciphertext"; return; }
callVerb("nostr_nip44_decrypt", [peer, ct, { nostr_index: idx }], $("nip44Out"));
};
$("nip44EncBtn").addEventListener("click", nip44Enc);
$("nip44DecBtn").addEventListener("click", nip44Dec);
$("otpEncBtn").addEventListener("click", () => {
const pt = $("otpPlain").value, enc = $("otpEnc").value;
callVerb("encrypt", [pt, { algorithm: "otp", encoding: enc }], $("otpOut"));
});
$("otpDecBtn").addEventListener("click", () => {
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"));
});
if (!("serial" in navigator)) {
log("Web Serial not supported in this browser. Use Chrome/Edge/Brave/Opera.");
connectBtn.disabled = true;
}
</script>
</body>
</html>