Prima Get an API key

Module kit / Identity and auth

Fastify passkey auth with SSH enrolment

identity-auth/fastify-passkey-auth-ssh-enrolment

Kind backend
Domain identity-auth
Maturity prod
Reusable yes
Stack fastify-5, simplewebauthn-server-13, node-22, scrypt, sqlite, postgres
Depends on _shared/test-layout, _shared/cloudflare-worker-and-estate-deploy
Frontends identity-auth/ui/aurum-login-and-enrolment
Reference implementation
  • aurum/src/web/auth.ts
  • aurum/src/web/webauthn.ts
  • aurum/src/cli/enroll.ts
  • aurum/src/db/migrations/pg/007_passkeys.sql
  • aurum/test/passkey.test.ts
  • aurum/test/webauthn-authenticator.ts
Measured on aurum week2-3-confirm-gate a17fce3 2026-09-12
Version 1

Purpose

A single-operator tool signs its one person in with a passkey, keeps a scrypt password as documented break-glass, and admits a machine caller on a bearer token; every other request is 401 by a global onRequest hook, so a route added tomorrow is protected today. The invariant that makes it trustworthy is that no HTTP route can create an enrolment: a passkey is added only by opening a link minted from a shell on the host (docker exec ... enroll.js), the link is a 32-byte token stored as its SHA-256, valid ten minutes, burned in the same transaction that inserts the credential, and unknown, expired and used links get one answer with one latency floor.

This recipe fixes the default-deny gate and its allow-list, the signed stateless session cookie, the two WebAuthn ceremonies with the RP id and origin taken from configuration and never from the request, the counter regression check done twice, the SSH-only mint CLI, and the recovery rule that the password may be switched off only once two passkeys exist.

When to use

Use it for an internal operator surface with one logical user, or a handful, where the phishing-resistant credential is worth more than self-service: a dashboard that approves outreach, a deploy console, a bookkeeping review screen. Trigger phrases: "passkey login for the dashboard", "no invite endpoint", "break-glass password", "the timers get 401", "enrol my phone", "lost the laptop".

Do not use it for a multi-tenant product with self-registration (B7 webauthn-passkeys-multi-rp on Labs holds passkeys per user and per relying party, with the Labs session and grants underneath), for a surface whose users cannot reach the host over SSH through an operator, or where a session must be revocable one at a time (the cookie here is stateless; Gotchas). The Labs fork of WebAuthn is B7; the clinic's staff auth still answers 501 for passkeys (B23).

Stack assumptions

Pinned as resolved in node_modules at a17fce3 (2026-09-12): fastify 5.10.0 (^5.0.0), @simplewebauthn/server 13.3.2 (^13.3.2), better-sqlite3 11.10.0 and pg 8.22.0 behind one AurumStore interface (B200), Node >=22 (process.loadEnvFile in the CLI, enroll.ts 24), tests on node --import tsx --test test/*.test.ts (package.json 26). The primitives are node:crypto only: scryptSync for the password, createHmac('sha256') for the cookie, timingSafeEqual for every comparison, randomBytes(32) for the token. Foundations assumed: S9 (tests are node --test, hermetic, one temp SQLite file per case), S13 (the app is one container on a loopback port behind the host's reverse proxy; deploy/compose.yml 37 to 38 publishes 127.0.0.1:8787; the hop the app sits behind is the tracked vhost's catch-all handle, deploy/Caddyfile.fragment 64 to 75, which wraps reverse_proxy in basic_auth, Gotchas). B198 (the hash-chained audit_log and the store.audit({actor, actorKind, action, detail}) shape) and B200 (the AurumStore interface, the one-shot migration CLI, ship.sh) are cited by inventory id; neither has a recipe in the kit at 2026-09-12, so the store method names and signatures under Application logic and the audit row shape under Data model are the contract a builder gets from this file.

Environment names and fail behaviour (src/web/auth.ts::loadConfig, 46 to 74):

NameDefaultBehaviour
AURUM_SESSION_SECRETnonethe HMAC key for the cookie; missing in NODE_ENV=production refuses to start (57 to 60); missing elsewhere is synthesised per process and logged as DEV MODE, still default-deny (61 to 66)
AURUM_API_TOKENnonethe machine bearer for the loopback systemd timers; same boot rule; in dev the synthesised value is logged once (64)
AURUM_DASH_PASSWORD_HASHnonesaltHex$hashHex from hashPassword (83 to 87); same boot rule; an unparseable value disables the password path silently (parseHash, 77 to 81, then 262)
AURUM_PUBLIC_ORIGINthe production originthe canonical origin: the WebAuthn RP id is its hostname (rpIdFrom, 278 to 280), the expected origin on both ceremonies, and the value the cookie CSRF check compares Origin against (239); never req.headers.host
AURUM_PASSWORD_LOGINonoff disables the scrypt path only once two or more passkeys are live; otherwise refused with a warning and the password stays on (190 to 198)
AURUM_STORE, AURUM_DATABASE_URL, AURUM_DB_PATHsee B200postgres needs the DSN or boot refuses (src/db/store.ts 67 to 69); a DSN without AURUM_STORE=postgres is logged and ignored and the app stays on SQLite (70 to 76, 88 to 92); any value other than postgres or sqlite refuses (64 to 66)
NODE_ENVunsetproduction turns the missing-secrets warning into a refusal

Data model

Three tables, identical on both stores (Postgres src/db/migrations/pg/007_passkeys.sql; SQLite schema v7 at src/db/sqlite-store.ts 193 to 231 applied through PRAGMA user_version, 266 to 271):

-- 007_passkeys.sql 10 to 23
webauthn_credentials (
  id             integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  credential_id  text NOT NULL UNIQUE,          -- base64url; the duplicate guard for a re-enrolled authenticator
  public_key     text NOT NULL,                 -- base64url COSE key
  counter        integer NOT NULL DEFAULT 0,    -- advanced only forward (advanceCredentialCounter)
  transports     text,                          -- JSON-encoded array, stays TEXT on both stores (header, 7 to 9)
  label          text NOT NULL,                 -- copied from the enrolment token's label
  device_type    text, backed_up boolean NOT NULL DEFAULT false,
  created_at     timestamptz NOT NULL, last_used_at timestamptz, revoked_at timestamptz
);
CREATE INDEX idx_webauthn_credentials_live ON webauthn_credentials(revoked_at);

-- 25 to 35: the link is a bearer credential; only its hash is here
enrollment_tokens (
  id                 integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  token_hash         text NOT NULL UNIQUE,      -- sha256 hex of the 32-byte base64url token
  label              text NOT NULL, minted_by text,   -- shell identity, audit context only (enroll.ts 36 to 44)
  created_at         timestamptz NOT NULL, expires_at timestamptz NOT NULL,   -- created + 10 min (types.ts 214)
  used_at            timestamptz,               -- set once, inside the redeem transaction
  used_credential_id integer REFERENCES webauthn_credentials(id)
);
CREATE INDEX idx_enrollment_tokens_open ON enrollment_tokens(used_at);

-- 37 to 46: one row per options call, consumed by one UPDATE ... RETURNING
webauthn_challenges (
  id                  integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  challenge           text NOT NULL UNIQUE,
  kind                text NOT NULL CHECK (kind IN ('registration','authentication')),
  enrollment_token_id integer REFERENCES enrollment_tokens(id),   -- a registration challenge is bound to its token
  created_at          timestamptz NOT NULL, expires_at timestamptz NOT NULL,   -- created + 5 min (types.ts 215)
  consumed_at         timestamptz
);
CREATE INDEX idx_webauthn_challenges_open ON webauthn_challenges(consumed_at);

What is not stored: the enrolment token itself (printed once to stdout, enroll.ts 121 to 126; the audit row records the fact of minting, the id, the label and the expiry, never the token or the URL, 117 to 119); any user table (one logical user, USER_ID is the constant bytes of aurum-founder and USER_NAME is aurum, webauthn.ts 40 to 42, so a re-enrolment replaces in the authenticator rather than stacking); any session row (the cookie is self-contained: base64url {sub, iat, exp} plus an HMAC-SHA256 tag, auth.ts 101 to 106).

Audit rows this layer writes into the hash-chained audit_log (B198), by action: login_failed, password_login (src/web/server.ts 74 to 75), passkey_enroll_attempt, passkey_enroll_rejected with reason in rate_limited | invalid_token | malformed | challenge | attestation | unverified | token | duplicate and, on all but the attestation and unverified branches, stage (214 to 215, 219 to 220 carry no stage), passkey_enrolled, passkey_login_failed, passkey_counter_regression, passkey_login (webauthn.ts), passkey_token_minted, passkey_credential_revoked, passkey_token_revoked (enroll.ts).

API surface

Every route not listed in PUBLIC_ROUTES (auth.ts 24 to 37) is gated by the onRequest hook (220 to 245): a Bearer equal to AURUM_API_TOKEN passes and is CSRF-exempt; else a cookie that verifies passes, and for a method other than GET, HEAD or OPTIONS (237) its Origin must equal AURUM_PUBLIC_ORIGIN with a plain !== (238 to 240, 403 {error: 'bad origin'}); else 401 {error: 'unauthorized'}. The path compared is req.url before ? (221); prefix entries match startsWith.

RouteAuth depTenancyCodesNotes
GET /healthpublicnone200liveness for the compose healthcheck
GET /loginpublicnone200 HTMLthe passkey button first; the password <details> only while passwordLoginEnabled() (247 to 249, 286 to 297)
POST /loginpublic; JSON body {password}none200 {ok: true} and the cookie; 401 {error: 'invalid credentials'} for a wrong password, a locked-out address, a disabled password path and a missing hash alike (251 to 270, uniform401 339 to 341)5 failures per address lock the address for 15 min (MAX_FAILS, LOCK_WINDOW_MS, 133 to 146); the HTML form's urlencoded POST is 415 today (Gotchas)
GET /enroll/:tokenpublic; the token in the pathnone200 HTML for a live token; 404 Nicht gefunden. HTML otherwise, after the 40 ms floor (webauthn.ts 122 to 139)20 per address per 15 min, then the same 404
POST /enroll/:token/optionspublic; Origin must equal the canonical origin (403 bad origin)none200 registration options (residentKey: 'required', userVerification: 'required', attestationType: 'none', excludeCredentials = the live credentials); 404 {error: 'not found'} for unknown, expired, used, rate-limited (141 to 170)writes one registration challenge bound to the token
POST /enroll/:token/verifypublic; Origin as abovenone200 {ok: true, label} and the session cookie; 404 for every failure (malformed, challenge, attestation, unverified, token already burned, duplicate credential) (172 to 249)the challenge is consumed first, then the token is burned and the credential inserted in one transaction
POST /api/webauthn/authenticate/optionspublic; Origin as abovenone200 {challenge, rpId, timeout, userVerification, allowCredentials: []}; 429 {error: 'slow down'} past 30 per address per 15 min (253 to 263)allowCredentials empty: discoverable credentials, no username field
POST /api/webauthn/authenticate/verifypublic; Origin as abovenone200 {ok: true} and the session cookie; 401 {error: 'invalid credentials'} for malformed, unknown or consumed challenge, unknown or revoked credential, counter regression, signature or origin failure (265 to 328); 429 as abovethe counter is checked before the library runs and again in the conditional UPDATE
POST /api/telegram/webhook*, GET /api/unsubscribe*public, each with its own credential (B131, B197)nonetheir ownon the allow-list because an unauthenticated caller must reach them
everything else (GET /, /api/*)the hooknone401 {error: 'unauthorized'}test/passkey.test.ts 323 to 327 proves it on a stand-in /api/stats

No logout route exists (grep -n logout src/web/*.ts is empty at a17fce3). The cookie is aurum_session=<value>; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=43200 (200 to 204), the same line for a password login and a passkey login (issueSession is the one handle webauthn.ts receives, AuthContext 169 to 178).

The CLI surface (src/cli/enroll.ts, run as docker exec -it aurum node dist/cli/enroll.js ...): --label <name> mints and prints the link once; --list prints live and revoked credentials, open links and the recovery note; --revoke <id> revokes a credential by row id; --revoke-tokens burns every open link; exit 2 on usage errors, 1 on a missing or already-revoked row (57 to 131).

Application logic

Boot (src/web/server.ts 68 to 80): registerAuth(app, {onLoginFail, onPasswordLogin, credentialCount}) first, so the hook is installed before any route; then registerWebAuthn(app, store, auth); then the webhook and unsubscribe routes. registerAuth (auth.ts 180 to 275), in order: load and validate the config; derive rpId; define passwordLoginEnabled and issueSession; add the onSend hook that mirrors the proxy's security headers and sets a CSP on HTML (207 to 217); add the onRequest gate; register GET and POST /login; log fail-closed default-deny active (rpId=...).

Enrolment, minted from the shell (enroll.ts 106 to 127): draw 32 random bytes as base64url, SHA-256 them, createEnrollmentToken({tokenHash, label, mintedBy: shellIdentity()}) with expires_at ten minutes out, write passkey_token_minted, print <AURUM_PUBLIC_ORIGIN>/enroll/<token> to stdout with the expiry and the recovery note (134 to 141).

Enrolment, consumed in the browser (webauthn.ts):

  1. GET /enroll/:token (122 to 139): audit the attempt; if the address is over the enrolment limiter, audit rate_limited and answer the uniform 404; liveToken (108 to 118) hashes the path token, loads the row by hash, re-compares with constantEq, refuses a used or expired row; a live row renders enrollHtml (343 to 388) after the 40 ms floor.
  2. POST /enroll/:token/options (141 to 170): originOk (88 to 91) or 403; limiter; liveToken; generateRegistrationOptions with rpID: auth.rpId, the constant user, residentKey: 'required', userVerification: 'required', attestationType: 'none', excludeCredentials from listCredentials(); createChallenge({kind: 'registration', enrollmentTokenId: row.id}); answer after the floor.
  3. POST /enroll/:token/verify (172 to 249): origin, limiter, liveToken, body shape; read the challenge out of clientDataJSON (readClientChallenge, 334 to 340) and consumeChallenge(challenge, 'registration'), one UPDATE ... WHERE consumed_at IS NULL AND expires_at > now RETURNING (pg-store.ts 950 to 962); the returned enrollmentTokenId must equal this token's row or the answer is the 404 (195 to 202); verifyRegistrationResponse with expectedOrigin: auth.publicOrigin, expectedRPID: auth.rpId, requireUserVerification: true; then redeemEnrollmentToken (pg-store.ts 984 to 1019): inside one transaction, UPDATE enrollment_tokens SET used_at ... WHERE token_hash = $2 AND used_at IS NULL AND expires_at > now RETURNING id, label, no row is {ok: false, reason: 'token'}; INSERT INTO webauthn_credentials with the token's label; UPDATE enrollment_tokens SET used_credential_id; a unique violation on credential_id rolls back and is duplicate, and the burned token stays burned on purpose (header, 976 to 983). On SQLite the same three statements run in db.transaction (sqlite-store.ts 969 to 1014). Then passkey_enrolled, issueSession(reply, 'dashboard'), {ok: true, label}.

Authentication (253 to 328): options are generateAuthenticationOptions({rpID, userVerification: 'required', allowCredentials: []}) with an authentication challenge row. Verify: origin, limiter, body shape, consume the challenge (a replay finds zero rows), credentialByCredentialId(body.id) which excludes revoked rows (pg-store.ts 1027 to 1034), then the explicit clone check: signCountFrom(authenticatorData) reads the big-endian counter at bytes 33 to 36 (94 to 98) and the presented value must exceed the stored one or both be zero, else passkey_counter_regression is audited and the answer is 401 (287 to 293); verifyAuthenticationResponse with the canonical origin and RP id; then advanceCredentialCounter(id, newCounter), whose UPDATE carries the same forward-only predicate and whose zero-row result is a second regression audit and a 401 (317 to 322; pg-store.ts 1064 to 1073); then passkey_login, the cookie, {ok: true}.

Password (auth.ts 251 to 270): locked-out address, disabled path, missing hash and wrong password all answer uniform401 after recordFail and the onLoginFail audit; only a verified password calls issueSession(reply, 'dashboard') and onPasswordLogin. verifyPassword derives with the stored salt and compares with timingSafeEqual (89 to 92).

Session verification on every gated request (108 to 119): split at the last ., recompute the HMAC over the payload, constantEq the tags, then parse and require Date.now() < exp. sub is carried and not read.

Invariants

  • No HTTP handler mints an enrolment token: test('no HTTP handler can mint an enrolment token') (346 to 364) walks src/ and asserts createEnrollmentToken is called only from src/cli/enroll.ts and defined in the stores.
  • The gate runs before every handler and denies by default: a path is either in PUBLIC_ROUTES or needs a credential (220 to 245; test 323 to 327).
  • The RP id and the expected origin come from AURUM_PUBLIC_ORIGIN and never from Host (rpIdFrom 278; webauthn.ts 158, 209 to 210, 257, 300 to 301; test 113 to 120 asserts rp.id equals the configured hostname).
  • Unknown, expired and used enrolment links are one answer: same status, same body, same floor (enrollNotFound 78 to 85; test 260 to 291 asserts status, body and content-type are identical across the three for GET and POST and that the verify bodies form a set of one; the 40 ms floor is enforced by uniformDelay 72 to 75 and asserted by no test).
  • The token burn and the credential insert are one transaction, and the burn is a conditional UPDATE so two concurrent redemptions yield exactly one success (test 176 to 200).
  • A challenge is single-use and, for registration, bound to the token that requested it (195 to 202; test 159 to 174).
  • The signature counter never moves backwards: checked before the library and enforced by the persistence predicate (test 227 to 258).
  • One session mechanism: issueSession is the only cookie writer, and the passkey and password paths produce the same Set-Cookie (test 113 to 140).
  • The password may be switched off only with two or more live passkeys; with fewer the request is refused loudly and the password keeps working (190 to 198; test 301 to 321).
  • Every enrolment and login outcome is an audit row with a reason (the action list under Data model).
  • A cookie-authenticated non-GET must carry the canonical Origin; a bearer request is exempt because it carries no ambient credential (236 to 241).

Frontend

identity-auth/ui/aurum-login-and-enrolment   GET /login (the passkey button, its click-invoked ceremony script, the break-glass
                                              details block), GET /enroll/:token (one button, the ceremony script,
                                              the two German outcome lines), the 401 JSON body the hook returns for
                                              a bare GET / (rendered as the browser's own page, no HTML of ours)
prospecting/ui/aurum-operator-dashboard       (F89) the single-file dashboard behind the gate; renders nothing of
                                              this recipe but is what a session unlocks

Minimum admin and debug surface: node dist/cli/enroll.js --list (credentials with created, last_used, type, counter, REVOKED; open links with expiry and minted_by; the recovery note), the boot log line [auth] fail-closed default-deny active (rpId=...) and [webauthn] enrolment is SSH-mint-only; no HTTP route creates tokens, and the audit_log rows by action.

Security & tenancy

Auth dependency: the onRequest hook, which is the authoritative boundary rather than the reverse proxy (header comment, 1 to 6). Workspace derivation: none; one logical user, no tenant column anywhere in this layer. Fail direction, per seam: missing secrets closed in production (57 to 60); the gate closed (unknown path, bad tag, expired, missing); CSRF closed on cookie non-GET; the enrolment link closed on every failure with one answer; the counter closed twice; the password path closed when locked out or disabled, with the same body as a wrong password so the body is no configuration oracle (255 to 259), though the response time is one and GET /login omits the details block while the path is off (292; Gotchas); the recovery rule fails toward keeping the password on.

Credentials at rest: the password as a scrypt hash in an environment variable (saltHex$hashHex, 64-byte derived key, 16-byte salt); the cookie key and the bearer in environment variables; the enrolment token as SHA-256 only; the passkey public key in the table; no private material anywhere on the host. The bearer for the timers lives in a 0600 curlrc, not in ExecStart (wiki platform/aurum.md, "Scheduled jobs", 2026-08-03).

Rate limits, all in-process per req.ip: 5 password failures per 15 min lock the address (auth.ts 133 to 146); 20 enrolment requests per 15 min and 30 assertion requests per 15 min in two separate buckets (webauthn.ts 49 to 64). Behind the loopback proxy req.ip is one address for everyone (Gotchas).

Attacker-controlled inputs: the path token (length-bounded 16 to 256, hashed before lookup, compared with constantEq, 109 to 114); Origin (never parsed; compared with constantEq on the five webauthn.ts routes, originOk 88 to 91, and with a plain !== in the hook's cookie CSRF check, auth.ts 239); clientDataJSON (only challenge is read from it, everything else is left to the library, 334 to 340); authenticatorData (length-checked before the counter read, 94 to 98); the cookie (tag verified before the payload is parsed, 108 to 119); the password field (read only after the lockout and the disabled-path checks, 253 to 260; scrypt runs only when the path is on and a parseable hash is configured, because 262 short-circuits on !cfg.passwordHash, so the missing-hash, unparseable-hash and disabled branches answer in under 2 ms against roughly 33 ms for a wrong password; the body is uniform, the latency is not; Gotchas).

Tenancy: none to derive. The audit rows carry actor: req.ip and actorKind: 'human' | 'system'; the CLI rows carry the shell identity (USER, SSH_CLIENT, HOSTNAME) as context, not as authorisation (enroll.ts 35 to 44).

Tests

  • test/passkey.test.ts (node --import tsx --test test/passkey.test.ts): 15 cases on a fresh SQLite file per case with registerAuth and registerWebAuthn on a real Fastify instance and a stand-in /api/stats (46 to 62). Run 2026-09-12 at a17fce3 (working tree; the one uncommitted line in auth.ts is an allow-list entry the cases do not touch): # pass 15, # fail 0, 4.2 s. The cases: enrol then login with the canonical RP id and the same cookie (113); expired token (142); single-use token (149); replayed registration response (159); two concurrent redemptions, exactly one success (176); wrong-origin assertion (202); cross-origin POST refused before the ceremony (216); counter regression rejected and audited (227); the persistence gate is itself conditional (248); unknown, expired and used indistinguishable (260); the scrypt path (293); AURUM_PASSWORD_LOGIN=off refused with fewer than two passkeys (301); the gate covers everything not allow-listed (323); the enrolment limiter is its own bucket (329); no HTTP handler can mint (346).
  • test/webauthn-authenticator.ts: a virtual authenticator producing real ES256 credentials, a CTAP2-canonical attestationObject with fmt: 'none', and P-256 assertions over authData || sha256(clientDataJSON), so the library's verification runs unmodified (1 to 9). It takes origin and signCount overrides so a test can sign for the wrong origin or a stale counter (91 to 109 of the test file).
  • Known-answer vectors: the cookie line under API surface; hashPassword(pw, saltHex) is deterministic for a given salt, so a stored AURUM_DASH_PASSWORD_HASH can be re-derived in a shell to prove which password it holds; the authenticator data counter offset (bytes 33 to 36, big-endian) and the 37-byte minimum.
  • Hermetic versus live: everything above is hermetic and touches no network; the Postgres store is exercised by the migration and store tests of B200, not here. Playwright: none; the pages are two HTML strings.

Deploy & ops

Config keys per app heredoc (S13): AURUM_SESSION_SECRET, AURUM_API_TOKEN, AURUM_DASH_PASSWORD_HASH, AURUM_PUBLIC_ORIGIN, AURUM_PASSWORD_LOGIN, AURUM_STORE=postgres with AURUM_DATABASE_URL (or AURUM_DB_PATH for SQLite), NODE_ENV=production, LOG_LEVEL. The .env is 0600 at /opt/alchemy/aurum/.env (B200, docs/pg-cutover.md 85).

Migrations order: 007_passkeys.sql after 006_labs_push.sql in src/db/migrations/pg/, applied by the one-shot migration CLI (B200); on SQLite the same tables are schema step v7 (sqlite-store.ts 193 to 231). Nothing in this layer needs a backfill.

Operator runbook for a passkey (README.md 39 to 42): docker exec -it aurum node dist/cli/enroll.js --label "<device>", open the printed link on that device within ten minutes, then --list; enrol a second device before setting AURUM_PASSWORD_LOGIN=off; keep the password hash in the password manager as break-glass regardless. Losing every enrolled device with the password off is a lockout with no HTTP way back; the way back is the shell.

Scheduler jobs: none owned here; the loopback timers (deploy/systemd/aurum-*.service) authenticate with the bearer from a 0600 curlrc. Argus checks: GET /health on the loopback port answers 200; GET / without a credential answers 401 {"error":"unauthorized"}; GET /login answers 200 and contains Mit Passkey anmelden. Rollback: the image tag (B200 ship.sh); a rotated AURUM_SESSION_SECRET signs everyone out, which is also the only revoke.

Gotchas

  • 2026-09-12 (measured at a17fce3, auth.ts 292 to 296 and the probe below; D8, a verified defect recorded here and not fixed in this wave): the break-glass block on GET /login is a plain <form method="post" action="/login">, which the browser submits as application/x-www-form-urlencoded; the server registers no parser for that type, so Fastify answers 415 FST_ERR_CTP_INVALID_MEDIA_TYPE before the handler runs. Measured with app.inject against registerAuth alone: the urlencoded body is 415, the same password as JSON is 200 with the cookie. test/passkey.test.ts 293 to 299 passes because inject sends JSON. The password path is reachable with curl -H 'content-type: application/json', not from the page. Fix it needs: register @fastify/formbody (or have the page post JSON with fetch), and change the test to send the urlencoded body the page sends.
  • 2026-09-12 (measured at a17fce3, auth.ts 254 to 258 and 262, and the probe below; D8, a verified defect recorded here and not fixed in this wave): the comment at 261 says scrypt runs either way; the code does not. if (!cfg.passwordHash || !verifyPassword(password, cfg.passwordHash)) short-circuits when no hash parsed, and the disabled-path branch (254 to 258) returns uniform401 before the body is read, so scrypt runs only when the path is on and AURUM_DASH_PASSWORD_HASH parses. Measured with app.inject against registerAuth alone, POST /login with {"password":"wrong"}, four calls per case: configured hash 32.1 to 44.7 ms; hash unset 0.3 to 0.8 ms; unparseable hash 0.2 to 1.8 ms; AURUM_PASSWORD_LOGIN=off with two passkeys 0.1 to 0.4 ms. All four answer 401 {"error":"invalid credentials"}. The body is uniform and the latency is not, so a remote caller can tell a live password path from a disabled or unconfigured one. Fix it needs: a dummy scrypt derivation on the short-circuit branches (or a floor like uniformDelay, webauthn.ts 72 to 75, which enrolment already has), and a test that times a wrong password against the unset-hash and disabled cases and bounds the gap.
  • 2026-09-12 (measured at a17fce3, deploy/Caddyfile.fragment 7 to 19 and 63 to 75; D8, recorded, not fixed; the same Gotcha is on F9): the tracked vhost fragment predates app-layer auth. Its header says server.ts has no authentication of any kind, and its catch-all handle wraps reverse_proxy in basic_auth, with public handle blocks only for /telegram/webhook*, /u/* and /health (40 to 61). Applied as written, /login, /enroll/* and /api/webauthn/authenticate/* sit behind Caddy's basic-auth prompt, which contradicts the deploy story above and the Argus checks under Deploy & ops. Whether the live vhost still matches the fragment was not measured (the estate is not touched from here). Fix it needs: public handle blocks for the three paths, or dropping basic_auth now that auth.ts is the boundary (header comment, 1 to 6), and deleting the stale header.
  • 2026-09-12 (measured at a17fce3, src/web/server.ts 68 and deploy/Caddyfile.fragment 72 to 74; D8): Fastify is constructed without trustProxy and sits behind the host's reverse proxy on 127.0.0.1:8787, so req.ip is the proxy's address for every browser request. The three limiters (5 password failures per 15 min, 20 enrolment calls, 30 assertions) are therefore one bucket shared by everyone, five wrong passwords from anywhere lock the password path for all for 15 min, and every audit row's actor and ip name the proxy. Fix it needs: trustProxy limited to the loopback hop (B168's TRUSTED_PROXY_COUNT discipline) and one test that a forwarded client address lands in req.ip.
  • 2026-09-12 (measured at a17fce3, webauthn.ts 122 to 131; D8): GET /enroll/:token writes passkey_enroll_attempt before the limiter is consulted and passkey_enroll_rejected after it, so an unauthenticated caller appends two rows per request to the hash-chained audit_log without bound, and that log is what compliance-sync replicates nightly (B198). Fix it needs: consult the limiter first, one row per outcome, and a ceiling on rejected-attempt rows per address per window.
  • 2026-09-12 (measured at a17fce3): no logout route and no session table; the cookie is valid until exp (12 h) whatever happens, and revoking a passkey (--revoke) does not end a session it opened. Rotating AURUM_SESSION_SECRET is the only revoke and ends every session. A surface that needs per-session revocation adds a jti and a denylist, or copies B21's server-side record.
  • 2026-09-12 (measured at a17fce3, pg-store.ts 964, sqlite-store.ts 957): pruneChallenges is implemented on both stores and called from nowhere in src/; webauthn_challenges grows by one row per options call and is never trimmed.
  • 2026-09-12 (measured at a17fce3): the working tree carries one uncommitted line in auth.ts, POST /api/unsubscribe on the allow-list; every line number in this recipe is the committed file's (git show a17fce3:src/web/auth.ts), which is one less than the working copy from line 30 on.
  • 2026-08-03 (wiki platform/aurum.md, "Scheduled jobs"): the systemd timers answered 401 for a full day because the units never sent AURUM_API_TOKEN; the fix put the bearer in /etc/aurum-cron.curlrc (0600), not in ExecStart, which is visible in systemctl show, ps and the journal.
  • 2026-07-28 (feb2c1a, docs/pg-cutover.md; wiki platform/aurum.md): the SQLite to Postgres cutover carried two enrolled passkeys, the only credentials that unlock the tool that approves outreach; the WAL held the data and a copy of the main file alone would have lost them. Checkpoint first; freeze, seal, move, verify.
  • 2026-07-28 (0203908): passkeys landed with the rule that the password stays until two passkeys exist; recoveryNote prints the posture after every CLI action so the operator always knows how close to lockout they are (enroll.ts 133 to 141).

Reference implementation

Paths relative to the aurum repo root; branch week2-3-confirm-gate at a17fce3 (2026-08-02); the passkey layer was introduced at 0203908 (2026-07-28) and moved onto Postgres at feb2c1a the same day.

  • src/web/auth.ts (341 lines committed): header (1 to 15), SESSION_TTL_MS and COOKIE (20 to 21), PUBLIC_ROUTES (24 to 37), loadConfig (46 to 74), parseHash (77 to 81), hashPassword (83 to 87), verifyPassword (89 to 92), constantEq (94 to 98), signSession (101 to 106), verifySession (108 to 119), readCookie (121 to 129), the lockout (132 to 146), isPublic (148 to 151), AuthDeps (153 to 163), AuthContext (169 to 178), registerAuth (180 to 275: passwordLoginEnabled 190 to 198, issueSession 200 to 204, onSend 207 to 217, onRequest 220 to 245, GET /login 247 to 249, POST /login 251 to 270), rpIdFrom (278 to 280), loginHtml (286 to 331), B64URL_JS (334 to 337), uniform401 (339 to 341).
  • src/web/webauthn.ts (388 lines): header (1 to 25), the constants (40 to 53), makeLimiter (55 to 64), sha256Hex (66 to 68), uniformDelay (72 to 75), enrollNotFound (78 to 85), originOk (88 to 91), signCountFrom (94 to 98), registerWebAuthn (100 to 331: liveToken 108 to 118, the three enrolment routes 122 to 249, the two authentication routes 253 to 328), readClientChallenge (334 to 340), enrollHtml (343 to 388).
  • src/cli/enroll.ts (143 lines): header (1 to 17), shellIdentity (36 to 44), main (57 to 131), recoveryNote (134 to 141).
  • src/db/migrations/pg/007_passkeys.sql (46 lines); src/db/types.ts (ENROLLMENT_TOKEN_TTL_MS, CHALLENGE_TTL_MS 214 to 215; EnrollmentToken 217 to 226; WebAuthnCredentialRow 228 to 240); src/db/pg-store.ts (createEnrollmentToken 881, enrollmentTokenByHash 901, openEnrollmentTokens 907, revokeOpenEnrollmentTokens 919, createChallenge 931, consumeChallenge 950, pruneChallenges 964, redeemEnrollmentToken 984, credentialByRowId 1021, credentialByCredentialId 1027, listCredentials 1036, liveCredentialCount 1044, revokeCredential 1050, advanceCredentialCounter 1064); src/db/sqlite-store.ts (schema v7 193 to 231, the same methods 883 to 1055).
  • src/web/server.ts 68 to 80 (the wiring and the two audit callbacks).
  • test/passkey.test.ts (365 lines) and test/webauthn-authenticator.ts (140 lines), as listed under Tests.
  • deploy/compose.yml (37 to 38), deploy/Caddyfile.fragment (7 to 19 the stale header, 40 to 75 the handles), README.md (30 to 60), docs/pg-cutover.md.
  • Wiki: platform/aurum.md (the dashboard line, "Scheduled jobs", the cutover notes), operations/prima-modules-inventory.md (row B22, F9).

Deviations of the reference from this recipe, dated 2026-09-12: the urlencoded break-glass form answers 415; the password path's timing tells a wrong password from a disabled or unconfigured path; the tracked Caddy fragment puts the three public passkey paths behind basic_auth; req.ip is the proxy behind the reverse proxy; two audit rows per unauthenticated enrolment probe; no logout and no per-session revoke; pruneChallenges uncalled; one uncommitted allow-list line in the working tree.

Acceptance checks

Run from the aurum repo at the commit in measured_on, or from a new service built to this recipe. Expected observations are exact.

  1. node --import tsx --test test/passkey.test.ts 2>&1 | grep -E '^# (pass|fail)' prints # pass 15 and # fail 0.
  2. grep -rn "createEnrollmentToken(" src --include='*.ts' | grep -v "src/db/" prints exactly one line and it is in src/cli/enroll.ts (the static half of test 346).
  3. grep -c "addHook('onRequest'" src/web/auth.ts prints 1; grep -n "reply.code(401)" src/web/auth.ts shows the two refusals inside that hook and uniform401: 229, 244 and 340 in git show a17fce3:src/web/auth.ts, one more each (230, 245, 341) on the working tree with its uncommitted allow-list line (last Gotcha).
  4. grep -n "req.headers.host\|request.hostname" src/web/auth.ts src/web/webauthn.ts prints only the two comment lines (auth.ts 277 in the committed file, 278 on the working tree; webauthn.ts 209) and no code line; grep -c "auth.publicOrigin\|auth.rpId" src/web/webauthn.ts prints at least 6.
  5. With the app running locally (NODE_ENV unset, the three variables set, AURUM_PUBLIC_ORIGIN=http://127.0.0.1:8787): curl -s -i http://127.0.0.1:8787/ shows 401 and {"error":"unauthorized"}; curl -s -i http://127.0.0.1:8787/api/anything shows the same; curl -s http://127.0.0.1:8787/login contains Mit Passkey anmelden.
  6. curl -s -i -X POST http://127.0.0.1:8787/login -H 'origin: http://127.0.0.1:8787' -H 'content-type: application/json' -d '{"password":"wrong"}' shows 401 and {"error":"invalid credentials"}; the sixth such call within 15 min shows the same body (the lockout); with the right password the response is 200 with a set-cookie line starting aurum_session= and containing HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=43200.
  7. curl -s -i -X POST http://127.0.0.1:8787/login -H 'origin: http://127.0.0.1:8787' -d 'password=anything' shows 415 at a17fce3 (the first Gotcha); on a service that fixed it, 401 or 200.
  8. node dist/cli/enroll.js --label probe prints one line matching /enroll/[A-Za-z0-9_-]{43}$ under the configured origin and a recovery: line; --list then shows one open link; curl -s -o /dev/null -w '%{http_code}' <link> prints 200, and prints 200 again on every repeat (GET /enroll/:token reads and renders, webauthn.ts 122 to 139; the token is burned only inside redeemEnrollmentToken at the verify step) until the link is used, ten minutes pass, or the address passes 20 requests in 15 min; after a completed verify, or after --revoke-tokens, the same link prints 404 with Nicht gefunden.; curl -s -w '%{http_code}' http://127.0.0.1:8787/enroll/not-a-token prints Nicht gefunden. and 404; the wall time of the two 404s differs by less than the 40 ms floor on a quiet host.
  9. curl -s -i -X POST http://127.0.0.1:8787/api/webauthn/authenticate/options -H 'content-type: application/json' -d '{}' (no Origin) shows 403 and {"error":"bad origin"}; with -H 'origin: http://127.0.0.1:8787' it shows 200 and a body whose rpId is 127.0.0.1 and whose allowCredentials is [].
  10. In the database after step 8's mint and before any verify or --revoke-tokens: SELECT length(token_hash), used_at IS NULL FROM enrollment_tokens ORDER BY id DESC LIMIT 1 prints 64 and t on Postgres, 64 and 1 on SQLite, the default store (the hash, never the token); SELECT count(*) FROM webauthn_challenges has grown by one per options call and never shrinks (the pruneChallenges Gotcha).
  11. AURUM_PASSWORD_LOGIN=off with fewer than two live rows in webauthn_credentials: the boot or first /login render logs AURUM_PASSWORD_LOGIN=off REFUSED and GET /login still contains Passwort (Break-glass).
  12. NODE_ENV=production with AURUM_SESSION_SECRET unset: the process exits before listening with refusing to start: missing auth secrets in production: AURUM_SESSION_SECRET.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.