163 lines
5.3 KiB
HTML
163 lines
5.3 KiB
HTML
<!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>
|
|
body { font-family: sans-serif; margin: 16px; max-width: 840px; }
|
|
button { margin-right: 8px; margin-bottom: 8px; }
|
|
pre { background: #111; color: #ddd; padding: 12px; border-radius: 6px; overflow: auto; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>n_signer Feather WebUSB Demo</h1>
|
|
<p>Connect to the ESP32-S3 WebUSB interface, send authenticated <code>get_public_key</code>, and display the result.</p>
|
|
|
|
<button id="connectBtn">Connect</button>
|
|
<button id="pubkeyBtn" disabled>Get Public Key</button>
|
|
|
|
<pre id="log"></pre>
|
|
|
|
<script type="module">
|
|
import { schnorr } from "https://esm.sh/@noble/curves@1.5.0/secp256k1?bundle";
|
|
|
|
const logEl = document.getElementById("log");
|
|
const connectBtn = document.getElementById("connectBtn");
|
|
const pubkeyBtn = document.getElementById("pubkeyBtn");
|
|
|
|
let dev = null;
|
|
let iface = null;
|
|
const EP_OUT = 1;
|
|
const EP_IN = 1;
|
|
|
|
function log(...args) {
|
|
logEl.textContent += args.join(" ") + "\n";
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}
|
|
|
|
function hex(bytes) {
|
|
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
function utf8(s) {
|
|
return new TextEncoder().encode(s);
|
|
}
|
|
|
|
async function sha256Hex(dataBytes) {
|
|
const h = await crypto.subtle.digest("SHA-256", dataBytes);
|
|
return hex(new Uint8Array(h));
|
|
}
|
|
|
|
function be32(n) {
|
|
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
|
}
|
|
|
|
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 = "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 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;
|
|
log("Connected. Interface", String(iface));
|
|
}
|
|
|
|
connectBtn.addEventListener("click", async () => {
|
|
try {
|
|
await connect();
|
|
} catch (e) {
|
|
log("Connect failed:", String(e));
|
|
}
|
|
});
|
|
|
|
pubkeyBtn.addEventListener("click", async () => {
|
|
try {
|
|
const params = [];
|
|
const auth = await buildAuth("get_public_key", params);
|
|
const req = { jsonrpc: "2.0", id: "web-1", method: "get_public_key", params, auth };
|
|
const resp = await sendRpc(req);
|
|
log("Response:", JSON.stringify(resp));
|
|
} catch (e) {
|
|
log("RPC failed:", String(e));
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|