<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>passki - WebAuthn ceremony demo</title>
<style>
:root {
--graphite: #1a1d23;
--zinc: #e7e9ed;
--zinc-edge: #cbd0d8;
--slate: #5b6270;
--copper: #a8551f;
--copper-lit: #f0a05a;
--alert: #a5342a;
--mono: ui-monospace, "SF Mono", "Cascadia Mono",
"JetBrains Mono", Menlo, Consolas, monospace;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI",
Roboto, "Helvetica Neue", Arial, sans-serif;
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--sans);
background: var(--zinc);
color: var(--graphite);
min-height: 100vh;
display: flex;
justify-content: center;
padding: 32px 24px 48px;
-webkit-font-smoothing: antialiased;
}
.frame {
width: 100%;
max-width: 940px;
}
.rail {
display: flex;
align-items: baseline;
gap: 14px;
flex-wrap: wrap;
padding-bottom: 18px;
border-bottom: 1px solid var(--zinc-edge);
}
.mark {
font-family: var(--mono);
font-size: 30px;
font-weight: 700;
letter-spacing: -0.035em;
}
.mark::after {
content: "";
display: inline-block;
width: 9px;
height: 9px;
margin-left: 9px;
border-radius: 2px;
background: var(--copper);
vertical-align: 3px;
}
.eyebrow {
font-family: var(--mono);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--slate);
}
.bench {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
gap: 34px;
padding-top: 28px;
}
@media (max-width: 780px) {
.bench {
grid-template-columns: minmax(0, 1fr);
gap: 26px;
}
}
.act + .act {
margin-top: 26px;
padding-top: 26px;
border-top: 1px solid var(--zinc-edge);
}
.act-head {
font-family: var(--mono);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--slate);
margin-bottom: 14px;
}
.field {
margin-bottom: 12px;
}
.field label {
display: block;
font-size: 13px;
margin-bottom: 6px;
color: var(--slate);
}
.field input {
width: 100%;
font-family: var(--mono);
font-size: 14px;
color: var(--graphite);
padding: 11px 12px;
background: #fbfbfc;
border: 1px solid var(--zinc-edge);
border-radius: 8px;
transition: border-color 0.15s ease;
}
.field input::placeholder {
color: #9aa1af;
}
.field input:hover {
border-color: #b3bac5;
}
.field input:focus {
outline: none;
border-color: var(--copper);
box-shadow: 0 0 0 3px rgba(168, 85, 31, 0.14);
}
.check {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
align-items: start;
margin-bottom: 14px;
cursor: pointer;
}
.check input {
width: 15px;
height: 15px;
margin-top: 1px;
accent-color: var(--copper);
cursor: pointer;
}
.check-text {
font-size: 13px;
line-height: 1.45;
color: var(--slate);
}
.check-hint {
display: block;
font-family: var(--mono);
font-size: 10.5px;
line-height: 1.6;
margin-top: 3px;
opacity: 0.75;
}
.go {
width: 100%;
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
color: #fdfaf7;
background: var(--graphite);
border: none;
border-radius: 8px;
padding: 12px;
margin-top: 4px;
cursor: pointer;
transition:
background 0.15s ease,
opacity 0.15s ease;
}
.go:hover {
background: #2b3039;
}
.go:active {
background: var(--copper);
}
.go[disabled] {
opacity: 0.45;
cursor: progress;
}
.status {
margin-top: 20px;
min-height: 20px;
font-size: 13px;
line-height: 1.5;
color: var(--slate);
padding-left: 12px;
border-left: 2px solid var(--zinc-edge);
}
.status[data-kind="ok"] {
color: var(--graphite);
border-left-color: var(--copper);
}
.status[data-kind="err"] {
color: var(--alert);
border-left-color: var(--alert);
}
.readout {
background: var(--graphite);
border-radius: 14px;
padding: 22px;
align-self: start;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.07),
0 14px 32px -18px rgba(20, 22, 28, 0.75);
}
.readout-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding-bottom: 16px;
margin-bottom: 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.readout-what {
font-family: var(--mono);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--copper-lit);
}
.readout-who {
font-family: var(--mono);
font-size: 12px;
color: #8b93a3;
overflow-wrap: anywhere;
}
.flags {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 18px;
}
.flag {
display: grid;
grid-template-columns: 9px 1fr auto;
align-items: center;
gap: 9px;
padding: 10px 11px;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 8px;
}
.bit {
width: 9px;
height: 9px;
border-radius: 50%;
background: #3a3f4a;
}
.flag[data-state="set"] .bit {
background: var(--copper-lit);
box-shadow: 0 0 11px rgba(240, 160, 90, 0.6);
}
.flag-name {
font-family: var(--mono);
font-size: 12px;
font-weight: 600;
color: #e4e7ec;
}
.flag[data-state="idle"] .flag-name {
color: #767e8d;
}
.flag-gloss {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #767e8d;
}
.rows {
display: grid;
gap: 14px;
}
.row-label {
font-family: var(--mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #767e8d;
margin-bottom: 5px;
}
.row-value {
font-family: var(--mono);
font-size: 12.5px;
line-height: 1.55;
color: #e4e7ec;
overflow-wrap: anywhere;
}
.row-value[data-muted="yes"] {
color: #767e8d;
}
.foot {
margin-top: 28px;
padding-top: 16px;
border-top: 1px solid var(--zinc-edge);
font-family: var(--mono);
font-size: 11px;
line-height: 1.9;
color: var(--slate);
}
.foot b {
font-weight: 600;
color: var(--graphite);
}
:focus-visible {
outline: 2px solid var(--copper);
outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
.readout[data-fresh="yes"] .flag,
.readout[data-fresh="yes"] .row {
animation: settle 0.34s cubic-bezier(0.2, 0.7, 0.3, 1)
backwards;
animation-delay: calc(var(--i) * 45ms);
}
@keyframes settle {
from {
opacity: 0;
transform: translateY(5px);
}
}
}
@media (prefers-color-scheme: dark) {
:root {
--zinc: #121418;
--zinc-edge: #2c313a;
--slate: #949cab;
--graphite: #e7e9ed;
--copper: #d4793c;
}
body {
color: var(--graphite);
}
.field input {
background: #1a1d23;
color: var(--graphite);
}
.go {
color: #14161c;
background: var(--graphite);
}
.go:hover {
background: #ffffff;
}
.readout {
background: #1a1d23;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 0 0 1px #2c313a;
}
}
</style>
</head>
<body>
<div class="frame">
<header class="rail">
<span class="mark">passki</span>
<span class="eyebrow">WebAuthn ceremony demo</span>
</header>
<main class="bench">
<section class="controls">
<form class="act" id="register-form" novalidate>
<h2 class="act-head">Register</h2>
<div class="field">
<label for="register-username">Username</label>
<input
type="text"
id="register-username"
autocomplete="username"
placeholder="alice"
/>
</div>
<label class="check">
<input type="checkbox" id="attestation" />
<span class="check-text"
>Request attestation
<span class="check-hint"
>direct instead of none, so a security key
reports its model</span
></span
>
</label>
<button type="submit" class="go" id="register-btn">
Create passkey
</button>
</form>
<form class="act" id="login-form" novalidate>
<h2 class="act-head">Sign in</h2>
<div class="field">
<label for="login-username">Username</label>
<input
type="text"
id="login-username"
autocomplete="username webauthn"
placeholder="leave empty to pick a passkey"
/>
</div>
<div class="field">
<label for="prf-context">Key context</label>
<input
type="text"
id="prf-context"
placeholder="notes, backup, …"
/>
</div>
<button type="submit" class="go" id="login-btn">
Sign in
</button>
</form>
<p class="status" id="status" role="status">
Ready. Create a passkey to begin.
</p>
</section>
<aside class="readout" id="readout" aria-live="polite">
<div class="readout-head">
<span class="readout-what" id="readout-what"
>Readout</span
>
<span class="readout-who" id="readout-who"
>no ceremony yet</span
>
</div>
<div class="flags">
<div class="flag" data-flag="rk" data-state="idle" style="--i: 0">
<span class="bit"></span>
<span class="flag-name">rk</span>
<span class="flag-gloss">discoverable</span>
</div>
<div class="flag" data-flag="prf" data-state="idle" style="--i: 1">
<span class="bit"></span>
<span class="flag-name">prf</span>
<span class="flag-gloss">hmac-secret</span>
</div>
<div class="flag" data-flag="be" data-state="idle" style="--i: 2">
<span class="bit"></span>
<span class="flag-name">be</span>
<span class="flag-gloss">syncable</span>
</div>
<div class="flag" data-flag="bs" data-state="idle" style="--i: 3">
<span class="bit"></span>
<span class="flag-name">bs</span>
<span class="flag-gloss">backed up</span>
</div>
</div>
<div class="rows">
<div class="row" style="--i: 4">
<div class="row-label">aaguid</div>
<div
class="row-value"
id="row-aaguid"
data-muted="yes"
>
-
</div>
</div>
<div class="row" style="--i: 5">
<div class="row-label">derived key</div>
<div class="row-value" id="row-key" data-muted="yes">
-
</div>
</div>
</div>
</aside>
</main>
<p class="foot">
<b>Empty username</b> signs in with a discoverable credential, so
the browser picks the passkey.<br />
<b>Key context</b> asks the authenticator to derive 32 bytes for
that string through the PRF extension.<br />
<b>Request attestation</b> reports the authenticator model. This
demo installs no trust anchors, so the AAGUID it returns stays
self-asserted.
</p>
</div>
<script>
let conditionalAbortController = null;
const $ = (id) => document.getElementById(id);
function setStatus(text, kind) {
const el = $("status");
el.textContent = text;
if (kind) {
el.dataset.kind = kind;
} else {
delete el.dataset.kind;
}
}
function renderReadout(what, who, facts) {
const readout = $("readout");
readout.removeAttribute("data-fresh");
$("readout-what").textContent = what;
$("readout-who").textContent = who;
for (const name of ["rk", "prf", "be", "bs"]) {
const value = facts[name];
document.querySelector(
`.flag[data-flag="${name}"]`,
).dataset.state =
value === true ? "set" : value === false ? "unset" : "idle";
}
setRow("row-aaguid", facts.aaguid);
setRow("row-key", facts.key);
requestAnimationFrame(() => {
readout.dataset.fresh = "yes";
});
}
function setRow(id, entry) {
const el = $(id);
el.textContent = entry.value ?? entry.reason;
el.dataset.muted = entry.value ? "no" : "yes";
}
function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let str = "";
for (const byte of bytes) {
str += String.fromCharCode(byte);
}
return btoa(str)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
function textToBase64url(text) {
return bufferToBase64url(new TextEncoder().encode(text).buffer);
}
function serializeExtensionResults(extResults) {
const out = {};
if (extResults.credProps !== undefined) {
out.credProps = extResults.credProps;
}
if (extResults.prf) {
const prf = {};
if (extResults.prf.enabled !== undefined) {
prf.enabled = extResults.prf.enabled;
}
if (extResults.prf.results) {
prf.results = {};
if (extResults.prf.results.first) {
prf.results.first = bufferToBase64url(extResults.prf.results.first);
}
if (extResults.prf.results.second) {
prf.results.second = bufferToBase64url(extResults.prf.results.second);
}
}
out.prf = prf;
}
return Object.keys(out).length ? out : null;
}
async function register() {
conditionalAbortController?.abort();
conditionalAbortController = null;
const username = $("register-username").value.trim();
const attestation = $("attestation").checked;
if (!username) {
setStatus("Enter a username to register.", "err");
$("register-username").focus();
initConditionalUI();
return;
}
const registerBtn = $("register-btn");
registerBtn.disabled = true;
setStatus("Waiting for the authenticator…");
try {
const startRes = await fetch("/register/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, attestation }),
});
if (!startRes.ok)
throw new Error("Registration start failed");
const options = await startRes.json();
const credential = await navigator.credentials.create({
publicKey:
PublicKeyCredential.parseCreationOptionsFromJSON(
options,
),
});
const clientExtensionResults = serializeExtensionResults(
credential.getClientExtensionResults()
);
const finishRes = await fetch("/register/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username,
credential_id: bufferToBase64url(credential.rawId),
public_key: bufferToBase64url(
credential.response.attestationObject,
),
client_data_json: bufferToBase64url(
credential.response.clientDataJSON,
),
client_extension_results: clientExtensionResults,
authenticator_attachment:
credential.authenticatorAttachment,
transports:
credential.response.getTransports?.() ?? [],
}),
});
if (!finishRes.ok)
throw new Error("Registration finish failed");
const result = await finishRes.json();
renderReadout("Registration", username, {
rk: result.resident_key,
prf: result.prf_supported,
be: result.backup_eligible,
bs: result.backed_up,
aaguid: {
value: result.aaguid,
reason: attestation
? "zeroed - authenticator stayed anonymous"
: "zeroed - attestation not requested",
},
key: { reason: "registration derives no key" },
});
setStatus("Passkey created.", "ok");
$("register-username").value = "";
} catch (err) {
console.error(err);
setStatus(`Registration failed - ${err.message}`, "err");
} finally {
registerBtn.disabled = false;
initConditionalUI();
}
}
async function login() {
conditionalAbortController?.abort();
conditionalAbortController = null;
const username = $("login-username").value.trim();
const context = $("prf-context").value.trim();
const loginBtn = $("login-btn");
loginBtn.disabled = true;
setStatus("Waiting for the authenticator…");
try {
const startBody = username ? { username } : {};
if (context) startBody.prf_salt = textToBase64url(context);
const result = await authenticate(startBody);
showAssertion(result, context);
$("login-username").value = "";
$("prf-context").value = "";
} catch (err) {
console.error(err);
setStatus(`Sign-in failed - ${err.message}`, "err");
} finally {
loginBtn.disabled = false;
initConditionalUI();
}
}
function showAssertion(result, context) {
renderReadout("Sign-in", result.username ?? "-", {
aaguid: { reason: "reported at registration only" },
key: {
value: result.prf_output,
reason: context
? "authenticator has no PRF support"
: "no key context given",
},
});
setStatus(result.message, "ok");
}
async function authenticate(startBody) {
const startRes = await fetch("/auth/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(startBody),
});
if (!startRes.ok) throw new Error("Authentication start failed");
const options = await startRes.json();
const credential = await navigator.credentials.get({
publicKey:
PublicKeyCredential.parseRequestOptionsFromJSON(options),
});
const clientExtensionResults = serializeExtensionResults(
credential.getClientExtensionResults()
);
const finishRes = await fetch("/auth/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...startBody,
credential_id: bufferToBase64url(credential.rawId),
authenticator_data: bufferToBase64url(
credential.response.authenticatorData,
),
client_data_json: bufferToBase64url(
credential.response.clientDataJSON,
),
signature: bufferToBase64url(
credential.response.signature,
),
user_handle: credential.response.userHandle
? bufferToBase64url(credential.response.userHandle)
: null,
client_extension_results: clientExtensionResults,
authenticator_attachment:
credential.authenticatorAttachment,
}),
});
if (!finishRes.ok)
throw new Error("Authentication finish failed");
return await finishRes.json();
}
async function initConditionalUI() {
const supported = await PublicKeyCredential.isConditionalMediationAvailable?.();
if (!supported) return;
conditionalAbortController?.abort();
conditionalAbortController = new AbortController();
const { signal } = conditionalAbortController;
try {
const startRes = await fetch("/auth/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!startRes.ok || signal.aborted) return;
const options = await startRes.json();
if (signal.aborted) return;
const credential = await navigator.credentials.get({
mediation: "conditional",
signal,
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
});
const clientExtensionResults = serializeExtensionResults(
credential.getClientExtensionResults()
);
const finishRes = await fetch("/auth/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
credential_id: bufferToBase64url(credential.rawId),
authenticator_data: bufferToBase64url(credential.response.authenticatorData),
client_data_json: bufferToBase64url(credential.response.clientDataJSON),
signature: bufferToBase64url(credential.response.signature),
user_handle: credential.response.userHandle
? bufferToBase64url(credential.response.userHandle)
: null,
client_extension_results: clientExtensionResults,
authenticator_attachment:
credential.authenticatorAttachment,
}),
});
if (!finishRes.ok) {
const text = await finishRes.text();
throw new Error(`Authentication finish failed: ${text}`);
}
const result = await finishRes.json();
showAssertion(result, "");
$("login-username").value = "";
$("prf-context").value = "";
initConditionalUI();
} catch (err) {
if (err.name !== "AbortError") {
console.error("Conditional UI error:", err);
}
}
}
$("register-form").addEventListener("submit", (e) => {
e.preventDefault();
register();
});
$("login-form").addEventListener("submit", (e) => {
e.preventDefault();
login();
});
initConditionalUI();
</script>
</body>
</html>