docs: design for MFA on local sign-in (TOTP, passkeys, step-up)

This commit is contained in:
2026-09-15 15:48:59 +00:00
parent fbcf436ef6
commit e0468aa7b9
@@ -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:<id>` | 5 min | pending ticket: `user_id`, `instance_id`, `methods`, `enrol_only`, `attempts` |
| `km:wa:<id>` | 5 min | WebAuthn session data (challenge) for one ceremony |
| `km:totp:<user_id>:<step>` | 90 s | `SET NX` replay guard: a TOTP code works once |
| `km:rl:auth:<ip>` | 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", <factor>]`, `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://<host>` (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.