feat(auth): passkeys, as a way in and as a second factor

A password and a TOTP code are both shared secrets: the server keeps something
that can be stolen from it, and the person keeps something that can be talked out
of them. A passkey is neither. The private half never leaves the device, and the
signature is bound to the origin that asked for it — so a convincing copy of the
sign-in page at another address gets a signature it cannot use.

Logging in asks for no email. The device knows which account its key belongs to,
which is not only convenience: it means the sign-in page cannot be used to find out
who has an account here. A key that verified its owner is both factors at once and
completes the login; one that only proved possession still meets the TOTP step,
exactly as a password does.

The relying party id is derived from VERDANDE_BASE_URL rather than configured
separately — a second setting that has to agree with the first is one that will
one day disagree, and the failure is a login nobody can complete. An IP address is
refused up front: no browser accepts one, and without the check the server issues
a challenge happily and the browser throws SecurityError long afterwards. The list
endpoint reports whether this deployment can offer them at all, so the interface
never shows a button that cannot work.

A challenge is stored server-side, answerable exactly once, and taken rather than
read — two requests racing the same one would both succeed, which is the shape of
a replay. A sign count that has gone backwards means the credential was cloned and
is refused rather than logged.

The Go tests cover who may reach these endpoints and that challenges are spent.
The ceremony itself needs an authenticator, so the smoke test drives Chromium's
virtual one over CDP — the only thing that can tell whether the base64url
conversions are right, since one wrong byte produces a signature that will not
verify and reads as a broken key rather than a broken encoder.

That test runs at localhost in its own context while the suite stays on the
loopback address, because WebAuthn refuses an IP and resolving localhost is slow
enough here to cost the suite minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 11:56:54 +02:00
co-authored by Claude Opus 5
parent 982c3aab18
commit fe52818ab6
21 changed files with 1769 additions and 29 deletions
+9
View File
@@ -181,6 +181,15 @@ export const api = {
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
totpDisable: (password) => post('/auth/totp/disable', { password }),
listSessions: () => get('/auth/sessions'),
// --- passkeys
listPasskeys: () => get('/auth/passkeys'),
beginPasskeyRegistration: () => post('/auth/passkeys/register/begin'),
finishPasskeyRegistration: (data) => post('/auth/passkeys/register/finish', data),
renamePasskey: (id, name) => patch(`/auth/passkeys/${id}`, { name }),
deletePasskey: (id) => del(`/auth/passkeys/${id}`),
beginPasskeyLogin: () => post('/auth/passkey/login/begin'),
finishPasskeyLogin: (data) => post('/auth/passkey/login/finish', data),
endSession: (id) => del(`/auth/sessions/${id}`),
// --- users, administrators only
+61
View File
@@ -20,6 +20,7 @@
import { app } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
import { available, signIn } from '$lib/passkey.js';
// loading | login | setup | totp | forgot | sent | invite | reset | done
let mode = $state('loading');
@@ -31,6 +32,41 @@
let fields = $state({});
let busy = $state(false);
// --- passkey -----------------------------------------------------------------
let passkeyReady = $state(false);
$effect(() => {
available().then((ok) => (passkeyReady = ok));
});
/**
* No email is asked for and none is sent: the device knows which account its
* key belongs to. That is not only convenience — it means this page cannot be
* used to find out who has an account here.
*/
async function signInWithPasskey() {
error = '';
busy = true;
try {
const result = await signIn();
if (result.totp_required) {
mode = 'totp';
} else {
app.user = result.user;
await app.load();
}
} catch (e) {
// A cancelled prompt is not a failure. Somebody changed their mind, and
// an error for that reads as though the key was rejected.
if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
error = humanMessage(e);
}
} finally {
busy = false;
}
}
/**
* The token from an emailed link, if this is one.
*
@@ -267,6 +303,15 @@
</label>
<button type="submit" disabled={busy}>{t('auth.signIn')}</button>
<!-- Only when the browser has somewhere to keep a key. Offering it where
it cannot work is a button that fails for reasons nobody can act on. -->
{#if passkeyReady}
<button type="button" class="passkey" disabled={busy} onclick={signInWithPasskey}>
{busy ? t('passkey.signingIn') : t('passkey.signIn')}
</button>
{/if}
<button type="button" class="link" onclick={() => (mode = 'forgot')}>
{t('auth.forgot')}
</button>
@@ -381,6 +426,22 @@
cursor: default;
}
/* Set apart from the primary button but not made secondary: it is an equal way
in, not a fallback. */
.passkey {
width: 100%;
padding: var(--s2) var(--s4);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
font-size: var(--text-sm);
color: var(--ink);
transition: border-color var(--fast) var(--ease);
}
.passkey:hover {
border-color: var(--accent);
}
.link {
color: var(--ink-faint);
font-size: var(--text-sm);
+23 -1
View File
@@ -613,5 +613,27 @@ export const da = {
'done.title': 'Færdige',
'done.hint': 'Alt du har lukket, nyeste først. Klik cirklen for at åbne en igen.',
'done.empty': 'Du har ikke lukket noget endnu.',
'done.unknownDay': 'Uden dato'
'done.unknownDay': 'Uden dato',
// --- passkeys ----------------------------------------------------------------------
'passkey.title': 'Passkeys',
'passkey.hint':
'En nøgle på en enhed i stedet for en hemmelighed i et hoved. Den private halvdel forlader aldrig enheden, og signaturen er bundet til adressen — så en overbevisende kopi af login-siden et andet sted får ingenting.',
'passkey.unsupported': 'Denne browser understøtter ikke passkeys.',
'passkey.none': 'Ingen nøgler endnu.',
'passkey.name': 'Navn på nøglen',
'passkey.namePlaceholder': 'fx min bærbare',
'passkey.add': 'Tilføj en nøgle',
'passkey.registering': 'Venter på enheden…',
'passkey.remove': 'Fjern',
'passkey.removeQuestion': 'Fjern "{name}"? Enheden kan ikke logge ind med den bagefter.',
'passkey.bothFactors': 'begge faktorer',
'passkey.possessionOnly': 'kun besiddelse — koden bliver stadig krævet',
'passkey.lastUsed': 'sidst brugt {when}',
'passkey.neverUsed': 'aldrig brugt',
'passkey.signIn': 'Log ind med en passkey',
'passkey.signingIn': 'Venter på enheden…',
'passkey.unavailable':
'Passkeys kan ikke bruges her — enten understøtter browseren dem ikke, eller også er serverens adresse en IP frem for et domænenavn, hvilket ingen authenticator accepterer.'
};
+23 -1
View File
@@ -608,5 +608,27 @@ export const en = {
'done.title': 'Done',
'done.hint': 'Everything you have closed, newest first. Click the circle to reopen one.',
'done.empty': 'You have not closed anything yet.',
'done.unknownDay': 'No date'
'done.unknownDay': 'No date',
// --- passkeys ----------------------------------------------------------------------
'passkey.title': 'Passkeys',
'passkey.hint':
'A key on a device instead of a secret in a head. The private half never leaves the device, and the signature is bound to the address that asked for it — so a convincing copy of the sign-in page somewhere else gets nothing.',
'passkey.unsupported': 'This browser does not support passkeys.',
'passkey.none': 'No keys yet.',
'passkey.name': 'Name for the key',
'passkey.namePlaceholder': 'e.g. my laptop',
'passkey.add': 'Add a key',
'passkey.registering': 'Waiting for the device…',
'passkey.remove': 'Remove',
'passkey.removeQuestion': 'Remove "{name}"? That device cannot sign in with it afterwards.',
'passkey.bothFactors': 'both factors',
'passkey.possessionOnly': 'possession only — the code is still required',
'passkey.lastUsed': 'last used {when}',
'passkey.neverUsed': 'never used',
'passkey.signIn': 'Sign in with a passkey',
'passkey.signingIn': 'Waiting for the device…',
'passkey.unavailable':
'Passkeys cannot be used here — either this browser does not support them, or the server’s address is an IP rather than a domain name, which no authenticator will accept.'
};
+115
View File
@@ -0,0 +1,115 @@
/**
* The browser half of a passkey ceremony.
*
* WebAuthn passes binary as ArrayBuffers and JSON cannot carry those, so every
* boundary between this and the server is a base64url conversion. Getting one of
* them wrong produces a signature that will not verify, reported as "that key was
* not accepted" — which reads as a broken key rather than a broken encoder. That
* is the whole reason this is one file and not three call sites.
*/
import { api } from './api.js';
/** Whether this browser can do it at all. */
export function supported() {
return typeof window !== 'undefined' && Boolean(window.PublicKeyCredential);
}
/**
* Whether a key can be offered without the person typing anything first.
*
* Not the same question as `supported()`: a browser can do WebAuthn and still have
* nowhere to put a discoverable credential. Asking first is what stops the sign-in
* page offering a button that cannot work.
*/
export async function available() {
if (!supported()) return false;
try {
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch {
return false;
}
}
const toBuffer = (value) => {
const normalised = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalised.padEnd(normalised.length + ((4 - (normalised.length % 4)) % 4), '=');
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
};
const toBase64URL = (buffer) =>
btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
/** Registers a key on the signed-in account. Returns the row the server stored. */
export async function register(name) {
const { challenge_id, options } = await api.beginPasskeyRegistration();
const publicKey = {
...options.publicKey,
challenge: toBuffer(options.publicKey.challenge),
user: { ...options.publicKey.user, id: toBuffer(options.publicKey.user.id) },
excludeCredentials: (options.publicKey.excludeCredentials ?? []).map((c) => ({
...c,
id: toBuffer(c.id)
}))
};
const credential = await navigator.credentials.create({ publicKey });
if (!credential) throw new Error('cancelled');
return api.finishPasskeyRegistration({
challenge_id,
name,
credential: {
id: credential.id,
rawId: toBase64URL(credential.rawId),
type: credential.type,
response: {
clientDataJSON: toBase64URL(credential.response.clientDataJSON),
attestationObject: toBase64URL(credential.response.attestationObject)
},
clientExtensionResults: credential.getClientExtensionResults()
}
});
}
/**
* Signs in with a key.
*
* No email is asked for and none is sent: the device knows which account its key
* belongs to. That is not only convenience — it means the sign-in page cannot be
* used to find out who has an account here.
*/
export async function signIn() {
const { challenge_id, options } = await api.beginPasskeyLogin();
const publicKey = {
...options.publicKey,
challenge: toBuffer(options.publicKey.challenge),
allowCredentials: (options.publicKey.allowCredentials ?? []).map((c) => ({
...c,
id: toBuffer(c.id)
}))
};
const credential = await navigator.credentials.get({ publicKey });
if (!credential) throw new Error('cancelled');
return api.finishPasskeyLogin({
challenge_id,
credential: {
id: credential.id,
rawId: toBase64URL(credential.rawId),
type: credential.type,
response: {
clientDataJSON: toBase64URL(credential.response.clientDataJSON),
authenticatorData: toBase64URL(credential.response.authenticatorData),
signature: toBase64URL(credential.response.signature),
userHandle: credential.response.userHandle
? toBase64URL(credential.response.userHandle)
: null
},
clientExtensionResults: credential.getClientExtensionResults()
}
});
}