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