NextMyDay/docs/specs/spec-006-enroll-login.md
Claus Lohmar 04c2394c34 docs: record the M0 review fixes and correct the README
REVIEW-001 triage updated (H1, H2, M1c, M1s, L3s, L1c/L2s, L8c, M2s, M3s,
M2c, L1s, L5s, L4s, L7c marked FIXED; L3c, L4c, L5c, L6c stay open with the
reason), and each fix is tied to its code and test.

- SPEC-003: the door pipeline (XFF right→left, the per-request setup gate,
  the isolated hardening headers, the pinned setup module), §6 the `__Host-`
  cookie and the startup-generated signing keys, §7 loopback enforcement
  per request, new AC-10…AC-12, D-2 amended (session_key removed), and a new
  §11 that maps every REVIEW-001 finding to its fix.
- SPEC-005 / SPEC-006 / SPEC-007: the same three points where they belong
  (the wizard's loopback + module pin, the nudge's configured-origin signal,
  the async /access notification ruled in).
- PROJECT_PLAN §10 register: A13 re-stated as enforced (per request + pin);
  A2/A5/A8 re-stated as enforced where the review verified them; A3/A6/A9/A15
  now enforced by this pass; A11 left open (L4c). §14 change log entries.
- README: the status said "planning / pre-alpha · nothing is built" — it is
  stale. State M0+M0.1, note that /setup PERSISTS config.yaml and a restart
  adopts the generated secrets, and state the per-request loopback rule.
2026-10-14 11:30:17 +00:00

155 lines
8.5 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 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).