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>
537 lines
17 KiB
HTML
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 · optional</label>
|
|
<input id="auth-salt" type="text" placeholder="e.g. work" autocomplete="off" spellcheck="false">
|
|
<button id="btn-derive">authenticate & 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>
|