docs: design for MFA on local sign-in (TOTP, passkeys, step-up)
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user