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 |
|
| 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):
| Name | Default | Behaviour |
|---|---|---|
AURUM_SESSION_SECRET | none | the 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_TOKEN | none | the machine bearer for the loopback systemd timers; same boot rule; in dev the synthesised value is logged once (64) |
AURUM_DASH_PASSWORD_HASH | none | saltHex$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_ORIGIN | the production origin | the 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_LOGIN | on | off 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_PATH | see B200 | postgres 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_ENV | unset | production 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.
| Route | Auth dep | Tenancy | Codes | Notes |
|---|---|---|---|---|
GET /health | public | none | 200 | liveness for the compose healthcheck |
GET /login | public | none | 200 HTML | the passkey button first; the password <details> only while passwordLoginEnabled() (247 to 249, 286 to 297) |
POST /login | public; JSON body {password} | none | 200 {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/:token | public; the token in the path | none | 200 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/options | public; Origin must equal the canonical origin (403 bad origin) | none | 200 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/verify | public; Origin as above | none | 200 {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/options | public; Origin as above | none | 200 {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/verify | public; Origin as above | none | 200 {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 above | the 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) | none | their own | on the allow-list because an unauthenticated caller must reach them |
everything else (GET /, /api/*) | the hook | none | 401 {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):
GET /enroll/:token(122 to 139): audit the attempt; if the address is over the enrolment limiter, auditrate_limitedand answer the uniform 404;liveToken(108 to 118) hashes the path token, loads the row by hash, re-compares withconstantEq, refuses a used or expired row; a live row rendersenrollHtml(343 to 388) after the 40 ms floor.POST /enroll/:token/options(141 to 170):originOk(88 to 91) or 403; limiter;liveToken;generateRegistrationOptionswithrpID: auth.rpId, the constant user,residentKey: 'required',userVerification: 'required',attestationType: 'none',excludeCredentialsfromlistCredentials();createChallenge({kind: 'registration', enrollmentTokenId: row.id}); answer after the floor.POST /enroll/:token/verify(172 to 249): origin, limiter,liveToken, body shape; read the challenge out ofclientDataJSON(readClientChallenge, 334 to 340) andconsumeChallenge(challenge, 'registration'), oneUPDATE ... WHERE consumed_at IS NULL AND expires_at > now RETURNING(pg-store.ts950 to 962); the returnedenrollmentTokenIdmust equal this token's row or the answer is the 404 (195 to 202);verifyRegistrationResponsewithexpectedOrigin: auth.publicOrigin,expectedRPID: auth.rpId,requireUserVerification: true; thenredeemEnrollmentToken(pg-store.ts984 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_credentialswith the token's label;UPDATE enrollment_tokens SET used_credential_id; a unique violation oncredential_idrolls back and isduplicate, and the burned token stays burned on purpose (header, 976 to 983). On SQLite the same three statements run indb.transaction(sqlite-store.ts969 to 1014). Thenpasskey_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) walkssrc/and assertscreateEnrollmentTokenis called only fromsrc/cli/enroll.tsand defined in the stores. - The gate runs before every handler and denies by default: a path is either in
PUBLIC_ROUTESor needs a credential (220 to 245; test 323 to 327). - The RP id and the expected origin come from
AURUM_PUBLIC_ORIGINand never fromHost(rpIdFrom278;webauthn.ts158, 209 to 210, 257, 300 to 301; test 113 to 120 assertsrp.idequals the configured hostname). - Unknown, expired and used enrolment links are one answer: same status, same body, same floor (
enrollNotFound78 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 byuniformDelay72 to 75 and asserted by no test). - The token burn and the credential insert are one transaction, and the burn is a conditional
UPDATEso 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:
issueSessionis the only cookie writer, and the passkey and password paths produce the sameSet-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
actionlist 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 withregisterAuthandregisterWebAuthnon 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 inauth.tsis 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=offrefused 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-canonicalattestationObjectwithfmt: 'none', and P-256 assertions overauthData || sha256(clientDataJSON), so the library's verification runs unmodified (1 to 9). It takesoriginandsignCountoverrides 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 storedAURUM_DASH_PASSWORD_HASHcan 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.ts292 to 296 and the probe below; D8, a verified defect recorded here and not fixed in this wave): the break-glass block onGET /loginis a plain<form method="post" action="/login">, which the browser submits asapplication/x-www-form-urlencoded; the server registers no parser for that type, so Fastify answers 415FST_ERR_CTP_INVALID_MEDIA_TYPEbefore the handler runs. Measured withapp.injectagainstregisterAuthalone: the urlencoded body is 415, the same password as JSON is 200 with the cookie.test/passkey.test.ts293 to 299 passes becauseinjectsends JSON. The password path is reachable withcurl -H 'content-type: application/json', not from the page. Fix it needs: register@fastify/formbody(or have the page post JSON withfetch), and change the test to send the urlencoded body the page sends. - 2026-09-12 (measured at a17fce3,
auth.ts254 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) returnsuniform401before the body is read, so scrypt runs only when the path is on andAURUM_DASH_PASSWORD_HASHparses. Measured withapp.injectagainstregisterAuthalone,POST /loginwith{"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=offwith 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 likeuniformDelay,webauthn.ts72 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.fragment7 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 saysserver.tshas no authentication of any kind, and its catch-allhandlewrapsreverse_proxyinbasic_auth, with publichandleblocks 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: publichandleblocks for the three paths, or droppingbasic_authnow thatauth.tsis the boundary (header comment, 1 to 6), and deleting the stale header. - 2026-09-12 (measured at a17fce3,
src/web/server.ts68 anddeploy/Caddyfile.fragment72 to 74; D8): Fastify is constructed withouttrustProxyand sits behind the host's reverse proxy on127.0.0.1:8787, soreq.ipis 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'sactorandipname the proxy. Fix it needs:trustProxylimited to the loopback hop (B168'sTRUSTED_PROXY_COUNTdiscipline) and one test that a forwarded client address lands inreq.ip. - 2026-09-12 (measured at a17fce3,
webauthn.ts122 to 131; D8):GET /enroll/:tokenwritespasskey_enroll_attemptbefore the limiter is consulted andpasskey_enroll_rejectedafter it, so an unauthenticated caller appends two rows per request to the hash-chainedaudit_logwithout bound, and that log is whatcompliance-syncreplicates 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. RotatingAURUM_SESSION_SECRETis the only revoke and ends every session. A surface that needs per-session revocation adds ajtiand a denylist, or copies B21's server-side record. - 2026-09-12 (measured at a17fce3,
pg-store.ts964,sqlite-store.ts957):pruneChallengesis implemented on both stores and called from nowhere insrc/;webauthn_challengesgrows 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/unsubscribeon 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 sentAURUM_API_TOKEN; the fix put the bearer in/etc/aurum-cron.curlrc(0600), not inExecStart, which is visible insystemctl show,psand the journal. - 2026-07-28 (feb2c1a,
docs/pg-cutover.md; wikiplatform/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;
recoveryNoteprints the posture after every CLI action so the operator always knows how close to lockout they are (enroll.ts133 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_MSandCOOKIE(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:passwordLoginEnabled190 to 198,issueSession200 to 204,onSend207 to 217,onRequest220 to 245,GET /login247 to 249,POST /login251 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:liveToken108 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_MS214 to 215;EnrollmentToken217 to 226;WebAuthnCredentialRow228 to 240);src/db/pg-store.ts(createEnrollmentToken881,enrollmentTokenByHash901,openEnrollmentTokens907,revokeOpenEnrollmentTokens919,createChallenge931,consumeChallenge950,pruneChallenges964,redeemEnrollmentToken984,credentialByRowId1021,credentialByCredentialId1027,listCredentials1036,liveCredentialCount1044,revokeCredential1050,advanceCredentialCounter1064);src/db/sqlite-store.ts(schema v7 193 to 231, the same methods 883 to 1055).src/web/server.ts68 to 80 (the wiring and the two audit callbacks).test/passkey.test.ts(365 lines) andtest/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.
node --import tsx --test test/passkey.test.ts 2>&1 | grep -E '^# (pass|fail)'prints# pass 15and# fail 0.grep -rn "createEnrollmentToken(" src --include='*.ts' | grep -v "src/db/"prints exactly one line and it is insrc/cli/enroll.ts(the static half of test 346).grep -c "addHook('onRequest'" src/web/auth.tsprints1;grep -n "reply.code(401)" src/web/auth.tsshows the two refusals inside that hook anduniform401: 229, 244 and 340 ingit show a17fce3:src/web/auth.ts, one more each (230, 245, 341) on the working tree with its uncommitted allow-list line (last Gotcha).grep -n "req.headers.host\|request.hostname" src/web/auth.ts src/web/webauthn.tsprints only the two comment lines (auth.ts277 in the committed file, 278 on the working tree;webauthn.ts209) and no code line;grep -c "auth.publicOrigin\|auth.rpId" src/web/webauthn.tsprints at least6.- With the app running locally (
NODE_ENVunset, the three variables set,AURUM_PUBLIC_ORIGIN=http://127.0.0.1:8787):curl -s -i http://127.0.0.1:8787/shows401and{"error":"unauthorized"};curl -s -i http://127.0.0.1:8787/api/anythingshows the same;curl -s http://127.0.0.1:8787/logincontainsMit Passkey anmelden. 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"}'shows401and{"error":"invalid credentials"}; the sixth such call within 15 min shows the same body (the lockout); with the right password the response is200with aset-cookieline startingaurum_session=and containingHttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=43200.curl -s -i -X POST http://127.0.0.1:8787/login -H 'origin: http://127.0.0.1:8787' -d 'password=anything'shows415at a17fce3 (the first Gotcha); on a service that fixed it,401or200.node dist/cli/enroll.js --label probeprints one line matching/enroll/[A-Za-z0-9_-]{43}$under the configured origin and arecovery:line;--listthen shows one open link;curl -s -o /dev/null -w '%{http_code}' <link>prints200, and prints200again on every repeat (GET /enroll/:tokenreads and renders,webauthn.ts122 to 139; the token is burned only insideredeemEnrollmentTokenat 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 prints404withNicht gefunden.;curl -s -w '%{http_code}' http://127.0.0.1:8787/enroll/not-a-tokenprintsNicht gefunden.and404; the wall time of the two 404s differs by less than the 40 ms floor on a quiet host.curl -s -i -X POST http://127.0.0.1:8787/api/webauthn/authenticate/options -H 'content-type: application/json' -d '{}'(noOrigin) shows403and{"error":"bad origin"}; with-H 'origin: http://127.0.0.1:8787'it shows200and a body whoserpIdis127.0.0.1and whoseallowCredentialsis[].- 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 1prints64andton Postgres,64and1on SQLite, the default store (the hash, never the token);SELECT count(*) FROM webauthn_challengeshas grown by one per options call and never shrinks (thepruneChallengesGotcha). AURUM_PASSWORD_LOGIN=offwith fewer than two live rows inwebauthn_credentials: the boot or first/loginrender logsAURUM_PASSWORD_LOGIN=off REFUSEDandGET /loginstill containsPasswort (Break-glass).NODE_ENV=productionwithAURUM_SESSION_SECRETunset: the process exits before listening withrefusing 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.