NextMyDay/docs/specs/spec-006-enroll-login.md
Claus Lohmar 57610cdbae docs: add the project plan, M0/M0.1 specs, agent guide and README
- 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)
2026-10-14 10:53:44 +00:00

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.1 is 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/passkey when shouldNudge holds; otherwise it lands on the app. login.finish (passkey login) never nudges.
  • shouldNudge — Users.PasskeyOptOut is 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 auth module: GET /{GUID}/auth/passkey (function passkey.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 POST passkey.add performs the finish (FinishRegistration → PutWebAuthnCredential) and is audited as passkey.add. This keeps the three POST actions each doing one thing.
  • Session-gating the actions — the auth module's anonymous actions (enroll/login) run before the session gate (serveAuthAction). passkey.add / passkey.later / passkey.never require a session, so the door forwards the passkey. prefix to serveGUID (like logout).
  • passkey.later persists nothing and is not audited (it is a skip, not an event); passkey.never writes users.passkey_opt_out = true through the users service, which audits user.passkey_opt_out (A11). The marker is on the user, so it survives identity rotation (D-008).