From 82745ab92a3f0caaad664db9470c906a2abb0ce8 Mon Sep 17 00:00:00 2001 From: Scott Hall Date: Tue, 27 Oct 2026 08:40:08 -0500 Subject: [PATCH 1/3] docs: six sign-in and step-up sentences now say what the auth code does BACKLOG #1133 ("research an honest pass for ASVS 6.1.3 -- whether documenting an inconsistently enforced strength can satisfy the verb"). Each sentence below described sign-in or step-up behaviour more broadly than messagefoundry/auth/ does; each is rewritten against the code. - CLOUD-DEPLOYMENT.md and AOAG-DEPLOYMENT.md, L7 cluster probe: no static or mintable API token exists; the only bearer is a sign-in session token, which expires, so something must refresh it (and the account owes MFA under require_mfa). AOAG now links to CLOUD 3.1. - ASVS-L2-PHASE0-CHANGES.md, AD row: directory password sign-in is retired; the bind as the user happens only on a directory account's password step-up (_reauth_ad). - REMOTE-CONSOLE.md: step-up re-proves by sign-in type (local password, directory re-bind, OIDC at the IdP); a code or passkey is asked only of a session that has not met its second factor. - CONFIGURATION.md step_up_max_age_seconds and SECURITY.md step-up section: a combined passkey sign-in is also seeded from any address. - SECURITY.md Table A lockout row: the sign-in lock also lets a combined passkey sign-in through. Proposed PR title: Six operator-doc sentences on sign-in and step-up match the auth code (BACKLOG #1133) Proposed ledger banner: ENGINE FIX 2026-10-27: six operator-doc sentences corrected; a vault re-read of the cell is owed --- docs/AOAG-DEPLOYMENT.md | 10 +++++----- docs/ASVS-L2-PHASE0-CHANGES.md | 2 +- docs/CLOUD-DEPLOYMENT.md | 10 +++++++--- docs/CONFIGURATION.md | 2 +- docs/REMOTE-CONSOLE.md | 8 ++++++-- docs/SECURITY.md | 7 ++++--- 6 files changed, 24 insertions(+), 15 deletions(-) diff --git a/docs/AOAG-DEPLOYMENT.md b/docs/AOAG-DEPLOYMENT.md index f85f6b7c9d..754ba62a65 100644 --- a/docs/AOAG-DEPLOYMENT.md +++ b/docs/AOAG-DEPLOYMENT.md @@ -622,11 +622,11 @@ or an NLB with: - **One VIP per inbound MLLP port, with the health check a TCP connect to that port.** Only the leader binds it, so the check passes only on the active engine and the VIP follows engine failover passively, including a failover to the DR-DC standby. -- **L7 alternative.** Probe `GET /cluster/status` and require `role == "primary"`. This needs a - least-privilege `monitoring:read` bearer token injected by the LB, because unauthenticated probes - get 401/403 on *every* node and black-hole traffic. Mint one by creating a dedicated local - service account with a `monitoring:read`-bearing role and using its bearer token - ([`SECURITY.md`](SECURITY.md), "Roles & permissions" / "Sessions"). The tokenless `/health` +- **L7 alternative.** Probe `GET /cluster/status` and require `role == "primary"`. This needs the + probe to carry a bearer token, because unauthenticated probes get 401/403 on *every* node and + black-hole traffic. The engine mints no static API token: the only bearer is a sign-in session + token of an account holding `monitoring:read`, which expires, so something beside the LB must + refresh it ([`CLOUD-DEPLOYMENT.md`](CLOUD-DEPLOYMENT.md) section 3.1 says what that takes). The tokenless `/health` returns 200 on every node and **cannot** select the primary; there is no tokenless primary-only endpoint. - **Set the probe interval comfortably above the ~1 s graph-reconcile interval** (e.g. 10 s) so diff --git a/docs/ASVS-L2-PHASE0-CHANGES.md b/docs/ASVS-L2-PHASE0-CHANGES.md index e17b59e1cf..d9b205fb25 100644 --- a/docs/ASVS-L2-PHASE0-CHANGES.md +++ b/docs/ASVS-L2-PHASE0-CHANGES.md @@ -788,7 +788,7 @@ tables in [`CONNECTIONS.md`](CONNECTIONS.md) §"Resource management & limits" (A | Store — SQLite (the default backend) | local | **no network socket** — a local database file (WAL) | n/a; `[store].ssl_root_cert` is **rejected** for SQLite | filesystem ACLs; at-rest encryption is the store DEK, not a transport credential | no — a local path | `[store].backend = sqlite`, `[store].path` | | Store — SQL Server | outbound | TDS over ODBC Driver 18, **default port 1433** | `encrypt` default true, `trust_server_certificate` default false; optional `ssl_root_cert` pins the server certificate via the Driver-18.1 `ServerCertificate` keyword, sent with `Encrypt=strict` (SQL Server 2022 or later); optional `multi_subnet_failover` for an AOAG listener | SQL / Integrated / Entra, with an opt-in `require_managed_identity` precondition (default off) | **yes** — `[store].server` | `[store].backend = sqlserver`, `[store].server`, `[store].port`, `[store].auth`, `[store].pool_size`, `[store].connect_timeout`, `[store].command_timeout`, `[store].warm_pool` | | Store — Postgres (asyncpg) | outbound | TCP; the model's `port` default is 1433 and a **left-at-default value is remapped to Postgres's conventional 5432** | TLS built from `encrypt` / `trust_server_certificate`; `ssl_root_cert` loads `ssl.create_default_context(cafile=…)`, so chain **and** hostname are still verified | username + password only — Postgres has **no managed-identity mode**, so it cannot satisfy `require_managed_identity` | **yes** — `[store].server` | `[store].backend = postgres`, plus the same `[store]` connection keys as SQL Server | -| Active Directory — login binds | outbound | LDAPS (TLS) by default; a plain `ldap://` bind requires the explicit `ad_allow_insecure_ldap` opt-in, which is honoured only under `[security].enforcement = warn` and inert under `enforce` (vault BACKLOG #2354) | `ad_tls_verify` default true (`CERT_REQUIRED`); `ad_tls_verify=false` now **refuses at construction**, with no escape (CHANGED 2026-10-12 by vault BACKLOG #3230; until then `MEFOR_ALLOW_INSECURE_TLS` released it); `ad_tls_ca_cert_file` anchors an internal CA without disabling verification | a service-account simple bind (`ad_bind_dn` + `ad_bind_password` / `ad_bind_password_secret`), then a **second bind as the user**, then 1–2 SUBTREE searches | **yes** — `[auth].ad_server` | `[auth].ad_server`, `ad_domain`, `ad_bind_dn`, `ad_user_search_base`, `ad_group_search_base`, `ad_use_nested_groups`, `ad_connect_timeout`, `ad_receive_timeout` | +| Active Directory — login binds | outbound | LDAPS (TLS) by default; a plain `ldap://` bind requires the explicit `ad_allow_insecure_ldap` opt-in, which is honoured only under `[security].enforcement = warn` and inert under `enforce` (vault BACKLOG #2354) | `ad_tls_verify` default true (`CERT_REQUIRED`); `ad_tls_verify=false` now **refuses at construction**, with no escape (CHANGED 2026-10-12 by vault BACKLOG #3230; until then `MEFOR_ALLOW_INSECURE_TLS` released it); `ad_tls_ca_cert_file` anchors an internal CA without disabling verification | **no sign-in binds as the user**: directory password sign-in is retired (BACKLOG #1137), and the engine refuses it before any bind. A Windows SSO or OIDC sign-in looks the principal up through a service-account simple bind (`ad_bind_dn` + `ad_bind_password` / `ad_bind_password_secret`) and 1–2 SUBTREE searches (`resolve_principal`). A **second bind as the user** happens only on a directory account's password step-up (`POST /me/reauth`, console `POST /ui/reauth`; `_reauth_ad`), after the service-account bind has found the entry | **yes** — `[auth].ad_server` | `[auth].ad_server`, `ad_domain`, `ad_bind_dn`, `ad_user_search_base`, `ad_group_search_base`, `ad_use_nested_groups`, `ad_connect_timeout`, `ad_receive_timeout` | | Active Directory — directory session reconciler (ADR 0079) | outbound (periodic) | the same LDAPS hop, off the login path | as above | the service-account bind only — one `resolve_principal` bind per signed-in directory user per pass | same server | `[auth].ad_session_recheck_seconds` (default 300 s, floored at 60 s; 0 = OFF), `ad_session_recheck_max_users`, `ad_session_recheck_strikes`, `ad_session_revoke_max`, `ad_session_revoke_max_fraction` | | Kerberos / SPNEGO browser SSO | n/a — **the engine opens no socket** | one SPNEGO *server* step against the host's SSPI (Windows) or GSSAPI/krb5 (Linux) provider using the local keytab / machine credential; any KDC traffic belongs to the OS provider | n/a — the engine terminates nothing here | the browser's SPNEGO ticket, validated by the OS provider | no — the host's realm / keytab | `[auth].kerberos_enabled` (experimental, off by default), `[auth].kerberos_spn` | | OIDC IdP — token endpoint (ADR 0142) | outbound | HTTPS POST to the operator-**pinned** endpoint; port from the URL. There is **no `.well-known` discovery** — every endpoint is pinned, so no attacker-influenced URL exists | `ssl.create_default_context(cadata=...)` over the bytes the anchor check read from `oidc_tls_ca_cert_file` (never a second read of the file, BACKLOG #1142), or the OS store, **always** `check_hostname=True` + `CERT_REQUIRED` (no verify-off escape by design); redirects are never followed | the confidential-client secret, or under `private_key_jwt` a signed client assertion and no secret (BACKLOG #296), plus the authorization code and the PKCE verifier | **yes** — `[auth].oidc_token_endpoint` | `[auth].oidc_token_endpoint`, `oidc_client_id`, `oidc_client_secret` (env `MEFOR_AUTH_OIDC_CLIENT_SECRET`) or `oidc_client_secret_ref`, or `oidc_token_endpoint_auth_method` with `oidc_client_private_key` / `_ref`, `oidc_allowed_endpoints`, `oidc_tls_ca_cert_file` | diff --git a/docs/CLOUD-DEPLOYMENT.md b/docs/CLOUD-DEPLOYMENT.md index 2209455ece..43cedcf66a 100644 --- a/docs/CLOUD-DEPLOYMENT.md +++ b/docs/CLOUD-DEPLOYMENT.md @@ -144,9 +144,13 @@ VIP follow failover **passively**, with no engine VIP code. by the `monitoring:read` permission (`Depends(require(Permission.MONITORING_READ))` in [`api/app.py`](../messagefoundry/api/app.py)), so an unauthenticated LB health probe gets `401/403` on **every** node (the leader included) and the LB would mark all backends unhealthy and black-hole MLLP. - To use it, the LB must **inject a static bearer token / session header** into the probe (mint a - least-privilege `monitoring:read` token; rotate it) — and **many L4 LBs cannot send auth headers at - all**. If your LB cannot authenticate the probe, use the **TCP-connect-to-2575** default above (it needs + To use it, the probe must carry a bearer token. The engine has **no static or long-lived API token + and no route that mints one**: the only bearer is the session token a sign-in returns, and that + session ends at its absolute or idle limit ([SECURITY.md](SECURITY.md#sessions)). So something + beside the LB must sign in as a dedicated least-privilege account holding `monitoring:read` (the + built-in `viewer` role carries it) and hand the LB a fresh token before each session ends; under + the shipped `[auth].require_mfa` that account also owes a second factor at each sign-in. **Many L4 + LBs cannot send auth headers at all**. If your LB cannot authenticate the probe, use the **TCP-connect-to-2575** default above (it needs no auth and works on any L4 LB); there is **no** tokenless primary-only HTTP endpoint to point an unauthenticated L7 check at. The `/health` liveness endpoint *is* tokenless, but it is 200 on every node and so cannot select the primary. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index dc2b16c3a5..5b4f2ad066 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -718,7 +718,7 @@ document ([SECURITY-DOCS-POLICY.md](SECURITY-DOCS-POLICY.md)). | `session_idle_timeout_minutes` | | | **→ moved to `[security].sign_out_after_idle_minutes`** (ADR 0118) — set it there; no longer accepted in `[auth]`. | | `session_absolute_hours` | | | **→ moved to `[security].max_session_hours`** (ADR 0118) — set it there; no longer accepted in `[auth]`. | | `max_sessions_per_user` | int | 5 | cap concurrent sessions per user (ASVS 7.1.2; `0` = unlimited); a login beyond the cap revokes the user's oldest live session; only live sessions count, and the same login revokes the user's sessions already past the idle or absolute limit. Sign-ins that still owe a second factor are counted apart: see the *Concurrent session count* row in `docs/SECURITY.md` | -| `step_up_max_age_seconds` | int | 300 | **step-up re-verification window** (ASVS 7.5.3): a highly sensitive operation requires the session to have re-verified its credential — at login or via `POST /me/reauth` — within this many seconds. A code accepted at `POST /auth/mfa-verify` (or the console's `POST /ui/mfa`) opens the window too, except on a session the OIDC sign-in minted, whose window opens only at the IdP (BACKLOG #2142). A **local** login that owes no second factor usually counts as the first verification (the sudo-timestamp model). The exception is a sign-in from an address the account's known-address record has not seen in the last 90 days, by an account with a baseline. An account has a baseline once it has finished a sign-in, or once its record holds a row of any age. That session is born with no window, which is the first-seen address challenge (BACKLOG #288). A sign-in that stopped at its factor adds nothing to the record; the *First-seen sign-in address* rows of Table A in [SECURITY.md](SECURITY.md) list the events that do, and a step-up or an enrolment writes only in some cases. A step-up from that address passes the challenge; signing in again does not. The check fails open, and the window opens, when the sign-in carries no client address or the account's known-address record cannot be read. A sign-in that proves the password and a TOTP code in one request counts from any address (ADR 0197). A directory login (Windows SSO by either route, or OIDC) does not: it is born with no window, so its first sensitive action asks for a step-up, or for its second factor where it owes one (BACKLOG #1144). An OIDC session that proves its factor still owes the IdP step-up | +| `step_up_max_age_seconds` | int | 300 | **step-up re-verification window** (ASVS 7.5.3): a highly sensitive operation requires the session to have re-verified its credential — at login or via `POST /me/reauth` — within this many seconds. A code accepted at `POST /auth/mfa-verify` (or the console's `POST /ui/mfa`) opens the window too, except on a session the OIDC sign-in minted, whose window opens only at the IdP (BACKLOG #2142). A **local** login that owes no second factor usually counts as the first verification (the sudo-timestamp model). The exception is a sign-in from an address the account's known-address record has not seen in the last 90 days, by an account with a baseline. An account has a baseline once it has finished a sign-in, or once its record holds a row of any age. That session is born with no window, which is the first-seen address challenge (BACKLOG #288). A sign-in that stopped at its factor adds nothing to the record; the *First-seen sign-in address* rows of Table A in [SECURITY.md](SECURITY.md) list the events that do, and a step-up or an enrolment writes only in some cases. A step-up from that address passes the challenge; signing in again does not. The check fails open, and the window opens, when the sign-in carries no client address or the account's known-address record cannot be read. A combined sign-in, which proves the password and a second factor in one request, counts from any address (ADR 0197): a TOTP code on an account with TOTP enrolled, or a passkey assertion on an account holding a discoverable passkey (ADR 0197 Amendment A). A directory login (Windows SSO by either route, or OIDC) does not: it is born with no window, so its first sensitive action asks for a step-up, or for its second factor where it owes one (BACKLOG #1144). An OIDC session that proves its factor still owes the IdP step-up | | `require_action_step_up` | bool | `true` | **action-bound step-up** ([ADR 0077](adr/0077-action-bound-step-up.md); ASVS 7.5.1/8.2.4). On by default: a fixed set of routes, four JSON routes and their console twins only under the condition below, requires a fresh proof **bound to that specific action** (`POST /me/reauth` with a matching `purpose`, single-use) instead of riding the session-wide `step_up_max_age_seconds` window. On the JSON API the set is `POST /me/mfa/enroll`, `POST /me/mfa/confirm`, `DELETE /me/mfa`, `DELETE /me/sessions` and `DELETE /me/sessions/{session_id}`, plus the administrator's `PATCH /users/{user_id}`, `POST /users/{user_id}/reset-password`, `POST /users/{user_id}/reset-mfa`, `PUT /users/{user_id}/federated-identity` and `DELETE /users/{user_id}/federated-identity`. Four group-wide saves join the set conditionally (vault BACKLOG #3401): `PUT /ad-group-map`, `PUT /ad-group-scope-map`, `PUT /roles/custom/{role_id}` and `DELETE /roles/custom/{role_id}` take a proof bound to `save_ends_own_sessions` **only when the save would end the caller's own sessions**, as a map save would for a caller whose account is a directory account, or a role save for a caller who holds that role. Any other save of those four keeps the session-window step-up. Since vault BACKLOG #2625 the set also holds the injection and bulk-export routes: `POST /messages/{message_id}/resend`, `POST /messages/{message_id}/edit-resend`, `POST /uploads/{file_id}/resend`, `GET` and `POST /messages/export`, `POST /connections/{name}/purge` and `POST /config/reload`. The console binds the same actions, and passkey enroll and delete too. It closes the most-exploitable default: a session hijacked inside the 300 s login-seeded window could otherwise bind an attacker's authenticator, deliver a fabricated message to a partner, or export bodies in bulk, with no fresh proof. It changes **only** that set; every other step-up route, the rest of the admin surface and replay among them, keeps the session-window step-up. So `false` takes the bound proof off message injection, bulk export, purge and config reload as well as off the factor and admin routes. `false` reverts to the legacy session-window behaviour (0.2.x semantics), the documented org opt-out. **One refusal survives the opt-out and no setting reaches it:** an MFA-pending session on an account that **already holds** a second factor cannot bind a new one or end sessions, on either surface — it must prove the existing factor first. Without that bound, `false` would turn a config knob into an account-takeover path, because the enrollment ceremonies mark a session MFA-satisfied on success. Enrolling a **first** factor is untouched | | `password_min_length` | int | 15 | local-password policy — ASVS 5.0-aligned, length-first | | `password_require_uppercase` / `password_require_lowercase` / `password_require_digit` / `password_require_symbol` | bool | `false` | character classes — **opt-in**, each independently (ASVS 5.0 forbids mandatory composition); turn one on only for a legacy standard that still mandates it | diff --git a/docs/REMOTE-CONSOLE.md b/docs/REMOTE-CONSOLE.md index db8b172d8a..69cc9dea84 100644 --- a/docs/REMOTE-CONSOLE.md +++ b/docs/REMOTE-CONSOLE.md @@ -253,8 +253,12 @@ write calls the same JSON handler, RBAC permission and audit path the API uses whoever holds the permission. Plan an exposure against this list, not against the dashboard's read-only-looking front page. -Every row is a `POST`. **Step-up** means the operator is re-verified (password, plus MFA where -enrolled) before the action runs — a stale window is redirected to `/ui/reauth` rather than refused. +Every row is a `POST`. **Step-up** means the operator re-proves a credential before the action runs: a local +account re-enters its password, a directory account signed in by Windows SSO re-enters its directory +password (a live bind), and an OIDC session goes back to its IdP and is never asked for a password. +The step-up asks for a TOTP code or a passkey only from a session that has not yet met its second +factor; a factor proved at sign-in is not asked for again. A stale window is redirected to +`/ui/reauth` rather than refused. **Dual control** applies only where `[approvals]` is enabled (it is **off by default**): the action is then held for a second, *distinct* approver holding `approvals:approve` instead of executing inline. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 98b755f6d8..a6cd258ee9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -2749,8 +2749,9 @@ first-seen address challenge (BACKLOG #288). The signal fails open, and the sess account's first sign-in, with no client address, or when the account's known-address record cannot be read (the *First-seen sign-in address* rows of Table A). An account an administrator reset before its first sign-in is the exception: its first sign-in is judged (vault BACKLOG #2946). A **combined** sign-in (password and -TOTP code in one request, ADR 0197) is seeded from any address, because it proved the code in the same -request. A local sign-in that still owes a factor is minted with no window, and so is **every +TOTP code in one request on an account with TOTP enrolled, ADR 0197, or password and passkey assertion +in one request on an account holding a discoverable passkey, ADR 0197 Amendment A) is seeded from any +address, because it proved the second factor in the same request. A local sign-in that still owes a factor is minted with no window, and so is **every directory sign-in**: Windows SSO by either route (`GET /ui/sso` and `POST /auth/negotiate`) and the OIDC callback (see the Authentication ambience row). `POST /auth/negotiate` stamped the window at login until BACKLOG #1144 step 5; the engine now decides this for every directory leg, and no caller @@ -3918,7 +3919,7 @@ slack. | Client-address monoculture | the set of distinct observed client addresses | allow-list in use **and** no trusted proxy declared **and** ≥ 50 observations **and** all resolved to the same loopback address | **LOG** — one-shot WARNING + `client_address_monoculture` on `GET /security/posture` | n/a | (derived; no knob) | | Login attempt rate, per client IP **and** globally | `api.security.client_ip`, which returns the ASGI `scope["client"][0]` the *Client source network* row evaluates; an attempt with no address is counted under the literal `"unknown"` | > 10 attempts per IP (`login_rate_limit_per_ip`), or > 60 across all clients (`login_rate_limit_global`), in a rolling 60 s window (`login_rate_limit_window_seconds`); a refused attempt is not itself counted | **THROTTLE** — 429 `too many attempts` with **no** `Retry-After` on the three JSON routes; 429 + a `Retry-After` of the whole sign-in window on `POST /ui/login` and on `POST /ui/login/passkey`, the passkey sign-in's begin leg; a **303** redirect to `/ui/login?e=rate_limited` (no 429, no `Retry-After`) on `GET /ui/sso`, `POST /ui/oidc/start` and `GET /ui/oidc/callback` (a sign-in return; a step-up return draws the ceremony budget, BACKLOG #3374), and on `GET /ui/oidc/start` only when its interstitial is skipped (see the [Route → limiter map](#route--limiter-map)). WARNING-logged, deliberately **not** audited | on, 10 / 60 / 60 s | `[auth].login_rate_limit_enabled` | | Credential-ceremony rate, per **actor** | `identity.user_id` (**not** an IP); on an IdP step-up's return, which has no session, the user id its staged flow names | > `login_rate_limit_per_ip` (10) ceremonies per actor per 60 s, on each of the actor's keys (the *Credential ceremonies* row of [Business-logic limits](#business-logic-limits-asvs-213) names them); **no** global dimension (`glob=0`, deliberately) | **THROTTLE** 429, logged | on with the row above | *gated by the same* `[auth].login_rate_limit_enabled` | -| Consecutive credential failures on one account | the account's two failure counters (ADR 0197): the **sign-in** counter and the **second-step** counter | ≥ 5 consecutive failures on one counter locks for 15 minutes, with one exception: while the credential in force is engine-generated (set by an administrator's account creation, `POST /users`, and by both resets), sign-in failures are counted and audited but arm no sign-in lock (`lockout_arms`, ADR 0197 Amendment A); a lapsed window restarts the counter; the second-step lock on a local account, and the sign-in lock on a local account with a TOTP enrolled, unless the engine KNOWS this release refuses that enrolment (a TOTP it cannot judge still escalates, and a known-refused one escalates too in the races listed under [WebAuthn passkeys](#webauthn-passkeys-wp-14b-adr-0068), BACKLOG #3422), double per cycle up to 24 hours | **DENY** before any verify on the password and second-factor legs, plus an audit row whose name is leg-specific — on the password path the uniform `auth.login_failed` (`bad_credentials`) row every refused sign-in writes and then `auth.login_locked`, which only `users:manage` reads ([Audit](#audit)), `auth.mfa_failed` / `auth.webauthn_failed` with `reason=locked` on at least the TOTP/recovery and assertion legs, and on a directory account's two enrolment legs (the sign-in password path still runs a dummy argon2 verify to keep timing flat). The second-step lock refuses every one of those legs. The sign-in lock has **two** exceptions: it does **not** refuse a combined sign-in (password and TOTP code in one request) on a local account with TOTP enrolled, and it refuses **no** second-factor leg. The Kerberos and OIDC sign-ins also refuse a row under either lock, but only **after** the ticket or token has verified, audited `auth.login_failed` with `reason=locked` (`_directory_login_refusal`, BACKLOG #1638); neither leg feeds the counter. The password legs of the post-session re-proofs, `POST /me/reauth` and `POST /me/password` and their console twins `POST /ui/reauth` and `POST /ui/account/password`, **feed** the sign-in counter but are **not** refused by either lock; the IdP step-up of an `oidc` session checks no engine credential and feeds neither counter; each **session** may fail `lockout_threshold` re-proofs (5 by default), and the failure that reaches it revokes that session, audited as `auth.reauth` with `session_revoked=true` or `auth.password_change_failed` with `reason=session_revoked` | 5 / 15 min | `[auth].lockout_threshold`, `lockout_minutes`, `lockout_max_minutes` | +| Consecutive credential failures on one account | the account's two failure counters (ADR 0197): the **sign-in** counter and the **second-step** counter | ≥ 5 consecutive failures on one counter locks for 15 minutes, with one exception: while the credential in force is engine-generated (set by an administrator's account creation, `POST /users`, and by both resets), sign-in failures are counted and audited but arm no sign-in lock (`lockout_arms`, ADR 0197 Amendment A); a lapsed window restarts the counter; the second-step lock on a local account, and the sign-in lock on a local account with a TOTP enrolled, unless the engine KNOWS this release refuses that enrolment (a TOTP it cannot judge still escalates, and a known-refused one escalates too in the races listed under [WebAuthn passkeys](#webauthn-passkeys-wp-14b-adr-0068), BACKLOG #3422), double per cycle up to 24 hours | **DENY** before any verify on the password and second-factor legs, plus an audit row whose name is leg-specific — on the password path the uniform `auth.login_failed` (`bad_credentials`) row every refused sign-in writes and then `auth.login_locked`, which only `users:manage` reads ([Audit](#audit)), `auth.mfa_failed` / `auth.webauthn_failed` with `reason=locked` on at least the TOTP/recovery and assertion legs, and on a directory account's two enrolment legs (the sign-in password path still runs a dummy argon2 verify to keep timing flat). The second-step lock refuses every one of those legs. The sign-in lock has **two** exceptions: it does **not** refuse a combined sign-in, which is either the password and a TOTP code in one request on a local account with TOTP enrolled, or the password and a passkey assertion in one request on a local account holding a discoverable passkey (ADR 0197 Amendment A wave 2); and it refuses **no** second-factor leg. The Kerberos and OIDC sign-ins also refuse a row under either lock, but only **after** the ticket or token has verified, audited `auth.login_failed` with `reason=locked` (`_directory_login_refusal`, BACKLOG #1638); neither leg feeds the counter. The password legs of the post-session re-proofs, `POST /me/reauth` and `POST /me/password` and their console twins `POST /ui/reauth` and `POST /ui/account/password`, **feed** the sign-in counter but are **not** refused by either lock; the IdP step-up of an `oidc` session checks no engine credential and feeds neither counter; each **session** may fail `lockout_threshold` re-proofs (5 by default), and the failure that reaches it revokes that session, audited as `auth.reauth` with `session_revoked=true` or `auth.password_change_failed` with `reason=session_revoked` | 5 / 15 min | `[auth].lockout_threshold`, `lockout_minutes`, `lockout_max_minutes` | | New client IP during a session | this request's address vs `session.client` | knob on **and** a session exists, is unrevoked, has an anchor, and the two are not the same host (as [Administrative-interface defense-in-depth](#administrative-interface-defense-in-depth-wp-l3-13-asvs-842) defines it) | **CHALLENGE** — force a fresh step-up on a step-up route, a PHI read (`require_phi_read`, a monitoring `reveal`, the console's `phi=True` pages) or a paced write (`require_paced`, the console's writes), since vault BACKLOG #2620 for the last two; the base gate never asks, so the monitoring polls are not refused. A first sighting also writes `auth.new_address_reverify_required` + an out-of-band notice, debounced and capped per session as that section states; repeats WARNING-log only. **Never** an RBAC deny | **on**; `false` is a named loosening | `[auth].admin_new_ip_step_up` | | First-seen sign-in address — sign-in owes no second factor (local leg) | the sign-in's client address vs the account's known-address record (the `known_login_addresses` table, vault BACKLOG #2145): one row per host from which the account finished a sign-in, passed a step-up, or confirmed a factor enrolment, keyed on the account id and deleted with the account. An administrator's password reset or MFA reset deletes every address in it (vault BACKLOG #2946). It then writes one marker row stamped at the epoch. No lookback read returns that row, and it still counts as a row of any age. Revoking every session clears nothing. These events write a row: a local sign-in that owed no factor, from a known address or with no baseline yet; a directory sign-in that owes nothing more, whatever the verdict except a failed read, so a first-seen address and an account's first sign-in both write (no directory session is seeded, so there is no challenge to skip); a combined sign-in; a second factor proved (a TOTP or recovery code at the MFA gate, or a passkey assertion), whether it finishes a sign-in or is a step-up on a session that has already met its factor; a factor enrolment confirmed, TOTP or passkey, first or later, from a known address or by an account with no baseline yet (not from a first-seen address, because a holder of the password alone may enrol an authenticator, ADR 0197 Amendment A; this only delays such a holder until a code from that authenticator is proved, and the enrolment sends its own notice); and a step-up, by password or at the IdP, by an account that owes no second factor. That test is asked of the account, not of this session. Under `require_mfa` every directory account owes a factor, so no directory step-up by password or at the IdP writes, even one that re-proved the MFA claim. At the sign-in itself a failed record read writes nothing, on either leg. A combined sign-in is the exception, because it proved its code from that address. A factor proved afterwards still writes. Nothing is written for a request with no client address. A host compares as one key: an IPv4-mapped form is its IPv4 address, and every loopback form is one host. Only a row seen within the lookback (`_LOGIN_ADDRESS_LOOKBACK_SECONDS` in `auth/service.py`, 90 days) counts as a match, and each write prunes the account's older rows. The no-baseline test reads rows of any age, so an account whose only row came from an enrolment and has aged out is still judged, not failed open. Read through the record's primary key at session mint on the local, Kerberos and OIDC legs, after the first credential verified | no known address seen within the lookback is the same host **and** the account has a baseline: it has finished a sign-in before, or holds a row of any age. With neither, no client address, or a failed read, the signal fails open and writes `auth.login_address_unevaluated` with `reason` `no_baseline`, `unknown_address` or `read_failed` | **CHALLENGE** — `auth.login_new_ip` + an out-of-band `login_new_ip` notice (at most one per account and address per 15 minutes; the audit row is written every time), and the session is minted **without** step-up freshness (`seed_reauth=False`), except a combined sign-in (password and TOTP code in one request, ADR 0197), which proved the second factor in the same request and is seeded as `verify_mfa` seeds it. **Never** refuses the login. The challenged sign-in does not make its address known, so a second sign-in from it is challenged again; a step-up from that address passes the challenge and records it (vault BACKLOG #2145) | on | (no knob) | | First-seen sign-in address — sign-in owes a second factor, or any directory sign-in | as the row above | as the row above | **LOG** — the same audit row and notice. A directory sign-in that owes nothing more also records the address, so the next one from it is not reported again; nothing else changes. **Under the shipped `require_mfa` a Kerberos sign-in always owes a factor**, and so does an OIDC one while `oidc_require_mfa_claim` is off. Such a sign-in records nothing; the factor leg that finishes it records the address instead: a TOTP or recovery code, a passkey, or a factor enrolment, under the conditions in the row above. So on this leg, as on the local one, every sign-in is unjudged (`no_baseline`) until one finishes, and a sign-in abandoned at the factor prompt records nothing. Under the same default no directory step-up records an address (see the row above). Such a session is born without step-up freshness anyway. A later proof stamps the window, for example a TOTP or recovery code, or a step-up (a passkey alone does not). On an OIDC session only the IdP step-up does (BACKLOG #2142). A Kerberos or password session steps up with the account password; an `oidc` session steps up at the IdP. Under the shipped `require_mfa` scope (`every_local_account`) this is the path every password-only local sign-in takes. A combined sign-in (password and TOTP code in one request) owes nothing more, so it takes the row above instead and is seeded from any address. **Never** refuses the login (BACKLOG #288) | on | (no knob) | From 0679b601231237480da3a9ceed9f497f93c988c5 Mon Sep 17 00:00:00 2001 From: Scott Hall Date: Tue, 27 Oct 2026 09:02:49 -0500 Subject: [PATCH 2/3] docs: review round 1 on the sign-in and step-up sentences BACKLOG #1133, code-review round 1 fixes: - CLOUD-DEPLOYMENT.md probe paragraph: names [security].require_mfa (the [auth] key is refused), says the account must be local, that no built-in role holds monitoring:read alone, that the token to hand over is the one POST /auth/mfa-verify returns, and drops the "long-lived" claim, which configuration can make false. - AOAG-DEPLOYMENT.md: the link no longer claims a complete recipe. - ASVS-L2-PHASE0-CHANGES.md AD row: names the equalizing decoy bind. - CONFIGURATION.md and SECURITY.md: the combined sign-in now covers the passkey form at the Table A first-seen address rows and the tripped- control paragraph too, and the passkey conditions link to the WebAuthn passkeys section instead of being restated. Proposed PR title: Six operator-doc sentences on sign-in and step-up match the auth code (BACKLOG #1133) Proposed ledger banner: ENGINE FIX 2026-10-27: six operator-doc sentences (and their same-doc siblings) corrected; a vault re-read of the cell is owed --- docs/AOAG-DEPLOYMENT.md | 2 +- docs/ASVS-L2-PHASE0-CHANGES.md | 2 +- docs/CLOUD-DEPLOYMENT.md | 16 +++++++++------- docs/CONFIGURATION.md | 2 +- docs/SECURITY.md | 13 +++++++------ 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/docs/AOAG-DEPLOYMENT.md b/docs/AOAG-DEPLOYMENT.md index 754ba62a65..08a28aadbe 100644 --- a/docs/AOAG-DEPLOYMENT.md +++ b/docs/AOAG-DEPLOYMENT.md @@ -626,7 +626,7 @@ or an NLB with: probe to carry a bearer token, because unauthenticated probes get 401/403 on *every* node and black-hole traffic. The engine mints no static API token: the only bearer is a sign-in session token of an account holding `monitoring:read`, which expires, so something beside the LB must - refresh it ([`CLOUD-DEPLOYMENT.md`](CLOUD-DEPLOYMENT.md) section 3.1 says what that takes). The tokenless `/health` + refresh it ([`CLOUD-DEPLOYMENT.md`](CLOUD-DEPLOYMENT.md) section 3.1 describes the constraint). The tokenless `/health` returns 200 on every node and **cannot** select the primary; there is no tokenless primary-only endpoint. - **Set the probe interval comfortably above the ~1 s graph-reconcile interval** (e.g. 10 s) so diff --git a/docs/ASVS-L2-PHASE0-CHANGES.md b/docs/ASVS-L2-PHASE0-CHANGES.md index d9b205fb25..1b5d19e90e 100644 --- a/docs/ASVS-L2-PHASE0-CHANGES.md +++ b/docs/ASVS-L2-PHASE0-CHANGES.md @@ -788,7 +788,7 @@ tables in [`CONNECTIONS.md`](CONNECTIONS.md) §"Resource management & limits" (A | Store — SQLite (the default backend) | local | **no network socket** — a local database file (WAL) | n/a; `[store].ssl_root_cert` is **rejected** for SQLite | filesystem ACLs; at-rest encryption is the store DEK, not a transport credential | no — a local path | `[store].backend = sqlite`, `[store].path` | | Store — SQL Server | outbound | TDS over ODBC Driver 18, **default port 1433** | `encrypt` default true, `trust_server_certificate` default false; optional `ssl_root_cert` pins the server certificate via the Driver-18.1 `ServerCertificate` keyword, sent with `Encrypt=strict` (SQL Server 2022 or later); optional `multi_subnet_failover` for an AOAG listener | SQL / Integrated / Entra, with an opt-in `require_managed_identity` precondition (default off) | **yes** — `[store].server` | `[store].backend = sqlserver`, `[store].server`, `[store].port`, `[store].auth`, `[store].pool_size`, `[store].connect_timeout`, `[store].command_timeout`, `[store].warm_pool` | | Store — Postgres (asyncpg) | outbound | TCP; the model's `port` default is 1433 and a **left-at-default value is remapped to Postgres's conventional 5432** | TLS built from `encrypt` / `trust_server_certificate`; `ssl_root_cert` loads `ssl.create_default_context(cafile=…)`, so chain **and** hostname are still verified | username + password only — Postgres has **no managed-identity mode**, so it cannot satisfy `require_managed_identity` | **yes** — `[store].server` | `[store].backend = postgres`, plus the same `[store]` connection keys as SQL Server | -| Active Directory — login binds | outbound | LDAPS (TLS) by default; a plain `ldap://` bind requires the explicit `ad_allow_insecure_ldap` opt-in, which is honoured only under `[security].enforcement = warn` and inert under `enforce` (vault BACKLOG #2354) | `ad_tls_verify` default true (`CERT_REQUIRED`); `ad_tls_verify=false` now **refuses at construction**, with no escape (CHANGED 2026-10-12 by vault BACKLOG #3230; until then `MEFOR_ALLOW_INSECURE_TLS` released it); `ad_tls_ca_cert_file` anchors an internal CA without disabling verification | **no sign-in binds as the user**: directory password sign-in is retired (BACKLOG #1137), and the engine refuses it before any bind. A Windows SSO or OIDC sign-in looks the principal up through a service-account simple bind (`ad_bind_dn` + `ad_bind_password` / `ad_bind_password_secret`) and 1–2 SUBTREE searches (`resolve_principal`). A **second bind as the user** happens only on a directory account's password step-up (`POST /me/reauth`, console `POST /ui/reauth`; `_reauth_ad`), after the service-account bind has found the entry | **yes** — `[auth].ad_server` | `[auth].ad_server`, `ad_domain`, `ad_bind_dn`, `ad_user_search_base`, `ad_group_search_base`, `ad_use_nested_groups`, `ad_connect_timeout`, `ad_receive_timeout` | +| Active Directory — login binds | outbound | LDAPS (TLS) by default; a plain `ldap://` bind requires the explicit `ad_allow_insecure_ldap` opt-in, which is honoured only under `[security].enforcement = warn` and inert under `enforce` (vault BACKLOG #2354) | `ad_tls_verify` default true (`CERT_REQUIRED`); `ad_tls_verify=false` now **refuses at construction**, with no escape (CHANGED 2026-10-12 by vault BACKLOG #3230; until then `MEFOR_ALLOW_INSECURE_TLS` released it); `ad_tls_ca_cert_file` anchors an internal CA without disabling verification | **no sign-in binds as the user**: directory password sign-in is retired (BACKLOG #1137), and the engine refuses it before any bind. A Windows SSO or OIDC sign-in looks the principal up through a service-account simple bind (`ad_bind_dn` + `ad_bind_password` / `ad_bind_password_secret`) and 1–2 SUBTREE searches (`resolve_principal`). A **second bind as the user** happens only on a directory account's password step-up (`POST /me/reauth`, console `POST /ui/reauth`; `_reauth_ad`), after a service-account search for the entry; an absent or disabled entry still gets a bind of the typed password against a DN that cannot exist (`_equalizing_bind`), so the timing does not reveal it | **yes** — `[auth].ad_server` | `[auth].ad_server`, `ad_domain`, `ad_bind_dn`, `ad_user_search_base`, `ad_group_search_base`, `ad_use_nested_groups`, `ad_connect_timeout`, `ad_receive_timeout` | | Active Directory — directory session reconciler (ADR 0079) | outbound (periodic) | the same LDAPS hop, off the login path | as above | the service-account bind only — one `resolve_principal` bind per signed-in directory user per pass | same server | `[auth].ad_session_recheck_seconds` (default 300 s, floored at 60 s; 0 = OFF), `ad_session_recheck_max_users`, `ad_session_recheck_strikes`, `ad_session_revoke_max`, `ad_session_revoke_max_fraction` | | Kerberos / SPNEGO browser SSO | n/a — **the engine opens no socket** | one SPNEGO *server* step against the host's SSPI (Windows) or GSSAPI/krb5 (Linux) provider using the local keytab / machine credential; any KDC traffic belongs to the OS provider | n/a — the engine terminates nothing here | the browser's SPNEGO ticket, validated by the OS provider | no — the host's realm / keytab | `[auth].kerberos_enabled` (experimental, off by default), `[auth].kerberos_spn` | | OIDC IdP — token endpoint (ADR 0142) | outbound | HTTPS POST to the operator-**pinned** endpoint; port from the URL. There is **no `.well-known` discovery** — every endpoint is pinned, so no attacker-influenced URL exists | `ssl.create_default_context(cadata=...)` over the bytes the anchor check read from `oidc_tls_ca_cert_file` (never a second read of the file, BACKLOG #1142), or the OS store, **always** `check_hostname=True` + `CERT_REQUIRED` (no verify-off escape by design); redirects are never followed | the confidential-client secret, or under `private_key_jwt` a signed client assertion and no secret (BACKLOG #296), plus the authorization code and the PKCE verifier | **yes** — `[auth].oidc_token_endpoint` | `[auth].oidc_token_endpoint`, `oidc_client_id`, `oidc_client_secret` (env `MEFOR_AUTH_OIDC_CLIENT_SECRET`) or `oidc_client_secret_ref`, or `oidc_token_endpoint_auth_method` with `oidc_client_private_key` / `_ref`, `oidc_allowed_endpoints`, `oidc_tls_ca_cert_file` | diff --git a/docs/CLOUD-DEPLOYMENT.md b/docs/CLOUD-DEPLOYMENT.md index 43cedcf66a..356b210dd7 100644 --- a/docs/CLOUD-DEPLOYMENT.md +++ b/docs/CLOUD-DEPLOYMENT.md @@ -144,13 +144,15 @@ VIP follow failover **passively**, with no engine VIP code. by the `monitoring:read` permission (`Depends(require(Permission.MONITORING_READ))` in [`api/app.py`](../messagefoundry/api/app.py)), so an unauthenticated LB health probe gets `401/403` on **every** node (the leader included) and the LB would mark all backends unhealthy and black-hole MLLP. - To use it, the probe must carry a bearer token. The engine has **no static or long-lived API token - and no route that mints one**: the only bearer is the session token a sign-in returns, and that - session ends at its absolute or idle limit ([SECURITY.md](SECURITY.md#sessions)). So something - beside the LB must sign in as a dedicated least-privilege account holding `monitoring:read` (the - built-in `viewer` role carries it) and hand the LB a fresh token before each session ends; under - the shipped `[auth].require_mfa` that account also owes a second factor at each sign-in. **Many L4 - LBs cannot send auth headers at all**. If your LB cannot authenticate the probe, use the **TCP-connect-to-2575** default above (it needs + To use it, the probe must carry a bearer token. The engine has **no static API token and no route + that mints one**: the only bearer is a sign-in session's token, and that session ends at its + absolute or idle limit ([SECURITY.md](SECURITY.md#sessions)). So something beside the LB must sign + in as a dedicated **local** account (directory password sign-in is retired) holding + `monitoring:read`, and hand the LB a fresh token before each session ends. No built-in role holds + `monitoring:read` alone; the narrowest, `viewer`, also grants `messages:read`. Under the shipped + `[security].require_mfa` that account also owes a second factor at each sign-in, and the token to + hand over is the one `POST /auth/mfa-verify` returns, because that call re-keys the session and + retires the token `POST /auth/login` returned. **Many L4 LBs cannot send auth headers at all**. If your LB cannot authenticate the probe, use the **TCP-connect-to-2575** default above (it needs no auth and works on any L4 LB); there is **no** tokenless primary-only HTTP endpoint to point an unauthenticated L7 check at. The `/health` liveness endpoint *is* tokenless, but it is 200 on every node and so cannot select the primary. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5b4f2ad066..2a7950eeab 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -718,7 +718,7 @@ document ([SECURITY-DOCS-POLICY.md](SECURITY-DOCS-POLICY.md)). | `session_idle_timeout_minutes` | | | **→ moved to `[security].sign_out_after_idle_minutes`** (ADR 0118) — set it there; no longer accepted in `[auth]`. | | `session_absolute_hours` | | | **→ moved to `[security].max_session_hours`** (ADR 0118) — set it there; no longer accepted in `[auth]`. | | `max_sessions_per_user` | int | 5 | cap concurrent sessions per user (ASVS 7.1.2; `0` = unlimited); a login beyond the cap revokes the user's oldest live session; only live sessions count, and the same login revokes the user's sessions already past the idle or absolute limit. Sign-ins that still owe a second factor are counted apart: see the *Concurrent session count* row in `docs/SECURITY.md` | -| `step_up_max_age_seconds` | int | 300 | **step-up re-verification window** (ASVS 7.5.3): a highly sensitive operation requires the session to have re-verified its credential — at login or via `POST /me/reauth` — within this many seconds. A code accepted at `POST /auth/mfa-verify` (or the console's `POST /ui/mfa`) opens the window too, except on a session the OIDC sign-in minted, whose window opens only at the IdP (BACKLOG #2142). A **local** login that owes no second factor usually counts as the first verification (the sudo-timestamp model). The exception is a sign-in from an address the account's known-address record has not seen in the last 90 days, by an account with a baseline. An account has a baseline once it has finished a sign-in, or once its record holds a row of any age. That session is born with no window, which is the first-seen address challenge (BACKLOG #288). A sign-in that stopped at its factor adds nothing to the record; the *First-seen sign-in address* rows of Table A in [SECURITY.md](SECURITY.md) list the events that do, and a step-up or an enrolment writes only in some cases. A step-up from that address passes the challenge; signing in again does not. The check fails open, and the window opens, when the sign-in carries no client address or the account's known-address record cannot be read. A combined sign-in, which proves the password and a second factor in one request, counts from any address (ADR 0197): a TOTP code on an account with TOTP enrolled, or a passkey assertion on an account holding a discoverable passkey (ADR 0197 Amendment A). A directory login (Windows SSO by either route, or OIDC) does not: it is born with no window, so its first sensitive action asks for a step-up, or for its second factor where it owes one (BACKLOG #1144). An OIDC session that proves its factor still owes the IdP step-up | +| `step_up_max_age_seconds` | int | 300 | **step-up re-verification window** (ASVS 7.5.3): a highly sensitive operation requires the session to have re-verified its credential — at login or via `POST /me/reauth` — within this many seconds. A code accepted at `POST /auth/mfa-verify` (or the console's `POST /ui/mfa`) opens the window too, except on a session the OIDC sign-in minted, whose window opens only at the IdP (BACKLOG #2142). A **local** login that owes no second factor usually counts as the first verification (the sudo-timestamp model). The exception is a sign-in from an address the account's known-address record has not seen in the last 90 days, by an account with a baseline. An account has a baseline once it has finished a sign-in, or once its record holds a row of any age. That session is born with no window, which is the first-seen address challenge (BACKLOG #288). A sign-in that stopped at its factor adds nothing to the record; the *First-seen sign-in address* rows of Table A in [SECURITY.md](SECURITY.md) list the events that do, and a step-up or an enrolment writes only in some cases. A step-up from that address passes the challenge; signing in again does not. The check fails open, and the window opens, when the sign-in carries no client address or the account's known-address record cannot be read. A combined sign-in (the password and a TOTP code or a passkey assertion in one request; [SECURITY.md](SECURITY.md#step-up-re-verification-on-sensitive-operations-wp-l3-16-asvs-753) gives the conditions) counts from any address (ADR 0197). A directory login (Windows SSO by either route, or OIDC) does not: it is born with no window, so its first sensitive action asks for a step-up, or for its second factor where it owes one (BACKLOG #1144). An OIDC session that proves its factor still owes the IdP step-up | | `require_action_step_up` | bool | `true` | **action-bound step-up** ([ADR 0077](adr/0077-action-bound-step-up.md); ASVS 7.5.1/8.2.4). On by default: a fixed set of routes, four JSON routes and their console twins only under the condition below, requires a fresh proof **bound to that specific action** (`POST /me/reauth` with a matching `purpose`, single-use) instead of riding the session-wide `step_up_max_age_seconds` window. On the JSON API the set is `POST /me/mfa/enroll`, `POST /me/mfa/confirm`, `DELETE /me/mfa`, `DELETE /me/sessions` and `DELETE /me/sessions/{session_id}`, plus the administrator's `PATCH /users/{user_id}`, `POST /users/{user_id}/reset-password`, `POST /users/{user_id}/reset-mfa`, `PUT /users/{user_id}/federated-identity` and `DELETE /users/{user_id}/federated-identity`. Four group-wide saves join the set conditionally (vault BACKLOG #3401): `PUT /ad-group-map`, `PUT /ad-group-scope-map`, `PUT /roles/custom/{role_id}` and `DELETE /roles/custom/{role_id}` take a proof bound to `save_ends_own_sessions` **only when the save would end the caller's own sessions**, as a map save would for a caller whose account is a directory account, or a role save for a caller who holds that role. Any other save of those four keeps the session-window step-up. Since vault BACKLOG #2625 the set also holds the injection and bulk-export routes: `POST /messages/{message_id}/resend`, `POST /messages/{message_id}/edit-resend`, `POST /uploads/{file_id}/resend`, `GET` and `POST /messages/export`, `POST /connections/{name}/purge` and `POST /config/reload`. The console binds the same actions, and passkey enroll and delete too. It closes the most-exploitable default: a session hijacked inside the 300 s login-seeded window could otherwise bind an attacker's authenticator, deliver a fabricated message to a partner, or export bodies in bulk, with no fresh proof. It changes **only** that set; every other step-up route, the rest of the admin surface and replay among them, keeps the session-window step-up. So `false` takes the bound proof off message injection, bulk export, purge and config reload as well as off the factor and admin routes. `false` reverts to the legacy session-window behaviour (0.2.x semantics), the documented org opt-out. **One refusal survives the opt-out and no setting reaches it:** an MFA-pending session on an account that **already holds** a second factor cannot bind a new one or end sessions, on either surface — it must prove the existing factor first. Without that bound, `false` would turn a config knob into an account-takeover path, because the enrollment ceremonies mark a session MFA-satisfied on success. Enrolling a **first** factor is untouched | | `password_min_length` | int | 15 | local-password policy — ASVS 5.0-aligned, length-first | | `password_require_uppercase` / `password_require_lowercase` / `password_require_digit` / `password_require_symbol` | bool | `false` | character classes — **opt-in**, each independently (ASVS 5.0 forbids mandatory composition); turn one on only for a legacy standard that still mandates it | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index a6cd258ee9..7ed68313e9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -2749,8 +2749,8 @@ first-seen address challenge (BACKLOG #288). The signal fails open, and the sess account's first sign-in, with no client address, or when the account's known-address record cannot be read (the *First-seen sign-in address* rows of Table A). An account an administrator reset before its first sign-in is the exception: its first sign-in is judged (vault BACKLOG #2946). A **combined** sign-in (password and -TOTP code in one request on an account with TOTP enrolled, ADR 0197, or password and passkey assertion -in one request on an account holding a discoverable passkey, ADR 0197 Amendment A) is seeded from any +a TOTP code or a passkey assertion in one request, ADR 0197; the passkey form only where +[WebAuthn passkeys](#webauthn-passkeys-wp-14b-adr-0068) says the combined passkey sign-in accepts it) is seeded from any address, because it proved the second factor in the same request. A local sign-in that still owes a factor is minted with no window, and so is **every directory sign-in**: Windows SSO by either route (`GET /ui/sso` and `POST /auth/negotiate`) and the OIDC callback (see the Authentication ambience row). `POST /auth/negotiate` stamped the window at @@ -3919,10 +3919,10 @@ slack. | Client-address monoculture | the set of distinct observed client addresses | allow-list in use **and** no trusted proxy declared **and** ≥ 50 observations **and** all resolved to the same loopback address | **LOG** — one-shot WARNING + `client_address_monoculture` on `GET /security/posture` | n/a | (derived; no knob) | | Login attempt rate, per client IP **and** globally | `api.security.client_ip`, which returns the ASGI `scope["client"][0]` the *Client source network* row evaluates; an attempt with no address is counted under the literal `"unknown"` | > 10 attempts per IP (`login_rate_limit_per_ip`), or > 60 across all clients (`login_rate_limit_global`), in a rolling 60 s window (`login_rate_limit_window_seconds`); a refused attempt is not itself counted | **THROTTLE** — 429 `too many attempts` with **no** `Retry-After` on the three JSON routes; 429 + a `Retry-After` of the whole sign-in window on `POST /ui/login` and on `POST /ui/login/passkey`, the passkey sign-in's begin leg; a **303** redirect to `/ui/login?e=rate_limited` (no 429, no `Retry-After`) on `GET /ui/sso`, `POST /ui/oidc/start` and `GET /ui/oidc/callback` (a sign-in return; a step-up return draws the ceremony budget, BACKLOG #3374), and on `GET /ui/oidc/start` only when its interstitial is skipped (see the [Route → limiter map](#route--limiter-map)). WARNING-logged, deliberately **not** audited | on, 10 / 60 / 60 s | `[auth].login_rate_limit_enabled` | | Credential-ceremony rate, per **actor** | `identity.user_id` (**not** an IP); on an IdP step-up's return, which has no session, the user id its staged flow names | > `login_rate_limit_per_ip` (10) ceremonies per actor per 60 s, on each of the actor's keys (the *Credential ceremonies* row of [Business-logic limits](#business-logic-limits-asvs-213) names them); **no** global dimension (`glob=0`, deliberately) | **THROTTLE** 429, logged | on with the row above | *gated by the same* `[auth].login_rate_limit_enabled` | -| Consecutive credential failures on one account | the account's two failure counters (ADR 0197): the **sign-in** counter and the **second-step** counter | ≥ 5 consecutive failures on one counter locks for 15 minutes, with one exception: while the credential in force is engine-generated (set by an administrator's account creation, `POST /users`, and by both resets), sign-in failures are counted and audited but arm no sign-in lock (`lockout_arms`, ADR 0197 Amendment A); a lapsed window restarts the counter; the second-step lock on a local account, and the sign-in lock on a local account with a TOTP enrolled, unless the engine KNOWS this release refuses that enrolment (a TOTP it cannot judge still escalates, and a known-refused one escalates too in the races listed under [WebAuthn passkeys](#webauthn-passkeys-wp-14b-adr-0068), BACKLOG #3422), double per cycle up to 24 hours | **DENY** before any verify on the password and second-factor legs, plus an audit row whose name is leg-specific — on the password path the uniform `auth.login_failed` (`bad_credentials`) row every refused sign-in writes and then `auth.login_locked`, which only `users:manage` reads ([Audit](#audit)), `auth.mfa_failed` / `auth.webauthn_failed` with `reason=locked` on at least the TOTP/recovery and assertion legs, and on a directory account's two enrolment legs (the sign-in password path still runs a dummy argon2 verify to keep timing flat). The second-step lock refuses every one of those legs. The sign-in lock has **two** exceptions: it does **not** refuse a combined sign-in, which is either the password and a TOTP code in one request on a local account with TOTP enrolled, or the password and a passkey assertion in one request on a local account holding a discoverable passkey (ADR 0197 Amendment A wave 2); and it refuses **no** second-factor leg. The Kerberos and OIDC sign-ins also refuse a row under either lock, but only **after** the ticket or token has verified, audited `auth.login_failed` with `reason=locked` (`_directory_login_refusal`, BACKLOG #1638); neither leg feeds the counter. The password legs of the post-session re-proofs, `POST /me/reauth` and `POST /me/password` and their console twins `POST /ui/reauth` and `POST /ui/account/password`, **feed** the sign-in counter but are **not** refused by either lock; the IdP step-up of an `oidc` session checks no engine credential and feeds neither counter; each **session** may fail `lockout_threshold` re-proofs (5 by default), and the failure that reaches it revokes that session, audited as `auth.reauth` with `session_revoked=true` or `auth.password_change_failed` with `reason=session_revoked` | 5 / 15 min | `[auth].lockout_threshold`, `lockout_minutes`, `lockout_max_minutes` | +| Consecutive credential failures on one account | the account's two failure counters (ADR 0197): the **sign-in** counter and the **second-step** counter | ≥ 5 consecutive failures on one counter locks for 15 minutes, with one exception: while the credential in force is engine-generated (set by an administrator's account creation, `POST /users`, and by both resets), sign-in failures are counted and audited but arm no sign-in lock (`lockout_arms`, ADR 0197 Amendment A); a lapsed window restarts the counter; the second-step lock on a local account, and the sign-in lock on a local account with a TOTP enrolled, unless the engine KNOWS this release refuses that enrolment (a TOTP it cannot judge still escalates, and a known-refused one escalates too in the races listed under [WebAuthn passkeys](#webauthn-passkeys-wp-14b-adr-0068), BACKLOG #3422), double per cycle up to 24 hours | **DENY** before any verify on the password and second-factor legs, plus an audit row whose name is leg-specific — on the password path the uniform `auth.login_failed` (`bad_credentials`) row every refused sign-in writes and then `auth.login_locked`, which only `users:manage` reads ([Audit](#audit)), `auth.mfa_failed` / `auth.webauthn_failed` with `reason=locked` on at least the TOTP/recovery and assertion legs, and on a directory account's two enrolment legs (the sign-in password path still runs a dummy argon2 verify to keep timing flat). The second-step lock refuses every one of those legs. The sign-in lock has **two** exceptions: it does **not** refuse a combined sign-in, either the password and a TOTP code in one request on a local account with TOTP enrolled, or the password and a passkey assertion the combined passkey sign-in accepts ([WebAuthn passkeys](#webauthn-passkeys-wp-14b-adr-0068), ADR 0197 Amendment A wave 2); and it refuses **no** second-factor leg. The Kerberos and OIDC sign-ins also refuse a row under either lock, but only **after** the ticket or token has verified, audited `auth.login_failed` with `reason=locked` (`_directory_login_refusal`, BACKLOG #1638); neither leg feeds the counter. The password legs of the post-session re-proofs, `POST /me/reauth` and `POST /me/password` and their console twins `POST /ui/reauth` and `POST /ui/account/password`, **feed** the sign-in counter but are **not** refused by either lock; the IdP step-up of an `oidc` session checks no engine credential and feeds neither counter; each **session** may fail `lockout_threshold` re-proofs (5 by default), and the failure that reaches it revokes that session, audited as `auth.reauth` with `session_revoked=true` or `auth.password_change_failed` with `reason=session_revoked` | 5 / 15 min | `[auth].lockout_threshold`, `lockout_minutes`, `lockout_max_minutes` | | New client IP during a session | this request's address vs `session.client` | knob on **and** a session exists, is unrevoked, has an anchor, and the two are not the same host (as [Administrative-interface defense-in-depth](#administrative-interface-defense-in-depth-wp-l3-13-asvs-842) defines it) | **CHALLENGE** — force a fresh step-up on a step-up route, a PHI read (`require_phi_read`, a monitoring `reveal`, the console's `phi=True` pages) or a paced write (`require_paced`, the console's writes), since vault BACKLOG #2620 for the last two; the base gate never asks, so the monitoring polls are not refused. A first sighting also writes `auth.new_address_reverify_required` + an out-of-band notice, debounced and capped per session as that section states; repeats WARNING-log only. **Never** an RBAC deny | **on**; `false` is a named loosening | `[auth].admin_new_ip_step_up` | -| First-seen sign-in address — sign-in owes no second factor (local leg) | the sign-in's client address vs the account's known-address record (the `known_login_addresses` table, vault BACKLOG #2145): one row per host from which the account finished a sign-in, passed a step-up, or confirmed a factor enrolment, keyed on the account id and deleted with the account. An administrator's password reset or MFA reset deletes every address in it (vault BACKLOG #2946). It then writes one marker row stamped at the epoch. No lookback read returns that row, and it still counts as a row of any age. Revoking every session clears nothing. These events write a row: a local sign-in that owed no factor, from a known address or with no baseline yet; a directory sign-in that owes nothing more, whatever the verdict except a failed read, so a first-seen address and an account's first sign-in both write (no directory session is seeded, so there is no challenge to skip); a combined sign-in; a second factor proved (a TOTP or recovery code at the MFA gate, or a passkey assertion), whether it finishes a sign-in or is a step-up on a session that has already met its factor; a factor enrolment confirmed, TOTP or passkey, first or later, from a known address or by an account with no baseline yet (not from a first-seen address, because a holder of the password alone may enrol an authenticator, ADR 0197 Amendment A; this only delays such a holder until a code from that authenticator is proved, and the enrolment sends its own notice); and a step-up, by password or at the IdP, by an account that owes no second factor. That test is asked of the account, not of this session. Under `require_mfa` every directory account owes a factor, so no directory step-up by password or at the IdP writes, even one that re-proved the MFA claim. At the sign-in itself a failed record read writes nothing, on either leg. A combined sign-in is the exception, because it proved its code from that address. A factor proved afterwards still writes. Nothing is written for a request with no client address. A host compares as one key: an IPv4-mapped form is its IPv4 address, and every loopback form is one host. Only a row seen within the lookback (`_LOGIN_ADDRESS_LOOKBACK_SECONDS` in `auth/service.py`, 90 days) counts as a match, and each write prunes the account's older rows. The no-baseline test reads rows of any age, so an account whose only row came from an enrolment and has aged out is still judged, not failed open. Read through the record's primary key at session mint on the local, Kerberos and OIDC legs, after the first credential verified | no known address seen within the lookback is the same host **and** the account has a baseline: it has finished a sign-in before, or holds a row of any age. With neither, no client address, or a failed read, the signal fails open and writes `auth.login_address_unevaluated` with `reason` `no_baseline`, `unknown_address` or `read_failed` | **CHALLENGE** — `auth.login_new_ip` + an out-of-band `login_new_ip` notice (at most one per account and address per 15 minutes; the audit row is written every time), and the session is minted **without** step-up freshness (`seed_reauth=False`), except a combined sign-in (password and TOTP code in one request, ADR 0197), which proved the second factor in the same request and is seeded as `verify_mfa` seeds it. **Never** refuses the login. The challenged sign-in does not make its address known, so a second sign-in from it is challenged again; a step-up from that address passes the challenge and records it (vault BACKLOG #2145) | on | (no knob) | -| First-seen sign-in address — sign-in owes a second factor, or any directory sign-in | as the row above | as the row above | **LOG** — the same audit row and notice. A directory sign-in that owes nothing more also records the address, so the next one from it is not reported again; nothing else changes. **Under the shipped `require_mfa` a Kerberos sign-in always owes a factor**, and so does an OIDC one while `oidc_require_mfa_claim` is off. Such a sign-in records nothing; the factor leg that finishes it records the address instead: a TOTP or recovery code, a passkey, or a factor enrolment, under the conditions in the row above. So on this leg, as on the local one, every sign-in is unjudged (`no_baseline`) until one finishes, and a sign-in abandoned at the factor prompt records nothing. Under the same default no directory step-up records an address (see the row above). Such a session is born without step-up freshness anyway. A later proof stamps the window, for example a TOTP or recovery code, or a step-up (a passkey alone does not). On an OIDC session only the IdP step-up does (BACKLOG #2142). A Kerberos or password session steps up with the account password; an `oidc` session steps up at the IdP. Under the shipped `require_mfa` scope (`every_local_account`) this is the path every password-only local sign-in takes. A combined sign-in (password and TOTP code in one request) owes nothing more, so it takes the row above instead and is seeded from any address. **Never** refuses the login (BACKLOG #288) | on | (no knob) | +| First-seen sign-in address — sign-in owes no second factor (local leg) | the sign-in's client address vs the account's known-address record (the `known_login_addresses` table, vault BACKLOG #2145): one row per host from which the account finished a sign-in, passed a step-up, or confirmed a factor enrolment, keyed on the account id and deleted with the account. An administrator's password reset or MFA reset deletes every address in it (vault BACKLOG #2946). It then writes one marker row stamped at the epoch. No lookback read returns that row, and it still counts as a row of any age. Revoking every session clears nothing. These events write a row: a local sign-in that owed no factor, from a known address or with no baseline yet; a directory sign-in that owes nothing more, whatever the verdict except a failed read, so a first-seen address and an account's first sign-in both write (no directory session is seeded, so there is no challenge to skip); a combined sign-in; a second factor proved (a TOTP or recovery code at the MFA gate, or a passkey assertion), whether it finishes a sign-in or is a step-up on a session that has already met its factor; a factor enrolment confirmed, TOTP or passkey, first or later, from a known address or by an account with no baseline yet (not from a first-seen address, because a holder of the password alone may enrol an authenticator, ADR 0197 Amendment A; this only delays such a holder until a code from that authenticator is proved, and the enrolment sends its own notice); and a step-up, by password or at the IdP, by an account that owes no second factor. That test is asked of the account, not of this session. Under `require_mfa` every directory account owes a factor, so no directory step-up by password or at the IdP writes, even one that re-proved the MFA claim. At the sign-in itself a failed record read writes nothing, on either leg. A combined sign-in is the exception, because it proved its second factor from that address. A factor proved afterwards still writes. Nothing is written for a request with no client address. A host compares as one key: an IPv4-mapped form is its IPv4 address, and every loopback form is one host. Only a row seen within the lookback (`_LOGIN_ADDRESS_LOOKBACK_SECONDS` in `auth/service.py`, 90 days) counts as a match, and each write prunes the account's older rows. The no-baseline test reads rows of any age, so an account whose only row came from an enrolment and has aged out is still judged, not failed open. Read through the record's primary key at session mint on the local, Kerberos and OIDC legs, after the first credential verified | no known address seen within the lookback is the same host **and** the account has a baseline: it has finished a sign-in before, or holds a row of any age. With neither, no client address, or a failed read, the signal fails open and writes `auth.login_address_unevaluated` with `reason` `no_baseline`, `unknown_address` or `read_failed` | **CHALLENGE** — `auth.login_new_ip` + an out-of-band `login_new_ip` notice (at most one per account and address per 15 minutes; the audit row is written every time), and the session is minted **without** step-up freshness (`seed_reauth=False`), except a combined sign-in (password and a TOTP code or a passkey assertion in one request, ADR 0197 and its Amendment A), which proved the second factor in the same request and is seeded as `verify_mfa` seeds it. **Never** refuses the login. The challenged sign-in does not make its address known, so a second sign-in from it is challenged again; a step-up from that address passes the challenge and records it (vault BACKLOG #2145) | on | (no knob) | +| First-seen sign-in address — sign-in owes a second factor, or any directory sign-in | as the row above | as the row above | **LOG** — the same audit row and notice. A directory sign-in that owes nothing more also records the address, so the next one from it is not reported again; nothing else changes. **Under the shipped `require_mfa` a Kerberos sign-in always owes a factor**, and so does an OIDC one while `oidc_require_mfa_claim` is off. Such a sign-in records nothing; the factor leg that finishes it records the address instead: a TOTP or recovery code, a passkey, or a factor enrolment, under the conditions in the row above. So on this leg, as on the local one, every sign-in is unjudged (`no_baseline`) until one finishes, and a sign-in abandoned at the factor prompt records nothing. Under the same default no directory step-up records an address (see the row above). Such a session is born without step-up freshness anyway. A later proof stamps the window, for example a TOTP or recovery code, or a step-up (a passkey alone does not). On an OIDC session only the IdP step-up does (BACKLOG #2142). A Kerberos or password session steps up with the account password; an `oidc` session steps up at the IdP. Under the shipped `require_mfa` scope (`every_local_account`) this is the path every password-only local sign-in takes. A combined sign-in (password and a TOTP code or a passkey assertion in one request) owes nothing more, so it takes the row above instead and is seeded from any address. **Never** refuses the login (BACKLOG #288) | on | (no knob) | | Credential recency | age of `session.reauth_at` | `now − reauth_at > step_up_max_age_seconds`, or `reauth_at is None` | **DENY** 403 + `X-Step-Up-Required: 1` (console: 303 → `/ui/reauth`; an `oidc` session's JSON refusal names the IdP leg, see [Step-up re-verification](#step-up-re-verification-on-sensitive-operations-wp-l3-16-asvs-753)) | 300 s | `[auth].step_up_max_age_seconds` | | Action-bound step-up grant | a single-use grant minted only by a successful step-up, on the **monotonic** clock: `reauth(purpose=…)` for the password or re-bind leg (`POST /me/reauth`, `POST /ui/reauth`), and `complete_oidc_step_up` for an `oidc` session's IdP leg, for the action the operator started from. Never by a sign-in or by `verify_mfa` | no unconsumed grant for this route's action | **DENY** 403 + `X-Step-Up-Required` + `X-Step-Up-Action: `; opting out falls back to the session window — **except on a factor bind or a session terminate**, see the row below | on | `[auth].require_action_step_up` | | Binding a NEW second factor, ending sessions, or changing the password | the session's MFA state × the account's existing factors | the action binds a factor (`mfa_enroll`, `mfa_confirm`, `webauthn_enroll`), ends sessions (`session_terminate`, BACKLOG #1951) or changes the password (`POST /me/password` and `/ui/account/password`, BACKLOG #1954) **and** the session has not satisfied its second factor **and** the account already holds one of either kind | **DENY** — the existing factor must be proven first (`POST /auth/mfa-verify`, `/ui/mfa`, or the code/passkey leg of `/ui/reauth`); the password routes answer 403 + `X-MFA-Required` (console: 303 → `/ui/mfa`, except a covered local account whose passkeys are not discoverable and which has no TOTP, which the console sends to the enrolment page). This refusal never reaches an account with **no** factor, which has nothing to prove: it enrols its first factor and ends its own sessions from a password-only session, which is what the MFA gate's exemptions are for. Two other rules still bind it. Under `[security].require_mfa` a covered local account's first factor must be TOTP or a discoverable passkey: a passkey whose client reports it is not discoverable is refused at the registration finish, and the password change is refused with `enrol an authenticator app first` until such a factor exists (`FactorEnrolmentRequired`, ADR 0197 Amendment A; console: a 303 to the enrolment page). And a must-change session cannot end its sessions at all, because the must-change confinement does not admit those routes. With the requirement off, such an account changes its password as before. The passkey registration **finish** asks the same rule again (`finish_webauthn_registration`, BACKLOG #3014), because its console route rides the action-less gate: a ceremony staged before the account gained a factor is refused with **400** `this account already has a second factor`, its ceremony ended, audited `auth.mfa_denied`. Both enrolment legs, this finish and the TOTP confirm (`confirm_mfa_enrollment`, whose first ask is its route's gate), ask it once more after the login-to-MFA floor and a directory account's directory lookup. A factor bound on another session during either is refused with a 400 `this account already has a second factor`, audited `auth.mfa_denied`. The TOTP confirm refuses this way only for a passkey, before it spends a step or mints a recovery code; a TOTP enabled meanwhile is left to its enable's compare-and-set, which ends the session (BACKLOG #2224). Read-then-act: a factor bound after that second ask, for example while the TOTP confirm hashes its recovery codes, is not caught (BACKLOG #3061) | on | **no knob** — `require_action_step_up` does not reach it, deliberately | @@ -5815,7 +5815,8 @@ ceiling (`glob=0`), where the sign-in window caps all addresses together at 60 a **What a tripped control looks like (6.1.1's "consequences of these defenses being triggered").** Control 1 refuses before checking the presented credential and audits the refusal, with one -exception: the sign-in lock lets a **combined** sign-in on a local account with TOTP enrolled through +exception: the sign-in lock lets a **combined** sign-in through, on a local account with TOTP enrolled +or as the combined passkey sign-in [WebAuthn passkeys](#webauthn-passkeys-wp-14b-adr-0068) describes, to a check of **both** factors, and refuses it only if they are not both right, in the same answer and the same padded time as every other refusal (ADR 0197). (The password path still hashes against a dummy value first, so a locked account answers in about the time a real check takes.) The event name differs per leg: From 5b3c9d447674b61fd99bcdf891860054a6193656 Mon Sep 17 00:00:00 2001 From: Scott Hall Date: Tue, 27 Oct 2026 09:24:07 -0500 Subject: [PATCH 3/3] docs: review round 2 on the sign-in and step-up sentences BACKLOG #1133, code-review round 2 fixes: - CLOUD-DEPLOYMENT.md probe paragraph: a probe keeps the idle clock fresh, so only the absolute limit bounds the session; Kerberos via POST /auth/negotiate is a non-interactive option; a custom role can hold monitoring:read alone; a new account must replace its generated password first; a combined POST /auth/login already returns the final token; names require_mfa_scope beside require_mfa. - ASVS-L2-PHASE0-CHANGES.md AD row: the lookup list is a floor, and the decoy bind equalizes the code path only, not wall-clock time. - SECURITY.md and CONFIGURATION.md: the passkey form of the combined sign-in names its account condition rather than "an assertion the sign-in accepts", and the TOTP form names its enrolment condition. Proposed PR title: Six operator-doc sentences on sign-in and step-up match the auth code (BACKLOG #1133) Proposed ledger banner: ENGINE FIX 2026-10-27: six operator-doc sentences (and their same-doc siblings) corrected; a vault re-read of the cell is owed --- docs/ASVS-L2-PHASE0-CHANGES.md | 2 +- docs/CLOUD-DEPLOYMENT.md | 19 ++++++++++++------- docs/CONFIGURATION.md | 2 +- docs/SECURITY.md | 8 ++++---- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/ASVS-L2-PHASE0-CHANGES.md b/docs/ASVS-L2-PHASE0-CHANGES.md index 1b5d19e90e..2c5defc1f7 100644 --- a/docs/ASVS-L2-PHASE0-CHANGES.md +++ b/docs/ASVS-L2-PHASE0-CHANGES.md @@ -788,7 +788,7 @@ tables in [`CONNECTIONS.md`](CONNECTIONS.md) §"Resource management & limits" (A | Store — SQLite (the default backend) | local | **no network socket** — a local database file (WAL) | n/a; `[store].ssl_root_cert` is **rejected** for SQLite | filesystem ACLs; at-rest encryption is the store DEK, not a transport credential | no — a local path | `[store].backend = sqlite`, `[store].path` | | Store — SQL Server | outbound | TDS over ODBC Driver 18, **default port 1433** | `encrypt` default true, `trust_server_certificate` default false; optional `ssl_root_cert` pins the server certificate via the Driver-18.1 `ServerCertificate` keyword, sent with `Encrypt=strict` (SQL Server 2022 or later); optional `multi_subnet_failover` for an AOAG listener | SQL / Integrated / Entra, with an opt-in `require_managed_identity` precondition (default off) | **yes** — `[store].server` | `[store].backend = sqlserver`, `[store].server`, `[store].port`, `[store].auth`, `[store].pool_size`, `[store].connect_timeout`, `[store].command_timeout`, `[store].warm_pool` | | Store — Postgres (asyncpg) | outbound | TCP; the model's `port` default is 1433 and a **left-at-default value is remapped to Postgres's conventional 5432** | TLS built from `encrypt` / `trust_server_certificate`; `ssl_root_cert` loads `ssl.create_default_context(cafile=…)`, so chain **and** hostname are still verified | username + password only — Postgres has **no managed-identity mode**, so it cannot satisfy `require_managed_identity` | **yes** — `[store].server` | `[store].backend = postgres`, plus the same `[store]` connection keys as SQL Server | -| Active Directory — login binds | outbound | LDAPS (TLS) by default; a plain `ldap://` bind requires the explicit `ad_allow_insecure_ldap` opt-in, which is honoured only under `[security].enforcement = warn` and inert under `enforce` (vault BACKLOG #2354) | `ad_tls_verify` default true (`CERT_REQUIRED`); `ad_tls_verify=false` now **refuses at construction**, with no escape (CHANGED 2026-10-12 by vault BACKLOG #3230; until then `MEFOR_ALLOW_INSECURE_TLS` released it); `ad_tls_ca_cert_file` anchors an internal CA without disabling verification | **no sign-in binds as the user**: directory password sign-in is retired (BACKLOG #1137), and the engine refuses it before any bind. A Windows SSO or OIDC sign-in looks the principal up through a service-account simple bind (`ad_bind_dn` + `ad_bind_password` / `ad_bind_password_secret`) and 1–2 SUBTREE searches (`resolve_principal`). A **second bind as the user** happens only on a directory account's password step-up (`POST /me/reauth`, console `POST /ui/reauth`; `_reauth_ad`), after a service-account search for the entry; an absent or disabled entry still gets a bind of the typed password against a DN that cannot exist (`_equalizing_bind`), so the timing does not reveal it | **yes** — `[auth].ad_server` | `[auth].ad_server`, `ad_domain`, `ad_bind_dn`, `ad_user_search_base`, `ad_group_search_base`, `ad_use_nested_groups`, `ad_connect_timeout`, `ad_receive_timeout` | +| Active Directory — login binds | outbound | LDAPS (TLS) by default; a plain `ldap://` bind requires the explicit `ad_allow_insecure_ldap` opt-in, which is honoured only under `[security].enforcement = warn` and inert under `enforce` (vault BACKLOG #2354) | `ad_tls_verify` default true (`CERT_REQUIRED`); `ad_tls_verify=false` now **refuses at construction**, with no escape (CHANGED 2026-10-12 by vault BACKLOG #3230; until then `MEFOR_ALLOW_INSECURE_TLS` released it); `ad_tls_ca_cert_file` anchors an internal CA without disabling verification | **no sign-in binds as the user**: directory password sign-in is retired (BACKLOG #1137), and the engine refuses it before any bind. At least a Windows SSO or OIDC sign-in, a directory account's second-factor and enrolment legs, and an OIDC step-up look the principal up through a service-account simple bind (`ad_bind_dn` + `ad_bind_password` / `ad_bind_password_secret`) and 1–2 SUBTREE searches (`resolve_principal`). A **second bind as the user** happens only on a directory account's password step-up (`POST /me/reauth`, console `POST /ui/reauth`; `_reauth_ad`), after a service-account search for the entry; an absent or disabled entry still gets a bind of the typed password against a DN that cannot exist (`_equalizing_bind`), which equalizes the code path; its docstring says it does not claim equal wall-clock time | **yes** — `[auth].ad_server` | `[auth].ad_server`, `ad_domain`, `ad_bind_dn`, `ad_user_search_base`, `ad_group_search_base`, `ad_use_nested_groups`, `ad_connect_timeout`, `ad_receive_timeout` | | Active Directory — directory session reconciler (ADR 0079) | outbound (periodic) | the same LDAPS hop, off the login path | as above | the service-account bind only — one `resolve_principal` bind per signed-in directory user per pass | same server | `[auth].ad_session_recheck_seconds` (default 300 s, floored at 60 s; 0 = OFF), `ad_session_recheck_max_users`, `ad_session_recheck_strikes`, `ad_session_revoke_max`, `ad_session_revoke_max_fraction` | | Kerberos / SPNEGO browser SSO | n/a — **the engine opens no socket** | one SPNEGO *server* step against the host's SSPI (Windows) or GSSAPI/krb5 (Linux) provider using the local keytab / machine credential; any KDC traffic belongs to the OS provider | n/a — the engine terminates nothing here | the browser's SPNEGO ticket, validated by the OS provider | no — the host's realm / keytab | `[auth].kerberos_enabled` (experimental, off by default), `[auth].kerberos_spn` | | OIDC IdP — token endpoint (ADR 0142) | outbound | HTTPS POST to the operator-**pinned** endpoint; port from the URL. There is **no `.well-known` discovery** — every endpoint is pinned, so no attacker-influenced URL exists | `ssl.create_default_context(cadata=...)` over the bytes the anchor check read from `oidc_tls_ca_cert_file` (never a second read of the file, BACKLOG #1142), or the OS store, **always** `check_hostname=True` + `CERT_REQUIRED` (no verify-off escape by design); redirects are never followed | the confidential-client secret, or under `private_key_jwt` a signed client assertion and no secret (BACKLOG #296), plus the authorization code and the PKCE verifier | **yes** — `[auth].oidc_token_endpoint` | `[auth].oidc_token_endpoint`, `oidc_client_id`, `oidc_client_secret` (env `MEFOR_AUTH_OIDC_CLIENT_SECRET`) or `oidc_client_secret_ref`, or `oidc_token_endpoint_auth_method` with `oidc_client_private_key` / `_ref`, `oidc_allowed_endpoints`, `oidc_tls_ca_cert_file` | diff --git a/docs/CLOUD-DEPLOYMENT.md b/docs/CLOUD-DEPLOYMENT.md index 356b210dd7..8df803e8a8 100644 --- a/docs/CLOUD-DEPLOYMENT.md +++ b/docs/CLOUD-DEPLOYMENT.md @@ -146,13 +146,18 @@ VIP follow failover **passively**, with no engine VIP code. **every** node (the leader included) and the LB would mark all backends unhealthy and black-hole MLLP. To use it, the probe must carry a bearer token. The engine has **no static API token and no route that mints one**: the only bearer is a sign-in session's token, and that session ends at its - absolute or idle limit ([SECURITY.md](SECURITY.md#sessions)). So something beside the LB must sign - in as a dedicated **local** account (directory password sign-in is retired) holding - `monitoring:read`, and hand the LB a fresh token before each session ends. No built-in role holds - `monitoring:read` alone; the narrowest, `viewer`, also grants `messages:read`. Under the shipped - `[security].require_mfa` that account also owes a second factor at each sign-in, and the token to - hand over is the one `POST /auth/mfa-verify` returns, because that call re-keys the session and - retires the token `POST /auth/login` returned. **Many L4 LBs cannot send auth headers at all**. If your LB cannot authenticate the probe, use the **TCP-connect-to-2575** default above (it needs + absolute limit at the latest ([SECURITY.md](SECURITY.md#sessions)). So something beside the LB + must sign in a dedicated account non-interactively (a local password, or Kerberos through + `POST /auth/negotiate`; directory password sign-in is retired) and hand the LB a fresh token + before each session ends. Give that account an administrator-defined custom role holding only + `monitoring:read` ([SECURITY.md](SECURITY.md#custom-roles-adr-0045)); the narrowest built-in + role, `viewer`, also grants `messages:read`. A new local account must first replace its + engine-generated password before its session can reach `/cluster/status`. Under the shipped + `[security].require_mfa` and `require_mfa_scope` the account also owes a second factor at each + sign-in: hand over the token of the finished sign-in, which is the one `POST /auth/login` returns + for a combined sign-in (password and TOTP code in one request), or the one `POST /auth/mfa-verify` + returns after a two-step sign-in, because that call re-keys the session and retires the earlier + token. **Many L4 LBs cannot send auth headers at all**. If your LB cannot authenticate the probe, use the **TCP-connect-to-2575** default above (it needs no auth and works on any L4 LB); there is **no** tokenless primary-only HTTP endpoint to point an unauthenticated L7 check at. The `/health` liveness endpoint *is* tokenless, but it is 200 on every node and so cannot select the primary. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2a7950eeab..fc435236f3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -718,7 +718,7 @@ document ([SECURITY-DOCS-POLICY.md](SECURITY-DOCS-POLICY.md)). | `session_idle_timeout_minutes` | | | **→ moved to `[security].sign_out_after_idle_minutes`** (ADR 0118) — set it there; no longer accepted in `[auth]`. | | `session_absolute_hours` | | | **→ moved to `[security].max_session_hours`** (ADR 0118) — set it there; no longer accepted in `[auth]`. | | `max_sessions_per_user` | int | 5 | cap concurrent sessions per user (ASVS 7.1.2; `0` = unlimited); a login beyond the cap revokes the user's oldest live session; only live sessions count, and the same login revokes the user's sessions already past the idle or absolute limit. Sign-ins that still owe a second factor are counted apart: see the *Concurrent session count* row in `docs/SECURITY.md` | -| `step_up_max_age_seconds` | int | 300 | **step-up re-verification window** (ASVS 7.5.3): a highly sensitive operation requires the session to have re-verified its credential — at login or via `POST /me/reauth` — within this many seconds. A code accepted at `POST /auth/mfa-verify` (or the console's `POST /ui/mfa`) opens the window too, except on a session the OIDC sign-in minted, whose window opens only at the IdP (BACKLOG #2142). A **local** login that owes no second factor usually counts as the first verification (the sudo-timestamp model). The exception is a sign-in from an address the account's known-address record has not seen in the last 90 days, by an account with a baseline. An account has a baseline once it has finished a sign-in, or once its record holds a row of any age. That session is born with no window, which is the first-seen address challenge (BACKLOG #288). A sign-in that stopped at its factor adds nothing to the record; the *First-seen sign-in address* rows of Table A in [SECURITY.md](SECURITY.md) list the events that do, and a step-up or an enrolment writes only in some cases. A step-up from that address passes the challenge; signing in again does not. The check fails open, and the window opens, when the sign-in carries no client address or the account's known-address record cannot be read. A combined sign-in (the password and a TOTP code or a passkey assertion in one request; [SECURITY.md](SECURITY.md#step-up-re-verification-on-sensitive-operations-wp-l3-16-asvs-753) gives the conditions) counts from any address (ADR 0197). A directory login (Windows SSO by either route, or OIDC) does not: it is born with no window, so its first sensitive action asks for a step-up, or for its second factor where it owes one (BACKLOG #1144). An OIDC session that proves its factor still owes the IdP step-up | +| `step_up_max_age_seconds` | int | 300 | **step-up re-verification window** (ASVS 7.5.3): a highly sensitive operation requires the session to have re-verified its credential — at login or via `POST /me/reauth` — within this many seconds. A code accepted at `POST /auth/mfa-verify` (or the console's `POST /ui/mfa`) opens the window too, except on a session the OIDC sign-in minted, whose window opens only at the IdP (BACKLOG #2142). A **local** login that owes no second factor usually counts as the first verification (the sudo-timestamp model). The exception is a sign-in from an address the account's known-address record has not seen in the last 90 days, by an account with a baseline. An account has a baseline once it has finished a sign-in, or once its record holds a row of any age. That session is born with no window, which is the first-seen address challenge (BACKLOG #288). A sign-in that stopped at its factor adds nothing to the record; the *First-seen sign-in address* rows of Table A in [SECURITY.md](SECURITY.md) list the events that do, and a step-up or an enrolment writes only in some cases. A step-up from that address passes the challenge; signing in again does not. The check fails open, and the window opens, when the sign-in carries no client address or the account's known-address record cannot be read. A combined sign-in (the password and, in the same request, a TOTP code on an account with TOTP enrolled or a passkey assertion as [SECURITY.md](SECURITY.md#webauthn-passkeys-wp-14b-adr-0068) describes, ADR 0197 Amendment A) counts from any address (ADR 0197). A directory login (Windows SSO by either route, or OIDC) does not: it is born with no window, so its first sensitive action asks for a step-up, or for its second factor where it owes one (BACKLOG #1144). An OIDC session that proves its factor still owes the IdP step-up | | `require_action_step_up` | bool | `true` | **action-bound step-up** ([ADR 0077](adr/0077-action-bound-step-up.md); ASVS 7.5.1/8.2.4). On by default: a fixed set of routes, four JSON routes and their console twins only under the condition below, requires a fresh proof **bound to that specific action** (`POST /me/reauth` with a matching `purpose`, single-use) instead of riding the session-wide `step_up_max_age_seconds` window. On the JSON API the set is `POST /me/mfa/enroll`, `POST /me/mfa/confirm`, `DELETE /me/mfa`, `DELETE /me/sessions` and `DELETE /me/sessions/{session_id}`, plus the administrator's `PATCH /users/{user_id}`, `POST /users/{user_id}/reset-password`, `POST /users/{user_id}/reset-mfa`, `PUT /users/{user_id}/federated-identity` and `DELETE /users/{user_id}/federated-identity`. Four group-wide saves join the set conditionally (vault BACKLOG #3401): `PUT /ad-group-map`, `PUT /ad-group-scope-map`, `PUT /roles/custom/{role_id}` and `DELETE /roles/custom/{role_id}` take a proof bound to `save_ends_own_sessions` **only when the save would end the caller's own sessions**, as a map save would for a caller whose account is a directory account, or a role save for a caller who holds that role. Any other save of those four keeps the session-window step-up. Since vault BACKLOG #2625 the set also holds the injection and bulk-export routes: `POST /messages/{message_id}/resend`, `POST /messages/{message_id}/edit-resend`, `POST /uploads/{file_id}/resend`, `GET` and `POST /messages/export`, `POST /connections/{name}/purge` and `POST /config/reload`. The console binds the same actions, and passkey enroll and delete too. It closes the most-exploitable default: a session hijacked inside the 300 s login-seeded window could otherwise bind an attacker's authenticator, deliver a fabricated message to a partner, or export bodies in bulk, with no fresh proof. It changes **only** that set; every other step-up route, the rest of the admin surface and replay among them, keeps the session-window step-up. So `false` takes the bound proof off message injection, bulk export, purge and config reload as well as off the factor and admin routes. `false` reverts to the legacy session-window behaviour (0.2.x semantics), the documented org opt-out. **One refusal survives the opt-out and no setting reaches it:** an MFA-pending session on an account that **already holds** a second factor cannot bind a new one or end sessions, on either surface — it must prove the existing factor first. Without that bound, `false` would turn a config knob into an account-takeover path, because the enrollment ceremonies mark a session MFA-satisfied on success. Enrolling a **first** factor is untouched | | `password_min_length` | int | 15 | local-password policy — ASVS 5.0-aligned, length-first | | `password_require_uppercase` / `password_require_lowercase` / `password_require_digit` / `password_require_symbol` | bool | `false` | character classes — **opt-in**, each independently (ASVS 5.0 forbids mandatory composition); turn one on only for a legacy standard that still mandates it | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 7ed68313e9..d5d752547d 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -2749,8 +2749,8 @@ first-seen address challenge (BACKLOG #288). The signal fails open, and the sess account's first sign-in, with no client address, or when the account's known-address record cannot be read (the *First-seen sign-in address* rows of Table A). An account an administrator reset before its first sign-in is the exception: its first sign-in is judged (vault BACKLOG #2946). A **combined** sign-in (password and -a TOTP code or a passkey assertion in one request, ADR 0197; the passkey form only where -[WebAuthn passkeys](#webauthn-passkeys-wp-14b-adr-0068) says the combined passkey sign-in accepts it) is seeded from any +a TOTP code on an account with TOTP enrolled, or a passkey assertion on a local account holding a discoverable passkey under the request's relying party, +in one request; ADR 0197 and its Amendment A, and [WebAuthn passkeys](#webauthn-passkeys-wp-14b-adr-0068)) is seeded from any address, because it proved the second factor in the same request. A local sign-in that still owes a factor is minted with no window, and so is **every directory sign-in**: Windows SSO by either route (`GET /ui/sso` and `POST /auth/negotiate`) and the OIDC callback (see the Authentication ambience row). `POST /auth/negotiate` stamped the window at @@ -3919,9 +3919,9 @@ slack. | Client-address monoculture | the set of distinct observed client addresses | allow-list in use **and** no trusted proxy declared **and** ≥ 50 observations **and** all resolved to the same loopback address | **LOG** — one-shot WARNING + `client_address_monoculture` on `GET /security/posture` | n/a | (derived; no knob) | | Login attempt rate, per client IP **and** globally | `api.security.client_ip`, which returns the ASGI `scope["client"][0]` the *Client source network* row evaluates; an attempt with no address is counted under the literal `"unknown"` | > 10 attempts per IP (`login_rate_limit_per_ip`), or > 60 across all clients (`login_rate_limit_global`), in a rolling 60 s window (`login_rate_limit_window_seconds`); a refused attempt is not itself counted | **THROTTLE** — 429 `too many attempts` with **no** `Retry-After` on the three JSON routes; 429 + a `Retry-After` of the whole sign-in window on `POST /ui/login` and on `POST /ui/login/passkey`, the passkey sign-in's begin leg; a **303** redirect to `/ui/login?e=rate_limited` (no 429, no `Retry-After`) on `GET /ui/sso`, `POST /ui/oidc/start` and `GET /ui/oidc/callback` (a sign-in return; a step-up return draws the ceremony budget, BACKLOG #3374), and on `GET /ui/oidc/start` only when its interstitial is skipped (see the [Route → limiter map](#route--limiter-map)). WARNING-logged, deliberately **not** audited | on, 10 / 60 / 60 s | `[auth].login_rate_limit_enabled` | | Credential-ceremony rate, per **actor** | `identity.user_id` (**not** an IP); on an IdP step-up's return, which has no session, the user id its staged flow names | > `login_rate_limit_per_ip` (10) ceremonies per actor per 60 s, on each of the actor's keys (the *Credential ceremonies* row of [Business-logic limits](#business-logic-limits-asvs-213) names them); **no** global dimension (`glob=0`, deliberately) | **THROTTLE** 429, logged | on with the row above | *gated by the same* `[auth].login_rate_limit_enabled` | -| Consecutive credential failures on one account | the account's two failure counters (ADR 0197): the **sign-in** counter and the **second-step** counter | ≥ 5 consecutive failures on one counter locks for 15 minutes, with one exception: while the credential in force is engine-generated (set by an administrator's account creation, `POST /users`, and by both resets), sign-in failures are counted and audited but arm no sign-in lock (`lockout_arms`, ADR 0197 Amendment A); a lapsed window restarts the counter; the second-step lock on a local account, and the sign-in lock on a local account with a TOTP enrolled, unless the engine KNOWS this release refuses that enrolment (a TOTP it cannot judge still escalates, and a known-refused one escalates too in the races listed under [WebAuthn passkeys](#webauthn-passkeys-wp-14b-adr-0068), BACKLOG #3422), double per cycle up to 24 hours | **DENY** before any verify on the password and second-factor legs, plus an audit row whose name is leg-specific — on the password path the uniform `auth.login_failed` (`bad_credentials`) row every refused sign-in writes and then `auth.login_locked`, which only `users:manage` reads ([Audit](#audit)), `auth.mfa_failed` / `auth.webauthn_failed` with `reason=locked` on at least the TOTP/recovery and assertion legs, and on a directory account's two enrolment legs (the sign-in password path still runs a dummy argon2 verify to keep timing flat). The second-step lock refuses every one of those legs. The sign-in lock has **two** exceptions: it does **not** refuse a combined sign-in, either the password and a TOTP code in one request on a local account with TOTP enrolled, or the password and a passkey assertion the combined passkey sign-in accepts ([WebAuthn passkeys](#webauthn-passkeys-wp-14b-adr-0068), ADR 0197 Amendment A wave 2); and it refuses **no** second-factor leg. The Kerberos and OIDC sign-ins also refuse a row under either lock, but only **after** the ticket or token has verified, audited `auth.login_failed` with `reason=locked` (`_directory_login_refusal`, BACKLOG #1638); neither leg feeds the counter. The password legs of the post-session re-proofs, `POST /me/reauth` and `POST /me/password` and their console twins `POST /ui/reauth` and `POST /ui/account/password`, **feed** the sign-in counter but are **not** refused by either lock; the IdP step-up of an `oidc` session checks no engine credential and feeds neither counter; each **session** may fail `lockout_threshold` re-proofs (5 by default), and the failure that reaches it revokes that session, audited as `auth.reauth` with `session_revoked=true` or `auth.password_change_failed` with `reason=session_revoked` | 5 / 15 min | `[auth].lockout_threshold`, `lockout_minutes`, `lockout_max_minutes` | +| Consecutive credential failures on one account | the account's two failure counters (ADR 0197): the **sign-in** counter and the **second-step** counter | ≥ 5 consecutive failures on one counter locks for 15 minutes, with one exception: while the credential in force is engine-generated (set by an administrator's account creation, `POST /users`, and by both resets), sign-in failures are counted and audited but arm no sign-in lock (`lockout_arms`, ADR 0197 Amendment A); a lapsed window restarts the counter; the second-step lock on a local account, and the sign-in lock on a local account with a TOTP enrolled, unless the engine KNOWS this release refuses that enrolment (a TOTP it cannot judge still escalates, and a known-refused one escalates too in the races listed under [WebAuthn passkeys](#webauthn-passkeys-wp-14b-adr-0068), BACKLOG #3422), double per cycle up to 24 hours | **DENY** before any verify on the password and second-factor legs, plus an audit row whose name is leg-specific — on the password path the uniform `auth.login_failed` (`bad_credentials`) row every refused sign-in writes and then `auth.login_locked`, which only `users:manage` reads ([Audit](#audit)), `auth.mfa_failed` / `auth.webauthn_failed` with `reason=locked` on at least the TOTP/recovery and assertion legs, and on a directory account's two enrolment legs (the sign-in password path still runs a dummy argon2 verify to keep timing flat). The second-step lock refuses every one of those legs. The sign-in lock has **two** exceptions: it does **not** refuse a combined sign-in, either the password and a TOTP code in one request on a local account with TOTP enrolled, or the password and a passkey assertion in one request on a local account holding a discoverable passkey under the request's relying party ([WebAuthn passkeys](#webauthn-passkeys-wp-14b-adr-0068), ADR 0197 Amendment A wave 2); and it refuses **no** second-factor leg. The Kerberos and OIDC sign-ins also refuse a row under either lock, but only **after** the ticket or token has verified, audited `auth.login_failed` with `reason=locked` (`_directory_login_refusal`, BACKLOG #1638); neither leg feeds the counter. The password legs of the post-session re-proofs, `POST /me/reauth` and `POST /me/password` and their console twins `POST /ui/reauth` and `POST /ui/account/password`, **feed** the sign-in counter but are **not** refused by either lock; the IdP step-up of an `oidc` session checks no engine credential and feeds neither counter; each **session** may fail `lockout_threshold` re-proofs (5 by default), and the failure that reaches it revokes that session, audited as `auth.reauth` with `session_revoked=true` or `auth.password_change_failed` with `reason=session_revoked` | 5 / 15 min | `[auth].lockout_threshold`, `lockout_minutes`, `lockout_max_minutes` | | New client IP during a session | this request's address vs `session.client` | knob on **and** a session exists, is unrevoked, has an anchor, and the two are not the same host (as [Administrative-interface defense-in-depth](#administrative-interface-defense-in-depth-wp-l3-13-asvs-842) defines it) | **CHALLENGE** — force a fresh step-up on a step-up route, a PHI read (`require_phi_read`, a monitoring `reveal`, the console's `phi=True` pages) or a paced write (`require_paced`, the console's writes), since vault BACKLOG #2620 for the last two; the base gate never asks, so the monitoring polls are not refused. A first sighting also writes `auth.new_address_reverify_required` + an out-of-band notice, debounced and capped per session as that section states; repeats WARNING-log only. **Never** an RBAC deny | **on**; `false` is a named loosening | `[auth].admin_new_ip_step_up` | -| First-seen sign-in address — sign-in owes no second factor (local leg) | the sign-in's client address vs the account's known-address record (the `known_login_addresses` table, vault BACKLOG #2145): one row per host from which the account finished a sign-in, passed a step-up, or confirmed a factor enrolment, keyed on the account id and deleted with the account. An administrator's password reset or MFA reset deletes every address in it (vault BACKLOG #2946). It then writes one marker row stamped at the epoch. No lookback read returns that row, and it still counts as a row of any age. Revoking every session clears nothing. These events write a row: a local sign-in that owed no factor, from a known address or with no baseline yet; a directory sign-in that owes nothing more, whatever the verdict except a failed read, so a first-seen address and an account's first sign-in both write (no directory session is seeded, so there is no challenge to skip); a combined sign-in; a second factor proved (a TOTP or recovery code at the MFA gate, or a passkey assertion), whether it finishes a sign-in or is a step-up on a session that has already met its factor; a factor enrolment confirmed, TOTP or passkey, first or later, from a known address or by an account with no baseline yet (not from a first-seen address, because a holder of the password alone may enrol an authenticator, ADR 0197 Amendment A; this only delays such a holder until a code from that authenticator is proved, and the enrolment sends its own notice); and a step-up, by password or at the IdP, by an account that owes no second factor. That test is asked of the account, not of this session. Under `require_mfa` every directory account owes a factor, so no directory step-up by password or at the IdP writes, even one that re-proved the MFA claim. At the sign-in itself a failed record read writes nothing, on either leg. A combined sign-in is the exception, because it proved its second factor from that address. A factor proved afterwards still writes. Nothing is written for a request with no client address. A host compares as one key: an IPv4-mapped form is its IPv4 address, and every loopback form is one host. Only a row seen within the lookback (`_LOGIN_ADDRESS_LOOKBACK_SECONDS` in `auth/service.py`, 90 days) counts as a match, and each write prunes the account's older rows. The no-baseline test reads rows of any age, so an account whose only row came from an enrolment and has aged out is still judged, not failed open. Read through the record's primary key at session mint on the local, Kerberos and OIDC legs, after the first credential verified | no known address seen within the lookback is the same host **and** the account has a baseline: it has finished a sign-in before, or holds a row of any age. With neither, no client address, or a failed read, the signal fails open and writes `auth.login_address_unevaluated` with `reason` `no_baseline`, `unknown_address` or `read_failed` | **CHALLENGE** — `auth.login_new_ip` + an out-of-band `login_new_ip` notice (at most one per account and address per 15 minutes; the audit row is written every time), and the session is minted **without** step-up freshness (`seed_reauth=False`), except a combined sign-in (password and a TOTP code or a passkey assertion in one request, ADR 0197 and its Amendment A), which proved the second factor in the same request and is seeded as `verify_mfa` seeds it. **Never** refuses the login. The challenged sign-in does not make its address known, so a second sign-in from it is challenged again; a step-up from that address passes the challenge and records it (vault BACKLOG #2145) | on | (no knob) | +| First-seen sign-in address — sign-in owes no second factor (local leg) | the sign-in's client address vs the account's known-address record (the `known_login_addresses` table, vault BACKLOG #2145): one row per host from which the account finished a sign-in, passed a step-up, or confirmed a factor enrolment, keyed on the account id and deleted with the account. An administrator's password reset or MFA reset deletes every address in it (vault BACKLOG #2946). It then writes one marker row stamped at the epoch. No lookback read returns that row, and it still counts as a row of any age. Revoking every session clears nothing. These events write a row: a local sign-in that owed no factor, from a known address or with no baseline yet; a directory sign-in that owes nothing more, whatever the verdict except a failed read, so a first-seen address and an account's first sign-in both write (no directory session is seeded, so there is no challenge to skip); a combined sign-in; a second factor proved (a TOTP or recovery code at the MFA gate, or a passkey assertion), whether it finishes a sign-in or is a step-up on a session that has already met its factor; a factor enrolment confirmed, TOTP or passkey, first or later, from a known address or by an account with no baseline yet (not from a first-seen address, because a holder of the password alone may enrol an authenticator, ADR 0197 Amendment A; this only delays such a holder until a code from that authenticator is proved, and the enrolment sends its own notice); and a step-up, by password or at the IdP, by an account that owes no second factor. That test is asked of the account, not of this session. Under `require_mfa` every directory account owes a factor, so no directory step-up by password or at the IdP writes, even one that re-proved the MFA claim. At the sign-in itself a failed record read writes nothing, on either leg. A combined sign-in is the exception, because it proved its second factor from that address. A factor proved afterwards still writes. Nothing is written for a request with no client address. A host compares as one key: an IPv4-mapped form is its IPv4 address, and every loopback form is one host. Only a row seen within the lookback (`_LOGIN_ADDRESS_LOOKBACK_SECONDS` in `auth/service.py`, 90 days) counts as a match, and each write prunes the account's older rows. The no-baseline test reads rows of any age, so an account whose only row came from an enrolment and has aged out is still judged, not failed open. Read through the record's primary key at session mint on the local, Kerberos and OIDC legs, after the first credential verified | no known address seen within the lookback is the same host **and** the account has a baseline: it has finished a sign-in before, or holds a row of any age. With neither, no client address, or a failed read, the signal fails open and writes `auth.login_address_unevaluated` with `reason` `no_baseline`, `unknown_address` or `read_failed` | **CHALLENGE** — `auth.login_new_ip` + an out-of-band `login_new_ip` notice (at most one per account and address per 15 minutes; the audit row is written every time), and the session is minted **without** step-up freshness (`seed_reauth=False`), except a combined sign-in (password and a TOTP code or a passkey assertion in one request, under the conditions of [Step-up re-verification](#step-up-re-verification-on-sensitive-operations-wp-l3-16-asvs-753); ADR 0197 and its Amendment A), which proved the second factor in the same request and is seeded as `verify_mfa` seeds it. **Never** refuses the login. The challenged sign-in does not make its address known, so a second sign-in from it is challenged again; a step-up from that address passes the challenge and records it (vault BACKLOG #2145) | on | (no knob) | | First-seen sign-in address — sign-in owes a second factor, or any directory sign-in | as the row above | as the row above | **LOG** — the same audit row and notice. A directory sign-in that owes nothing more also records the address, so the next one from it is not reported again; nothing else changes. **Under the shipped `require_mfa` a Kerberos sign-in always owes a factor**, and so does an OIDC one while `oidc_require_mfa_claim` is off. Such a sign-in records nothing; the factor leg that finishes it records the address instead: a TOTP or recovery code, a passkey, or a factor enrolment, under the conditions in the row above. So on this leg, as on the local one, every sign-in is unjudged (`no_baseline`) until one finishes, and a sign-in abandoned at the factor prompt records nothing. Under the same default no directory step-up records an address (see the row above). Such a session is born without step-up freshness anyway. A later proof stamps the window, for example a TOTP or recovery code, or a step-up (a passkey alone does not). On an OIDC session only the IdP step-up does (BACKLOG #2142). A Kerberos or password session steps up with the account password; an `oidc` session steps up at the IdP. Under the shipped `require_mfa` scope (`every_local_account`) this is the path every password-only local sign-in takes. A combined sign-in (password and a TOTP code or a passkey assertion in one request) owes nothing more, so it takes the row above instead and is seeded from any address. **Never** refuses the login (BACKLOG #288) | on | (no knob) | | Credential recency | age of `session.reauth_at` | `now − reauth_at > step_up_max_age_seconds`, or `reauth_at is None` | **DENY** 403 + `X-Step-Up-Required: 1` (console: 303 → `/ui/reauth`; an `oidc` session's JSON refusal names the IdP leg, see [Step-up re-verification](#step-up-re-verification-on-sensitive-operations-wp-l3-16-asvs-753)) | 300 s | `[auth].step_up_max_age_seconds` | | Action-bound step-up grant | a single-use grant minted only by a successful step-up, on the **monotonic** clock: `reauth(purpose=…)` for the password or re-bind leg (`POST /me/reauth`, `POST /ui/reauth`), and `complete_oidc_step_up` for an `oidc` session's IdP leg, for the action the operator started from. Never by a sign-in or by `verify_mfa` | no unconsumed grant for this route's action | **DENY** 403 + `X-Step-Up-Required` + `X-Step-Up-Action: `; opting out falls back to the session window — **except on a factor bind or a session terminate**, see the row below | on | `[auth].require_action_step_up` |