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

152 lines
8.3 KiB
Markdown

# 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).