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>
74 lines
3.8 KiB
SQL
74 lines
3.8 KiB
SQL
-- Passkeys: a key on a device instead of a secret in a head.
|
|
--
|
|
-- The account already has two ways in — a password, and a TOTP code on top of it.
|
|
-- Both are shared secrets: the server keeps something that can be stolen from it,
|
|
-- and the person keeps something that can be phished out of them. A passkey is
|
|
-- neither. The private half never leaves the device, the public half is all this
|
|
-- table holds, and the signature is bound to the origin — so a convincing copy of
|
|
-- the sign-in page at a different address gets nothing.
|
|
--
|
|
-- One row per credential rather than per account, because a person has more than
|
|
-- one device and losing a laptop should not mean losing the way in. The name is
|
|
-- theirs to write: "min bærbare" is what makes a list of keys reviewable, and a
|
|
-- list nobody can read is a list nobody revokes from.
|
|
--
|
|
-- `sign_count` is the authenticator's own counter. It is stored to be compared:
|
|
-- a counter that goes backwards means the credential has been cloned, which is
|
|
-- the one thing this design cannot otherwise detect. Not every authenticator keeps
|
|
-- one — the spec allows zero — so zero means "not offered" rather than "suspicious".
|
|
CREATE TABLE passkeys (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
|
-- The credential id the authenticator generated, raw bytes, base64url as
|
|
-- stored. Unique across the instance: it is what a login is looked up by,
|
|
-- before anybody has said who they are.
|
|
credential_id TEXT NOT NULL,
|
|
public_key BLOB NOT NULL,
|
|
-- The AAGUID identifies the authenticator model, which is what lets a list say
|
|
-- "iCloud Keychain" beside a key somebody named three months ago and forgot.
|
|
aaguid TEXT NOT NULL DEFAULT '',
|
|
sign_count INTEGER NOT NULL DEFAULT 0,
|
|
-- Whether the credential lives on the device itself (a resident key). Only
|
|
-- those can start a login with no email typed first, so the interface has to
|
|
-- know which it has before it offers that.
|
|
discoverable INTEGER NOT NULL DEFAULT 0,
|
|
-- Whether the authenticator verified the person — a PIN, a fingerprint, a
|
|
-- face. A key that only proves possession is a first factor; one that verified
|
|
-- its owner is both, and that is what decides whether a password is still
|
|
-- asked for.
|
|
user_verified INTEGER NOT NULL DEFAULT 0,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
created_at INTEGER NOT NULL,
|
|
last_used_at INTEGER
|
|
);
|
|
|
|
CREATE UNIQUE INDEX idx_passkeys_credential ON passkeys (credential_id);
|
|
CREATE INDEX idx_passkeys_user ON passkeys (user_id);
|
|
|
|
-- The challenge a registration or a login is answered against.
|
|
--
|
|
-- Kept server-side rather than in a cookie: the whole point of the challenge is
|
|
-- that the server chose it and remembers choosing it. One row per attempt, deleted
|
|
-- when it is answered, and swept when it expires — a challenge that outlives its
|
|
-- ceremony is a replay waiting to be tried.
|
|
--
|
|
-- user_id is null for a login that has not said who it is yet, which is the case
|
|
-- a discoverable credential exists for.
|
|
CREATE TABLE webauthn_challenges (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT REFERENCES users (id) ON DELETE CASCADE,
|
|
challenge TEXT NOT NULL,
|
|
-- 'register' or 'login'. A challenge issued for one must not be answerable by
|
|
-- the other: registering is done by somebody already signed in, and logging in
|
|
-- is not.
|
|
purpose TEXT NOT NULL,
|
|
-- The library's own session state, which carries the allowed credentials and
|
|
-- the verification requirement it will check the answer against.
|
|
session BLOB NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
CHECK (purpose IN ('register', 'login'))
|
|
);
|
|
|
|
CREATE INDEX idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at);
|