Files
nostr-passkey/web/index.html
Michael Schapira - krilin 5f35195e72 Initial prototype: passkey PRF → HKDF → Nostr key
Full pipeline proving a WebAuthn PRF assertion can anchor a stable
Nostr keypair. Core derivation in nostr_passkey/derivation.py (pure,
unit-tested), WebAuthn ceremony glue in webauthn_flow.py, FastAPI
surface in app.py, single-page WebAuthn client in web/index.html, and
an end-to-end simulation in scripts/demo.py for running without a real
authenticator.

Verified working against Firefox 149 + macOS Touch ID over HTTPS on
https://localhost:8000 with a self-signed loopback cert.

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

537 lines
17 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;
}
* { 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: #000; color: #fff; }
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: #000;
}
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: #000; color: #fff;
}
input[type=text]::placeholder { color: var(--muted); }
input[type=text]:focus::placeholder { color: #888; }
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: #000; color: #fff; }
button:active:not(:disabled) { background: #222; }
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: #000; 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: #000; border-bottom-color: #000; }
.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: #000; }
.diag .v.bad { color: #000; font-weight: 700; }
.diag .v.bad::before { content: "✗ "; }
.diag .v.ok::before { content: "✓ "; }
</style>
</head>
<body>
<main>
<header>
<div class="title">
<h1>nostr-passkey</h1>
<span class="ver">v0.1 · prototype</span>
</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);
</script>
</body>
</html>