906 lines
34 KiB
HTML
906 lines
34 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 CYD Web Serial Demo</title>
|
||
<script>
|
||
/* §12 Dark mode: set class synchronously before paint. */
|
||
(function () {
|
||
if (localStorage.getItem('theme') === 'dark') {
|
||
document.documentElement.classList.add('dark-mode');
|
||
}
|
||
})();
|
||
</script>
|
||
<style>
|
||
/*
|
||
* Two-tier rule block (§13). This is a single-file demo so the "global"
|
||
* sheet is inlined here, but the same rules apply: variables everywhere,
|
||
* no hardcoded hex in component rules, spacing on the 5/10/15/20/40 ladder.
|
||
*/
|
||
|
||
/* ---- §17 Minimum variable set ---- */
|
||
:root {
|
||
--font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Courier New", Courier, monospace;
|
||
|
||
--primary-color: #000000;
|
||
--secondary-color: #ffffff;
|
||
--accent-color: #ff0000;
|
||
--muted-color: #dddddd;
|
||
|
||
--color: var(--primary-color);
|
||
--background-color: var(--secondary-color);
|
||
--border-color: var(--muted-color);
|
||
|
||
--border-radius: 5px;
|
||
--border-width: 1px;
|
||
--border: var(--border-width) solid var(--border-color);
|
||
|
||
--header-height: 2vh;
|
||
--header-min-height: 60px;
|
||
--footer-height: 2vh;
|
||
--footer-min-height: 20px;
|
||
|
||
--image-grayscale: 100%;
|
||
--image-grayscale-hover: 20%;
|
||
}
|
||
|
||
/* §12 Dark mode = swap variables only. */
|
||
html.dark-mode, body.dark-mode {
|
||
--primary-color: #ffffff;
|
||
--secondary-color: #000000;
|
||
--muted-color: #777777;
|
||
}
|
||
|
||
/* ---- §3 Typography / global reset ---- */
|
||
* {
|
||
font-family: var(--font-family);
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
html, body {
|
||
color: var(--color);
|
||
background-color: var(--background-color);
|
||
line-height: 1.4;
|
||
}
|
||
|
||
body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
a { color: var(--accent-color); }
|
||
|
||
/* ---- §4 Layout shell ---- */
|
||
#divHeader {
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0;
|
||
height: var(--header-height);
|
||
min-height: var(--header-min-height);
|
||
background-color: var(--secondary-color);
|
||
border-bottom: 3px solid var(--primary-color);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 20px;
|
||
z-index: 2;
|
||
}
|
||
#divHeader .divHeaderText {
|
||
font-size: 200%;
|
||
font-weight: bold;
|
||
flex: 1;
|
||
text-align: center;
|
||
}
|
||
#divHeader .divHeaderButtons {
|
||
width: 40px;
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
}
|
||
/* Keep the hamburger clickable above the open sidenav (§10 overlay). */
|
||
#btnHamburger { position: relative; z-index: 4; }
|
||
|
||
#divBody {
|
||
position: relative;
|
||
top: max(var(--header-height), var(--header-min-height));
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
justify-content: space-around;
|
||
width: 100%;
|
||
padding: 20px;
|
||
gap: 20px;
|
||
}
|
||
|
||
#divFooter {
|
||
position: fixed;
|
||
bottom: 0; left: 0; right: 0;
|
||
height: var(--footer-height);
|
||
min-height: var(--footer-min-height);
|
||
border-top: 3px solid var(--primary-color);
|
||
background-color: var(--secondary-color);
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 0 20px;
|
||
z-index: 2;
|
||
}
|
||
.divFooterBox {
|
||
font-size: 12px;
|
||
color: var(--muted-color);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
#divFooterCenter { text-align: center; flex: 1; }
|
||
#divFooterRight { text-align: right; }
|
||
|
||
/* ---- §10 SideNav ---- */
|
||
#divSideNav {
|
||
position: fixed;
|
||
top: 0; left: 0;
|
||
height: 100%;
|
||
width: 0;
|
||
transition: width 0.5s;
|
||
border-right: 3px solid var(--primary-color);
|
||
background-color: var(--secondary-color);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
z-index: 3;
|
||
}
|
||
#divSideNav.open { width: clamp(400px, 50vw, 600px); }
|
||
#divSideNavBody {
|
||
flex: 1;
|
||
padding: 20px;
|
||
overflow-y: auto;
|
||
font-size: 14px;
|
||
}
|
||
/* Close button pinned to the top-right of the open sidenav. */
|
||
#btnCloseSideNav {
|
||
position: absolute;
|
||
top: 10px;
|
||
right: 10px;
|
||
width: 32px;
|
||
height: 32px;
|
||
background: var(--secondary-color);
|
||
color: var(--primary-color);
|
||
border: var(--border);
|
||
border-radius: var(--border-radius);
|
||
cursor: pointer;
|
||
font-size: 20px;
|
||
line-height: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: border-color 0.2s;
|
||
z-index: 4;
|
||
}
|
||
#btnCloseSideNav:hover { border-color: var(--accent-color); }
|
||
#divSideNavBody h3 {
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
margin-top: 20px;
|
||
margin-bottom: 10px;
|
||
}
|
||
#divSideNavBody h3:first-child { margin-top: 0; }
|
||
#divSideNavBody p {
|
||
margin-bottom: 10px;
|
||
color: var(--muted-color);
|
||
}
|
||
#divSideNavBody code {
|
||
color: var(--primary-color);
|
||
white-space: pre-wrap;
|
||
}
|
||
#divVersionBar {
|
||
flex-shrink: 0;
|
||
border-top: 3px solid var(--primary-color);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 20px;
|
||
}
|
||
#versionDisplay { font-size: 14px; }
|
||
#divVersionBarButtons { display: flex; gap: 10px; }
|
||
|
||
/* Hamburger button (§6 icon-only button) */
|
||
#btnHamburger {
|
||
width: 32px; height: 32px;
|
||
background: var(--secondary-color);
|
||
color: var(--primary-color);
|
||
border: var(--border);
|
||
border-radius: var(--border-radius);
|
||
cursor: pointer;
|
||
font-size: 20px;
|
||
line-height: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: border-color 0.2s;
|
||
}
|
||
#btnHamburger:hover { border-color: var(--accent-color); }
|
||
|
||
/* ---- §6 Buttons ---- */
|
||
.btn {
|
||
font-family: var(--font-family);
|
||
color: var(--primary-color);
|
||
background-color: var(--secondary-color);
|
||
border: var(--border-width) solid var(--primary-color);
|
||
border-radius: var(--border-radius);
|
||
padding: 8px 16px;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
transition: border-color 0.2s, background-color 0.2s, color 0.2s;
|
||
}
|
||
.btn:hover { border-color: var(--accent-color); }
|
||
.btn:active {
|
||
background-color: var(--accent-color);
|
||
color: var(--secondary-color);
|
||
border-color: var(--primary-color);
|
||
}
|
||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.btn.secondary { border-color: var(--muted-color); }
|
||
.btn.danger { border-color: var(--accent-color); color: var(--accent-color); }
|
||
.btn.danger:active { background-color: var(--accent-color); color: var(--secondary-color); }
|
||
|
||
/* ---- §7 Forms ---- */
|
||
.inpStyle {
|
||
border: var(--border);
|
||
border-radius: var(--border-radius);
|
||
padding: 10px;
|
||
font-size: 100%;
|
||
font-family: var(--font-family);
|
||
color: var(--primary-color);
|
||
background-color: var(--secondary-color);
|
||
width: 100%;
|
||
}
|
||
.inpStyle:focus { outline: none; border-color: var(--accent-color); }
|
||
textarea.inpStyle { resize: vertical; min-height: 64px; }
|
||
input[type="number"].inpStyle { max-width: 130px; }
|
||
label {
|
||
font-size: 12px;
|
||
color: var(--muted-color);
|
||
display: block;
|
||
margin: 10px 0 5px;
|
||
}
|
||
|
||
/* ---- §9 Cards ---- */
|
||
.divPostItem {
|
||
padding: 15px;
|
||
border: 1px solid var(--primary-color);
|
||
border-radius: 10px;
|
||
margin-bottom: 15px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background-color: var(--secondary-color);
|
||
}
|
||
.divPostItem h2 {
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
margin-bottom: 10px;
|
||
}
|
||
.divPostItem .row {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
margin-top: 10px;
|
||
}
|
||
.divPostItem .note {
|
||
font-size: 12px;
|
||
color: var(--muted-color);
|
||
margin-bottom: 10px;
|
||
}
|
||
.divPostItem .warn {
|
||
font-size: 12px;
|
||
color: var(--accent-color);
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
/* Connection card spans full width */
|
||
.divPostItem.full { width: 100%; }
|
||
.divPostItem.section { flex: 1 1 320px; min-width: 320px; }
|
||
|
||
/* ---- §11 Feedback / state ---- */
|
||
.status {
|
||
padding: 5px 10px;
|
||
border: var(--border);
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
color: var(--muted-color);
|
||
}
|
||
.status.ok { border-color: var(--primary-color); color: var(--primary-color); }
|
||
.status.err { border-color: var(--accent-color); color: var(--accent-color); }
|
||
|
||
pre {
|
||
margin-top: 10px;
|
||
padding: 10px;
|
||
border: var(--border);
|
||
border-radius: var(--border-radius);
|
||
background-color: var(--secondary-color);
|
||
color: var(--primary-color);
|
||
overflow: auto;
|
||
max-height: 200px;
|
||
font-size: 12px;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
/* §14 reduced motion */
|
||
@media (prefers-reduced-motion: reduce) {
|
||
#divSideNav { transition: none; }
|
||
.btn, #btnHamburger, img { transition: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- §16 canonical shell -->
|
||
<div id="divHeader">
|
||
<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="divHeaderButtons"></div>
|
||
</div>
|
||
|
||
<div id="divBody">
|
||
<!-- 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>
|
||
<div class="row">
|
||
<button id="connectBtn" class="btn">Connect Web Serial</button>
|
||
<button id="disconnectBtn" class="btn secondary" disabled>Disconnect</button>
|
||
<span id="connStatus" class="status">Disconnected</span>
|
||
</div>
|
||
<pre id="log"></pre>
|
||
</section>
|
||
|
||
<!-- get_info (metadata) -->
|
||
<section class="divPostItem section">
|
||
<h2>get_info (version / capabilities)</h2>
|
||
<p class="note">No approval required. Safe to call before the mnemonic is loaded.</p>
|
||
<div class="row">
|
||
<button id="infoBtn" class="btn" disabled>get_info</button>
|
||
</div>
|
||
<pre id="infoOut"></pre>
|
||
</section>
|
||
|
||
<!-- Get Public Key (algorithm-based) -->
|
||
<section class="divPostItem section">
|
||
<h2>get_public_key (algorithm)</h2>
|
||
<label for="gpkAlg">Algorithm</label>
|
||
<select id="gpkAlg" class="inpStyle">
|
||
<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" class="inpStyle" />
|
||
<div class="row">
|
||
<button id="gpkBtn" class="btn" disabled>get_public_key</button>
|
||
</div>
|
||
<pre id="gpkOut"></pre>
|
||
</section>
|
||
|
||
<!-- Nostr Get Public Key -->
|
||
<section class="divPostItem section">
|
||
<h2>nostr_get_public_key</h2>
|
||
<label for="ngpkIdx">nostr_index</label>
|
||
<input id="ngpkIdx" type="number" value="0" min="0" class="inpStyle" />
|
||
<label for="ngpkFmt">format</label>
|
||
<select id="ngpkFmt" class="inpStyle"><option>bare</option><option>structured</option></select>
|
||
<div class="row">
|
||
<button id="ngpkBtn" class="btn" disabled>nostr_get_public_key</button>
|
||
</div>
|
||
<pre id="ngpkOut"></pre>
|
||
</section>
|
||
|
||
<!-- Sign / Verify -->
|
||
<section class="divPostItem section">
|
||
<h2>sign / verify</h2>
|
||
<label for="signAlg">Algorithm</label>
|
||
<select id="signAlg" class="inpStyle">
|
||
<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" class="inpStyle" />
|
||
<label for="signScheme">scheme (secp256k1 only)</label>
|
||
<select id="signScheme" class="inpStyle"><option>schnorr</option><option>ecdsa</option></select>
|
||
<label for="signMsg">message (hex — 32 bytes / 64 hex for schnorr)</label>
|
||
<input id="signMsg" value="2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" class="inpStyle" />
|
||
<div class="row">
|
||
<button id="signBtn" class="btn" disabled>sign</button>
|
||
<button id="verifyBtn" class="btn" disabled>verify</button>
|
||
</div>
|
||
<pre id="signOut"></pre>
|
||
</section>
|
||
|
||
<!-- KEM encapsulate / decapsulate -->
|
||
<section class="divPostItem section">
|
||
<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" class="inpStyle" 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" class="inpStyle" />
|
||
<label for="kemCt">ciphertext hex (for decapsulate)</label>
|
||
<textarea id="kemCt" class="inpStyle" placeholder="filled by encapsulate"></textarea>
|
||
<div class="row">
|
||
<button id="encapBtn" class="btn" disabled>encapsulate</button>
|
||
<button id="decapBtn" class="btn" disabled>decapsulate</button>
|
||
</div>
|
||
<pre id="kemOut"></pre>
|
||
</section>
|
||
|
||
<!-- derive_shared_secret (x25519) -->
|
||
<section class="divPostItem section">
|
||
<h2>derive_shared_secret (x25519)</h2>
|
||
<label for="x25519Peer">peer pubkey hex (32 bytes / 64 hex)</label>
|
||
<input id="x25519Peer" class="inpStyle" placeholder="64 hex chars" />
|
||
<label for="x25519Idx">index</label>
|
||
<input id="x25519Idx" type="number" value="0" min="0" class="inpStyle" />
|
||
<div class="row">
|
||
<button id="x25519Btn" class="btn" disabled>derive_shared_secret</button>
|
||
</div>
|
||
<pre id="x25519Out"></pre>
|
||
</section>
|
||
|
||
<!-- derive (HMAC) -->
|
||
<section class="divPostItem section">
|
||
<h2>derive (secp256k1 HMAC-SHA256)</h2>
|
||
<label for="deriveData">data (UTF-8 string)</label>
|
||
<input id="deriveData" value="hello-derive" class="inpStyle" />
|
||
<label for="deriveIdx">index (required)</label>
|
||
<input id="deriveIdx" type="number" value="0" min="0" class="inpStyle" />
|
||
<div class="row">
|
||
<button id="deriveBtn" class="btn" disabled>derive</button>
|
||
</div>
|
||
<pre id="deriveOut"></pre>
|
||
</section>
|
||
|
||
<!-- Nostr Sign Event -->
|
||
<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>
|
||
<label for="nseIdx">nostr_index</label>
|
||
<input id="nseIdx" type="number" value="0" min="0" class="inpStyle" />
|
||
<div class="row">
|
||
<button id="nseBtn" class="btn" disabled>nostr_sign_event</button>
|
||
</div>
|
||
<pre id="nseOut"></pre>
|
||
</section>
|
||
|
||
<!-- Nostr Mine Event -->
|
||
<section class="divPostItem section">
|
||
<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>
|
||
<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>
|
||
<input id="nmeDiff" type="number" value="4" min="1" max="16" class="inpStyle" />
|
||
<label for="nmeTimeout">timeout (sec)</label>
|
||
<input id="nmeTimeout" type="number" value="30" min="1" max="60" class="inpStyle" />
|
||
<div class="row">
|
||
<button id="nmeBtn" class="btn" disabled>nostr_mine_event</button>
|
||
</div>
|
||
<pre id="nmeOut"></pre>
|
||
</section>
|
||
|
||
<!-- NIP-04 -->
|
||
<section class="divPostItem section">
|
||
<h2>nostr_nip04_encrypt / decrypt</h2>
|
||
<label for="nip04Peer">peer pubkey hex (32-byte x-only)</label>
|
||
<input id="nip04Peer" class="inpStyle" placeholder="64 hex chars" />
|
||
<label for="nip04Msg">plaintext</label>
|
||
<textarea id="nip04Msg" class="inpStyle">hello via nip04</textarea>
|
||
<label for="nip04Cipher">ciphertext (for decrypt)</label>
|
||
<textarea id="nip04Cipher" class="inpStyle" placeholder="ciphertext?iv=..."></textarea>
|
||
<label for="nip04Idx">nostr_index</label>
|
||
<input id="nip04Idx" type="number" value="0" min="0" class="inpStyle" />
|
||
<div class="row">
|
||
<button id="nip04EncBtn" class="btn" disabled>encrypt</button>
|
||
<button id="nip04DecBtn" class="btn" disabled>decrypt</button>
|
||
</div>
|
||
<pre id="nip04Out"></pre>
|
||
</section>
|
||
|
||
<!-- NIP-44 -->
|
||
<section class="divPostItem section">
|
||
<h2>nostr_nip44_encrypt / decrypt</h2>
|
||
<label for="nip44Peer">peer pubkey hex (32-byte x-only)</label>
|
||
<input id="nip44Peer" class="inpStyle" placeholder="64 hex chars" />
|
||
<label for="nip44Msg">plaintext</label>
|
||
<textarea id="nip44Msg" class="inpStyle">hello via nip44</textarea>
|
||
<label for="nip44Cipher">ciphertext (for decrypt)</label>
|
||
<textarea id="nip44Cipher" class="inpStyle" placeholder="base64 payload"></textarea>
|
||
<label for="nip44Idx">nostr_index</label>
|
||
<input id="nip44Idx" type="number" value="0" min="0" class="inpStyle" />
|
||
<div class="row">
|
||
<button id="nip44EncBtn" class="btn" disabled>encrypt</button>
|
||
<button id="nip44DecBtn" class="btn" disabled>decrypt</button>
|
||
</div>
|
||
<pre id="nip44Out"></pre>
|
||
</section>
|
||
|
||
<!-- 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>
|
||
<label for="otpCipher">ciphertext (for decrypt, base64)</label>
|
||
<textarea id="otpCipher" class="inpStyle" placeholder="filled by encrypt"></textarea>
|
||
<label for="otpEnc">encoding</label>
|
||
<select id="otpEnc" class="inpStyle"><option>ascii</option><option>binary</option></select>
|
||
<div class="row">
|
||
<button id="otpEncBtn" class="btn" disabled>encrypt</button>
|
||
<button id="otpDecBtn" class="btn" disabled>decrypt</button>
|
||
</div>
|
||
<pre id="otpOut"></pre>
|
||
</section>
|
||
</div>
|
||
|
||
<div id="divFooter">
|
||
<div id="divFooterLeft" class="divFooterBox">n_signer</div>
|
||
<div id="divFooterCenter" class="divFooterBox">ready</div>
|
||
<div id="divFooterRight" class="divFooterBox">Web Serial</div>
|
||
</div>
|
||
|
||
<!-- §10 SideNav -->
|
||
<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>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>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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
</div>
|
||
<div id="divVersionBar">
|
||
<span id="versionDisplay">demo v1</span>
|
||
<div id="divVersionBarButtons">
|
||
<button id="themeToggleButton" class="btn secondary" title="Toggle theme">theme</button>
|
||
</div>
|
||
</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 footerStatusEl = document.getElementById("divFooterCenter");
|
||
const connectBtn = document.getElementById("connectBtn");
|
||
const disconnectBtn = document.getElementById("disconnectBtn");
|
||
const btnHamburger = document.getElementById("btnHamburger");
|
||
const divSideNav = document.getElementById("divSideNav");
|
||
const themeToggleButton = document.getElementById("themeToggleButton");
|
||
|
||
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();
|
||
footerStatusEl.textContent = text;
|
||
}
|
||
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) {
|
||
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;
|
||
}
|
||
}
|
||
|
||
/* ---- SideNav + theme (§10, §12) ---- */
|
||
function openSideNav() { divSideNav.classList.add("open"); }
|
||
function closeSideNav() { divSideNav.classList.remove("open"); }
|
||
|
||
btnHamburger.addEventListener("click", () => {
|
||
if (divSideNav.classList.contains("open")) closeSideNav(); else openSideNav();
|
||
});
|
||
document.getElementById("btnCloseSideNav").addEventListener("click", closeSideNav);
|
||
/* Click on the scrim (the body, outside the sidenav) closes it. */
|
||
document.getElementById("divBody").addEventListener("click", () => {
|
||
if (divSideNav.classList.contains("open")) closeSideNav();
|
||
});
|
||
/* ESC closes sidenav (§14). */
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && divSideNav.classList.contains("open")) {
|
||
closeSideNav();
|
||
}
|
||
});
|
||
themeToggleButton.addEventListener("click", () => {
|
||
const isDark = document.documentElement.classList.toggle("dark-mode");
|
||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||
});
|
||
|
||
/* ---- 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;
|
||
/* Auto-fetch version/capabilities so the user sees what firmware is running. */
|
||
try { document.getElementById("infoBtn").click(); } catch (e) { log("auto get_info failed:", e.message); }
|
||
} 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);
|
||
|
||
$("infoBtn").addEventListener("click", () => {
|
||
callVerb("get_info", [], $("infoOut"));
|
||
});
|
||
|
||
$("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;
|
||
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) {
|
||
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>
|