- PROJECT_PLAN.md: the decision log of record through D-042 and the M0.1 slice - docs/specs: SPEC-002…009 — routing · door · identity/sessions · /setup · enroll/login · roles/chain/recovery · module registry · the web layer & PWA (plus docs/specs/README.md, the index) - AGENT.md: the working rules — commit AND push at every submilestone (gate green), Conventional Commits, never commit secrets - README.md: the quickstart, the first-run/SSH-tunnel steps and the localhost/loopback caveat (SPEC-009 §13 Q-3): with an https base URL the CSRF/session cookies are Secure, so reach the app at localhost/127.0.0.1 - config.example.yaml: the settings template (D-035)
8.3 KiB
SPEC-006 — enroll & login (Passkey + TOTP)
| Field | Value |
|---|---|
| Status | Implemented (2026-10-13) — see §7; build/vet/test/-race green · static binary · live-smoked · rev 2026-10-14 (D-041): the passkey nudge (§8) |
| Created | 2026-10-13 |
| Author | OpenArchitect |
| Target milestone | M0 |
| Governing decisions | D-005 · D-006 · D-007 · PROJECT_PLAN.md §4.5/§6 |
| Related | SPEC-004 (identity/sessions) · SPEC-007 (recovery) |
1. Goal
The GUID-driven enroll/login state machine (D-006): GET /{GUID} decides — by the identity's
status — whether the visitor enrolls, logs in, or lands on the app. Two factors only:
Passkey (primary) + TOTP (fallback) (D-005). No /auth/enroll/{token}.
2. Non-goals
Recovery (SPEC-007). Module content. No email/OTP as a login factor.
3. The state machine
GET /{GUID}
identity not found → 404
status pending_enrollment
within the window → ENROLL
window expired → 404 ("ask your manager to re-invite")
status active · no session → LOGIN
status active · valid session → the app (module default)
status revoked → 404
The GUID is identification; the session is the credential — a bookmark is never a login.
4. Enrollment (mandatory, both factors)
POST /{GUID}?do=auth&action=enroll.begin → WebAuthn register (begin)
POST /{GUID}?do=auth&action=enroll.finish → WebAuthn register (finish) → credential stored
POST /{GUID}?do=auth&action=totp.begin → issue the TOTP secret (encrypted, A9)
POST /{GUID}?do=auth&action=totp.verify → verify a code → identity ACTIVE → session minted
- Both factors are required — enrollment does not complete with only one.
- The window is bounded by
enroll_window(A1); outside it, enrollment is refused.
5. Login
POST /{GUID}?do=auth&action=login.begin → WebAuthn assertion (begin)
POST /{GUID}?do=auth&action=login.finish → assertion (finish) → session minted
(fallback) action=totp.verify → a valid TOTP code → session minted
POST /{GUID}?do=auth&action=logout → session revoked
- Passkey is primary; TOTP is the fallback (device loss without a full recovery).
- The session is rotated on login (fixation defence).
- Failures return generic responses; the routes are rate-limited (A3).
- Pre-session CSRF (A15): the anonymous enroll/login forms carry a signed double-submit cookie token, verified server-side — SPEC-003 §6's session-bound CSRF does not apply yet.
6. Acceptance criteria
| # | Criterion |
|---|---|
| AC-1 | /{GUID} with pending_enrollment (in window) → enrollment, not login |
| AC-2 | Enrollment completes only with Passkey and TOTP |
| AC-3 | /{GUID} with active + no session → login; with a session → the app |
| AC-4 | The enrollment window expiry blocks enrollment (A1) |
| AC-5 | Login rotates the session id; logout revokes it |
| AC-6 | TOTP secret is stored encrypted and never logged (A9) |
| AC-7 | The enroll/login routes return 429 under rate-limit (A3) and use generic error text |
7. Implementation notes (accepted rulings)
Built 2026-10-13. build · vet · test · -race green; static binary; live-smoked
(no-CSRF → 403 · TOTP-only → 400 (two-factor) · unknown GUID → 404). R-4 applied (context actor
- affected-user subject). WebAuthn behind
webauthn.Service(fake in tests); TOTP real (pquerna/otp); the ceremony challenge rides a signed cookie (no new table).
| # | Item | Ruling |
|---|---|---|
| D-1 | auth.Identities gained WithinEnrollmentWindow |
Accepted — keeps the flow on interfaces. |
| D-2 | server.Deps.PreSession + config enrollment.window (default 72h) |
Accepted — documented here + in config.example.yaml. |
| F-1 | The login screen returns 401 (HTML). | RESOLVED (SPEC-005) — a rendered page is 200; only a failed credential attempt is 401/400. Applied to flow.LoginScreen + server.LoginPlaceholder; SPEC-003/006 tests updated. |
| D-3 | Ceremony state cookie is signed, not encrypted | Accepted — the challenge must be unpredictable + tamper-evident, not confidential. |
| D-4 | flow covered end-to-end via the door (no package-local unit test) |
Accepted — stronger evidence than unit mocks. |
| D-5 | flow.Deps.SuccessPath — an optional seam overriding where a successful enroll/login lands (default /{GUID}/home). |
Accepted — added for SPEC-005's wizard (it injects /setup); nil keeps the SPEC-006 behaviour unchanged. |
| — | totp.begin returns the otpauth URL (embeds the secret) by design |
Accepted — serve it Cache-Control: no-store, HTTPS only; never logged/audited (proven). |
Follow-ups: a web layer / PWA slice (SPEC-009) — the screens need same-origin WebAuthn
JS (CSP is script-src 'self'). Per-action rate-limit buckets deferred.
8. The passkey nudge (revision 2026-10-14 · D-041)
After a successful TOTP login, redirect to a passkey-enrollment screen when all hold:
the identity has no passkey · users.passkey_opt_out is false · the origin is not loopback ·
not in setup mode.
POST /{GUID}?do=auth&action=passkey.add → WebAuthn register → credential stored (audited)
POST /{GUID}?do=auth&action=passkey.later → skip this time (nothing persisted)
POST /{GUID}?do=auth&action=passkey.never → users.passkey_opt_out = true (audited)
- Suppressed on loopback / in setup mode — a passkey enrolled at
127.0.0.1is unusable at the public domain (O-13 · D-041). - The marker is a user preference (
users.passkey_opt_out, SPEC-004 §4) — it survives identity rotation, so an opted-out user is not re-nudged after a recovery. - A TOTP login with a passkey present is not nudged; a passkey login never is.
- A session exists here, so SPEC-003 §6's session-bound CSRF applies (A15 does not).
| # | Criterion |
|---|---|
| AC-8 | A TOTP login with no passkey, on a non-loopback origin → the nudge screen |
| AC-9 | Don't ask again sets passkey_opt_out; later logins never nudge |
| AC-10 | The nudge does not appear on loopback or in setup mode |
| AC-11 | passkey.add completes a real WebAuthn registration and is audited |
8.1 Implementation notes (built 2026-10-14, D-041)
build · vet · test (incl. -race) green; static CGO-free binary. AC-8…AC-11 green.
- Where it fires —
flow.TOTPVerify(the ACTIVE-identity branch only): a successful TOTP login redirects to/{GUID}/auth/passkeywhenshouldNudgeholds; otherwise it lands on the app.login.finish(passkey login) never nudges. shouldNudge—Users.PasskeyOptOutis false · the identity has no passkey (Credentials.HasWebAuthnCredential) · the request origin (r.Host) is not loopback (httpx.IsLoopbackHost) · the nudge is not suppressed (Deps.SuppressNudge, wired to setup mode). Any lookup error fails safe — no nudge.- The screen — a session-gated route on the
authmodule:GET /{GUID}/auth/passkey(functionpasskey.prompt). The door already holds a session, so the forms carry the session-bound CSRF token (httpx.TokenFromRequest), not the pre-session one. - WebAuthn is two rounds, D-041 names one action — the screen's GET performs the
begin (
BeginRegistration, signed ceremony cookie) and embeds the publicKey options; the single POSTpasskey.addperforms the finish (FinishRegistration→PutWebAuthnCredential) and is audited aspasskey.add. This keeps the three POST actions each doing one thing. - Session-gating the actions — the
authmodule's anonymous actions (enroll/login) run before the session gate (serveAuthAction).passkey.add/passkey.later/passkey.neverrequire a session, so the door forwards thepasskey.prefix toserveGUID(likelogout). passkey.laterpersists nothing and is not audited (it is a skip, not an event);passkey.neverwritesusers.passkey_opt_out = truethrough the users service, which auditsuser.passkey_opt_out(A11). The marker is on the user, so it survives identity rotation (D-008).