# 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 origin is **not** loopback (`httpx.IsLoopbackHost`) · the nudge is not suppressed (`Deps.SuppressNudge`, wired to setup mode). Any lookup error fails safe — **no** nudge. - **The origin signal is the configured `base_url` host** (`Deps.BaseURL`), falling back to the request `Host` only when it is unset (L7c, REVIEW-001): a reverse proxy that rewrites `Host` must not be able to suppress — or enable — the 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).