From e0468aa7b9f7ac7770c7f78229416dc573cce775 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 15 Sep 2026 15:48:59 +0000 Subject: [PATCH] docs: design for MFA on local sign-in (TOTP, passkeys, step-up) --- .../2026-09-15-mfa-local-signin-design.md | 339 ++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-15-mfa-local-signin-design.md diff --git a/docs/superpowers/specs/2026-09-15-mfa-local-signin-design.md b/docs/superpowers/specs/2026-09-15-mfa-local-signin-design.md new file mode 100644 index 0000000..cfcfaf3 --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-mfa-local-signin-design.md @@ -0,0 +1,339 @@ +# MFA for local sign-in (TOTP and passkeys) - design + +Date: 2026-09-15 +Gap review entry: `#r-mfa` ("Build next", effort S - revised upward, see Scope) + +## Problem + +Local and HQ-projected members sign in with an email address and a password and +nothing else. MFA exists only through SSO, which is a paid feature. One phished +password on a Free instance gives an attacker root script execution across the +fleet, root consoles and decrypted private keys. MFA is a hard requirement on +most security questionnaires and must ship on every tier, with no licence gate. + +## Scope + +In: + +- TOTP as a second factor, with 10 single-use recovery codes. +- WebAuthn passkeys, both as a second factor after a password and as + passwordless sign-in (discoverable credentials, user verification required). +- An owner setting, `require_mfa`, that forces enrolment for password users. +- Owner/admin reset of another member's MFA. +- Step-up re-authentication before three sensitive actions: secret reveal, + private key download, console connect. +- A per-IP rate limit on every unauthenticated sign-in endpoint. + +Out: + +- MFA owned by Vantage HQ and projected to instances. HQ users enrol per + instance, like local users. +- Step-up for API tokens (see Known limitations). +- Forcing OIDC users through their IdP again (`prompt=login`) for step-up. +- A grace period for `require_mfa`. +- SMS or email codes. + +## Who MFA applies to + +| `auth_source` | Can enrol | Covered by `require_mfa` | Step-up factor | +| ------------- | --------- | ------------------------ | --------------------------- | +| `local` | yes | yes | MFA if enrolled, else password | +| `hq` | yes | yes | MFA if enrolled, else password | +| `oidc` | no | no | none - passes through | + +OIDC users are exempt because their IdP owns authentication; their session +carries `amr: ["oidc"]`. + +## Approach: pending-login ticket + +A password that checks out no longer mints a session when the user has MFA or +must enrol. It mints a **pending-login ticket** instead: a Redis key referenced +by a separate short-lived cookie. Only the MFA endpoints accept the ticket, and +only a completed second factor (or completed enrolment) exchanges it for a +`km_session`. + +The reason for this shape over "a session with an `mfa_pending` flag" is that a +half-authenticated user never becomes a `*Session` at all, so no route mounted +under `auth.Middleware` - today's or a future one - can serve them by +forgetting a check. The flag design fails open; this one fails closed. + +## Data model + +### `user_mfa` (control plane, new) + +One document per user who has started enrolment. + +| Field | Type | Notes | +| ------------------- | ----------- | ----- | +| `instance_id` | string | tenant scope | +| `user_id` | string | unique index `{instance_id, user_id}` | +| `webauthn_handle` | binary(64) | random, never the `user_id` (WebAuthn user handle) | +| `totp_secret_enc` | string | AES-256-GCM via `services.encryptString`; empty when TOTP is off | +| `totp_confirmed_at` | *time | nil means setup started but not confirmed - TOTP not active | +| `recovery_codes` | []{hash, used_at} | 10 codes, SHA-256 of the normalised code | +| `updated_at` | time | | + +A user "has MFA" when `totp_confirmed_at` is set or they own at least one +passkey. A pending unconfirmed TOTP secret is replaced on the next setup call. + +### `webauthn_credentials` (control plane, new) + +One document per passkey. + +| Field | Type | Notes | +| --------------- | -------- | ----- | +| `instance_id` | string | | +| `user_id` | string | index `{instance_id, user_id}` | +| `credential_id` | binary | unique index `{instance_id, credential_id}` | +| `public_key` | binary | COSE key; not secret | +| `sign_count` | uint32 | clone detection: a non-increasing non-zero count fails the assertion | +| `aaguid` | binary | | +| `transports` | []string | | +| `name` | string | user-editable, default from AAGUID or "Passkey" | +| `created_at`, `last_used_at` | time | | + +Both collections are added to `ScopedCollections`, with a test asserting it, +so an instance purge removes them. Their index builder is fatal on failure, +like `EnsureAuthIndexes` - the unique indexes are a security property. + +### `vantage-shared` changes (one release, then bump `server`'s pin) + +- `models.Settings.RequireMFA bool` (`bson:"require_mfa"`). A plain bool is + correct here: absent must mean off. +- `backup.ciphertextFields["user_mfa"] = []string{"totp_secret_enc"}`. Without + it `vantagectl verify` silently skips these secrets. + +### Redis keys + +| Key | TTL | Holds | +| --------------------------- | ----- | ----- | +| `km:mfa:` | 5 min | pending ticket: `user_id`, `instance_id`, `methods`, `enrol_only`, `attempts` | +| `km:wa:` | 5 min | WebAuthn session data (challenge) for one ceremony | +| `km:totp::` | 90 s | `SET NX` replay guard: a TOTP code works once | +| `km:rl:auth:` | 1 min | fixed-window counter for the sign-in rate limit | + +The pending ticket cookie is `km_mfa_pending`: HttpOnly, SameSite=Lax, Secure +under the same rule as `SetSessionCookie`, Path `/`, MaxAge 300. It is cleared +when exchanged or when the ticket is deleted. + +### `Session` additions + +- `AMR []string` - how the session was authenticated: `pwd`, `otp`, `webauthn`, + `recovery`, `oidc`. An HQ user's password sign-in is `pwd` like a local one. +- `StepUpAt *time.Time` - last successful step-up. Sign-in itself counts as a + step-up, so `StepUpAt` is set at session creation. + +Updating `StepUpAt` rewrites the Redis value under the same session ID, keeping +the remaining TTL (`KEEPTTL`). + +## Sign-in flows + +### Password, then second factor + +1. `POST /auth/login` runs unchanged up to `VerifyPassword`. +2. Then: + - user is `oidc`: unreachable (no local password). + - user has MFA: create a ticket with `methods` (`totp`, `webauthn`, + `recovery` as applicable), set `km_mfa_pending`, answer + `200 {mfa_required: true, methods: [...]}`. + - user has no MFA and `require_mfa` is on: create a ticket with + `enrol_only: true`, answer `200 {enrol_required: true}`. + - otherwise: mint the session as today, `amr: ["pwd"]`. +3. The client calls one of: + - `POST /auth/mfa/totp {code}` + - `POST /auth/mfa/recovery {code}` + - `POST /auth/mfa/webauthn/begin` then `POST /auth/mfa/webauthn/finish` +4. On success the ticket is deleted, the session is minted with + `amr: ["pwd", ]`, `TouchLastLogin` runs, and the response matches + today's `{ok: true}`. + +Each failure increments `attempts`; the fifth deletes the ticket. + +### Forced enrolment + +An `enrol_only` ticket is accepted only by: + +- `POST /auth/mfa/enrol/totp/setup` → `{secret, otpauth_uri}` +- `POST /auth/mfa/enrol/totp/confirm {code}` → `{recovery_codes}` and the session +- `POST /auth/mfa/enrol/passkey/begin|finish` → `{recovery_codes}` and the session + +It is refused by the plain `/auth/mfa/*` verify endpoints and vice versa, so a +user cannot skip enrolment by presenting the ticket elsewhere. + +Existing sessions are not revoked when `require_mfa` is switched on; they end +at their 24h TTL and the next sign-in enforces enrolment. + +### Passwordless passkey + +1. `POST /auth/passkey/begin` - resolves the instance with + `resolveLoginInstance`, applies the locked-instance and + `LocalLoginPermitted` checks exactly as `/auth/login` does, and returns + assertion options with an empty `allowCredentials` and + `userVerification: "required"`. +2. `POST /auth/passkey/finish` - looks the credential up by + `{instance_id, credential_id}`, verifies the assertion including the UV flag, + and mints the session with `amr: ["webauthn"]`. + +A user-verified passkey is possession plus biometric or PIN, so it satisfies +`require_mfa` on its own. + +The discoverable lookup is scoped by `instance_id` from the host, never by the +credential alone - the same rule that makes `users` lookups instance-scoped. + +### WebAuthn relying party + +- RP ID: the request host with any port removed. On cloud that is the + instance subdomain; self-hosted, the install's own host. The reverse proxy + must preserve `Host`, which the instance host guard already requires. +- Expected origin: `https://` (or `http://` only when the request itself + is plain HTTP on `localhost`, for development). +- A passkey is bound to its host. Moving an instance to a new address + invalidates its passkeys; TOTP and recovery codes still work. The docs say so. + +## Step-up + +`auth.RequireStepUp()` is new middleware, mounted after `auth.Middleware` on: + +- `POST /api/secrets/:group/reveal` +- `GET /api/keys/:id/private-key` +- `POST /api/console/connect` +- the MFA-management endpoints marked "step-up" below + +It passes when any of these holds: + +- the request authenticated with an API token (`TokenID != ""`); +- `AMR` contains `oidc`; +- `StepUpAt` is within the last 10 minutes. + +Otherwise it answers `403 {error: "re-authentication required", code: +"step_up_required", methods: [...]}`, where `methods` is the user's MFA factors, +or `["password"]` when they have none. + +`POST /api/me/step-up` accepts `{totp}`, `{recovery}` or `{password}` (password +only for a user with no MFA), and `POST /api/me/step-up/webauthn/begin|finish` +does the same with a passkey. Success sets `StepUpAt`. Failures are rate +limited by the same per-IP limiter and audited. + +## Account endpoints (session-authenticated, every role) + +| Method and path | Step-up | Notes | +| --------------------------------------- | ------- | ----- | +| `GET /api/me/mfa` | no | TOTP on/off, passkeys, recovery codes remaining, `require_mfa` | +| `POST /api/me/mfa/totp/setup` | yes | returns secret and `otpauth://` URI; replaces an unconfirmed one | +| `POST /api/me/mfa/totp/confirm` | no | code must verify; returns recovery codes when none exist yet | +| `DELETE /api/me/mfa/totp` | yes | refused if last factor and `require_mfa` | +| `POST /api/me/mfa/recovery/regenerate` | yes | invalidates the old set, returns 10 new codes once | +| `POST /api/me/passkeys/begin`, `/finish`| yes | registers a discoverable credential, UV required | +| `PATCH /api/me/passkeys/:id` | no | rename | +| `DELETE /api/me/passkeys/:id` | yes | refused if last factor and `require_mfa` | +| `DELETE /api/org/users/:id/mfa` | yes | owner or admin; an admin cannot reset an owner; clears TOTP, passkeys, recovery codes | + +The `/me/*` endpoints refuse OIDC users with 409 `mfa_not_applicable`. +The first factor enrolled from `/account/security` issues recovery codes; +later factors do not regenerate them. + +`require_mfa` is set through the existing `PUT /api/settings`, owner only for +that field (admins may save other settings). + +Every handler carries swag annotations; `openapi.json` is regenerated. + +## Rate limiting + +There is no rate limit on `/auth/login` today. A new `RateLimitAuth()` fixed +window, on the `RateLimitTokens` pattern, applies to `/auth/login`, +`/auth/mfa/*`, `/auth/passkey/*` and `/api/me/step-up*`: 20 requests per minute +per `c.ClientIP()`, answering 429 with `Retry-After`. The ticket's 5-attempt +cap still bounds guesses per ticket; the IP limit bounds tickets per attacker. + +## Audit events + +`mfa.enrolled` (factor), `mfa.removed` (factor), `mfa.reset` (target user), +`mfa.recovery_used`, `mfa.recovery_regenerated`, `mfa.failed` (factor, at +sign-in), `step_up.ok`, `step_up.failed`, `settings.require_mfa` (on/off). +Details never contain codes, secrets or credential IDs. + +## UI (`web/`) + +- **`/login`** + - "Sign in with passkey" button when `window.PublicKeyCredential` exists and + local login is enabled. + - After the password: a second-factor step with a 6-digit code input, + "Use passkey", and "Use a recovery code". + - `enrol_required`: an inline wizard - choose TOTP (QR code, secret as text, + confirm code) or passkey, then recovery codes shown once with copy and an + "I have saved these" checkbox before continuing. + - `mfa_ticket_expired` returns the form to the password step with a message. +- **`/account/security`** - new page, every role, linked from the sidebar user + menu. TOTP status and setup/remove, passkey list with rename and remove, + recovery codes remaining and regenerate. Hidden content with an explanation + for OIDC users. +- **`/settings`** - owner-only "Require MFA for password sign-in" toggle; the + users table gains an MFA column and a "Reset MFA" action. +- **`StepUpModal`** - `request()` in `web/lib/api.ts` catches 403 with + `code: "step_up_required"`, opens the modal with the offered methods, and on + success retries the original request once. Callers of the three sensitive + actions need no changes. +- QR codes are drawn client-side (the `qrcode` npm package); nothing is fetched + from outside, so air-gapped installs work. + +## Errors + +| Situation | Answer | +| ---------------------------------- | ------ | +| missing or expired ticket | 401 `mfa_ticket_expired` | +| wrong code | 401 `invalid_code`, `attempts_left` | +| fifth wrong code | ticket deleted, 401 `mfa_ticket_expired` | +| replayed TOTP code | 401 `invalid_code` | +| WebAuthn verification failure | 401 `invalid_assertion` | +| origin or RP ID mismatch | 400 `origin_mismatch` | +| removing last factor under policy | 409 `mfa_required_by_policy` | +| rate limited | 429 with `Retry-After` | +| Redis unavailable | 503; sign-in already depends on Redis | + +TOTP: SHA-1, 6 digits, 30 s, ±1 step, issuer = instance name, account = email. + +## Libraries + +- `github.com/pquerna/otp` - TOTP generation and validation. +- `github.com/go-webauthn/webauthn` - WebAuthn ceremonies. +- `qrcode` (npm) - QR rendering in the browser. + +## Testing + +Go unit tests: + +- TOTP verify, skew window, replay guard. +- Recovery codes: normalisation, single use, regenerate invalidates the old set. +- Ticket state machine: TTL, attempt cap, `enrol_only` scope in both directions. +- Login branching: no MFA, MFA, `require_mfa` without MFA, OIDC-exempt. +- `RequireStepUp`: inside and outside the window, token exemption, OIDC exemption. +- Last-factor refusal under `require_mfa`; admin cannot reset an owner. +- Passwordless passkey honours locked instances and `LocalLoginPermitted`. +- `user_mfa` and `webauthn_credentials` present in `ScopedCollections`. +- WebAuthn ceremonies against a software authenticator fixture. + +Playwright: + +- TOTP enrol and sign-in, with codes generated in the test. +- Passkey enrol, second-factor sign-in, passwordless sign-in and step-up using + Chrome's virtual authenticator (`WebAuthn.addVirtualAuthenticator` over CDP). +- Forced enrolment when `require_mfa` is on. +- Step-up modal on secret reveal, then no prompt within 10 minutes. + +## Known limitations + +- **API tokens bypass step-up.** A token with `secrets:read` or `keys:read` can + reveal secrets and download private keys without re-authentication. Tokens + have no human present to prompt. Scoped, short-lived tokens are the mitigation + and are tracked separately in the gap review. +- OIDC users are never prompted for step-up; their IdP's session policy governs. +- HQ users on several instances enrol once per instance. +- Passkeys stop working if an instance changes host. + +## Documentation + +- `CLAUDE.md`: a "Multi-factor authentication" subsystem section covering the + ticket design, step-up, the shared-module changes and `ciphertextFields`. +- `vantage-docs`: enrolling, recovery codes, owner reset, `require_mfa`, + passkeys bound to the host. +- Gap review: mark `#r-mfa` shipped, and the MFA row in the comparison table.