Files
nostr-passkey/web/index.html
Michael Schapira - krilin fa20d2e516 Add dark theme toggle to web UI
CSS variables drive the palette; a ◐ button in the header flips
between light and dark, with localStorage persistence and a no-flash
inline init script. Respects prefers-color-scheme by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 23:41:45 -04:00

610 lines
19 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>nostr-passkey</title>
<style>
:root {
--fg: #000;
--bg: #fff;
--muted: #6b6b6b;
--rule: #000;
--soft: #f4f4f4;
--active: #222; /* pressed-button background */
--focus-muted: #888; /* placeholder on inverted input */
color-scheme: light;
}
/* Auto dark mode when the user hasn't explicitly chosen light. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--fg: #fff;
--bg: #000;
--muted: #8a8a8a;
--rule: #fff;
--soft: #0f0f0f;
--active: #d8d8d8;
--focus-muted: #777;
color-scheme: dark;
}
}
/* Explicit opt-in via the header toggle wins over OS preference. */
:root[data-theme="dark"] {
--fg: #fff;
--bg: #000;
--muted: #8a8a8a;
--rule: #fff;
--soft: #0f0f0f;
--active: #d8d8d8;
--focus-muted: #777;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background: var(--bg); color: var(--fg);
font-family: ui-monospace, "JetBrains Mono", "IBM Plex Mono", Menlo, Consolas, monospace;
font-size: 14px; line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
::selection { background: var(--fg); color: var(--bg); }
main {
max-width: 620px;
margin: 72px auto 96px;
padding: 0 28px;
}
header {
border-bottom: 1px solid var(--rule);
padding-bottom: 18px;
margin-bottom: 36px;
}
header .title {
display: flex;
align-items: baseline;
gap: 14px;
}
h1 {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.28em;
text-transform: uppercase;
margin: 0;
}
.ver {
font-size: 10px;
color: var(--muted);
letter-spacing: 0.2em;
text-transform: uppercase;
}
.sub {
font-size: 11px;
color: var(--muted);
margin-top: 8px;
letter-spacing: 0.06em;
}
.sub b { color: var(--fg); font-weight: 600; }
section {
border: 1px solid var(--rule);
padding: 22px 26px;
margin-bottom: 22px;
}
section h2 {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.3em;
text-transform: uppercase;
margin: 0 0 18px;
display: flex;
align-items: center;
gap: 12px;
}
section h2::before {
content: "";
display: inline-block;
width: 10px; height: 1px;
background: var(--fg);
}
label {
display: block;
font-size: 9px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 5px;
}
input[type=text] {
width: 100%;
padding: 11px 13px;
border: 1px solid var(--rule);
background: var(--bg);
color: var(--fg);
font-family: inherit;
font-size: 14px;
margin-bottom: 16px;
border-radius: 0;
outline: none;
transition: background 0.08s, color 0.08s;
}
input[type=text]:focus {
background: var(--fg); color: var(--bg);
}
input[type=text]::placeholder { color: var(--muted); }
input[type=text]:focus::placeholder { color: var(--focus-muted); }
button {
display: block;
width: 100%;
padding: 13px 16px;
border: 1px solid var(--rule);
background: var(--bg);
color: var(--fg);
font-family: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.28em;
text-transform: uppercase;
cursor: pointer;
border-radius: 0;
transition: background 0.08s, color 0.08s;
}
button:hover:not(:disabled) { background: var(--fg); color: var(--bg); }
button:active:not(:disabled) { background: var(--active); }
button:disabled {
color: var(--muted);
cursor: not-allowed;
border-color: var(--muted);
}
.row { margin-bottom: 14px; }
.row:last-child { margin-bottom: 0; }
pre {
margin: 0;
padding: 12px 14px;
border: 1px solid var(--rule);
background: var(--soft);
font-family: inherit;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
#log {
margin-top: 36px;
font-size: 11px;
color: var(--muted);
border-top: 1px solid var(--rule);
padding-top: 18px;
min-height: 48px;
white-space: pre-wrap;
}
#log .line::before { content: "> "; color: var(--fg); }
#log .line.err { color: var(--fg); font-weight: 600; }
#log .line.err::before { content: "! "; }
footer {
margin-top: 48px;
padding-top: 16px;
border-top: 1px solid var(--rule);
font-size: 10px;
color: var(--muted);
letter-spacing: 0.08em;
display: flex;
justify-content: space-between;
}
footer a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--muted); }
footer a:hover { color: var(--fg); border-bottom-color: var(--fg); }
/* Theme toggle — overrides the global button rule via class specificity. */
.theme-toggle {
margin-left: auto;
width: auto;
padding: 3px 9px;
font-size: 13px;
line-height: 1;
font-weight: 400;
letter-spacing: 0;
text-transform: none;
border: 1px solid var(--rule);
background: var(--bg);
color: var(--fg);
cursor: pointer;
transition: background 0.08s, color 0.08s;
}
.theme-toggle:hover { background: var(--fg); color: var(--bg); }
.theme-toggle:focus-visible { outline: 1px solid var(--fg); outline-offset: 2px; }
.diag {
border: 1px solid var(--rule);
padding: 14px 18px;
margin-bottom: 22px;
font-size: 11px;
}
.diag h2 {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.3em;
text-transform: uppercase;
margin: 0 0 10px;
color: var(--muted);
}
.diag .kv { display: grid; grid-template-columns: 140px 1fr; gap: 4px 12px; }
.diag .k { color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
.diag .v { word-break: break-all; }
.diag .v.ok { color: var(--fg); }
.diag .v.bad { color: var(--fg); font-weight: 700; }
.diag .v.bad::before { content: "✗ "; }
.diag .v.ok::before { content: "✓ "; }
</style>
<script>
// No-flash theme init: runs synchronously before first paint so a
// user who last chose "dark" doesn't see a white flash on load.
(function () {
try {
var t = localStorage.getItem("nostr-passkey:theme");
if (t === "dark" || t === "light") {
document.documentElement.setAttribute("data-theme", t);
}
} catch (_) { /* localStorage blocked — fall back to OS preference */ }
})();
</script>
</head>
<body>
<main>
<header>
<div class="title">
<h1>nostr-passkey</h1>
<span class="ver">v0.1 · prototype</span>
<button id="btn-theme" class="theme-toggle" type="button" aria-label="Toggle theme" title="Toggle theme"></button>
</div>
<div class="sub"><b>passkey</b><b>prf</b><b>hkdf</b><b>nostr key</b></div>
</header>
<div class="diag" id="diag">
<h2>00 · environment</h2>
<div class="kv" id="diag-kv"></div>
</div>
<section>
<h2>01 · register</h2>
<label for="reg-user">username</label>
<input id="reg-user" type="text" value="alice" autocomplete="off" spellcheck="false">
<button id="btn-register">create passkey</button>
</section>
<section>
<h2>02 · derive</h2>
<label for="auth-user">username</label>
<input id="auth-user" type="text" value="alice" autocomplete="off" spellcheck="false">
<label for="auth-salt">salt &nbsp;·&nbsp; optional</label>
<input id="auth-salt" type="text" placeholder="e.g. work" autocomplete="off" spellcheck="false">
<button id="btn-derive">authenticate &amp; derive</button>
</section>
<section id="result-section" style="display:none;">
<h2>03 · derived identity</h2>
<div class="row"><label>npub</label><pre id="out-npub"></pre></div>
<div class="row"><label>nsec</label><pre id="out-nsec"></pre></div>
<div class="row"><label>pubkey hex</label><pre id="out-pubhex"></pre></div>
<div class="row"><label>privkey hex</label><pre id="out-privhex"></pre></div>
</section>
<div id="log"></div>
<footer>
<span>stateless · prf-anchored · hkdf-sha256</span>
<a href="/docs" target="_blank" rel="noopener">openapi</a>
</footer>
</main>
<script>
// ---------- base64url helpers ----------
function b64uToBuf(s) {
const pad = "=".repeat((4 - s.length % 4) % 4);
const b64 = (s + pad).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(b64);
const buf = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i);
return buf.buffer;
}
function bufToB64u(buf) {
const bytes = new Uint8Array(buf);
let s = "";
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// ---------- log (mirrors to browser console for full stacks) ----------
const logEl = document.getElementById("log");
function log(msg, kind) {
const line = document.createElement("div");
line.className = "line" + (kind === "err" ? " err" : "");
line.textContent = msg;
logEl.appendChild(line);
if (kind === "err") console.error("[nostr-passkey]", msg);
else console.log("[nostr-passkey]", msg);
}
function clearLog() { logEl.innerHTML = ""; }
// Expand a DOMException / Error into every field we can get at. Some
// of these properties only exist on specific browsers — read them
// defensively so we don't throw while reporting an error.
function describeError(e) {
if (!e) return "(null error)";
const parts = [];
const name = e.name || e.constructor?.name || "Error";
parts.push(name);
if (e.code !== undefined) parts.push(`code=${e.code}`);
if (e.message) parts.push(e.message);
const seen = new Set();
for (const k of Object.getOwnPropertyNames(e)) {
if (seen.has(k) || ["name", "message", "stack", "code"].includes(k)) continue;
seen.add(k);
try {
const v = e[k];
if (v !== undefined && typeof v !== "function") parts.push(`${k}=${v}`);
} catch (_) { /* ignore */ }
}
return parts.join(" · ");
}
// ---------- environment diagnostics ----------
async function paintDiagnostics() {
const kv = document.getElementById("diag-kv");
const rows = [];
function row(key, val, ok) {
rows.push({ key, val: String(val), ok });
}
row("origin", window.location.origin, true);
row("isSecureContext", window.isSecureContext, !!window.isSecureContext);
row("crypto.subtle", !!(window.crypto && window.crypto.subtle), !!(window.crypto && window.crypto.subtle));
row("PublicKeyCredential", !!window.PublicKeyCredential, !!window.PublicKeyCredential);
let uvpa = "n/a";
if (window.PublicKeyCredential && PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) {
try {
uvpa = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch (e) { uvpa = "error: " + describeError(e); }
}
row("platformAuth", uvpa, uvpa === true);
let condMed = "n/a";
if (window.PublicKeyCredential && PublicKeyCredential.isConditionalMediationAvailable) {
try { condMed = await PublicKeyCredential.isConditionalMediationAvailable(); }
catch (e) { condMed = "error: " + describeError(e); }
}
row("condMediation", condMed, condMed === true);
row("userAgent", navigator.userAgent, true);
kv.innerHTML = "";
for (const r of rows) {
const k = document.createElement("div"); k.className = "k"; k.textContent = r.key;
const v = document.createElement("div"); v.className = "v " + (r.ok ? "ok" : "bad"); v.textContent = r.val;
kv.appendChild(k); kv.appendChild(v);
}
// Also dump everything to the console once, in structured form.
console.log("[nostr-passkey] env", {
origin: window.location.origin,
isSecureContext: window.isSecureContext,
hasPKC: !!window.PublicKeyCredential,
platformAuth: uvpa,
condMediation: condMed,
userAgent: navigator.userAgent,
});
}
paintDiagnostics();
// ---------- api ----------
async function api(path, body) {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${path} ${res.status}: ${text}`);
}
return res.json();
}
// ---------- options decoding ----------
// Server sends all BufferSource fields as base64url strings; the
// WebAuthn API needs them as ArrayBuffers.
function decodeCreationOptions(opts) {
const o = structuredClone(opts);
o.challenge = b64uToBuf(o.challenge);
o.user.id = b64uToBuf(o.user.id);
if (Array.isArray(o.excludeCredentials)) {
o.excludeCredentials = o.excludeCredentials.map(c => ({ ...c, id: b64uToBuf(c.id) }));
}
const prf = o.extensions && o.extensions.prf && o.extensions.prf.eval;
if (prf) {
if (prf.first) prf.first = b64uToBuf(prf.first);
if (prf.second) prf.second = b64uToBuf(prf.second);
}
return o;
}
function decodeRequestOptions(opts) {
const o = structuredClone(opts);
o.challenge = b64uToBuf(o.challenge);
if (Array.isArray(o.allowCredentials)) {
o.allowCredentials = o.allowCredentials.map(c => ({ ...c, id: b64uToBuf(c.id) }));
}
const prf = o.extensions && o.extensions.prf && o.extensions.prf.eval;
if (prf) {
if (prf.first) prf.first = b64uToBuf(prf.first);
if (prf.second) prf.second = b64uToBuf(prf.second);
}
return o;
}
// ---------- credential encoding ----------
function encodeRegistrationCredential(cred) {
return {
id: cred.id,
rawId: bufToB64u(cred.rawId),
type: cred.type,
response: {
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
attestationObject: bufToB64u(cred.response.attestationObject),
},
clientExtensionResults: cred.getClientExtensionResults(),
};
}
function encodeAssertionCredential(cred) {
const ext = cred.getClientExtensionResults();
const extOut = {};
if (ext.prf && ext.prf.results) {
extOut.prf = { results: {} };
if (ext.prf.results.first) extOut.prf.results.first = bufToB64u(ext.prf.results.first);
if (ext.prf.results.second) extOut.prf.results.second = bufToB64u(ext.prf.results.second);
}
return {
id: cred.id,
rawId: bufToB64u(cred.rawId),
type: cred.type,
response: {
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
authenticatorData: bufToB64u(cred.response.authenticatorData),
signature: bufToB64u(cred.response.signature),
userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null,
},
clientExtensionResults: extOut,
};
}
// ---------- flows ----------
async function doRegister() {
const btn = document.getElementById("btn-register");
btn.disabled = true;
clearLog();
try {
const username = document.getElementById("reg-user").value.trim();
if (!username) throw new Error("username required");
log(`register/start user=${username}`);
const { session_id, options } = await api("/register/start", { username });
log(`options received · rp.id=${options.rp?.id} challenge_len=${options.challenge?.length}`);
console.log("[nostr-passkey] raw options", options);
const publicKey = decodeCreationOptions(options);
console.log("[nostr-passkey] decoded publicKey", publicKey);
log(`invoking navigator.credentials.create({publicKey})`);
let cred;
try {
cred = await navigator.credentials.create({ publicKey });
} catch (e) {
// Surface every field the browser gives us. "The operation is insecure"
// in Firefox, "NotAllowedError" in Chrome, etc. Each browser puts the
// interesting detail in a different field.
log(`create() threw · ${describeError(e)}`, "err");
console.error("[nostr-passkey] create() error object", e);
throw e;
}
log(`credential created · id=${cred.id?.slice(0, 16)}… type=${cred.type} attachment=${cred.authenticatorAttachment || "?"}`);
const regExt = cred.getClientExtensionResults();
console.log("[nostr-passkey] registration clientExtensionResults", regExt);
const prfEnabled = regExt && regExt.prf && regExt.prf.enabled;
if (prfEnabled === true) {
log("prf extension · enabled on this credential ✓");
} else if (prfEnabled === false) {
log("prf extension · NOT enabled (authenticator refused)", "err");
} else {
log("prf extension · status unknown (no prf field in clientExtensionResults)", "err");
}
await api("/register/finish", {
username,
session_id,
credential: encodeRegistrationCredential(cred),
});
log(`ok · passkey registered for ${username}`);
} catch (e) {
if (!logEl.querySelector(".err")) log(describeError(e), "err");
} finally {
btn.disabled = false;
}
}
async function doDerive() {
const btn = document.getElementById("btn-derive");
btn.disabled = true;
clearLog();
document.getElementById("result-section").style.display = "none";
try {
const username = document.getElementById("auth-user").value.trim();
const salt = document.getElementById("auth-salt").value;
if (!username) throw new Error("username required");
log(`auth/start user=${username}${salt ? ` salt=${salt}` : ""}`);
const { session_id, options } = await api("/auth/start", { username });
log(`options received · rpId=${options.rpId} allowCreds=${(options.allowCredentials||[]).length}`);
console.log("[nostr-passkey] raw options", options);
const publicKey = decodeRequestOptions(options);
console.log("[nostr-passkey] decoded publicKey", publicKey);
log(`invoking navigator.credentials.get({publicKey})`);
let cred;
try {
cred = await navigator.credentials.get({ publicKey });
} catch (e) {
log(`get() threw · ${describeError(e)}`, "err");
console.error("[nostr-passkey] get() error object", e);
throw e;
}
const ext = cred.getClientExtensionResults();
console.log("[nostr-passkey] clientExtensionResults", ext);
if (!ext.prf || !ext.prf.results || !ext.prf.results.first) {
throw new Error("authenticator did not return prf output (needs prf + uv)");
}
log(`prf output · ${ext.prf.results.first.byteLength} bytes`);
log("deriving nostr key via hkdf-sha256");
const identity = await api("/auth/finish", {
username,
session_id,
credential: encodeAssertionCredential(cred),
salt: salt || null,
});
document.getElementById("out-npub").textContent = identity.npub;
document.getElementById("out-nsec").textContent = identity.nsec;
document.getElementById("out-pubhex").textContent = identity.pubkey_hex;
document.getElementById("out-privhex").textContent = identity.privkey_hex;
document.getElementById("result-section").style.display = "block";
log("ok · identity derived");
} catch (e) {
if (!logEl.querySelector(".err")) log(describeError(e), "err");
} finally {
btn.disabled = false;
}
}
document.getElementById("btn-register").addEventListener("click", doRegister);
document.getElementById("btn-derive").addEventListener("click", doDerive);
// ---------- theme toggle ----------
function currentTheme() {
return document.documentElement.getAttribute("data-theme")
|| (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
}
function toggleTheme() {
const next = currentTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
try { localStorage.setItem("nostr-passkey:theme", next); } catch (_) {}
}
document.getElementById("btn-theme").addEventListener("click", toggleTheme);
</script>
</body>
</html>