فهرست منبع

Security hardening

Efren Yevale Varela 3 هفته پیش
والد
کامیت
dc084ddbd2

+ 3 - 3
README.md

@@ -239,7 +239,7 @@ certificate.
 - Master keys (pepper, TOTP, and PII) must each be at least 32 bytes; shorter
 - Master keys (pepper, TOTP, and PII) must each be at least 32 bytes; shorter
   values are refused at load (`WeakKeyMaterial`).
   values are refused at load (`WeakKeyMaterial`).
 - `user disable` purges the account's Redis sessions, tokens, grants, codes,
 - `user disable` purges the account's Redis sessions, tokens, grants, codes,
-  device records, and lockout counter before flipping `users.disabled`; that
+  device records, and lockout counters before flipping `users.disabled`; that
   user's live tokens then introspect inactive and refuse refresh
   user's live tokens then introspect inactive and refuse refresh
   (`invalid_grant`).
   (`invalid_grant`).
 - Production requires `issuer` on `https` and a database URL with
 - Production requires `issuer` on `https` and a database URL with
@@ -260,12 +260,12 @@ certificate.
   `client attach-redirect-uri|detach-redirect-uri|redirect-uris`. Each accepts
   `client attach-redirect-uri|detach-redirect-uri|redirect-uris`. Each accepts
   `--kind authorize|post_logout` (default `authorize`); `post_logout` URIs are
   `--kind authorize|post_logout` (default `authorize`); `post_logout` URIs are
   the return targets for `POST /logout` (`GET /logout` is read-only).
   the return targets for `POST /logout` (`GET /logout` is read-only).
-- Users and entitlements: `user create|enroll-totp|disable|grant-scope|revoke-scope|scopes`.
+- Users and entitlements: `user create|enroll-totp|disable|unlock|grant-scope|revoke-scope|scopes`.
 - Admin: `admin bootstrap` (see [RUNBOOK.md](RUNBOOK.md)).
 - Admin: `admin bootstrap` (see [RUNBOOK.md](RUNBOOK.md)).
 - Declarative apply: `config sync --file` and `config check`.
 - Declarative apply: `config sync --file` and `config check`.
 - PII migration and rotation: `pii backfill`, `pii verify`, and `pii rotate`,
 - PII migration and rotation: `pii backfill`, `pii verify`, and `pii rotate`,
   plus `totp rewrap` for the TOTP key. See [docs/OPERATIONS.md](docs/OPERATIONS.md).
   plus `totp rewrap` for the TOTP key. See [docs/OPERATIONS.md](docs/OPERATIONS.md).
-- DCR initial access: `initial-access-token issue`.
+- DCR initial access: `initial-access-token issue [--show-secret]`.
 - Utilities: `keygen` and `healthcheck`.
 - Utilities: `keygen` and `healthcheck`.
 
 
 Full flags are in [docs/OPERATIONS.md](docs/OPERATIONS.md).
 Full flags are in [docs/OPERATIONS.md](docs/OPERATIONS.md).

+ 6 - 2
docs/ARCHITECTURE.md

@@ -58,8 +58,12 @@ is fixed in `build_router_with_options` in `src/routes.rs`:
    allowlist is empty, which is the default.
    allowlist is empty, which is the default.
 4. Resilience: sheds excess load, caps concurrency, and times out slow
 4. Resilience: sheds excess load, caps concurrency, and times out slow
    handlers before they reach rate limiting.
    handlers before they reach rate limiting.
-5. Rate limit: enforces the per-IP and per-client budgets. Health and metadata
-   have no rule and pass through; those handlers fail closed on their own.
+5. Rate limit: enforces the per-IP and per-client budgets. The per-client
+   bucket is keyed by the `(peer_ip, client_id)` composite, because
+   `client_id` is read before client authentication and must never select a
+   global bucket on its own; the per-IP bucket is the primary control. Health
+   and metadata have no rule and pass through; those handlers fail closed on
+   their own.
 6. Body limit: caps request bodies at 2 MiB.
 6. Body limit: caps request bodies at 2 MiB.
 
 
 A handler that needs a dependency takes `AppState` and calls a repository or the
 A handler that needs a dependency takes `AppState` and calls a repository or the

+ 32 - 2
docs/CONFIGURATION.md

@@ -36,6 +36,15 @@ with every `.` replaced by `__`, uppercased. For example:
 A secret may be given inline or as a mounted file. Inline wins when both are
 A secret may be given inline or as a mounted file. Inline wins when both are
 set. Mounted files are read and trimmed of trailing newlines.
 set. Mounted files are read and trimmed of trailing newlines.
 
 
+String-list keys (`cors_allowed_origins`, `rate_limit.trusted_proxies`,
+`redis.nodes`, `redis.seeds`) accept a real array in the file and either a
+JSON array string or a comma-separated string from the environment, because
+config-rs 0.15 keeps env values as strings and never coerces arrays. Both
+`D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES='["10.0.0.5"]'` and
+`D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES='10.0.0.5,10.0.1.0/24'` load; a blank
+string loads as empty, and malformed JSON array text fails the load instead
+of silently meaning empty.
+
 The key ring follows the same pattern: `token_pepper_file`,
 The key ring follows the same pattern: `token_pepper_file`,
 `totp_encryption_key_file`, and `pii_encryption_key_file` (environment
 `totp_encryption_key_file`, and `pii_encryption_key_file` (environment
 spellings `D_OAUTH_TOKEN_PEPPER_FILE`, `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`, and
 spellings `D_OAUTH_TOKEN_PEPPER_FILE`, `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`, and
@@ -59,8 +68,8 @@ which names the offending key.
 | `bind_addr` | `0.0.0.0:8080` | A socket address. The TLS public listener. |
 | `bind_addr` | `0.0.0.0:8080` | A socket address. The TLS public listener. |
 | `health_addr` | `0.0.0.0:8081` | A socket address. `serve` refuses a loopback address; `config check` does not. The plaintext health listener. |
 | `health_addr` | `0.0.0.0:8081` | A socket address. `serve` refuses a loopback address; `config check` does not. The plaintext health listener. |
 | `environment` | `production` | `production` or `development`. Development relaxes the database TLS requirement. |
 | `environment` | `production` | `production` or `development`. Development relaxes the database TLS requirement. |
-| `cookie_domain` | none (host-only) | Optional cookie domain. |
-| `cookie_secure` | `true` | Adds the `Secure` attribute. Set `false` only for localhost testing. |
+| `cookie_domain` | none (host-only) | Reserved: any non-blank value is rejected at load (`InvalidCookieDomain`). The `__Host-` session cookie forbids `Domain`, so sessions stay host-only; blank values are treated as absent. |
+| `cookie_secure` | `true` | Adds the `Secure` attribute and uses the `__Host-session_id` cookie name. `false` is a development-only localhost exception (production refuses to load without `Secure` with `InsecureSessionCookie`); it drops `Secure` and the cookie name falls back to plain `session_id`, because browsers reject a `__Host-` cookie without `Secure`. Both variants are host-only (`Path=/`, `HttpOnly`, `SameSite=Lax`, no `Domain`). In production the server reads **only** `__Host-session_id`; the plain `session_id` cookie is never honored there, so a cookie planted by a sibling subdomain cannot fixate a login onto an attacker presession. The development variant reads plain `session_id` first and may additionally fall back to `__Host-session_id` for a cookie left over from before a config flip. `POST /logout` clears every name the reader honors. The `false` gate checks only `environment = production`: a non-production environment (for example staging) running `cookie_secure = false` re-opens plain-cookie sibling-subdomain fixation, so staging must use the production environment or keep secure cookies. |
 | `allow_open_registration` | `false` | When false, DCR requires a valid initial-access token. |
 | `allow_open_registration` | `false` | When false, DCR requires a valid initial-access token. |
 | `dcr_redirect_policy` | `catalog` | `catalog`, `catalog_loopback`, or `open`. See below. |
 | `dcr_redirect_policy` | `catalog` | `catalog`, `catalog_loopback`, or `open`. See below. |
 | `cors_allowed_origins` | empty | An explicit origin allowlist. Empty means no CORS layer. A lone `*` is silently dropped (no CORS layer, no error). |
 | `cors_allowed_origins` | empty | An explicit origin allowlist. Empty means no CORS layer. A lone `*` is silently dropped (no CORS layer, no error). |
@@ -242,9 +251,30 @@ iteration count and parallelism.
 | `rate_limit.per_client` | `300` |
 | `rate_limit.per_client` | `300` |
 | `rate_limit.login_per_ip` | `20` |
 | `rate_limit.login_per_ip` | `20` |
 | `rate_limit.token_per_client` | `60` |
 | `rate_limit.token_per_client` | `60` |
+| `rate_limit.trusted_proxies` | empty |
 
 
 The window must be non-zero.
 The window must be non-zero.
 
 
+`rate_limit.trusted_proxies` (environment spelling
+`D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES`) is the L7 reverse-proxy allowlist for
+rate-limit IP attribution only. Each entry is an exact IP (`10.0.0.1`) or a
+CIDR (`10.0.0.0/8`, `2001:db8::/32`). From the environment use a JSON array
+string (`D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES='["10.0.0.5"]'`) or a
+comma-separated string (`D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES='10.0.0.5'`); in
+the file use a real array (`trusted_proxies = ["10.0.0.5"]`). Default empty means forwarded headers
+are never honored: the limiter keys the IP bucket off the verified TCP peer.
+When the peer belongs to the allowlist, the limiter attributes the
+rightmost-untrusted `X-Forwarded-For` entry instead; a header from an
+untrusted peer is ignored, and a malformed header falls back to the peer, so
+spoofing cannot evade or shift the bucket. A malformed entry, an out-of-range
+prefix, a trust-everything `/0`, or more than 64 entries fails the load with
+`InvalidTrustedProxy`. Entries are not deduplicated, so duplicates count
+toward the 64-entry cap. A value starting with `[` parses as a JSON array, so
+a bracketed IPv6 literal (`[2001:db8::1]`) fails the load — always use bare
+IPs, in entries and in arrays. This trust never authenticates a client and never
+carries a client certificate: in-process mTLS still requires L4/TCP
+passthrough (see [DEPLOYMENT.md](DEPLOYMENT.md#mtls-and-the-l7-constraint)).
+
 ## Scope registry and token exchange
 ## Scope registry and token exchange
 
 
 `scope_registry` is an optional declarative input applied only by `config sync`.
 `scope_registry` is an optional declarative input applied only by `config sync`.

+ 22 - 0
docs/DEPLOYMENT.md

@@ -211,6 +211,28 @@ the address alone is not a verifiable name. Validate with
   `serve` has no hot reload. Rotate before expiry and check with
   `serve` has no hot reload. Rotate before expiry and check with
   `openssl x509 -in d-oauth.crt -noout -checkend 2592000`.
   `openssl x509 -in d-oauth.crt -noout -checkend 2592000`.
 
 
+### Rate-limit attribution behind the proxy
+
+Behind an L7 proxy every client arrives from the proxy's peer address, so the
+per-IP rate-limit bucket would collapse into one global chokepoint. Set
+`rate_limit.trusted_proxies` to the proxy peers (exact IPs or CIDRs, e.g.
+`["10.0.0.5", "10.0.1.0/24"]`, or
+`D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES='["10.0.0.5"]'` (equivalently
+`D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES='10.0.0.5,10.0.1.0/24'`)): when the TCP peer is
+  allowlisted, the limiter attributes the rightmost-untrusted `X-Forwarded-For`
+  entry instead of the peer. Only the rightmost 32 chain entries are
+  considered; unparseable entries are skipped and a chain with no parseable
+  entry falls back to the verified peer. Trusted proxies must emit bare IPs
+  only — no ports, brackets, or `unknown` tokens — so each entry parses as an
+  IP address. The default is empty, which ignores forwarded
+headers entirely and is correct for direct L4/TCP passthrough. Keep entries
+tight (proxy peers only; `/0` is rejected at load), and make the proxy strip
+any inbound `X-Forwarded-For` it did not set, so a client cannot plant a
+leftmost claim ahead of the trusted hops. This setting affects rate-limit
+bucket selection only: it never authenticates a client, and in-process mTLS
+still requires L4/TCP passthrough (see below), because forwarded
+client-certificate headers are never trusted.
+
 ## The cluster contract
 ## The cluster contract
 
 
 A correct Deployment has these properties:
 A correct Deployment has these properties:

+ 9 - 2
docs/OAUTH.md

@@ -116,8 +116,15 @@ feature defined by OAuth 2.1 or OIDC. `GET /logout` is read-only: it renders
 a confirm page (live session) or a neutral signed-out page (anonymous) and
 a confirm page (live session) or a neutral signed-out page (anonymous) and
 never destroys the session or writes cookies. Only `POST /logout`, carrying
 never destroys the session or writes cookies. Only `POST /logout`, carrying
 the session-bound CSRF `form_token`, destroys the authorization-server
 the session-bound CSRF `form_token`, destroys the authorization-server
-session and expires the `session_id` cookie (the clearing cookie mirrors the
-session cookie's `Path`/`HttpOnly`/`SameSite`/`Secure`/`Domain`). `GET /logout` takes **no query
+session and expires the session cookie (`__Host-session_id`, or plain
+`session_id` when `cookie_secure = false`; the clearing cookie mirrors the
+session cookie's name and its `Path`/`HttpOnly`/`SameSite`/`Secure`; no `Domain` is ever
+emitted, so the cookie stays host-only in both variants). In production the
+session reader honors **only** `__Host-session_id`: a plain `session_id` cookie
+is never read, so a cookie planted by a sibling subdomain cannot fixate a
+victim's login onto an attacker presession. The development variant reads plain
+`session_id` and may additionally fall back to `__Host-session_id`; `POST
+/logout` clears every name the reader honors. `GET /logout` takes **no query
 parameters** and issues no redirect at all, so a forced navigation
 parameters** and issues no redirect at all, so a forced navigation
 cannot cause an open redirect; only `POST /logout` may redirect, and only to a
 cannot cause an open redirect; only `POST /logout` may redirect, and only to a
 `post_logout` URI that was **registered for the client and frozen in session
 `post_logout` URI that was **registered for the client and frozen in session

+ 33 - 7
docs/OPERATIONS.md

@@ -45,6 +45,7 @@ d-oauth-server config check
 d-oauth-server user create --email <email> [--admin] [--password-stdin] [--password-file <path>]
 d-oauth-server user create --email <email> [--admin] [--password-stdin] [--password-file <path>]
 d-oauth-server user enroll-totp --email <email>
 d-oauth-server user enroll-totp --email <email>
 d-oauth-server user disable --email <email>
 d-oauth-server user disable --email <email>
+d-oauth-server user unlock --email <email>
 d-oauth-server user grant-scope --email <email> --scope <scope>
 d-oauth-server user grant-scope --email <email> --scope <scope>
 d-oauth-server user revoke-scope --email <email> --scope <scope>
 d-oauth-server user revoke-scope --email <email> --scope <scope>
 d-oauth-server user scopes --email <email>
 d-oauth-server user scopes --email <email>
@@ -52,16 +53,29 @@ d-oauth-server user scopes --email <email>
 
 
 - `create` reads the password from stdin by default or from `--password-file`.
 - `create` reads the password from stdin by default or from `--password-file`.
   The two sources are mutually exclusive. `--admin` sets `is_admin`. A created
   The two sources are mutually exclusive. `--admin` sets `is_admin`. A created
-  user always starts with `must_change_password = true`.
+  user always starts with `must_change_password = true`. A `--password-file`
+  must not be world-readable (use mode `0600`); a world-readable file is
+  rejected before it is read. Passwords are held in wiped-on-drop memory and
+  never appear in logs, errors, or audit events.
 - `enroll-totp` generates an encrypted TOTP secret and prints the secret and the
 - `enroll-totp` generates an encrypted TOTP secret and prints the secret and the
-  `otpauth://` URI exactly once. Register the secret in an authenticator now.
+  `otpauth://` URI exactly once. The secret prints only to an interactive
+  terminal; a redirected stdout (pipe, file, or job log) refuses unless
+  `--show-secret` is passed to acknowledge the disclosure. Register the secret
+  in an authenticator now.
 - `disable` first purges all of the user's Redis ephemeral state (login
 - `disable` first purges all of the user's Redis ephemeral state (login
   sessions and their `:meta` sidecars, access/refresh tokens and their grants,
   sessions and their `:meta` sidecars, access/refresh tokens and their grants,
   authorization codes, device records with their `:cnf`/`:poll` sidecars and
   authorization codes, device records with their `:cnf`/`:poll` sidecars and
-  `user_code` mappings, and the lockout counter), then flips the Postgres
+  `user_code` mappings, and the lockout counters (account plus per-IP)), then flips the Postgres
   `disabled` flag. If Redis is unavailable the command fails and the user
   `disabled` flag. If Redis is unavailable the command fails and the user
   stays enabled (fail closed). Disabling alone does not touch PAR records,
   stays enabled (fail closed). Disabling alone does not touch PAR records,
   replay markers, or rate-limit counters, which carry no subject.
   replay markers, or rate-limit counters, which carry no subject.
+- `unlock` clears the user's login lockout counters (the per-account counter
+  and every per-IP composite bucket) without touching the account. The user
+  must exist; a Redis outage fails closed and clears nothing. Unknown-label
+  buckets carry no account label and are never touched. The per-IP sweep uses
+  `SCAN`, so on Cluster topologies it covers the contacted node only and
+  buckets on other nodes may survive; re-run against each node or wait out
+  the window.
 - `grant-scope` requires the scope to exist in the catalog.
 - `grant-scope` requires the scope to exist in the catalog.
 - `revoke-scope` is idempotent.
 - `revoke-scope` is idempotent.
 
 
@@ -123,16 +137,23 @@ d-oauth-server admin bootstrap --email <email> [--password-file <path>] [--force
 
 
 Creates the first admin under an advisory lock in one transaction. It refuses
 Creates the first admin under an advisory lock in one transaction. It refuses
 while any enabled admin exists unless `--force` is given (a loud warning and an
 while any enabled admin exists unless `--force` is given (a loud warning and an
-audit event). The full replacement procedure is in `RUNBOOK.md`.
+audit event). The password comes from `--password-file`, from stdin, or — when
+neither yields a value — a generated 256-bit password that prints exactly once
+and only to a TTY (a non-TTY stdout with a generated password is an error, so
+the secret can never leak into job logs). A `--password-file` must not be
+world-readable (use mode `0600`). The full replacement procedure is in
+`RUNBOOK.md`.
 
 
 ### Initial access tokens (DCR)
 ### Initial access tokens (DCR)
 
 
 ```bash
 ```bash
-d-oauth-server initial-access-token issue --uses <n> --ttl <seconds> [--label <text>]
+d-oauth-server initial-access-token issue --uses <n> --ttl <seconds> [--label <text>] [--show-secret]
 ```
 ```
 
 
 Mints a one-time-use DCR initial-access token. The raw token is printed once;
 Mints a one-time-use DCR initial-access token. The raw token is printed once;
-only its keyed hash and remaining-use budget are stored. The token is required
+only its keyed hash and remaining-use budget are stored. The token prints only
+to an interactive terminal; a redirected stdout refuses unless `--show-secret`
+is passed to acknowledge the disclosure. The token is required
 when `allow_open_registration` is false.
 when `allow_open_registration` is false.
 
 
 ### Key generation and health
 ### Key generation and health
@@ -354,7 +375,12 @@ survives a Redis loss.
 - Rotating the token pepper without retaining the old key invalidates every
 - Rotating the token pepper without retaining the old key invalidates every
   live opaque token. Plan the overlap.
   live opaque token. Plan the overlap.
 - `admin bootstrap` with a non-TTY stdout and no password source refuses rather
 - `admin bootstrap` with a non-TTY stdout and no password source refuses rather
-  than print a generated password into job logs.
+  than print a generated password into job logs. `user enroll-totp` and
+  `initial-access-token issue` refuse a redirected stdout the same way unless
+  `--show-secret` is passed.
+- A `--password-file` must use mode `0600` (or any mode without the
+  world-readable bit); a world-readable password file is rejected before it is
+  read.
 
 
 ## Operational runbook
 ## Operational runbook
 
 

+ 16 - 2
docs/REDIS.md

@@ -38,7 +38,9 @@ differ.
 | `oauth:{state}:dpop_jti:<hash>` | DPoP `jti` replay marker. | Proof freshness window |
 | `oauth:{state}:dpop_jti:<hash>` | DPoP `jti` replay marker. | Proof freshness window |
 | `oauth:{state}:client_assertion_jti:<hash>` | `private_key_jwt` `jti` replay marker. | Remaining assertion validity (`exp + 30s skew - now`), clamped `[1s, 360s]` (300s max lifetime + 2×30s skew) |
 | `oauth:{state}:client_assertion_jti:<hash>` | `private_key_jwt` `jti` replay marker. | Remaining assertion validity (`exp + 30s skew - now`), clamped `[1s, 360s]` (300s max lifetime + 2×30s skew) |
 | `oauth:{state}:rotation:<request-id>` | Rotation idempotency mapping. | `min(900s, refresh TTL)` |
 | `oauth:{state}:rotation:<request-id>` | Rotation idempotency mapping. | `min(900s, refresh TTL)` |
-| `oauth:{state}:loginfail:<sha256>` | Failed-login counter (hashed label). | `ui::login::LOCKOUT_WINDOW` (15 min) |
+| `oauth:{state}:loginfail:<sha256>` | Failed-login counter (hashed label; the global per-account cap). | `ui::login::LOCKOUT_WINDOW` (15 min) |
+| `oauth:{state}:loginfail:ip:<sha256>:<sha256>` | Composite failed-login counter for one source IP and one account (both halves hashed; locks that IP only). | `ui::login::LOCKOUT_WINDOW` (15 min) |
+| `oauth:{state}:loginfail:unknown:<sha256>` | Unknown-label bucket for one source IP (hashed IP only; no per-label keys, so label enumeration cannot spray the keyspace). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:totp_replay:<fingerprint>` | TOTP used-code replay marker. The fingerprint is an HMAC under the token pepper (never the code itself), so a dump does not reveal the code. | Time step window |
 | `oauth:{state}:totp_replay:<fingerprint>` | TOTP used-code replay marker. The fingerprint is an HMAC under the token pepper (never the code itself), so a dump does not reveal the code. | Time step window |
 | `oauth:{rl}:ratelimit:<bucket-hash>` | Fixed-window rate-limit counter. | `rate_limit.window` (60s) |
 | `oauth:{rl}:ratelimit:<bucket-hash>` | Fixed-window rate-limit counter. | `rate_limit.window` (60s) |
 
 
@@ -187,7 +189,9 @@ leaves the user enabled). The purge deletes every record whose `subject`
 field is the canonical email or the user-id string:
 field is the canonical email or the user-id string:
 
 
 - `session:<id>` records plus their `:meta` sidecars.
 - `session:<id>` records plus their `:meta` sidecars.
-- The `loginfail:<sha256>` lockout counter for the normalized email.
+- The `loginfail:<sha256>` account counter plus every `loginfail:ip:*:<sha256>`
+  composite bucket for the normalized email. The `loginfail:unknown:<sha256>`
+  buckets carry no account label and are never touched.
 - `code:<hash>` authorization-code records.
 - `code:<hash>` authorization-code records.
 - `device:<hash>` records plus their `:cnf`/`:poll` sidecars and the
 - `device:<hash>` records plus their `:cnf`/`:poll` sidecars and the
   `user_code:<code-hash>` mappings that point at them.
   `user_code:<code-hash>` mappings that point at them.
@@ -203,6 +207,16 @@ Like the DCR `DELETE` revocation, the purge scans with `SCAN MATCH <prefix>*
 COUNT 100` and filters on the raw hash field. On Cluster topologies a scan
 COUNT 100` and filters on the raw hash field. On Cluster topologies a scan
 covers the contacted node only (Single and Sentinel scan fully).
 covers the contacted node only (Single and Sentinel scan fully).
 
 
+`user unlock --email` sweeps the same `loginfail` keyspace the same way
+(direct `DEL` of the account counter plus `SCAN MATCH` over the per-IP
+composite buckets), so the same Cluster caveat applies: buckets living on
+other nodes survive the sweep.
+
+A password change or TOTP re-enrollment reuses the same session sweep scoped
+to the subject minus the session in hand (`purge_sibling_sessions` in the
+same file): sibling `session:<id>` records plus their `:meta` sidecars are
+deleted, everything else is untouched, and no new key layout is introduced.
+
 ## Operational runbook
 ## Operational runbook
 
 
 Configure Redis with persistence and eviction off. A minimal single-node
 Configure Redis with persistence and eviction off. A minimal single-node

+ 70 - 9
docs/SECURITY.md

@@ -40,8 +40,13 @@ https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1).
 - The TLS public listener is the only route to OAuth handlers.
 - The TLS public listener is the only route to OAuth handlers.
 - The plaintext health listener carries no OAuth routes.
 - The plaintext health listener carries no OAuth routes.
 - Postgres holds durable domain state; Redis holds ephemeral state.
 - Postgres holds durable domain state; Redis holds ephemeral state.
-- Forwarded client-cert and client-IP headers are never trusted: the listener
-  uses only the TLS peer address and the certificate chain it verified itself.
+- Forwarded client-cert headers are never trusted: the listener uses only
+  the certificate chain it verified itself. Forwarded client-IP headers are
+  never trusted either, except for the single narrow purpose of rate-limit
+  bucket selection, and then only when the TCP peer is a configured
+  `rate_limit.trusted_proxies` entry (rightmost-untrusted `X-Forwarded-For`
+  over bare-IP entries only, rightmost 32 entries capped, unparseable entries
+  skipped, fail-closed onto the peer).
 
 
 ## Attack surface
 ## Attack surface
 
 
@@ -63,6 +68,7 @@ class, or runtime setting).
 | Operator `exec` into a running pod | No shell and a non-root, read-only-rootfs container limit what runs; every secret is a file the container user already reads, and no secret is in the environment | `pods/exec` (and `docker exec`) bypass the application's own authorization; restrict it with RBAC and audit it (`docs/OPERATIONS.md`). |
 | Operator `exec` into a running pod | No shell and a non-root, read-only-rootfs container limit what runs; every secret is a file the container user already reads, and no secret is in the environment | `pods/exec` (and `docker exec`) bypass the application's own authorization; restrict it with RBAC and audit it (`docs/OPERATIONS.md`). |
 | Kubernetes API access from the pod | `automountServiceAccountToken: false`; the binary never calls the API | A future feature that needs the API would have to re-enable it deliberately. |
 | Kubernetes API access from the pod | `automountServiceAccountToken: false`; the binary never calls the API | A future feature that needs the API would have to re-enable it deliberately. |
 | Image/registry compromise | Digest pinning, cosign verification, and a committed digest reference with no shippable `:latest`; CI writes the signed build digest into `kustomization.yaml` | A registry compromise before the digest is recorded still ships; the pin step must run. |
 | Image/registry compromise | Digest pinning, cosign verification, and a committed digest reference with no shippable `:latest`; CI writes the signed build digest into `kustomization.yaml` | A registry compromise before the digest is recorded still ships; the pin step must run. |
+| Rate-limit evasion via spoofed `X-Forwarded-For` behind an L7 proxy | Default-empty   `rate_limit.trusted_proxies` (headers ignored unless the TCP peer is an allowlisted proxy IP/CIDR); rightmost-untrusted selection; malformed headers fall back to the verified peer; attribution is bucket selection only, never authentication | An allowlist entry that is too broad (or a compromised proxy) attributes attacker-chosen IPs; keep entries to the proxy peers and never a `/0` (rejected at load). Proxies must emit bare IPs (no port/brackets/`unknown`); anything else is skipped, and only the rightmost 32 entries are considered. |
 
 
 Last reviewed: 2026-10-09. File-mounted DB/Redis passwords, digest pinning,
 Last reviewed: 2026-10-09. File-mounted DB/Redis passwords, digest pinning,
 NetworkPolicy, `automountServiceAccountToken: false`, and scheduled fuzzing are
 NetworkPolicy, `automountServiceAccountToken: false`, and scheduled fuzzing are
@@ -322,6 +328,27 @@ configure one as fail-open is a load-time error.
 - The login, consent, and device pages set a strict CSP with
 - The login, consent, and device pages set a strict CSP with
   `frame-ancestors 'none'`. They use server-side sessions, CSRF protection, and
   `frame-ancestors 'none'`. They use server-side sessions, CSRF protection, and
   no open redirect.
   no open redirect.
+- The session cookie is `__Host-session_id` (RFC 6265bis): `HttpOnly`,
+  `Path=/`, `SameSite=Lax`, host-only with no `Domain` ever emitted, and
+  `Secure` in every environment except a localhost development exception
+  (`cookie_secure = false`, which production rejects at load with
+  `InsecureSessionCookie`). The exception also drops the `__Host-` prefix
+  (the cookie is then plain `session_id` with the same `Path`/`HttpOnly`/
+  `SameSite`/no-`Domain` attributes), because browsers reject a `__Host-`
+   cookie without `Secure` and the session would otherwise never persist. Any non-blank `cookie_domain` is rejected at load
+   (`InvalidCookieDomain`) as incompatible with the `__Host-` prefix; blank
+   values are treated as absent. The
+  server-side session lives 12 h absolute (`ttl.session_secs`, pinned by test);
+  reads never re-arm the TTL, so there is no idle sliding — a live session
+   always dies 12 h after its last write. In production the session reader
+  honors `__Host-session_id` **alone**: a plain `session_id` cookie is never
+  read there, so a sibling subdomain cannot plant a session id and fixate a
+  victim's login onto the attacker's pending presession. The development
+  variant reads plain `session_id` and only additionally falls back to
+  `__Host-session_id` for a cookie left over from before a config flip. Only
+  `POST /logout` clears the session, emitting a clearing cookie for **every**
+  name the reader honors (the `__Host-` name in production; both names in
+  development), each mirroring the session-cookie name and every attribute.
 - `GET /logout` is a server-specific session endpoint (not OAuth 2.1/OIDC
 - `GET /logout` is a server-specific session endpoint (not OAuth 2.1/OIDC
   defined). It is read-only: it renders a confirm page and never destroys the
   defined). It is read-only: it renders a confirm page and never destroys the
   session or writes cookies, so a forced navigation cannot log a victim out.
   session or writes cookies, so a forced navigation cannot log a victim out.
@@ -337,13 +364,38 @@ configure one as fail-open is a load-time error.
   to an attacker target. There is no `post_logout_redirect_uri` parameter and no
   to an attacker target. There is no `post_logout_redirect_uri` parameter and no
   `id_token_hint`.
   `id_token_hint`.
 - Failed-login and lockout counters live in Redis, not Postgres, so a login
 - Failed-login and lockout counters live in Redis, not Postgres, so a login
-  flood does not write rows.
+  flood does not write rows. Lockout is composite: 5 failures from one IP for
+  one account (`ui::login::LOCKOUT_THRESHOLD`) lock that IP's bucket only, so
+  a burst from an attacker IP never locks the victim from another IP; 20
+  failures from any IPs (`ui::login::ACCOUNT_LOCKOUT_THRESHOLD`) lock the
+  account globally as the distributed-burst cap. Attempts for absent labels
+  never mint per-label keys — they share the source IP's single
+  `loginfail:unknown` bucket — so label enumeration cannot spray the keyspace.
+   `user unlock --email` clears the account counter and every per-IP bucket
+   for the user instead of waiting out the 15-minute window. Lockout buckets
+   use the TCP peer address, never the rate-limit `trusted_proxies`
+   attribution: behind an L7 proxy every login shares the proxy's peer IP,
+   so the per-IP composites collapse into one bucket and the 20-failure
+   account cap becomes the effective control. Keep L4/TCP passthrough (see
+   `docs/DEPLOYMENT.md`) where per-IP lockout isolation matters.
 - Rate limiting classifies by full request path: `/token`,
 - Rate limiting classifies by full request path: `/token`,
   `/authorize`/`/consent`/`/device*`/`/introspect`/`/revoke`/`/par`,
   `/authorize`/`/consent`/`/device*`/`/introspect`/`/revoke`/`/par`,
-  `/login*`/`/logout`, and `/register*`; `/health`, `/.well-known`, and
-  `/assets` are unrated. Counters live in a Redis keyspace separated from token
-  state. A breach is `429` + `Retry-After`; a store outage or a missing peer
-  address fails closed `503`.
+   `/login*`/`/logout`, and `/register*`; `/health`, `/.well-known`, and
+   `/assets` are unrated. The per-client bucket is keyed by the
+   `(peer_ip, client_id)` composite, not by the raw `client_id`: the id is
+   read before client authentication, so a global per-id bucket would let an
+   attacker exhaust a victim client's budget or rotate fake ids to evade the
+    limit; the per-IP bucket remains the primary control. Counters live in a Redis keyspace separated from token
+    state. A breach is `429` + `Retry-After`; a store outage or a missing peer
+    address fails closed `503`. Behind an L7 proxy the IP bucket is attributed
+    from `X-Forwarded-For` only when the TCP peer is a configured
+    `rate_limit.trusted_proxies` entry (exact IP/CIDR, default empty): the
+    rightmost-untrusted chain entry is used over the rightmost 32 bare-IP
+    entries, a header from an untrusted peer
+    is ignored, and a header with no parseable bare-IP entry falls back to the verified peer, so a
+    spoofed header can neither evade the limit nor shift a victim's bucket.
+    This attribution never authenticates a client and never carries a client
+    certificate.
 - TOTP enrollment confirmation (`confirm_totp`) shares the login lockout counter
 - TOTP enrollment confirmation (`confirm_totp`) shares the login lockout counter
   and policy: a failed code increments it, success clears it, and a locked
   and policy: a failed code increments it, success clears it, and a locked
   account rejects even a correct code — indistinguishably — until the window
   account rejects even a correct code — indistinguishably — until the window
@@ -351,12 +403,21 @@ configure one as fail-open is a load-time error.
 - A disabled subject is dead everywhere: introspection reports it inactive and
 - A disabled subject is dead everywhere: introspection reports it inactive and
   refresh rejects it with `invalid_grant` before minting; `user disable` first
   refresh rejects it with `invalid_grant` before minting; `user disable` first
   purges every Redis record whose subject is the user (sessions + `:meta`, the
   purges every Redis record whose subject is the user (sessions + `:meta`, the
-  `loginfail` counter, authorization codes, device records + `:cnf`/`:poll` +
+  `loginfail` account counter plus every per-IP composite bucket, authorization codes, device records + `:cnf`/`:poll` +
   `user_code` mappings, grants via cascade revoke plus member `:act`, and
   `user_code` mappings, grants via cascade revoke plus member `:act`, and
   standalone access/refresh records), then flips Postgres `disabled`. A Redis
   standalone access/refresh records), then flips Postgres `disabled`. A Redis
   outage fails closed and leaves the user enabled. Records with no subject
   outage fails closed and leaves the user enabled. Records with no subject
   (`par:`, `pending:`, `dpop_jti:`, `client_assertion_jti:`, `rotation:`,
   (`par:`, `pending:`, `dpop_jti:`, `client_assertion_jti:`, `rotation:`,
-  `totp_replay:`, rate-limit counters) are untouched.
+   `totp_replay:`, rate-limit counters) are untouched.
+- A password change (`change_password`) or TOTP re-enrollment
+  (`confirm_totp`) ends the subject's other login sessions (records plus
+  `:meta` sidecars) via the same session sweep, scoped to the subject minus
+  the session in hand, which survives and is re-issued full. The sweep runs
+  before the credential write, so a Redis outage fails the call with `503`
+  with the password unchanged (the caller retries); a later database failure
+  costs only re-authentication. OAuth tokens, codes, and grants are
+  untouched: ending the sessions forces re-authentication, while live tokens
+  keep working until they expire or the account is disabled.
 - CORS is empty by default and never a wildcard.
 - CORS is empty by default and never a wildcard.
 - DCR is protected by a one-time initial-access token; open registration is off
 - DCR is protected by a one-time initial-access token; open registration is off
   by default.
   by default.

+ 66 - 7
src/cli.rs

@@ -8,7 +8,9 @@
 //! stdin/env/file, **never argv**, and a secret is printed only when the
 //! stdin/env/file, **never argv**, and a secret is printed only when the
 //! operator explicitly asked for a one-time value (a new user's password must
 //! operator explicitly asked for a one-time value (a new user's password must
 //! be supplied; `user enroll-totp` prints the enrollment secret once;
 //! be supplied; `user enroll-totp` prints the enrollment secret once;
-//! `initial-access-token issue` prints the token once).
+//! `initial-access-token issue` prints the token once). One-time prints go
+//! only to an interactive terminal unless the explicit `--show-secret` flag
+//! acknowledges the disclosure, and password files must not be world-readable.
 //!
 //!
 //! `migrate` is forward-only and applies `sqlx::migrate!` under sqlx's own
 //! `migrate` is forward-only and applies `sqlx::migrate!` under sqlx's own
 //! Postgres advisory lock; `serve` never migrates. Mutations are audited on the
 //! Postgres advisory lock; `serve` never migrates. Mutations are audited on the
@@ -167,6 +169,11 @@ pub enum UserCommand {
     /// User email.
     /// User email.
     #[arg(long)]
     #[arg(long)]
     email: String,
     email: String,
+    /// Print the one-time secret even when stdout is not a TTY. Without this
+    /// flag the secret prints only to an interactive terminal, so it can
+    /// never leak into job logs or pipes by accident.
+    #[arg(long)]
+    show_secret: bool,
   },
   },
   /// Disable a user account.
   /// Disable a user account.
   Disable {
   Disable {
@@ -174,6 +181,12 @@ pub enum UserCommand {
     #[arg(long)]
     #[arg(long)]
     email: String,
     email: String,
   },
   },
+  /// Clear a user's login lockout counters (account plus per-IP buckets).
+  Unlock {
+    /// User email.
+    #[arg(long)]
+    email: String,
+  },
   /// Grant a catalog scope entitlement to a user.
   /// Grant a catalog scope entitlement to a user.
   GrantScope {
   GrantScope {
     /// User email.
     /// User email.
@@ -372,6 +385,11 @@ pub enum InitialAccessTokenCommand {
     /// Operator label for audit (never a secret).
     /// Operator label for audit (never a secret).
     #[arg(long, default_value = "cli")]
     #[arg(long, default_value = "cli")]
     label: String,
     label: String,
+    /// Print the one-time token even when stdout is not a TTY. Without this
+    /// flag the token prints only to an interactive terminal, so it can
+    /// never leak into job logs or pipes by accident.
+    #[arg(long)]
+    show_secret: bool,
   },
   },
 }
 }
 
 
@@ -483,15 +501,21 @@ async fn run_user(context: &context::Context, command: UserCommand) -> Result<()
       password_file,
       password_file,
     } => {
     } => {
       let password = context::read_secret(password_stdin, password_file.as_deref())?;
       let password = context::read_secret(password_stdin, password_file.as_deref())?;
-      user::create(context, &email, admin, &password).await
+      user::create(context, &email, admin, password.as_str()).await
     }
     }
-    UserCommand::EnrollTotp { email } => {
+    UserCommand::EnrollTotp { email, show_secret } => {
+      require_secret_output(show_secret)?;
       let enrollment = user::enroll_totp(context, &email).await?;
       let enrollment = user::enroll_totp(context, &email).await?;
-      println!("totp secret (shown once): {}", enrollment.secret);
-      println!("otpauth uri: {}", enrollment.uri);
+      println!("totp secret (shown once): {}", enrollment.secret.as_str());
+      println!("otpauth uri: {}", enrollment.uri.as_str());
       Ok(())
       Ok(())
     }
     }
     UserCommand::Disable { email } => user::disable(context, &email).await,
     UserCommand::Disable { email } => user::disable(context, &email).await,
+    UserCommand::Unlock { email } => {
+      let removed = user::unlock(context, &email).await?;
+      println!("unlocked user {email} (cleared {removed} lockout counter(s))");
+      Ok(())
+    }
     UserCommand::GrantScope { email, scope } => user::grant_scope(context, &email, &scope).await,
     UserCommand::GrantScope { email, scope } => user::grant_scope(context, &email, &scope).await,
     UserCommand::RevokeScope { email, scope } => user::revoke_scope(context, &email, &scope).await,
     UserCommand::RevokeScope { email, scope } => user::revoke_scope(context, &email, &scope).await,
     UserCommand::Scopes { email } => {
     UserCommand::Scopes { email } => {
@@ -547,10 +571,45 @@ async fn run_pii(context: &context::Context, command: PiiCommand) -> Result<(),
 
 
 async fn run_initial_access_token(command: InitialAccessTokenCommand) -> Result<(), CliError> {
 async fn run_initial_access_token(command: InitialAccessTokenCommand) -> Result<(), CliError> {
   match command {
   match command {
-    InitialAccessTokenCommand::Issue { uses, ttl, label } => {
+    InitialAccessTokenCommand::Issue {
+      uses,
+      ttl,
+      label,
+      show_secret,
+    } => {
+      require_secret_output(show_secret)?;
       let token = ops::issue_initial_access_token(uses, ttl, &label).await?;
       let token = ops::issue_initial_access_token(uses, ttl, &label).await?;
-      println!("initial access token (shown once): {token}");
+      println!("initial access token (shown once): {}", token.as_str());
       Ok(())
       Ok(())
     }
     }
   }
   }
 }
 }
+
+/// Refuse a one-time secret print that would land anywhere but an operator's
+/// terminal, unless the operator passed the explicit `--show-secret` flag.
+///
+/// A redirected stdout (pipe, file, or job log) without the flag is an error
+/// so enrollment secrets and initial-access tokens can never leak into logs
+/// by accident; the error carries no secret material.
+///
+/// # Errors
+/// Returns [`CliError::Input`] when stdout is not a TTY and `show_secret` is
+/// false.
+pub fn require_secret_output(show_secret: bool) -> Result<(), CliError> {
+  use std::io::IsTerminal as _;
+  check_secret_output(show_secret, std::io::stdout().is_terminal())
+}
+
+/// Flag logic behind [`require_secret_output`], with the TTY probe as an
+/// explicit parameter so the matrix is unit-testable without a terminal.
+fn check_secret_output(show_secret: bool, is_tty: bool) -> Result<(), CliError> {
+  if show_secret || is_tty {
+    Ok(())
+  } else {
+    Err(CliError::Input(
+      "refusing to print a one-time secret to a non-TTY stdout: re-run attached \
+       to a terminal or pass --show-secret to acknowledge the disclosure"
+        .to_owned(),
+    ))
+  }
+}

+ 30 - 19
src/cli/admin.rs

@@ -29,6 +29,7 @@ use base64::Engine as _;
 use ring::rand::{SecureRandom as _, SystemRandom};
 use ring::rand::{SecureRandom as _, SystemRandom};
 use sqlx::{PgConnection, PgPool};
 use sqlx::{PgConnection, PgPool};
 use uuid::Uuid;
 use uuid::Uuid;
+use zeroize::Zeroizing;
 
 
 use super::context::{CliError, audit, require_nonempty};
 use super::context::{CliError, audit, require_nonempty};
 use super::secret;
 use super::secret;
@@ -40,10 +41,11 @@ const GENERATED_BYTES: usize = 32;
 
 
 /// How the bootstrap password was supplied.
 /// How the bootstrap password was supplied.
 ///
 ///
-/// `Debug` is redacted: a `Provided` password must never reach logs.
+/// `Debug` is redacted: a `Provided` password prints its length plus
+/// `[REDACTED]` only, never the password itself.
 pub enum BootstrapPassword {
 pub enum BootstrapPassword {
-  /// Operator-supplied password (file or stdin).
-  Provided(String),
+  /// Operator-supplied password (file or stdin), wiped on drop.
+  Provided(Zeroizing<String>),
   /// Generate a fresh 256-bit password (printed once by the CLI wrapper).
   /// Generate a fresh 256-bit password (printed once by the CLI wrapper).
   Generate,
   Generate,
 }
 }
@@ -51,7 +53,11 @@ pub enum BootstrapPassword {
 impl fmt::Debug for BootstrapPassword {
 impl fmt::Debug for BootstrapPassword {
   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     match self {
     match self {
-      Self::Provided(_) => f.write_str("Provided([REDACTED])"),
+      Self::Provided(password) => f
+        .debug_struct("BootstrapPassword")
+        .field("secret", &"[REDACTED]")
+        .field("secret_len", &password.len())
+        .finish(),
       Self::Generate => f.write_str("Generate"),
       Self::Generate => f.write_str("Generate"),
     }
     }
   }
   }
@@ -67,7 +73,7 @@ pub struct BootstrapOutcome {
   /// Canonical email.
   /// Canonical email.
   pub email: String,
   pub email: String,
   /// The generated password, when [`BootstrapPassword::Generate`] was used.
   /// The generated password, when [`BootstrapPassword::Generate`] was used.
-  pub generated_password: Option<String>,
+  pub generated_password: Option<Zeroizing<String>>,
 }
 }
 
 
 impl fmt::Debug for BootstrapOutcome {
 impl fmt::Debug for BootstrapOutcome {
@@ -75,9 +81,13 @@ impl fmt::Debug for BootstrapOutcome {
     f.debug_struct("BootstrapOutcome")
     f.debug_struct("BootstrapOutcome")
       .field("id", &self.id)
       .field("id", &self.id)
       .field("email", &self.email)
       .field("email", &self.email)
+      .field("generated_password", &"[REDACTED]")
       .field(
       .field(
-        "generated_password",
-        &self.generated_password.as_ref().map(|_| "[REDACTED]"),
+        "generated_password_len",
+        &self
+          .generated_password
+          .as_ref()
+          .map(|password| password.len()),
       )
       )
       .finish()
       .finish()
   }
   }
@@ -94,14 +104,11 @@ impl fmt::Debug for BootstrapOutcome {
 /// Returns [`CliError::Io`] when the file/stdin read fails.
 /// Returns [`CliError::Io`] when the file/stdin read fails.
 pub fn resolve_password(password_file: Option<&Path>) -> Result<BootstrapPassword, CliError> {
 pub fn resolve_password(password_file: Option<&Path>) -> Result<BootstrapPassword, CliError> {
   if let Some(path) = password_file {
   if let Some(path) = password_file {
-    let raw = std::fs::read_to_string(path)?;
-    let password = raw.trim_end_matches(['\r', '\n']).to_owned();
+    let password = super::context::read_secret(false, Some(path))?;
     return Ok(BootstrapPassword::Provided(password));
     return Ok(BootstrapPassword::Provided(password));
   }
   }
-  let mut raw = String::new();
-  std::io::Read::read_to_string(&mut std::io::stdin(), &mut raw)?;
-  let password = raw.trim_end_matches(['\r', '\n']).to_owned();
-  if password.is_empty() {
+  let password = super::context::read_secret(false, None)?;
+  if password.trim().is_empty() {
     Ok(BootstrapPassword::Generate)
     Ok(BootstrapPassword::Generate)
   } else {
   } else {
     Ok(BootstrapPassword::Provided(password))
     Ok(BootstrapPassword::Provided(password))
@@ -110,14 +117,18 @@ pub fn resolve_password(password_file: Option<&Path>) -> Result<BootstrapPasswor
 
 
 /// Generate a fresh 256-bit password, base64-encoded (43 characters).
 /// Generate a fresh 256-bit password, base64-encoded (43 characters).
 ///
 ///
+/// The password is held in [`Zeroizing`] so it is wiped on drop.
+///
 /// # Errors
 /// # Errors
 /// Returns [`CliError::Crypto`] when OS randomness is unavailable.
 /// Returns [`CliError::Crypto`] when OS randomness is unavailable.
-pub fn generate_password() -> Result<String, CliError> {
-  let mut bytes = [0u8; GENERATED_BYTES];
+pub fn generate_password() -> Result<Zeroizing<String>, CliError> {
+  let mut bytes = Zeroizing::new([0u8; GENERATED_BYTES]);
   SystemRandom::new()
   SystemRandom::new()
-    .fill(&mut bytes)
+    .fill(&mut bytes[..])
     .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
     .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
-  Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
+  Ok(Zeroizing::new(
+    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes[..]),
+  ))
 }
 }
 
 
 /// Create the first admin: advisory-locked existence gate plus a repository
 /// Create the first admin: advisory-locked existence gate plus a repository
@@ -152,7 +163,7 @@ pub async fn bootstrap(
   // Hash before taking the lock so the lock is held only for the check and the
   // Hash before taking the lock so the lock is held only for the check and the
   // insert. The plaintext below is never logged and never stored; only the
   // insert. The plaintext below is never logged and never stored; only the
   // Argon2id hash reaches the database.
   // Argon2id hash reaches the database.
-  let password_hash = secret::hash_password(config, &plaintext)?;
+  let password_hash = secret::hash_password(config, plaintext.as_str())?;
 
 
   // Serialize within the process so a single process never parks multiple
   // Serialize within the process so a single process never parks multiple
   // pooled connections on the database lock while the winner still needs a
   // pooled connections on the database lock while the winner still needs a
@@ -191,7 +202,7 @@ async fn bootstrap_locked(
   email: &str,
   email: &str,
   password_hash: &str,
   password_hash: &str,
   force: bool,
   force: bool,
-  generated_password: Option<String>,
+  generated_password: Option<Zeroizing<String>>,
 ) -> Result<BootstrapOutcome, CliError> {
 ) -> Result<BootstrapOutcome, CliError> {
   let existing: i64 =
   let existing: i64 =
     sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_admin AND NOT disabled")
     sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_admin AND NOT disabled")

+ 53 - 11
src/cli/context.rs

@@ -9,6 +9,7 @@
 use std::{io::Read as _, path::Path, sync::Arc};
 use std::{io::Read as _, path::Path, sync::Arc};
 
 
 use sqlx::PgPool;
 use sqlx::PgPool;
+use zeroize::Zeroizing;
 
 
 use crate::config::Config;
 use crate::config::Config;
 
 
@@ -143,27 +144,64 @@ pub fn audit(operation: &str, subject: &str) {
 ///
 ///
 /// `from_stdin` and `file` are mutually exclusive. Trailing CR/LF is trimmed so
 /// `from_stdin` and `file` are mutually exclusive. Trailing CR/LF is trimmed so
 /// `echo secret | ...` and a file with a trailing newline behave identically.
 /// `echo secret | ...` and a file with a trailing newline behave identically.
+/// The secret is held in [`Zeroizing`] so it is wiped on drop, and a password
+/// file that is world-readable (mode `o+r`) is rejected before it is read.
+/// Error messages never carry the secret itself.
 ///
 ///
 /// # Errors
 /// # Errors
-/// Returns [`CliError::Input`] when both sources are given and
-/// [`CliError::Io`] when reading fails.
-pub fn read_secret(from_stdin: bool, file: Option<&Path>) -> Result<String, CliError> {
+/// Returns [`CliError::Input`] when both sources are given, when the file is
+/// world-readable, or when the file is not valid UTF-8, and [`CliError::Io`]
+/// when reading fails.
+pub fn read_secret(from_stdin: bool, file: Option<&Path>) -> Result<Zeroizing<String>, CliError> {
   match (from_stdin, file) {
   match (from_stdin, file) {
     (true, Some(_)) => Err(CliError::Input(
     (true, Some(_)) => Err(CliError::Input(
       "--password-stdin and --password-file are mutually exclusive".to_owned(),
       "--password-stdin and --password-file are mutually exclusive".to_owned(),
     )),
     )),
-    (_, Some(path)) => {
-      let raw = std::fs::read_to_string(path)?;
-      Ok(trim_newline(&raw))
-    }
+    (_, Some(path)) => read_secret_file(path),
     (_, None) => {
     (_, None) => {
-      let mut raw = String::new();
+      let mut raw = Zeroizing::new(String::new());
       std::io::stdin().read_to_string(&mut raw)?;
       std::io::stdin().read_to_string(&mut raw)?;
-      Ok(trim_newline(&raw))
+      Ok(trim_newline(raw))
     }
     }
   }
   }
 }
 }
 
 
+/// Read a secret file after rejecting world-readable permissions.
+///
+/// The file bytes are held in [`Zeroizing`] and converted without retaining a
+/// second plaintext copy alongside the returned value.
+fn read_secret_file(path: &Path) -> Result<Zeroizing<String>, CliError> {
+  reject_world_readable(path)?;
+  let bytes = Zeroizing::new(std::fs::read(path)?);
+  let text = std::str::from_utf8(&bytes)
+    .map_err(|_| CliError::Input("password file is not valid UTF-8".to_owned()))?;
+  Ok(trim_newline(Zeroizing::new(text.to_owned())))
+}
+
+/// Reject a password file anyone on the system can read.
+///
+/// A world-readable (`o+r`) password file is refused before any byte is read;
+/// operator secret files must use a restrictive mode such as `0600`. The error
+/// names the path only, never the secret.
+#[cfg(unix)]
+fn reject_world_readable(path: &Path) -> Result<(), CliError> {
+  use std::os::unix::fs::PermissionsExt as _;
+  let mode = std::fs::metadata(path)?.permissions().mode();
+  if mode & 0o004 != 0 {
+    return Err(CliError::Input(format!(
+      "password file {} must not be world-readable (use mode 0600)",
+      path.display()
+    )));
+  }
+  Ok(())
+}
+
+/// Permission enforcement is Unix-only; other platforms accept the file.
+#[cfg(not(unix))]
+fn reject_world_readable(_path: &Path) -> Result<(), CliError> {
+  Ok(())
+}
+
 /// Reject an empty secret early with a clear message.
 /// Reject an empty secret early with a clear message.
 ///
 ///
 /// # Errors
 /// # Errors
@@ -175,6 +213,10 @@ pub fn require_nonempty(secret: &str, what: &str) -> Result<(), CliError> {
   Ok(())
   Ok(())
 }
 }
 
 
-fn trim_newline(raw: &str) -> String {
-  raw.trim_end_matches(['\r', '\n']).to_owned()
+/// Trim trailing CR/LF in place so no second plaintext copy of the secret is
+/// ever allocated.
+fn trim_newline(mut raw: Zeroizing<String>) -> Zeroizing<String> {
+  let len = raw.trim_end_matches(['\r', '\n']).len();
+  raw.truncate(len);
+  raw
 }
 }

+ 11 - 7
src/cli/ops.rs

@@ -4,6 +4,7 @@
 use std::{path::Path, sync::Arc, time::Duration};
 use std::{path::Path, sync::Arc, time::Duration};
 
 
 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
 use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
+use zeroize::Zeroizing;
 
 
 use super::context::{CliError, audit, open_pool};
 use super::context::{CliError, audit, open_pool};
 use super::{KeyAlg, secret};
 use super::{KeyAlg, secret};
@@ -116,8 +117,8 @@ pub async fn healthcheck(url: &str) -> Result<i32, CliError> {
 
 
 /// `initial-access-token issue`: mint a token and store only its hash.
 /// `initial-access-token issue`: mint a token and store only its hash.
 ///
 ///
-/// The raw token is printed exactly once; Redis stores the keyed hash plus the
-/// bounded-use record and TTL.
+/// The raw token is held in [`Zeroizing`] and printed exactly once by the
+/// caller; Redis stores the keyed hash plus the bounded-use record and TTL.
 ///
 ///
 /// # Errors
 /// # Errors
 /// Returns [`CliError`] for invalid budgets, config/state failures, or a store
 /// Returns [`CliError`] for invalid budgets, config/state failures, or a store
@@ -126,7 +127,7 @@ pub async fn issue_initial_access_token(
   uses: u64,
   uses: u64,
   ttl_secs: u64,
   ttl_secs: u64,
   label: &str,
   label: &str,
-) -> Result<String, CliError> {
+) -> Result<Zeroizing<String>, CliError> {
   let config = Arc::new(Config::load()?);
   let config = Arc::new(Config::load()?);
   issue_with(config, uses, ttl_secs, label).await
   issue_with(config, uses, ttl_secs, label).await
 }
 }
@@ -141,7 +142,7 @@ pub async fn issue_with(
   uses: u64,
   uses: u64,
   ttl_secs: u64,
   ttl_secs: u64,
   label: &str,
   label: &str,
-) -> Result<String, CliError> {
+) -> Result<Zeroizing<String>, CliError> {
   if uses == 0 {
   if uses == 0 {
     return Err(CliError::Input("--uses must be at least 1".to_owned()));
     return Err(CliError::Input("--uses must be at least 1".to_owned()));
   }
   }
@@ -151,8 +152,8 @@ pub async fn issue_with(
     ));
     ));
   }
   }
   let state = AppState::connect(config.clone()).await?;
   let state = AppState::connect(config.clone()).await?;
-  let token = crate::crypto::tokens::mint_token()?;
-  let hash = secret::token_hash(&config, &token)?;
+  let token = Zeroizing::new(crate::crypto::tokens::mint_token()?);
+  let hash = secret::token_hash(&config, token.as_str())?;
   let store = crate::store::Store::from_config(&config.redis);
   let store = crate::store::Store::from_config(&config.redis);
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let record = crate::store::dcr::InitialAccessRecord {
   let record = crate::store::dcr::InitialAccessRecord {
@@ -204,7 +205,10 @@ pub async fn admin_bootstrap(
   let outcome = super::admin::bootstrap(&config, &pool, email, password, force).await?;
   let outcome = super::admin::bootstrap(&config, &pool, email, password, force).await?;
   println!("bootstrapped admin {} (id {})", outcome.email, outcome.id);
   println!("bootstrapped admin {} (id {})", outcome.email, outcome.id);
   if let Some(generated) = outcome.generated_password {
   if let Some(generated) = outcome.generated_password {
-    println!("generated one-time bootstrap password (shown once): {generated}");
+    println!(
+      "generated one-time bootstrap password (shown once): {}",
+      generated.as_str()
+    );
   }
   }
   println!("first login must change the password and enroll TOTP before full access");
   println!("first login must change the password and enroll TOTP before full access");
   Ok(())
   Ok(())

+ 13 - 9
src/cli/secret.rs

@@ -125,14 +125,16 @@ fn encrypt_v2(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliError> {
   if kid.len() > u8::MAX as usize {
   if kid.len() > u8::MAX as usize {
     return Err(CliError::Crypto("TOTP key id is too long".to_owned()));
     return Err(CliError::Crypto("TOTP key id is too long".to_owned()));
   }
   }
-  let key = derive_key(secret.expose_secret().as_bytes())
-    .map_err(|_| CliError::Crypto("TOTP key derivation failed".to_owned()))?;
+  let key = Zeroizing::new(
+    derive_key(secret.expose_secret().as_bytes())
+      .map_err(|_| CliError::Crypto("TOTP key derivation failed".to_owned()))?,
+  );
   let mut nonce_bytes = [0u8; NONCE_LEN];
   let mut nonce_bytes = [0u8; NONCE_LEN];
   SystemRandom::new()
   SystemRandom::new()
     .fill(&mut nonce_bytes)
     .fill(&mut nonce_bytes)
     .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
     .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
 
 
-  let unbound = UnboundKey::new(&AES_256_GCM, &key)
+  let unbound = UnboundKey::new(&AES_256_GCM, &key[..])
     .map_err(|_| CliError::Crypto("TOTP key rejected by AEAD".to_owned()))?;
     .map_err(|_| CliError::Crypto("TOTP key rejected by AEAD".to_owned()))?;
   let sealing = LessSafeKey::new(unbound);
   let sealing = LessSafeKey::new(unbound);
   let mut in_out = plaintext.as_bytes().to_vec();
   let mut in_out = plaintext.as_bytes().to_vec();
@@ -374,17 +376,19 @@ fn open_with_aad(
 
 
 /// Generate a fresh 160-bit TOTP secret, base32 (RFC 4648, no padding).
 /// Generate a fresh 160-bit TOTP secret, base32 (RFC 4648, no padding).
 ///
 ///
+/// The secret is held in [`Zeroizing`] so it is wiped on drop.
+///
 /// # Errors
 /// # Errors
 /// Returns [`CliError::Crypto`] when the OS RNG fails.
 /// Returns [`CliError::Crypto`] when the OS RNG fails.
-pub fn generate_totp_secret() -> Result<String, CliError> {
-  let mut bytes = [0u8; 20];
+pub fn generate_totp_secret() -> Result<Zeroizing<String>, CliError> {
+  let mut bytes = Zeroizing::new([0u8; 20]);
   SystemRandom::new()
   SystemRandom::new()
-    .fill(&mut bytes)
+    .fill(&mut bytes[..])
     .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
     .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
-  Ok(base32::encode(
+  Ok(Zeroizing::new(base32::encode(
     base32::Alphabet::Rfc4648 { padding: false },
     base32::Alphabet::Rfc4648 { padding: false },
-    &bytes,
-  ))
+    &bytes[..],
+  )))
 }
 }
 
 
 /// Build an `otpauth://` enrollment URI for a generated TOTP secret.
 /// Build an `otpauth://` enrollment URI for a generated TOTP secret.

+ 3 - 0
src/cli/test_support.rs

@@ -58,6 +58,9 @@ pub enum TestError {
   /// A Redis operation failed.
   /// A Redis operation failed.
   #[error("redis error: {0}")]
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
   Redis(#[from] redis::RedisError),
+  /// A lockout-counter operation failed.
+  #[error("store error: {0}")]
+  Store(#[from] crate::store::StoreError),
   /// A required value was unexpectedly absent.
   /// A required value was unexpectedly absent.
   #[error("missing value: {0}")]
   #[error("missing value: {0}")]
   Missing(&'static str),
   Missing(&'static str),

+ 359 - 26
src/cli/tests.rs

@@ -1,7 +1,7 @@
 //! `test(cli)` suite: config validation, user provisioning, token issuance,
 //! `test(cli)` suite: config validation, user provisioning, token issuance,
 //! TOTP encryption, scope grants, idempotent config sync, and audit emission.
 //! TOTP encryption, scope grants, idempotent config sync, and audit emission.
 
 
-use std::sync::Arc;
+use std::{net::IpAddr, sync::Arc};
 
 
 use base64::Engine as _;
 use base64::Engine as _;
 use clap::Parser as _;
 use clap::Parser as _;
@@ -20,7 +20,8 @@ use testcontainers::{
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 
 
 use super::{
 use super::{
-  Cli, ClientCommand, RedirectUriCommand, ScopeCommand, admin, ops, redirect, scope, secret,
+  Cli, ClientCommand, Command, RedirectUriCommand, ScopeCommand, UserCommand, admin, ops, redirect,
+  scope, secret,
   sync::{RedirectEntry, ScopeEntry, SyncFile},
   sync::{RedirectEntry, ScopeEntry, SyncFile},
   test_support::{self, TestError},
   test_support::{self, TestError},
   user,
   user,
@@ -166,8 +167,8 @@ async fn cli_user_create_and_lookup() -> Result<(), TestError> {
 async fn cli_initial_access_token_stores_only_hash() -> Result<(), TestError> {
 async fn cli_initial_access_token_stores_only_hash() -> Result<(), TestError> {
   let harness = test_support::harness().await?;
   let harness = test_support::harness().await?;
   let token = ops::issue_with(harness.config.clone(), 3, 600, "cli-test").await?;
   let token = ops::issue_with(harness.config.clone(), 3, 600, "cli-test").await?;
-  let hash = secret::token_hash(&harness.config, &token)?;
-  assert_ne!(hash, token);
+  let hash = secret::token_hash(&harness.config, token.as_str())?;
+  assert_ne!(hash.as_str(), token.as_str());
 
 
   let store = crate::store::Store::from_config(&harness.config.redis);
   let store = crate::store::Store::from_config(&harness.config.redis);
   let mut conn = AppState::connect(harness.config.clone())
   let mut conn = AppState::connect(harness.config.clone())
@@ -182,7 +183,7 @@ async fn cli_initial_access_token_stores_only_hash() -> Result<(), TestError> {
     .await?;
     .await?;
   assert_eq!(present, 1);
   assert_eq!(present, 1);
 
 
-  let raw_key = store.dcr_iat_key(&token);
+  let raw_key = store.dcr_iat_key(token.as_str());
   let raw_present: i64 = redis::cmd("EXISTS")
   let raw_present: i64 = redis::cmd("EXISTS")
     .arg(&raw_key)
     .arg(&raw_key)
     .query_async(&mut conn)
     .query_async(&mut conn)
@@ -193,7 +194,7 @@ async fn cli_initial_access_token_stores_only_hash() -> Result<(), TestError> {
     .arg(&key)
     .arg(&key)
     .query_async(&mut conn)
     .query_async(&mut conn)
     .await?;
     .await?;
-  assert!(fields.iter().all(|field| !field.contains(&token)));
+  assert!(fields.iter().all(|field| !field.contains(token.as_str())));
   Ok(())
   Ok(())
 }
 }
 
 
@@ -223,7 +224,7 @@ async fn cli_user_enroll_totp_stores_encrypted_secret() -> Result<(), TestError>
     "plaintext TOTP secret found in stored blob"
     "plaintext TOTP secret found in stored blob"
   );
   );
   let decrypted = secret::decrypt_totp(context.config(), &blob)?;
   let decrypted = secret::decrypt_totp(context.config(), &blob)?;
-  assert_eq!(decrypted, enrollment.secret);
+  assert_eq!(decrypted.as_str(), enrollment.secret.as_str());
   assert!(enrollment.uri.starts_with("otpauth://totp/"));
   assert!(enrollment.uri.starts_with("otpauth://totp/"));
   Ok(())
   Ok(())
 }
 }
@@ -235,31 +236,46 @@ async fn cli_totp_v3_cross_decrypts() -> Result<(), TestError> {
   let plaintext = secret::generate_totp_secret()?;
   let plaintext = secret::generate_totp_secret()?;
 
 
   // CLI -> crypto: a v3 envelope the CLI wrote opens under crypto::totp.
   // CLI -> crypto: a v3 envelope the CLI wrote opens under crypto::totp.
-  let cli_blob = secret::encrypt_totp(config, &plaintext, true)?;
+  let cli_blob = secret::encrypt_totp(config, plaintext.as_str(), true)?;
   assert_eq!(
   assert_eq!(
     cli_blob.first().copied(),
     cli_blob.first().copied(),
     Some(3),
     Some(3),
     "CLI envelope write must be v3"
     "CLI envelope write must be v3"
   );
   );
-  assert_eq!(totp::decrypt_secret(&config.keys, &cli_blob)?, plaintext);
+  assert_eq!(
+    totp::decrypt_secret(&config.keys, &cli_blob)?.as_str(),
+    plaintext.as_str()
+  );
 
 
   // CLI round-trips through its own reader.
   // CLI round-trips through its own reader.
-  assert_eq!(secret::decrypt_totp(config, &cli_blob)?, plaintext);
+  assert_eq!(
+    secret::decrypt_totp(config, &cli_blob)?.as_str(),
+    plaintext.as_str()
+  );
 
 
   // crypto -> CLI: a v3 envelope crypto wrote opens under the CLI.
   // crypto -> CLI: a v3 envelope crypto wrote opens under the CLI.
-  let crypto_blob = totp::encrypt_secret(&config.keys, &plaintext, true)?;
+  let crypto_blob = totp::encrypt_secret(&config.keys, plaintext.as_str(), true)?;
   assert_eq!(crypto_blob.first().copied(), Some(3));
   assert_eq!(crypto_blob.first().copied(), Some(3));
-  assert_eq!(secret::decrypt_totp(config, &crypto_blob)?, plaintext);
+  assert_eq!(
+    secret::decrypt_totp(config, &crypto_blob)?.as_str(),
+    plaintext.as_str()
+  );
 
 
   // The version gate keeps the legacy v2 write cross-compatible too.
   // The version gate keeps the legacy v2 write cross-compatible too.
-  let legacy = secret::encrypt_totp(config, &plaintext, false)?;
+  let legacy = secret::encrypt_totp(config, plaintext.as_str(), false)?;
   assert_eq!(
   assert_eq!(
     legacy.first().copied(),
     legacy.first().copied(),
     Some(2),
     Some(2),
     "gated CLI write must stay v2"
     "gated CLI write must stay v2"
   );
   );
-  assert_eq!(totp::decrypt_secret(&config.keys, &legacy)?, plaintext);
-  assert_eq!(secret::decrypt_totp(config, &legacy)?, plaintext);
+  assert_eq!(
+    totp::decrypt_secret(&config.keys, &legacy)?.as_str(),
+    plaintext.as_str()
+  );
+  assert_eq!(
+    secret::decrypt_totp(config, &legacy)?.as_str(),
+    plaintext.as_str()
+  );
 
 
   // Data AAD excludes the kid: re-wrap the DEK under a different kid (same key
   // Data AAD excludes the kid: re-wrap the DEK under a different kid (same key
   // material) leaving the data nonce and ciphertext byte-identical, and the
   // material) leaving the data nonce and ciphertext byte-identical, and the
@@ -272,7 +288,10 @@ async fn cli_totp_v3_cross_decrypts() -> Result<(), TestError> {
   let target = KeyId::new("v99");
   let target = KeyId::new("v99");
   let rewrapped = rewrap_v3(&cli_blob, kid.as_str(), material, target.as_str())?;
   let rewrapped = rewrap_v3(&cli_blob, kid.as_str(), material, target.as_str())?;
   let ring = key_ring_with_totp(&config.keys, target, material)?;
   let ring = key_ring_with_totp(&config.keys, target, material)?;
-  assert_eq!(totp::decrypt_secret(&ring, &rewrapped)?, plaintext);
+  assert_eq!(
+    totp::decrypt_secret(&ring, &rewrapped)?.as_str(),
+    plaintext.as_str()
+  );
 
 
   // A truncated blob fails closed without leaking plaintext.
   // A truncated blob fails closed without leaking plaintext.
   let truncated = &cli_blob[..12];
   let truncated = &cli_blob[..12];
@@ -842,7 +861,7 @@ async fn admin_bootstrap_creates_first_admin_must_change() -> Result<(), TestErr
     &config,
     &config,
     &pool,
     &pool,
     &email,
     &email,
-    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned()),
+    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned().into()),
     false,
     false,
   )
   )
   .await?;
   .await?;
@@ -898,7 +917,7 @@ async fn admin_bootstrap_concurrent_creates_exactly_one() -> Result<(), TestErro
         &config,
         &config,
         &pool,
         &pool,
         &email,
         &email,
-        admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned()),
+        admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned().into()),
         false,
         false,
       )
       )
       .await
       .await
@@ -938,7 +957,7 @@ async fn admin_bootstrap_second_refuses_and_force_is_audited() -> Result<(), Tes
     &config,
     &config,
     &pool,
     &pool,
     &first,
     &first,
-    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned()),
+    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned().into()),
     false,
     false,
   )
   )
   .await?;
   .await?;
@@ -948,7 +967,7 @@ async fn admin_bootstrap_second_refuses_and_force_is_audited() -> Result<(), Tes
     &config,
     &config,
     &pool,
     &pool,
     &second,
     &second,
-    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned()),
+    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned().into()),
     false,
     false,
   )
   )
   .await;
   .await;
@@ -972,7 +991,7 @@ async fn admin_bootstrap_second_refuses_and_force_is_audited() -> Result<(), Tes
     &config,
     &config,
     &pool,
     &pool,
     &second,
     &second,
-    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned()),
+    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned().into()),
     true,
     true,
   )
   )
   .await?;
   .await?;
@@ -1010,16 +1029,16 @@ async fn admin_bootstrap_generated_password_is_256_bit_and_unlogged() -> Result<
     .generated_password
     .generated_password
     .ok_or(TestError::Missing("generated password"))?;
     .ok_or(TestError::Missing("generated password"))?;
   let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
   let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
-    .decode(&generated)
+    .decode(generated.as_str())
     .map_err(|_| TestError::Missing("generated password must be base64"))?;
     .map_err(|_| TestError::Missing("generated password must be base64"))?;
   assert_eq!(raw.len(), 32, "generated passwords are 256-bit");
   assert_eq!(raw.len(), 32, "generated passwords are 256-bit");
 
 
-  let verdict = user_repo::verify_password(&pool, &config.keys, &email, &generated).await?;
+  let verdict = user_repo::verify_password(&pool, &config.keys, &email, generated.as_str()).await?;
   assert_eq!(verdict, user_repo::PasswordVerdict::Matched);
   assert_eq!(verdict, user_repo::PasswordVerdict::Matched);
 
 
   let log = test_support::captured(&capture.buffer);
   let log = test_support::captured(&capture.buffer);
   assert!(
   assert!(
-    !log.contains(&generated),
+    !log.contains(generated.as_str()),
     "generated password leaked into logs"
     "generated password leaked into logs"
   );
   );
   assert!(
   assert!(
@@ -1053,7 +1072,7 @@ async fn admin_bootstrap_first_login_restricted_until_rotation() -> Result<(), T
     &config,
     &config,
     &pool,
     &pool,
     &email,
     &email,
-    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned()),
+    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned().into()),
     false,
     false,
   )
   )
   .await?;
   .await?;
@@ -1090,6 +1109,7 @@ async fn admin_bootstrap_first_login_restricted_until_rotation() -> Result<(), T
       password: BOOTSTRAP_PASSWORD.to_owned(),
       password: BOOTSTRAP_PASSWORD.to_owned(),
       code: String::new(),
       code: String::new(),
       csrf,
       csrf,
+      peer_ip: None,
     },
     },
   )
   )
   .await?
   .await?
@@ -1148,7 +1168,7 @@ async fn admin_bootstrap_first_login_restricted_until_rotation() -> Result<(), T
   let code = totp::build_totp(&offer.secret)?
   let code = totp::build_totp(&offer.secret)?
     .generate(counter * totp::TOTP_STEP_SECS)
     .generate(counter * totp::TOTP_STEP_SECS)
     .to_string();
     .to_string();
-  let full = match confirm_totp(&mut deps!(), &restricted, &code).await? {
+  let full = match confirm_totp(&mut deps!(), &restricted, &code, None).await? {
     ConfirmOutcome::Completed { session_id, .. } => session_id,
     ConfirmOutcome::Completed { session_id, .. } => session_id,
     _ => {
     _ => {
       return Err(TestError::Missing(
       return Err(TestError::Missing(
@@ -1194,6 +1214,155 @@ fn admin_bootstrap_has_no_password_argv_and_migrate_seeds_no_admin() {
   assert!(rejected.is_err(), "passwords must never travel via argv");
   assert!(rejected.is_err(), "passwords must never travel via argv");
 }
 }
 
 
+#[test]
+fn user_unlock_parses_with_email() {
+  let parsed = Cli::try_parse_from([
+    "d-oauth-server",
+    "user",
+    "unlock",
+    "--email",
+    "ops@example.test",
+  ]);
+  assert!(parsed.is_ok(), "documented unlock flags must parse");
+  if let Ok(cli) = parsed {
+    assert!(
+      matches!(
+        cli.command,
+        Command::User {
+          command: UserCommand::Unlock { .. },
+        }
+      ),
+      "user unlock must parse to the unlock command"
+    );
+  }
+}
+
+/// `user unlock` clears the account counter and every composite bucket for
+/// the user, emits `user.unlock`, and leaves the label-less unknown buckets
+/// untouched.
+#[tokio::test]
+async fn user_unlock_clears_lockout_and_emits_audit() -> Result<(), TestError> {
+  let (_harness, context) = test_support::context().await?;
+  let email = test_support::unique_email("unlock");
+  user::create(&context, &email, false, PASSWORD).await?;
+
+  let store = crate::store::Store::from_config(&context.config().redis);
+  let mut conn = AppState::connect(context.config().clone())
+    .await?
+    .redis
+    .clone();
+  let policy = user_repo::LockoutPolicy::new(5, 20, std::time::Duration::from_secs(900))
+    .ok_or(TestError::Missing("lockout policy"))?;
+  let attacker: IpAddr = "192.0.2.77"
+    .parse()
+    .map_err(|_| TestError::Missing("attacker ip"))?;
+  for _ in 0..5 {
+    user_repo::register_login_failure(&mut conn, &store, policy, &email, Some(attacker)).await?;
+  }
+  user_repo::register_unknown_failure(&mut conn, &store, policy, Some(attacker)).await?;
+  if !user_repo::is_locked_out(&mut conn, &store, policy, &email, Some(attacker)).await? {
+    return Err(TestError::Missing("account must lock before unlock"));
+  }
+
+  let capture = test_support::install_capture()?;
+  let removed = user::unlock(&context, &email).await?;
+  drop(capture.guard);
+  if removed < 2 {
+    return Err(TestError::Harness(format!(
+      "unlock must remove the account and composite keys, removed {removed}"
+    )));
+  }
+  if user_repo::is_locked_out(&mut conn, &store, policy, &email, Some(attacker)).await? {
+    return Err(TestError::Harness(
+      "unlock must release the lockout".to_owned(),
+    ));
+  }
+  if user_repo::unknown_failure_count(&mut conn, &store, Some(attacker)).await? != 1 {
+    return Err(TestError::Harness(
+      "unlock must leave unknown buckets untouched".to_owned(),
+    ));
+  }
+  let log = test_support::captured(&capture.buffer);
+  if !log.contains("cli.mutation") {
+    return Err(TestError::Harness(format!("missing audit event: {log}")));
+  }
+  if !log.contains("user.unlock") {
+    return Err(TestError::Harness(format!("missing operation: {log}")));
+  }
+  Ok(())
+}
+
+/// `user unlock` on an absent address is a clean, payload-free not-found.
+#[tokio::test]
+async fn user_unlock_unknown_user_is_not_found() -> Result<(), TestError> {
+  let (_harness, context) = test_support::context().await?;
+  let missing = test_support::unique_email("unlock-missing");
+  match user::unlock(&context, &missing).await {
+    Err(super::context::CliError::NotFound(message)) => {
+      if message.contains(&missing) || message.contains('@') {
+        return Err(TestError::Harness(format!(
+          "not-found error leaked the email: {message}"
+        )));
+      }
+      Ok(())
+    }
+    Err(other) => Err(TestError::Harness(format!("unexpected error: {other:?}"))),
+    Ok(_) => Err(TestError::Missing("missing user must be NotFound")),
+  }
+}
+
+/// `user unlock` fails closed when Redis is unreachable: the command errors
+/// without an audit event and the account row stays exactly as it was.
+#[tokio::test]
+async fn user_unlock_redis_outage_fails_closed() -> Result<(), TestError> {
+  let (_postgres, pg_url) = test_support::postgres_container().await?;
+  let fixture = serde_json::json!({
+    "issuer": "https://auth.example.test",
+    "environment": "development",
+    "tls": {"cert_path": "/tmp/cli-test-cert.pem", "key_path": "/tmp/cli-test-key.pem"},
+    "database": {"url": pg_url},
+    "redis": {"url": "redis://127.0.0.1:1", "durability": "waitaof"},
+    "token_pepper": "cli-test-pepper-0123456789abcdef",
+    "totp_encryption_key": "cli-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "cli-test-pii-key-0123456789abcdef",
+  });
+  let source = ::config::Config::try_from(&fixture)?;
+  let config = Arc::new(Config::from_source(source)?);
+  let pool = super::context::open_pool(&config).await?;
+  crate::state::MIGRATOR.run(&pool).await?;
+  pool.close().await;
+  let context = super::context::Context::from_config(config).await?;
+  let email = test_support::unique_email("unlock-outage");
+  user::create(&context, &email, false, PASSWORD).await?;
+
+  let capture = test_support::install_capture()?;
+  let outcome = user::unlock(&context, &email).await;
+  drop(capture.guard);
+  match outcome {
+    Err(super::context::CliError::NotFound(_)) => {
+      return Err(TestError::Harness(
+        "a live user must not be NotFound".to_owned(),
+      ));
+    }
+    Err(_) => {}
+    Ok(_) => return Err(TestError::Missing("unlock over a dead Redis must fail")),
+  }
+  if test_support::captured(&capture.buffer).contains("user.unlock") {
+    return Err(TestError::Harness(
+      "a failed unlock must not emit user.unlock".to_owned(),
+    ));
+  }
+  let record = user_repo::find_by_email(context.pool(), &context.config().keys, &email)
+    .await?
+    .ok_or(TestError::Missing("created user"))?;
+  if record.disabled {
+    return Err(TestError::Harness(
+      "a failed unlock must not touch the account".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
 #[tokio::test]
 #[tokio::test]
 async fn admin_bootstrap_migrate_seeds_no_admin() -> Result<(), TestError> {
 async fn admin_bootstrap_migrate_seeds_no_admin() -> Result<(), TestError> {
   let (_db, pool, _config) = bootstrap_setup("redis://127.0.0.1:1").await?;
   let (_db, pool, _config) = bootstrap_setup("redis://127.0.0.1:1").await?;
@@ -1471,3 +1640,167 @@ async fn user_disable_purges_ephemeral_state() -> Result<(), TestError> {
   assert_eq!(decoy_present, 1, "disable must keep unrelated subjects");
   assert_eq!(decoy_present, 1, "disable must keep unrelated subjects");
   Ok(())
   Ok(())
 }
 }
+
+// ---------------------------------------------------------------------------
+// `test(cli_secret_hygiene)` suite (T3-D1, red-first)
+// ---------------------------------------------------------------------------
+
+#[test]
+#[cfg(unix)]
+fn cli_password_file_world_readable_rejected() -> Result<(), TestError> {
+  use std::os::unix::fs::PermissionsExt as _;
+  let path = std::env::temp_dir().join(format!(
+    "d-oauth-cli-secret-hygiene-{}",
+    uuid::Uuid::new_v4()
+  ));
+  std::fs::write(&path, "placeholder-secret-value\n")?;
+  std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))?;
+  let result = super::context::read_secret(false, Some(&path));
+  let _ = std::fs::remove_file(&path);
+  assert!(
+    result.is_err(),
+    "a world-readable password file must be rejected"
+  );
+  Ok(())
+}
+
+#[test]
+fn cli_enroll_totp_accepts_show_secret_flag() {
+  let parsed = Cli::try_parse_from([
+    "d-oauth-server",
+    "user",
+    "enroll-totp",
+    "--email",
+    "ops@example.test",
+    "--show-secret",
+  ]);
+  assert!(
+    parsed.is_ok(),
+    "enroll-totp must accept an explicit --show-secret flag"
+  );
+}
+
+#[test]
+fn cli_iat_issue_accepts_show_secret_flag() {
+  let parsed = Cli::try_parse_from([
+    "d-oauth-server",
+    "initial-access-token",
+    "issue",
+    "--uses",
+    "3",
+    "--ttl",
+    "600",
+    "--show-secret",
+  ]);
+  assert!(
+    parsed.is_ok(),
+    "initial-access-token issue must accept an explicit --show-secret flag"
+  );
+}
+
+#[test]
+fn cli_bootstrap_debug_redacts_with_length() {
+  let debug = format!(
+    "{:?}",
+    admin::BootstrapPassword::Provided("placeholder-secret-value".to_owned().into())
+  );
+  assert!(
+    !debug.contains("placeholder-secret-value"),
+    "Debug must never carry the password: {debug}"
+  );
+  assert!(
+    debug.contains("[REDACTED]"),
+    "Debug must mark the password redacted: {debug}"
+  );
+  let outcome = admin::BootstrapOutcome {
+    id: uuid::Uuid::new_v4(),
+    email: "ops@example.test".to_owned(),
+    generated_password: Some("placeholder-secret-value".to_owned().into()),
+  };
+  let debug = format!("{outcome:?}");
+  assert!(
+    !debug.contains("placeholder-secret-value"),
+    "Debug must never carry the generated password: {debug}"
+  );
+  assert!(
+    debug.contains("[REDACTED]"),
+    "Debug must mark the generated password redacted: {debug}"
+  );
+}
+
+#[test]
+fn cli_secret_output_guard_refuses_without_flag() {
+  assert!(
+    super::check_secret_output(false, false).is_err(),
+    "one-time secret output to a non-TTY stdout without --show-secret must refuse"
+  );
+  assert!(
+    super::check_secret_output(false, true).is_ok(),
+    "one-time secret output to a TTY without --show-secret must allow"
+  );
+  assert!(
+    super::require_secret_output(true).is_ok(),
+    "an explicit --show-secret flag must allow the one-time print"
+  );
+  assert!(
+    super::check_secret_output(true, false).is_ok(),
+    "an explicit --show-secret flag must allow a non-TTY print"
+  );
+  assert!(
+    super::check_secret_output(true, true).is_ok(),
+    "an explicit --show-secret flag must allow a TTY print"
+  );
+}
+
+#[test]
+#[cfg(unix)]
+fn cli_password_file_group_readable_accepted() -> Result<(), TestError> {
+  use std::os::unix::fs::PermissionsExt as _;
+  let path =
+    std::env::temp_dir().join(format!("d-oauth-cli-secret-group-{}", uuid::Uuid::new_v4()));
+  std::fs::write(&path, "placeholder-secret-value\n")?;
+  std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640))?;
+  let result = super::context::read_secret(false, Some(&path));
+  let _ = std::fs::remove_file(&path);
+  assert!(
+    result.is_ok(),
+    "a group-readable (non-world-readable) password file must be accepted"
+  );
+  Ok(())
+}
+
+#[test]
+#[cfg(unix)]
+fn cli_password_file_non_utf8_rejected() -> Result<(), TestError> {
+  use std::os::unix::fs::PermissionsExt as _;
+  let path = std::env::temp_dir().join(format!(
+    "d-oauth-cli-secret-nonutf8-{}",
+    uuid::Uuid::new_v4()
+  ));
+  std::fs::write(&path, [0xff, 0xfe, b'\n'])?; // invalid UTF-8, no secret material
+  std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
+  let result = super::context::read_secret(false, Some(&path));
+  let _ = std::fs::remove_file(&path);
+  assert!(
+    result.is_err(),
+    "a non-UTF-8 password file must be rejected"
+  );
+  Ok(())
+}
+
+#[test]
+#[cfg(unix)]
+fn cli_password_file_trims_trailing_newline() -> Result<(), TestError> {
+  use std::os::unix::fs::PermissionsExt as _;
+  let path = std::env::temp_dir().join(format!("d-oauth-cli-secret-trim-{}", uuid::Uuid::new_v4()));
+  std::fs::write(&path, "placeholder-secret-value\r\n")?;
+  std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
+  let secret = super::context::read_secret(false, Some(&path))?;
+  let _ = std::fs::remove_file(&path);
+  assert_eq!(
+    secret.as_str(),
+    "placeholder-secret-value",
+    "a trailing CR/LF must be trimmed in place"
+  );
+  Ok(())
+}

+ 7 - 4
src/cli/totp/tests.rs

@@ -101,8 +101,8 @@ async fn totp_rewrap_moves_v3_rows_to_active_key() -> Result<(), TestError> {
   let new_only = ring_with_totp(&[(NEW_ID, NEW_MATERIAL)])?;
   let new_only = ring_with_totp(&[(NEW_ID, NEW_MATERIAL)])?;
 
 
   let plaintext = crate::cli::secret::generate_totp_secret()?;
   let plaintext = crate::cli::secret::generate_totp_secret()?;
-  let v3 = crypto_totp::encrypt_secret(&old, &plaintext, true)?;
-  let v2 = crypto_totp::encrypt_secret(&old, &plaintext, false)?;
+  let v3 = crypto_totp::encrypt_secret(&old, plaintext.as_str(), true)?;
+  let v2 = crypto_totp::encrypt_secret(&old, plaintext.as_str(), false)?;
   // A v3 header that names a key but carries no body: malformed, must be skipped.
   // A v3 header that names a key but carries no body: malformed, must be skipped.
   let malformed = vec![3u8, 3, b'o', b'l', b'd'];
   let malformed = vec![3u8, 3, b'o', b'l', b'd'];
 
 
@@ -140,7 +140,10 @@ async fn totp_rewrap_moves_v3_rows_to_active_key() -> Result<(), TestError> {
     now.wrapped_dek, before.wrapped_dek,
     now.wrapped_dek, before.wrapped_dek,
     "wrapped DEK must be re-sealed under the active key"
     "wrapped DEK must be re-sealed under the active key"
   );
   );
-  assert_eq!(crypto_totp::decrypt_secret(&new_only, &after)?, plaintext);
+  assert_eq!(
+    crypto_totp::decrypt_secret(&new_only, &after)?.as_str(),
+    plaintext.as_str()
+  );
 
 
   // v1/v2 and malformed blobs are skipped and never overwritten.
   // v1/v2 and malformed blobs are skipped and never overwritten.
   assert_eq!(stored_blob(&pool, v2_id).await?, v2);
   assert_eq!(stored_blob(&pool, v2_id).await?, v2);
@@ -158,7 +161,7 @@ async fn totp_rewrap_is_idempotent() -> Result<(), TestError> {
   let full = ring_with_totp(&[(NEW_ID, NEW_MATERIAL), (OLD_ID, OLD_MATERIAL)])?;
   let full = ring_with_totp(&[(NEW_ID, NEW_MATERIAL), (OLD_ID, OLD_MATERIAL)])?;
 
 
   let plaintext = crate::cli::secret::generate_totp_secret()?;
   let plaintext = crate::cli::secret::generate_totp_secret()?;
-  let v3 = crypto_totp::encrypt_secret(&old, &plaintext, true)?;
+  let v3 = crypto_totp::encrypt_secret(&old, plaintext.as_str(), true)?;
   let id = Uuid::new_v4();
   let id = Uuid::new_v4();
   insert_user(&pool, id, &v3).await?;
   insert_user(&pool, id, &v3).await?;
 
 

+ 57 - 6
src/cli/user.rs

@@ -1,6 +1,9 @@
 //! `user` subcommands: create, enroll TOTP, disable, and scope entitlements.
 //! `user` subcommands: create, enroll TOTP, disable, and scope entitlements.
 
 
+use std::fmt;
+
 use uuid::Uuid;
 use uuid::Uuid;
+use zeroize::Zeroizing;
 
 
 use super::{
 use super::{
   context::{CliError, Context, audit, require_nonempty},
   context::{CliError, Context, audit, require_nonempty},
@@ -9,11 +12,25 @@ use super::{
 use crate::{db::user_repo, error::AppError};
 use crate::{db::user_repo, error::AppError};
 
 
 /// The one-time TOTP enrollment material printed to the operator.
 /// The one-time TOTP enrollment material printed to the operator.
+///
+/// `Debug` is redacted: the secret and the `otpauth://` URI (which embeds the
+/// secret) print their lengths plus `[REDACTED]` only.
 pub struct Enrollment {
 pub struct Enrollment {
-  /// Base32 TOTP secret (shown once).
-  pub secret: String,
-  /// `otpauth://` URI for authenticator apps.
-  pub uri: String,
+  /// Base32 TOTP secret (shown once), wiped on drop.
+  pub secret: Zeroizing<String>,
+  /// `otpauth://` URI for authenticator apps (embeds the secret).
+  pub uri: Zeroizing<String>,
+}
+
+impl fmt::Debug for Enrollment {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    f.debug_struct("Enrollment")
+      .field("secret", &"[REDACTED]")
+      .field("secret_len", &self.secret.len())
+      .field("uri", &"[REDACTED]")
+      .field("uri_len", &self.uri.len())
+      .finish()
+  }
 }
 }
 
 
 /// Create an admin-provisioned user.
 /// Create an admin-provisioned user.
@@ -63,7 +80,7 @@ pub async fn enroll_totp(context: &Context, email: &str) -> Result<Enrollment, C
   let totp_secret = secret::generate_totp_secret()?;
   let totp_secret = secret::generate_totp_secret()?;
   let blob = secret::encrypt_totp(
   let blob = secret::encrypt_totp(
     context.config(),
     context.config(),
-    &totp_secret,
+    totp_secret.as_str(),
     !context.config().pii_dual_write,
     !context.config().pii_dual_write,
   )?;
   )?;
 
 
@@ -76,7 +93,11 @@ pub async fn enroll_totp(context: &Context, email: &str) -> Result<Enrollment, C
 
 
   audit("user.enroll_totp", &email);
   audit("user.enroll_totp", &email);
   Ok(Enrollment {
   Ok(Enrollment {
-    uri: secret::otpauth_uri(&context.config().issuer, &email, &totp_secret),
+    uri: Zeroizing::new(secret::otpauth_uri(
+      &context.config().issuer,
+      &email,
+      totp_secret.as_str(),
+    )),
     secret: totp_secret,
     secret: totp_secret,
   })
   })
 }
 }
@@ -115,6 +136,36 @@ pub async fn disable(context: &Context, email: &str) -> Result<(), CliError> {
   Ok(())
   Ok(())
 }
 }
 
 
+/// Clear a user's login lockout counters without touching the account.
+///
+/// The per-account counter and every per-IP composite bucket for the
+/// canonical email are deleted, so a lockout tripped by another source's
+/// failures (or a legitimate burst) releases immediately instead of waiting
+/// out the window.
+/// The unknown-label buckets carry no account label and are never touched.
+/// The user must exist; a Redis or connect failure fails closed and clears
+/// nothing.
+///
+/// # Errors
+/// Returns [`CliError`] when the user is missing or when Redis is
+/// unavailable.
+pub async fn unlock(context: &Context, email: &str) -> Result<u64, CliError> {
+  let email = email.trim();
+  require_nonempty(email, "email")?;
+  let record = user_repo::find_by_email(context.pool(), &context.config().keys, email)
+    .await
+    .map_err(map_user_repo_error)?
+    .ok_or_else(|| CliError::NotFound("user not found".to_owned()))?;
+  let state = crate::state::AppState::connect(context.config().clone()).await?;
+  let store = crate::store::Store::from_config(&context.config().redis);
+  let mut conn = state.redis.clone();
+  let removed = user_repo::unlock_login(&mut conn, &store, &record.email)
+    .await
+    .map_err(CliError::from)?;
+  audit("user.unlock", &record.email);
+  Ok(removed)
+}
+
 /// Grant a catalog scope entitlement to a user.
 /// Grant a catalog scope entitlement to a user.
 ///
 ///
 /// # Errors
 /// # Errors

+ 84 - 1
src/config.rs

@@ -42,7 +42,9 @@ pub use db::DbConfig;
 pub use policy::{FailMode, FailurePolicy, RetryableClass};
 pub use policy::{FailMode, FailurePolicy, RetryableClass};
 pub use redis::{DurabilityMode, RedisConfig, RedisTopology};
 pub use redis::{DurabilityMode, RedisConfig, RedisTopology};
 pub use scope::{ClientClass, GrantKind, ScopeDefinition, TokenExchangeRule};
 pub use scope::{ClientClass, GrantKind, ScopeDefinition, TokenExchangeRule};
-pub use types::{Argon2Params, DcrRedirectPolicy, Environment, RateLimitConfig, TtlConfig};
+pub use types::{
+  Argon2Params, DcrRedirectPolicy, Environment, RateLimitConfig, TrustedProxyNet, TtlConfig,
+};
 
 
 /// Environment-variable prefix for layered settings (`D_OAUTH_*`).
 /// Environment-variable prefix for layered settings (`D_OAUTH_*`).
 pub const ENV_PREFIX: &str = "D_OAUTH";
 pub const ENV_PREFIX: &str = "D_OAUTH";
@@ -184,6 +186,68 @@ pub(crate) fn env_source() -> ::config::Environment {
     .try_parsing(true)
     .try_parsing(true)
 }
 }
 
 
+/// Deserialize a string list that arrives as a real sequence from a file or
+/// as a single string from `D_OAUTH_*` env vars (config-rs 0.15 keeps env
+/// values as strings: only bool/int/float are coerced, never arrays). A
+/// string starting with `[` parses as a JSON array (fail-closed on malformed
+/// JSON); any other string splits on `,` with surrounding whitespace trimmed
+/// and empty items dropped, so `10.0.0.5` and `a,b` both load. Blank strings
+/// load as empty.
+pub(crate) fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
+where
+  D: serde::Deserializer<'de>,
+{
+  struct StringOrVec;
+  impl<'de> serde::de::Visitor<'de> for StringOrVec {
+    type Value = Vec<String>;
+    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+      f.write_str("a string list or a JSON array string")
+    }
+    fn visit_none<E: serde::de::Error>(self) -> Result<Vec<String>, E> {
+      Ok(Vec::new())
+    }
+    fn visit_unit<E: serde::de::Error>(self) -> Result<Vec<String>, E> {
+      Ok(Vec::new())
+    }
+    fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<String>, A::Error> {
+      let mut items = Vec::new();
+      while let Some(item) = seq.next_element::<String>()? {
+        items.push(item);
+      }
+      Ok(items)
+    }
+    fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Vec<String>, E> {
+      parse_env_string_list(value).map_err(serde::de::Error::custom)
+    }
+    fn visit_string<E: serde::de::Error>(self, value: String) -> Result<Vec<String>, E> {
+      parse_env_string_list(&value).map_err(serde::de::Error::custom)
+    }
+  }
+  deserializer.deserialize_any(StringOrVec)
+}
+
+/// Split an env-provided string list: JSON array when it starts with `[`
+/// (malformed JSON is an error, never a silent empty), otherwise
+/// comma-separated with trimming.
+fn parse_env_string_list(value: &str) -> Result<Vec<String>, String> {
+  let trimmed = value.trim();
+  if trimmed.is_empty() {
+    return Ok(Vec::new());
+  }
+  if trimmed.starts_with('[') {
+    return serde_json::from_str::<Vec<String>>(trimmed)
+      .map_err(|err| format!("invalid string list {value:?}: {err}"));
+  }
+  Ok(
+    trimmed
+      .split(',')
+      .map(str::trim)
+      .filter(|item| !item.is_empty())
+      .map(str::to_owned)
+      .collect(),
+  )
+}
+
 /// Configuration loading/validation failures.
 /// Configuration loading/validation failures.
 #[derive(Debug, Error)]
 #[derive(Debug, Error)]
 pub enum ConfigError {
 pub enum ConfigError {
@@ -300,6 +364,13 @@ pub enum ConfigError {
   /// The rate-limit window was zero.
   /// The rate-limit window was zero.
   #[error("invalid rate-limit window")]
   #[error("invalid rate-limit window")]
   InvalidRateLimit,
   InvalidRateLimit,
+  /// A `rate_limit.trusted_proxies` entry was not an exact IP or a valid
+  /// CIDR, was a trust-everything `/0`, or the allowlist exceeded its bound.
+  #[error("invalid trusted proxy entry: {value}")]
+  InvalidTrustedProxy {
+    /// The rejected value.
+    value: String,
+  },
   /// A TTL was zero.
   /// A TTL was zero.
   #[error("invalid token TTL: every lifetime must be non-zero")]
   #[error("invalid token TTL: every lifetime must be non-zero")]
   InvalidTtl,
   InvalidTtl,
@@ -328,6 +399,18 @@ pub enum ConfigError {
     /// The rejected value.
     /// The rejected value.
     id: String,
     id: String,
   },
   },
+  /// Production was configured without the `Secure` session-cookie
+  /// attribute (`cookie_secure = false` is a development-only exception).
+  #[error("production requires the Secure session-cookie attribute")]
+  InsecureSessionCookie,
+  /// `cookie_domain` was set: any non-blank value is rejected as
+  /// incompatible with the `__Host-` session-cookie prefix (which forbids
+  /// `Domain`; sessions stay host-only by design).
+  #[error("invalid cookie domain: {value}")]
+  InvalidCookieDomain {
+    /// The rejected value.
+    value: String,
+  },
 }
 }
 
 
 /// Read an optional secret from a mounted file, trimming trailing newlines.
 /// Read an optional secret from a mounted file, trimming trailing newlines.

+ 24 - 2
src/config/build.rs

@@ -48,6 +48,11 @@ impl Config {
       .into_iter()
       .into_iter()
       .map(ScopeDefinition::from_raw)
       .map(ScopeDefinition::from_raw)
       .collect::<Result<Vec<_>, _>>()?;
       .collect::<Result<Vec<_>, _>>()?;
+    let cookie_secure = raw.cookie_secure.unwrap_or(true);
+    if environment.is_production() && !cookie_secure {
+      return Err(ConfigError::InsecureSessionCookie);
+    }
+    let cookie_domain = validate_cookie_domain(raw.cookie_domain)?;
     let token_exchange_allowlist = raw
     let token_exchange_allowlist = raw
       .token_exchange_allowlist
       .token_exchange_allowlist
       .into_iter()
       .into_iter()
@@ -67,8 +72,8 @@ impl Config {
       ttl,
       ttl,
       argon2,
       argon2,
       rate_limit,
       rate_limit,
-      cookie_domain: raw.cookie_domain,
-      cookie_secure: raw.cookie_secure.unwrap_or(true),
+      cookie_domain,
+      cookie_secure,
       allow_open_registration: raw.allow_open_registration,
       allow_open_registration: raw.allow_open_registration,
       pii_dual_write: raw.pii_dual_write,
       pii_dual_write: raw.pii_dual_write,
       dcr_redirect_policy: raw.dcr_redirect_policy,
       dcr_redirect_policy: raw.dcr_redirect_policy,
@@ -276,3 +281,20 @@ fn validate_timeout_ladder(db: &DbConfig, policy: &FailurePolicy) -> Result<(),
     Err(ConfigError::InvalidTimeoutLadder)
     Err(ConfigError::InvalidTimeoutLadder)
   }
   }
 }
 }
+
+/// Validate `cookie_domain` against the issuer host (T3-D2).
+///
+/// Every non-blank value is rejected: the `__Host-` session-cookie prefix
+/// forbids `Domain`, so sessions stay host-only by design and the key remains
+/// reserved. Blank values are treated as absent, matching the other optional
+/// keys.
+fn validate_cookie_domain(raw: Option<String>) -> Result<Option<String>, ConfigError> {
+  let Some(domain) = raw else {
+    return Ok(None);
+  };
+  let normalized = domain.trim().trim_start_matches('.').to_ascii_lowercase();
+  if normalized.is_empty() {
+    return Ok(None);
+  }
+  Err(ConfigError::InvalidCookieDomain { value: domain })
+}

+ 1 - 0
src/config/raw.rs

@@ -59,6 +59,7 @@ pub(crate) struct RawConfig {
   pub cookie_secure: Option<bool>,
   pub cookie_secure: Option<bool>,
   pub allow_open_registration: bool,
   pub allow_open_registration: bool,
   pub dcr_redirect_policy: DcrRedirectPolicy,
   pub dcr_redirect_policy: DcrRedirectPolicy,
+  #[serde(default, deserialize_with = "crate::config::deserialize_string_or_vec")]
   pub cors_allowed_origins: Vec<String>,
   pub cors_allowed_origins: Vec<String>,
   pub scope_registry: Vec<RawScopeDefinition>,
   pub scope_registry: Vec<RawScopeDefinition>,
   pub allow_token_exchange: bool,
   pub allow_token_exchange: bool,

+ 2 - 0
src/config/redis.rs

@@ -266,10 +266,12 @@ impl RedisConfig {
 #[serde(default)]
 #[serde(default)]
 pub(crate) struct RawRedisConfig {
 pub(crate) struct RawRedisConfig {
   pub url: Option<String>,
   pub url: Option<String>,
+  #[serde(default, deserialize_with = "crate::config::deserialize_string_or_vec")]
   pub nodes: Vec<String>,
   pub nodes: Vec<String>,
   pub master_name: Option<String>,
   pub master_name: Option<String>,
   pub sentinel_auth: Option<String>,
   pub sentinel_auth: Option<String>,
   pub data_auth: Option<String>,
   pub data_auth: Option<String>,
+  #[serde(default, deserialize_with = "crate::config::deserialize_string_or_vec")]
   pub seeds: Vec<String>,
   pub seeds: Vec<String>,
   pub auth: Option<String>,
   pub auth: Option<String>,
   pub tls: bool,
   pub tls: bool,

+ 329 - 0
src/config/tests.rs

@@ -589,3 +589,332 @@ fn read_secret_file_preserves_surrounding_spaces() -> Result<(), Box<dyn Error>>
   assert_eq!(secret.expose_secret(), "  spaced-secret  ");
   assert_eq!(secret.expose_secret(), "  spaced-secret  ");
   Ok(())
   Ok(())
 }
 }
+
+/// The trusted-proxy allowlist defaults to empty: headers are never honored
+/// unless the operator configures the proxy peers explicitly.
+#[test]
+fn config_trusted_proxies_default_empty() -> Result<(), Box<dyn Error>> {
+  let cfg = try_load(&base())?;
+  assert!(cfg.rate_limit.trusted_proxies.is_empty());
+  Ok(())
+}
+
+/// Exact IPs and CIDRs load into the allowlist and match only their range.
+#[test]
+fn config_accepts_trusted_proxy_allowlist() -> Result<(), Box<dyn Error>> {
+  use std::net::IpAddr;
+  use std::str::FromStr as _;
+  let mut map = base();
+  map.insert(
+    "rate_limit".to_owned(),
+    json!({ "trusted_proxies": ["10.0.0.1", "192.168.0.0/16", "2001:db8::/32"] }),
+  );
+  let cfg = try_load(&map)?;
+  assert_eq!(cfg.rate_limit.trusted_proxies.len(), 3);
+  let contains = |text: &str| {
+    cfg
+      .rate_limit
+      .trusted_proxies
+      .iter()
+      .any(|net| net.contains(IpAddr::from_str(text).unwrap_or(IpAddr::from([0, 0, 0, 0]))))
+  };
+  assert!(contains("10.0.0.1"));
+  assert!(!contains("10.0.0.2"));
+  assert!(contains("192.168.34.7"));
+  assert!(!contains("192.167.0.1"));
+  assert!(contains("2001:db8::1"));
+  assert!(!contains("2001:db9::1"));
+  Ok(())
+}
+
+/// T3-D2: the server-side session lifetime is pinned at 12 h. The review
+/// outcome is absolute expiry with no idle sliding: reads never re-arm the
+/// TTL, so a live session always dies 12 h after its last write.
+#[test]
+fn config_session_ttl_pinned_to_12h() -> Result<(), Box<dyn Error>> {
+  let cfg = try_load(&base())?;
+  assert_eq!(cfg.ttl.session, Duration::from_secs(43_200));
+  Ok(())
+}
+
+/// T3-D2: a zero session lifetime is rejected like every other zero TTL.
+#[test]
+fn config_rejects_zero_session_ttl() {
+  let mut map = base();
+  map.insert("ttl".to_owned(), json!({ "session_secs": 0 }));
+  assert!(matches!(try_load(&map), Err(ConfigError::InvalidTtl)));
+}
+
+/// T3-D2: production without the `Secure` cookie attribute is rejected at
+/// load; the `cookie_secure = false` gate is a development-only exception.
+#[test]
+fn config_rejects_production_cookie_without_secure() {
+  let mut map = base();
+  map.insert("cookie_secure".to_owned(), Value::Bool(false));
+  assert!(matches!(
+    try_load(&map),
+    Err(ConfigError::InsecureSessionCookie)
+  ));
+}
+
+/// T3-D2: development keeps the localhost exception (`Secure` off), and the
+/// session cookie drops the `__Host-` prefix there (browsers reject `__Host-`
+/// without `Secure`).
+#[test]
+fn config_allows_development_cookie_without_secure() -> Result<(), Box<dyn Error>> {
+  let mut map = base();
+  map.insert(
+    "environment".to_owned(),
+    Value::String("development".to_owned()),
+  );
+  map.insert("cookie_secure".to_owned(), Value::Bool(false));
+  let cfg = try_load(&map)?;
+  assert!(!cfg.cookie_secure);
+  assert_eq!(
+    crate::ui::login::session_cookie_name(&cfg),
+    crate::ui::login::INSECURE_SESSION_COOKIE
+  );
+  Ok(())
+}
+
+/// T3-D2: a `cookie_domain` outside the issuer host is rejected at load.
+#[test]
+fn config_rejects_cookie_domain_mismatch() {
+  let mut map = base();
+  map.insert(
+    "cookie_domain".to_owned(),
+    Value::String("evil.example".to_owned()),
+  );
+  assert!(matches!(
+    try_load(&map),
+    Err(ConfigError::InvalidCookieDomain { .. })
+  ));
+}
+
+/// T3-D2: even a matching `cookie_domain` is rejected: the `__Host-` session
+/// cookie forbids `Domain`, so sessions stay host-only by design.
+#[test]
+fn config_rejects_cookie_domain_even_when_suffix_of_issuer_host() {
+  for domain in ["auth.example.com", "example.com"] {
+    let mut map = base();
+    map.insert("cookie_domain".to_owned(), Value::String(domain.to_owned()));
+    assert!(
+      matches!(try_load(&map), Err(ConfigError::InvalidCookieDomain { .. })),
+      "cookie_domain {domain:?} must be rejected under the __Host- prefix"
+    );
+  }
+}
+
+/// T3-D2: a blank `cookie_domain` is treated as absent, matching the other
+/// optional keys — only a non-blank value is rejected.
+#[test]
+fn config_blank_cookie_domain_treated_as_absent() -> Result<(), Box<dyn Error>> {
+  for domain in ["", "   ", ".", " . "] {
+    let mut map = base();
+    map.insert("cookie_domain".to_owned(), Value::String(domain.to_owned()));
+    let cfg = try_load(&map)?;
+    assert!(
+      cfg.cookie_domain.is_none(),
+      "blank cookie_domain {domain:?} must be treated as absent"
+    );
+  }
+  Ok(())
+}
+
+/// The documented env form `D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES='["10.0.0.5"]'`
+/// loads through `env_source()`: the JSON array string becomes one allowlist
+/// entry (config-rs 0.15 leaves env values as strings without this handling).
+#[test]
+fn config_trusted_proxies_env_array_loads() -> Result<(), Box<dyn Error>> {
+  use std::net::IpAddr;
+  use std::str::FromStr as _;
+  let mut env = std::collections::HashMap::new();
+  env.insert(
+    "D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES".to_owned(),
+    r#"["10.0.0.5"]"#.to_owned(),
+  );
+  let built = ::config::Config::builder()
+    .add_source(super::env_source().source(Some(env)))
+    .build()?;
+  let raw: super::raw::RawConfig = built.try_deserialize()?;
+  assert_eq!(raw.rate_limit.trusted_proxies, vec!["10.0.0.5"]);
+  let validated = super::types::RateLimitConfig::from_raw(raw.rate_limit)?;
+  assert_eq!(validated.trusted_proxies.len(), 1);
+  let probe = IpAddr::from_str("10.0.0.5").map_err(|_| "bad test ip")?;
+  assert!(
+    validated
+      .trusted_proxies
+      .iter()
+      .any(|net| net.contains(probe))
+  );
+  Ok(())
+}
+
+/// A malformed entry, an out-of-range prefix, or a trust-everything `/0`
+/// fails the load: an unparseable allowlist must never silently mean "trust
+/// nobody" or "trust everybody".
+#[test]
+fn config_rejects_invalid_trusted_proxy() {
+  for entry in [
+    "not-an-ip",
+    "",
+    "10.0.0.0/33",
+    "2001:db8::/129",
+    "10.0.0.1/-1",
+    "10.0.0.0/0",
+    "0.0.0.0/0",
+    "::/0",
+    "10.0.0.1/abc",
+    "10.0.0.256",
+  ] {
+    let mut map = base();
+    map.insert(
+      "rate_limit".to_owned(),
+      json!({ "trusted_proxies": [entry] }),
+    );
+    assert!(
+      matches!(try_load(&map), Err(ConfigError::InvalidTrustedProxy { .. })),
+      "entry {entry:?} must be rejected"
+    );
+  }
+}
+
+/// T3-C3: CIDR boundary prefixes match exactly their range. A `/32` (v4) or
+/// `/128` (v6) host route matches only its own address; a `/1` splits the
+/// address space in half.
+#[test]
+fn config_trusted_proxy_cidr_boundaries() -> Result<(), Box<dyn Error>> {
+  use std::net::IpAddr;
+  use std::str::FromStr as _;
+
+  use super::types::TrustedProxyNet;
+  let host_v4 = TrustedProxyNet::from_str("10.0.0.5/32")?;
+  assert!(host_v4.contains(IpAddr::from_str("10.0.0.5")?));
+  assert!(!host_v4.contains(IpAddr::from_str("10.0.0.6")?));
+  assert!(!host_v4.contains(IpAddr::from_str("10.0.0.4")?));
+  let host_v6 = TrustedProxyNet::from_str("2001:db8::1/128")?;
+  assert!(host_v6.contains(IpAddr::from_str("2001:db8::1")?));
+  assert!(!host_v6.contains(IpAddr::from_str("2001:db8::2")?));
+  let lower_half = TrustedProxyNet::from_str("0.0.0.0/1")?;
+  assert!(lower_half.contains(IpAddr::from_str("10.0.0.1")?));
+  assert!(lower_half.contains(IpAddr::from_str("127.0.0.1")?));
+  assert!(!lower_half.contains(IpAddr::from_str("128.0.0.1")?));
+  assert!(!lower_half.contains(IpAddr::from_str("200.0.0.1")?));
+  let upper_half = TrustedProxyNet::from_str("128.0.0.0/1")?;
+  assert!(upper_half.contains(IpAddr::from_str("200.0.0.1")?));
+  assert!(!upper_half.contains(IpAddr::from_str("10.0.0.1")?));
+  Ok(())
+}
+
+/// T3-C3: a CIDR base with host bits set is normalized to its network base,
+/// so `192.168.1.5/24` behaves exactly like `192.168.1.0/24`.
+#[test]
+fn config_trusted_proxy_prefix_normalization() -> Result<(), Box<dyn Error>> {
+  use std::net::IpAddr;
+  use std::str::FromStr as _;
+
+  use super::types::TrustedProxyNet;
+  let host_bits = TrustedProxyNet::from_str("192.168.1.5/24")?;
+  let canonical = TrustedProxyNet::from_str("192.168.1.0/24")?;
+  for address in ["192.168.1.1", "192.168.1.5", "192.168.1.254"] {
+    let ip = IpAddr::from_str(address)?;
+    assert!(
+      host_bits.contains(ip),
+      "{address} must be inside 192.168.1.5/24"
+    );
+    assert_eq!(host_bits.contains(ip), canonical.contains(ip));
+  }
+  for address in ["192.168.2.5", "192.168.0.5", "10.0.0.1"] {
+    let ip = IpAddr::from_str(address)?;
+    assert!(
+      !host_bits.contains(ip),
+      "{address} must be outside 192.168.1.5/24"
+    );
+  }
+  let host_bits_v6 = TrustedProxyNet::from_str("2001:db8::1/32")?;
+  assert!(host_bits_v6.contains(IpAddr::from_str("2001:db8::dead:beef")?));
+  assert!(!host_bits_v6.contains(IpAddr::from_str("2001:db9::1")?));
+  Ok(())
+}
+
+/// T3-C3: an entry never matches an address of the other family, so a v4
+/// allowlist cannot be satisfied by a v6 peer and vice versa.
+#[test]
+fn config_trusted_proxy_family_mismatch_never_matches() -> Result<(), Box<dyn Error>> {
+  use std::net::IpAddr;
+  use std::str::FromStr as _;
+
+  use super::types::TrustedProxyNet;
+  let v4_net = TrustedProxyNet::from_str("10.0.0.0/8")?;
+  assert!(!v4_net.contains(IpAddr::from_str("::1")?));
+  assert!(!v4_net.contains(IpAddr::from_str("::ffff:10.0.0.1")?));
+  assert!(!v4_net.contains(IpAddr::from_str("2001:db8::1")?));
+  let v6_net = TrustedProxyNet::from_str("2001:db8::/32")?;
+  assert!(!v6_net.contains(IpAddr::from_str("127.0.0.1")?));
+  assert!(!v6_net.contains(IpAddr::from_str("10.0.0.1")?));
+  let v4_host = TrustedProxyNet::from_str("10.0.0.1")?;
+  assert!(!v4_host.contains(IpAddr::from_str("::ffff:10.0.0.1")?));
+  Ok(())
+}
+
+/// T3-C3: the allowlist cap admits exactly 64 entries; the 65th fails the
+/// load instead of silently truncating the operator's intent.
+#[test]
+fn config_trusted_proxy_allowlist_cap_64_accepts_65_rejects() -> Result<(), Box<dyn Error>> {
+  let entries: Vec<Value> = (0..64)
+    .map(|index| Value::String(format!("10.0.{index}.1")))
+    .collect();
+  let mut map = base();
+  map.insert(
+    "rate_limit".to_owned(),
+    json!({ "trusted_proxies": entries }),
+  );
+  let cfg = try_load(&map)?;
+  assert_eq!(cfg.rate_limit.trusted_proxies.len(), 64);
+  let entries: Vec<Value> = (0..65)
+    .map(|index| Value::String(format!("10.0.{index}.1")))
+    .collect();
+  let mut map = base();
+  map.insert(
+    "rate_limit".to_owned(),
+    json!({ "trusted_proxies": entries }),
+  );
+  assert!(
+    matches!(try_load(&map), Err(ConfigError::InvalidTrustedProxy { .. })),
+    "65 trusted-proxy entries must exceed the allowlist cap"
+  );
+  Ok(())
+}
+
+/// T3-C3: surrounding whitespace is trimmed and duplicate entries load as
+/// written (no silent dedup, no silent drop).
+#[test]
+fn config_trusted_proxy_whitespace_and_duplicates() -> Result<(), Box<dyn Error>> {
+  use std::net::IpAddr;
+  use std::str::FromStr as _;
+
+  let mut map = base();
+  map.insert(
+    "rate_limit".to_owned(),
+    json!({ "trusted_proxies": ["  10.0.0.1  ", "10.0.0.1", "\t192.168.0.0/16\n"] }),
+  );
+  let cfg = try_load(&map)?;
+  assert_eq!(cfg.rate_limit.trusted_proxies.len(), 3);
+  let probe_v4 = IpAddr::from_str("10.0.0.1")?;
+  assert!(
+    cfg
+      .rate_limit
+      .trusted_proxies
+      .iter()
+      .any(|net| net.contains(probe_v4))
+  );
+  let probe_range = IpAddr::from_str("192.168.9.9")?;
+  assert!(
+    cfg
+      .rate_limit
+      .trusted_proxies
+      .iter()
+      .any(|net| net.contains(probe_range))
+  );
+  Ok(())
+}

+ 115 - 1
src/config/types.rs

@@ -4,6 +4,8 @@
 //! encode the plan's adopted defaults; conversion to the validated type
 //! encode the plan's adopted defaults; conversion to the validated type
 //! performs the fail-fast checks and returns [`ConfigError`](super::ConfigError).
 //! performs the fail-fast checks and returns [`ConfigError`](super::ConfigError).
 
 
+use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
+use std::str::FromStr;
 use std::time::Duration;
 use std::time::Duration;
 
 
 use serde::Deserialize;
 use serde::Deserialize;
@@ -191,7 +193,7 @@ impl Default for RawArgon2 {
 }
 }
 
 
 /// Rate-limit knobs; counters live in Redis under the rate-limit key prefix.
 /// Rate-limit knobs; counters live in Redis under the rate-limit key prefix.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq)]
 pub struct RateLimitConfig {
 pub struct RateLimitConfig {
   /// Whether rate limiting is enforced.
   /// Whether rate limiting is enforced.
   pub enabled: bool,
   pub enabled: bool,
@@ -205,6 +207,12 @@ pub struct RateLimitConfig {
   pub login_per_ip: u32,
   pub login_per_ip: u32,
   /// Per-client token-request budget per window.
   /// Per-client token-request budget per window.
   pub token_per_client: u32,
   pub token_per_client: u32,
+  /// TCP peers whose `X-Forwarded-For` is honored for rate-limit IP
+  /// attribution (default empty: headers are never honored). Each entry is an
+  /// exact IP or a CIDR. This trust is narrowly scoped to rate-limit bucket
+  /// selection only: it never authenticates a client and never carries a
+  /// client certificate (mTLS still requires L4/TCP passthrough).
+  pub trusted_proxies: Vec<TrustedProxyNet>,
 }
 }
 
 
 impl Default for RateLimitConfig {
 impl Default for RateLimitConfig {
@@ -216,6 +224,7 @@ impl Default for RateLimitConfig {
       per_client: 300,
       per_client: 300,
       login_per_ip: 20,
       login_per_ip: 20,
       token_per_client: 60,
       token_per_client: 60,
+      trusted_proxies: Vec::new(),
     }
     }
   }
   }
 }
 }
@@ -225,6 +234,16 @@ impl RateLimitConfig {
     if raw.window_secs == 0 {
     if raw.window_secs == 0 {
       return Err(ConfigError::InvalidRateLimit);
       return Err(ConfigError::InvalidRateLimit);
     }
     }
+    if raw.trusted_proxies.len() > MAX_TRUSTED_PROXIES {
+      return Err(ConfigError::InvalidTrustedProxy {
+        value: format!("allowlist exceeds {MAX_TRUSTED_PROXIES} entries"),
+      });
+    }
+    let trusted_proxies = raw
+      .trusted_proxies
+      .iter()
+      .map(|entry| TrustedProxyNet::from_str(entry))
+      .collect::<Result<Vec<_>, _>>()?;
     Ok(Self {
     Ok(Self {
       enabled: raw.enabled,
       enabled: raw.enabled,
       window: Duration::from_secs(raw.window_secs),
       window: Duration::from_secs(raw.window_secs),
@@ -232,6 +251,7 @@ impl RateLimitConfig {
       per_client: raw.per_client,
       per_client: raw.per_client,
       login_per_ip: raw.login_per_ip,
       login_per_ip: raw.login_per_ip,
       token_per_client: raw.token_per_client,
       token_per_client: raw.token_per_client,
+      trusted_proxies,
     })
     })
   }
   }
 }
 }
@@ -246,6 +266,8 @@ pub(crate) struct RawRateLimit {
   pub per_client: u32,
   pub per_client: u32,
   pub login_per_ip: u32,
   pub login_per_ip: u32,
   pub token_per_client: u32,
   pub token_per_client: u32,
+  #[serde(default, deserialize_with = "crate::config::deserialize_string_or_vec")]
+  pub trusted_proxies: Vec<String>,
 }
 }
 
 
 impl Default for RawRateLimit {
 impl Default for RawRateLimit {
@@ -258,6 +280,98 @@ impl Default for RawRateLimit {
       per_client: r.per_client,
       per_client: r.per_client,
       login_per_ip: r.login_per_ip,
       login_per_ip: r.login_per_ip,
       token_per_client: r.token_per_client,
       token_per_client: r.token_per_client,
+      trusted_proxies: Vec::new(),
+    }
+  }
+}
+
+/// Bound on the trusted-proxy allowlist: the limiter scans it once per
+/// request, so an unbounded list would be a per-request cost the operator did
+/// not intend.
+const MAX_TRUSTED_PROXIES: usize = 64;
+
+/// One trusted-proxy allowlist entry: an exact IP or a masked CIDR base.
+///
+/// Parsed with [`FromStr`] (`"10.0.0.1"`, `"10.0.0.0/8"`,
+/// `"2001:db8::/32"`); a bare IP is a host route. A `/0` prefix would trust
+/// every peer and is rejected, as is any malformed entry or out-of-range
+/// prefix length — all fail the config load via
+/// [`ConfigError::InvalidTrustedProxy`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct TrustedProxyNet {
+  network: IpAddr,
+  prefix_len: u8,
+}
+
+impl TrustedProxyNet {
+  /// Whether `ip` falls inside this entry (family mismatch never matches).
+  #[must_use]
+  pub fn contains(self, ip: IpAddr) -> bool {
+    match (self.network, ip) {
+      (IpAddr::V4(net), IpAddr::V4(addr)) => {
+        masked_v4(net, self.prefix_len) == masked_v4(addr, self.prefix_len)
+      }
+      (IpAddr::V6(net), IpAddr::V6(addr)) => {
+        masked_v6(net, self.prefix_len) == masked_v6(addr, self.prefix_len)
+      }
+      _ => false,
+    }
+  }
+}
+
+impl FromStr for TrustedProxyNet {
+  type Err = ConfigError;
+
+  fn from_str(raw: &str) -> Result<Self, Self::Err> {
+    let invalid = |value: &str| ConfigError::InvalidTrustedProxy {
+      value: value.to_owned(),
+    };
+    let entry = raw.trim();
+    if entry.is_empty() {
+      return Err(invalid(raw));
     }
     }
+    let (addr_text, prefix_len) = match entry.split_once('/') {
+      Some((addr_text, prefix_text)) => {
+        let max = if addr_text.contains(':') { 128 } else { 32 };
+        let prefix_len: u8 = prefix_text.trim().parse().map_err(|_| invalid(raw))?;
+        if prefix_len == 0 || prefix_len > max {
+          return Err(invalid(raw));
+        }
+        (addr_text.trim(), prefix_len)
+      }
+      None => {
+        let max = if entry.contains(':') { 128 } else { 32 };
+        (entry, max)
+      }
+    };
+    let addr: IpAddr = addr_text.parse().map_err(|_| invalid(raw))?;
+    let network = match addr {
+      IpAddr::V4(base) => IpAddr::V4(masked_v4(base, prefix_len)),
+      IpAddr::V6(base) => IpAddr::V6(masked_v6(base, prefix_len)),
+    };
+    Ok(Self {
+      network,
+      prefix_len,
+    })
   }
   }
 }
 }
+
+/// The network base with the host bits beyond `prefix_len` cleared.
+/// `prefix_len` is at most 32 here (enforced by [`FromStr`]).
+fn masked_v4(addr: Ipv4Addr, prefix_len: u8) -> Ipv4Addr {
+  let bits = u32::from(addr);
+  let mask = u32::MAX
+    .checked_shl(32 - u32::from(prefix_len))
+    .unwrap_or(0);
+  Ipv4Addr::from(bits & mask)
+}
+
+/// The network base with the host bits beyond `prefix_len` cleared.
+/// `prefix_len` is at most 128 here (enforced by [`FromStr`]).
+fn masked_v6(addr: Ipv6Addr, prefix_len: u8) -> Ipv6Addr {
+  let bits = u128::from(addr);
+  let mask = u128::MAX
+    .checked_shl(128 - u32::from(prefix_len))
+    .unwrap_or(0);
+  Ipv6Addr::from(bits & mask)
+}

+ 3 - 2
src/db/user_repo.rs

@@ -474,6 +474,7 @@ pub fn hash_password(password: &str, params: &Argon2Params) -> Result<String, Ap
 mod lockout;
 mod lockout;
 
 
 pub use lockout::{
 pub use lockout::{
-  LockoutPolicy, clear_login_failures, is_locked_out, lockout_remaining, login_failure_count,
-  register_login_failure,
+  LockoutPolicy, clear_login_failures, ip_failure_count, is_locked_out, is_unknown_locked_out,
+  lockout_remaining, login_failure_count, register_login_failure, register_unknown_failure,
+  unknown_failure_count, unlock_login,
 };
 };

+ 609 - 16
src/db/user_repo/lockout.rs

@@ -5,8 +5,16 @@
 //! No lockout state is ever written to Postgres. The low-level counter
 //! No lockout state is ever written to Postgres. The low-level counter
 //! primitive is [`crate::store::sessions`]; this module owns the policy
 //! primitive is [`crate::store::sessions`]; this module owns the policy
 //! (threshold, lock duration, unlock-on-success/expiry) on top of it.
 //! (threshold, lock duration, unlock-on-success/expiry) on top of it.
+//!
+//! T3-C1 composite policy: every failure increments both the per-account
+//! counter (a global abuse cap) and the per-(IP, account) composite counter.
+//! An account is locked for a source IP when either bucket reaches its
+//! threshold, so failures from one IP never lock the account for another IP
+//! while a distributed burst still trips the global cap. Failures for absent
+//! labels never mint per-label keys; they share the source IP's single
+//! unknown bucket, so label enumeration cannot spray the keyspace.
 
 
-use std::time::Duration;
+use std::{net::IpAddr, time::Duration};
 
 
 use crate::{
 use crate::{
   state::RedisConn,
   state::RedisConn,
@@ -15,35 +23,58 @@ use crate::{
 
 
 use super::normalize_email;
 use super::normalize_email;
 
 
-/// Login lockout policy: after `threshold` failures within `lock_duration`,
-/// the account is locked until the counter's TTL expires or a successful login
-/// clears it.
+/// Login lockout policy: after `threshold` failures from one IP for one
+/// account within `lock_duration`, that IP is locked out for the account;
+/// after `account_threshold` failures from any IPs in the window, the account
+/// is locked globally. The window doubles as the lock duration.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub struct LockoutPolicy {
 pub struct LockoutPolicy {
-  /// Failures within the window that trigger a lock.
+  /// Failures from one IP for one account within the window that lock it.
   pub threshold: u64,
   pub threshold: u64,
+  /// Failures from any IPs within the window that lock the account globally.
+  pub account_threshold: u64,
   /// Fixed window that doubles as the lock duration.
   /// Fixed window that doubles as the lock duration.
   pub lock_duration: Duration,
   pub lock_duration: Duration,
 }
 }
 
 
 impl LockoutPolicy {
 impl LockoutPolicy {
-  /// Validate a policy; `None` when the threshold is zero or the window empty.
+  /// Validate a policy; `None` when either threshold is zero or the window
+  /// is empty.
   #[must_use]
   #[must_use]
-  pub fn new(threshold: u64, lock_duration: Duration) -> Option<Self> {
-    (threshold > 0 && !lock_duration.is_zero()).then_some(Self {
+  pub fn new(threshold: u64, account_threshold: u64, lock_duration: Duration) -> Option<Self> {
+    (threshold > 0 && account_threshold > 0 && !lock_duration.is_zero()).then_some(Self {
       threshold,
       threshold,
+      account_threshold,
       lock_duration,
       lock_duration,
     })
     })
   }
   }
 
 
-  /// Whether `count` failures meet or exceed the lock threshold.
+  /// Whether `count` failures meet or exceed the per-IP lock threshold.
   #[must_use]
   #[must_use]
   pub fn is_locked(&self, count: u64) -> bool {
   pub fn is_locked(&self, count: u64) -> bool {
     count >= self.threshold
     count >= self.threshold
   }
   }
+
+  /// Whether `count` failures meet or exceed the global account cap.
+  #[must_use]
+  pub fn is_account_locked(&self, count: u64) -> bool {
+    count >= self.account_threshold
+  }
 }
 }
 
 
-/// Record a failed login and return the in-window failure count.
+/// Source-IP identity for composite keying: the peer address, or the fixed
+/// `unknown-peer` bucket when the peer is absent (one bounded key shared by
+/// all peer-less attempts, never a raw value in Redis).
+fn ip_label(peer_ip: Option<IpAddr>) -> String {
+  peer_ip.map_or_else(|| "unknown-peer".to_owned(), |ip| ip.to_string())
+}
+
+/// Record a failed login and return the in-window failure count for this
+/// source.
+///
+/// Both the per-account counter and, when the peer is known, the composite
+/// per-(IP, account) counter advance. The returned count is the composite one
+/// for a known peer and the account one otherwise.
 ///
 ///
 /// # Errors
 /// # Errors
 /// Returns a [`StoreError`] when Redis is unavailable.
 /// Returns a [`StoreError`] when Redis is unavailable.
@@ -52,16 +83,41 @@ pub async fn register_login_failure(
   store: &Store,
   store: &Store,
   policy: LockoutPolicy,
   policy: LockoutPolicy,
   account_label: &str,
   account_label: &str,
+  peer_ip: Option<IpAddr>,
 ) -> Result<u64, StoreError> {
 ) -> Result<u64, StoreError> {
-  sessions::record_failed_login(
+  let canonical = normalize_email(account_label);
+  let account =
+    sessions::record_failed_login(conn, store, &canonical, policy.lock_duration).await?;
+  let Some(peer) = peer_ip else {
+    return Ok(account);
+  };
+  sessions::record_ip_failure(
     conn,
     conn,
     store,
     store,
-    &normalize_email(account_label),
+    &ip_label(Some(peer)),
+    &canonical,
     policy.lock_duration,
     policy.lock_duration,
   )
   )
   .await
   .await
 }
 }
 
 
+/// Record a failed login for an absent account label and return the
+/// in-window unknown count for this source.
+///
+/// No per-label key is created: the attempt counts toward the source IP's
+/// single shared bucket, keeping the keyspace bounded under enumeration.
+///
+/// # Errors
+/// Returns a [`StoreError`] when Redis is unavailable.
+pub async fn register_unknown_failure(
+  conn: &mut RedisConn,
+  store: &Store,
+  policy: LockoutPolicy,
+  peer_ip: Option<IpAddr>,
+) -> Result<u64, StoreError> {
+  sessions::record_unknown_failure(conn, store, &ip_label(peer_ip), policy.lock_duration).await
+}
+
 /// Current in-window failure count for an account label.
 /// Current in-window failure count for an account label.
 ///
 ///
 /// # Errors
 /// # Errors
@@ -74,7 +130,45 @@ pub async fn login_failure_count(
   sessions::failed_login_count(conn, store, &normalize_email(account_label)).await
   sessions::failed_login_count(conn, store, &normalize_email(account_label)).await
 }
 }
 
 
-/// Whether the account is currently locked out.
+/// Current in-window composite count for one source IP and account label
+/// (`0` when the peer is absent: peer-less attempts track the account
+/// counter only).
+///
+/// # Errors
+/// Returns a [`StoreError`] when Redis is unavailable.
+pub async fn ip_failure_count(
+  conn: &mut RedisConn,
+  store: &Store,
+  account_label: &str,
+  peer_ip: Option<IpAddr>,
+) -> Result<u64, StoreError> {
+  let Some(peer) = peer_ip else {
+    return Ok(0);
+  };
+  sessions::ip_failure_count(
+    conn,
+    store,
+    &ip_label(Some(peer)),
+    &normalize_email(account_label),
+  )
+  .await
+}
+
+/// Current in-window unknown-label count for one source.
+///
+/// # Errors
+/// Returns a [`StoreError`] when Redis is unavailable.
+pub async fn unknown_failure_count(
+  conn: &mut RedisConn,
+  store: &Store,
+  peer_ip: Option<IpAddr>,
+) -> Result<u64, StoreError> {
+  sessions::unknown_failure_count(conn, store, &ip_label(peer_ip)).await
+}
+
+/// Whether the account is currently locked out for this source: the
+/// composite per-(IP, account) bucket reached `threshold`, or the global
+/// per-account counter reached `account_threshold`.
 ///
 ///
 /// # Errors
 /// # Errors
 /// Returns a [`StoreError`] when Redis is unavailable.
 /// Returns a [`StoreError`] when Redis is unavailable.
@@ -83,12 +177,39 @@ pub async fn is_locked_out(
   store: &Store,
   store: &Store,
   policy: LockoutPolicy,
   policy: LockoutPolicy,
   account_label: &str,
   account_label: &str,
+  peer_ip: Option<IpAddr>,
+) -> Result<bool, StoreError> {
+  let canonical = normalize_email(account_label);
+  let account = sessions::failed_login_count(conn, store, &canonical).await?;
+  if policy.is_account_locked(account) {
+    return Ok(true);
+  }
+  let Some(peer) = peer_ip else {
+    return Ok(false);
+  };
+  let composite =
+    sessions::ip_failure_count(conn, store, &ip_label(Some(peer)), &canonical).await?;
+  Ok(policy.is_locked(composite))
+}
+
+/// Whether unknown labels are currently locked out for this source: the
+/// source IP's shared unknown bucket reached `threshold`.
+///
+/// # Errors
+/// Returns a [`StoreError`] when Redis is unavailable.
+pub async fn is_unknown_locked_out(
+  conn: &mut RedisConn,
+  store: &Store,
+  policy: LockoutPolicy,
+  peer_ip: Option<IpAddr>,
 ) -> Result<bool, StoreError> {
 ) -> Result<bool, StoreError> {
-  let count = login_failure_count(conn, store, account_label).await?;
+  let count = sessions::unknown_failure_count(conn, store, &ip_label(peer_ip)).await?;
   Ok(policy.is_locked(count))
   Ok(policy.is_locked(count))
 }
 }
 
 
-/// Clear the failed-login counter after a successful login.
+/// Clear the failed-login counters after a successful login: the per-account
+/// counter and this source's composite bucket. Other sources' buckets keep
+/// their TTL, so a successful login does not release an attacker's IP.
 ///
 ///
 /// # Errors
 /// # Errors
 /// Returns a [`StoreError`] when Redis is unavailable.
 /// Returns a [`StoreError`] when Redis is unavailable.
@@ -96,8 +217,64 @@ pub async fn clear_login_failures(
   conn: &mut RedisConn,
   conn: &mut RedisConn,
   store: &Store,
   store: &Store,
   account_label: &str,
   account_label: &str,
+  peer_ip: Option<IpAddr>,
 ) -> Result<(), StoreError> {
 ) -> Result<(), StoreError> {
-  sessions::clear_failed_logins(conn, store, &normalize_email(account_label)).await
+  let canonical = normalize_email(account_label);
+  sessions::clear_failed_logins(conn, store, &canonical).await?;
+  if let Some(peer) = peer_ip {
+    sessions::clear_ip_failures(conn, store, &ip_label(Some(peer)), &canonical).await?;
+  }
+  Ok(())
+}
+
+/// Clear every lockout counter for an account label: the per-account counter
+/// and all per-IP composite buckets. Backs the `user unlock` admin command.
+///
+/// Returns the number of keys removed. The unknown buckets carry no label
+/// and are never touched.
+///
+/// # Errors
+/// Returns a [`StoreError`] when Redis is unavailable.
+pub async fn unlock_login(
+  conn: &mut RedisConn,
+  store: &Store,
+  account_label: &str,
+) -> Result<u64, StoreError> {
+  let account_hash = store::sha256_hex(normalize_email(account_label).as_bytes());
+  let account_key = store.login_fail_key(&account_hash);
+  let mut removed = 0;
+  let deleted: i64 = redis::cmd("DEL")
+    .arg(&account_key)
+    .query_async(conn)
+    .await
+    .map_err(StoreError::Redis)?;
+  removed += u64::try_from(deleted).unwrap_or(0);
+  let pattern = format!("{}*:{account_hash}", store.login_fail_ip_prefix());
+  let mut cursor = "0".to_owned();
+  loop {
+    let (next, batch): (String, Vec<String>) = redis::cmd("SCAN")
+      .arg(&cursor)
+      .arg("MATCH")
+      .arg(&pattern)
+      .arg("COUNT")
+      .arg(100)
+      .query_async(conn)
+      .await
+      .map_err(StoreError::Redis)?;
+    for key in &batch {
+      let deleted: i64 = redis::cmd("DEL")
+        .arg(key)
+        .query_async(conn)
+        .await
+        .map_err(StoreError::Redis)?;
+      removed += u64::try_from(deleted).unwrap_or(0);
+    }
+    if next == "0" {
+      break;
+    }
+    cursor = next;
+  }
+  Ok(removed)
 }
 }
 
 
 /// Remaining lockout time, or `None` when the account is not locked (the
 /// Remaining lockout time, or `None` when the account is not locked (the
@@ -120,3 +297,419 @@ pub async fn lockout_remaining(
     .map_err(StoreError::Redis)?;
     .map_err(StoreError::Redis)?;
   Ok((pttl > 0).then(|| Duration::from_millis(u64::try_from(pttl).unwrap_or(0))))
   Ok((pttl > 0).then(|| Duration::from_millis(u64::try_from(pttl).unwrap_or(0))))
 }
 }
+
+#[cfg(test)]
+mod tests {
+  use std::{
+    net::{IpAddr, Ipv4Addr},
+    sync::atomic::{AtomicU64, Ordering},
+    time::Duration,
+  };
+
+  use testcontainers::{
+    GenericImage, ImageExt,
+    core::{IntoContainerPort, WaitFor},
+    runners::AsyncRunner,
+  };
+
+  use super::{
+    LockoutPolicy, clear_login_failures, ip_failure_count, is_locked_out, is_unknown_locked_out,
+    login_failure_count, register_login_failure, register_unknown_failure, unknown_failure_count,
+    unlock_login,
+  };
+  use crate::{config::DurabilityMode, state::RedisConn, store::Store, test_guard::ContainerGuard};
+
+  static NONCE: AtomicU64 = AtomicU64::new(1);
+
+  fn attacker_ip() -> IpAddr {
+    IpAddr::V4(Ipv4Addr::new(192, 0, 2, 7))
+  }
+
+  fn victim_ip() -> IpAddr {
+    IpAddr::V4(Ipv4Addr::new(198, 51, 100, 23))
+  }
+
+  fn policy() -> Result<LockoutPolicy, Box<dyn std::error::Error + Send + Sync>> {
+    LockoutPolicy::new(5, 20, Duration::from_secs(900)).ok_or("invalid policy".into())
+  }
+
+  async fn harness() -> Result<(ContainerGuard, RedisConn, Store), TestError> {
+    let n = NONCE.fetch_add(1, Ordering::Relaxed);
+    let container = GenericImage::new("redis", "7.2-alpine")
+      .with_exposed_port(6379.tcp())
+      .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
+      .with_cmd(["redis-server", "--appendonly", "yes"])
+      .start()
+      .await?;
+    let guarded = ContainerGuard::new(container);
+    let port = guarded.get_host_port_ipv4(6379).await?;
+    let url = format!("redis://127.0.0.1:{port}");
+    let client = redis::Client::open(url.as_str())?;
+    let manager_config = redis::aio::ConnectionManagerConfig::new()
+      .set_response_timeout(Some(Duration::from_secs(10)))
+      .set_connection_timeout(Some(Duration::from_secs(5)));
+    let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
+    let store = Store::from_parts(
+      format!("lockout-test-{n}:{{state}}:"),
+      "oauth:{rl}:".to_owned(),
+      DurabilityMode::WaitAof,
+    );
+    Ok((guarded, RedisConn::Single(manager), store))
+  }
+
+  #[derive(Debug, thiserror::Error)]
+  enum TestError {
+    #[error("container error: {0}")]
+    Containers(#[from] testcontainers::TestcontainersError),
+    #[error("redis error: {0}")]
+    Redis(#[from] redis::RedisError),
+    #[error("store error: {0}")]
+    Store(#[from] crate::store::StoreError),
+    #[error("unexpected test value: {0}")]
+    Unexpected(String),
+  }
+
+  type TestResult = Result<(), TestError>;
+
+  async fn scan_login_fail(conn: &mut RedisConn, store: &Store) -> Result<Vec<String>, TestError> {
+    let pattern = format!("{}*", store.login_fail_prefix());
+    let mut cursor = "0".to_owned();
+    let mut out = Vec::new();
+    loop {
+      let (next, batch): (String, Vec<String>) = redis::cmd("SCAN")
+        .arg(&cursor)
+        .arg("MATCH")
+        .arg(&pattern)
+        .arg("COUNT")
+        .arg(100)
+        .query_async(conn)
+        .await?;
+      out.extend(batch);
+      if next == "0" {
+        break;
+      }
+      cursor = next;
+    }
+    Ok(out)
+  }
+
+  /// T3-C1 (a): five failures from an attacker IP lock that IP's composite
+  /// bucket only; the same account from a different IP is not locked.
+  #[tokio::test]
+  async fn lockout_attacker_ip_burst_does_not_lock_victim_ip() -> TestResult {
+    let (_guard, mut conn, store) = harness().await?;
+    let policy = policy().map_err(|_| TestError::Unexpected("invalid policy".to_owned()))?;
+    let label = "victim@example.test";
+    for _ in 0..5 {
+      register_login_failure(&mut conn, &store, policy, label, Some(attacker_ip())).await?;
+    }
+    if !is_locked_out(&mut conn, &store, policy, label, Some(attacker_ip())).await? {
+      return Err(TestError::Unexpected(
+        "the attacker IP bucket must lock after the threshold".to_owned(),
+      ));
+    }
+    if is_locked_out(&mut conn, &store, policy, label, Some(victim_ip())).await? {
+      return Err(TestError::Unexpected(
+        "the victim IP must not inherit the attacker IP lockout".to_owned(),
+      ));
+    }
+    Ok(())
+  }
+
+  /// T3-C1 (b): unknown labels never mint per-label keys; every unknown
+  /// attempt from one IP lands in that IP's single bounded bucket.
+  #[tokio::test]
+  async fn lockout_unknown_labels_share_one_bounded_bucket() -> TestResult {
+    let (_guard, mut conn, store) = harness().await?;
+    let policy = policy().map_err(|_| TestError::Unexpected("invalid policy".to_owned()))?;
+    for _ in 0..25 {
+      register_unknown_failure(&mut conn, &store, policy, Some(attacker_ip())).await?;
+    }
+    if !is_unknown_locked_out(&mut conn, &store, policy, Some(attacker_ip())).await? {
+      return Err(TestError::Unexpected(
+        "the unknown bucket must lock after the threshold".to_owned(),
+      ));
+    }
+    if is_unknown_locked_out(&mut conn, &store, policy, Some(victim_ip())).await? {
+      return Err(TestError::Unexpected(
+        "the unknown lockout must not leak across IPs".to_owned(),
+      ));
+    }
+    if unknown_failure_count(&mut conn, &store, Some(attacker_ip())).await? != 25 {
+      return Err(TestError::Unexpected(
+        "the unknown bucket must count every attempt".to_owned(),
+      ));
+    }
+    let keys = scan_login_fail(&mut conn, &store).await?;
+    if keys.len() != 1 {
+      return Err(TestError::Unexpected(format!(
+        "25 unknown labels must leave exactly one key, found {}",
+        keys.len()
+      )));
+    }
+    Ok(())
+  }
+
+  /// T3-C1 (c): the admin unlock clears the account counter and every
+  /// per-IP composite bucket for the label.
+  #[tokio::test]
+  async fn lockout_unlock_clears_composite_and_account() -> TestResult {
+    let (_guard, mut conn, store) = harness().await?;
+    let policy = LockoutPolicy::new(2, 4, Duration::from_secs(900))
+      .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
+    let label = "locked@example.test";
+    for _ in 0..2 {
+      register_login_failure(&mut conn, &store, policy, label, Some(attacker_ip())).await?;
+      register_login_failure(&mut conn, &store, policy, label, Some(victim_ip())).await?;
+    }
+    if !is_locked_out(&mut conn, &store, policy, label, Some(attacker_ip())).await?
+      || !is_locked_out(&mut conn, &store, policy, label, Some(victim_ip())).await?
+    {
+      return Err(TestError::Unexpected(
+        "both IP buckets must lock before unlock".to_owned(),
+      ));
+    }
+    let removed = unlock_login(&mut conn, &store, label).await?;
+    if removed < 3 {
+      return Err(TestError::Unexpected(format!(
+        "unlock must remove the account and both composite keys, removed {removed}"
+      )));
+    }
+    if is_locked_out(&mut conn, &store, policy, label, Some(attacker_ip())).await?
+      || is_locked_out(&mut conn, &store, policy, label, Some(victim_ip())).await?
+    {
+      return Err(TestError::Unexpected(
+        "unlock must release every IP bucket".to_owned(),
+      ));
+    }
+    if ip_failure_count(&mut conn, &store, label, Some(attacker_ip())).await? != 0 {
+      return Err(TestError::Unexpected(
+        "unlock must clear the composite counter".to_owned(),
+      ));
+    }
+    Ok(())
+  }
+
+  /// T3-C1 (a-ii): the global account cap locks even a never-seen IP while
+  /// every source stays below the per-IP threshold (distributed burst).
+  #[tokio::test]
+  async fn lockout_global_cap_locks_fresh_ip_while_each_ip_below_threshold() -> TestResult {
+    let (_guard, mut conn, store) = harness().await?;
+    let policy = LockoutPolicy::new(5, 8, Duration::from_secs(900))
+      .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
+    let label = "global-cap@example.test";
+    let sources = [
+      IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)),
+      IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)),
+      IpAddr::V4(Ipv4Addr::new(192, 0, 2, 3)),
+      IpAddr::V4(Ipv4Addr::new(192, 0, 2, 4)),
+    ];
+    for source in sources {
+      for _ in 0..2 {
+        register_login_failure(&mut conn, &store, policy, label, Some(source)).await?;
+      }
+    }
+    for source in sources {
+      if ip_failure_count(&mut conn, &store, label, Some(source)).await? != 2 {
+        return Err(TestError::Unexpected(
+          "each source must stay below the per-IP threshold".to_owned(),
+        ));
+      }
+    }
+    // Four sources times two failures meets the global cap of eight, so an
+    // IP that never failed is locked too.
+    let fresh = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 99));
+    if !is_locked_out(&mut conn, &store, policy, label, Some(fresh)).await? {
+      return Err(TestError::Unexpected(
+        "the global cap must lock an IP that never failed".to_owned(),
+      ));
+    }
+    if !is_locked_out(&mut conn, &store, policy, label, None).await? {
+      return Err(TestError::Unexpected(
+        "the global cap must lock the peer-less path too".to_owned(),
+      ));
+    }
+    Ok(())
+  }
+
+  /// T3-C1 peer-less path: without a source IP only the per-account counter
+  /// advances; no composite bucket is minted and the clear removes just it.
+  #[tokio::test]
+  async fn lockout_peerless_attempts_track_account_counter_only() -> TestResult {
+    let (_guard, mut conn, store) = harness().await?;
+    let policy = LockoutPolicy::new(5, 20, Duration::from_secs(900))
+      .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
+    let label = "peerless@example.test";
+    for want in 1..=2u64 {
+      let count = register_login_failure(&mut conn, &store, policy, label, None).await?;
+      if count != want {
+        return Err(TestError::Unexpected(format!(
+          "peer-less attempts must return the account count, want {want}, got {count}"
+        )));
+      }
+    }
+    if ip_failure_count(&mut conn, &store, label, None).await? != 0 {
+      return Err(TestError::Unexpected(
+        "peer-less attempts must not mint a composite bucket".to_owned(),
+      ));
+    }
+    if is_locked_out(&mut conn, &store, policy, label, None).await? {
+      return Err(TestError::Unexpected(
+        "two peer-less failures must not lock under a 5/20 policy".to_owned(),
+      ));
+    }
+    let keys = scan_login_fail(&mut conn, &store).await?;
+    if keys.len() != 1 {
+      return Err(TestError::Unexpected(format!(
+        "peer-less attempts must leave exactly one key, found {}",
+        keys.len()
+      )));
+    }
+    clear_login_failures(&mut conn, &store, label, None).await?;
+    if login_failure_count(&mut conn, &store, label).await? != 0 {
+      return Err(TestError::Unexpected(
+        "a peer-less clear must reset the account counter".to_owned(),
+      ));
+    }
+    if !scan_login_fail(&mut conn, &store).await?.is_empty() {
+      return Err(TestError::Unexpected(
+        "a peer-less clear must leave no lockout keys".to_owned(),
+      ));
+    }
+    Ok(())
+  }
+
+  /// T3-C1 (c-ii): a successful login clears the account counter and only
+  /// the calling IP's composite bucket; another IP's locked bucket survives.
+  #[tokio::test]
+  async fn lockout_clear_success_releases_only_calling_ip() -> TestResult {
+    let (_guard, mut conn, store) = harness().await?;
+    let policy = LockoutPolicy::new(5, 100, Duration::from_secs(900))
+      .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
+    let label = "scoped-clear@example.test";
+    for _ in 0..5 {
+      register_login_failure(&mut conn, &store, policy, label, Some(attacker_ip())).await?;
+      register_login_failure(&mut conn, &store, policy, label, Some(victim_ip())).await?;
+    }
+    clear_login_failures(&mut conn, &store, label, Some(attacker_ip())).await?;
+    if login_failure_count(&mut conn, &store, label).await? != 0 {
+      return Err(TestError::Unexpected(
+        "success must reset the per-account counter".to_owned(),
+      ));
+    }
+    if ip_failure_count(&mut conn, &store, label, Some(attacker_ip())).await? != 0 {
+      return Err(TestError::Unexpected(
+        "success must clear the calling IP bucket".to_owned(),
+      ));
+    }
+    if ip_failure_count(&mut conn, &store, label, Some(victim_ip())).await? != 5 {
+      return Err(TestError::Unexpected(
+        "success must leave the other IP bucket locked".to_owned(),
+      ));
+    }
+    if is_locked_out(&mut conn, &store, policy, label, Some(attacker_ip())).await? {
+      return Err(TestError::Unexpected(
+        "the calling IP must be released".to_owned(),
+      ));
+    }
+    if !is_locked_out(&mut conn, &store, policy, label, Some(victim_ip())).await? {
+      return Err(TestError::Unexpected(
+        "the other IP must stay locked".to_owned(),
+      ));
+    }
+    Ok(())
+  }
+
+  /// T3-C1 addressing: composite buckets key on the IP's string form, so an
+  /// IPv4 address and its IPv4-mapped IPv6 twin are deliberately distinct
+  /// buckets (no cross-family normalization; each family locks alone).
+  #[tokio::test]
+  async fn lockout_ipv4_and_mapped_ipv6_use_distinct_buckets() -> TestResult {
+    let (_guard, mut conn, store) = harness().await?;
+    let policy = LockoutPolicy::new(5, 100, Duration::from_secs(900))
+      .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
+    let label = "family-split@example.test";
+    let v4 = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 7));
+    let mapped: IpAddr = "::ffff:192.0.2.7"
+      .parse()
+      .map_err(|_| TestError::Unexpected("the mapped address must parse".to_owned()))?;
+    if v4.to_string() == mapped.to_string() {
+      return Err(TestError::Unexpected(
+        "the test needs two string-distinct addresses".to_owned(),
+      ));
+    }
+    for _ in 0..5 {
+      register_login_failure(&mut conn, &store, policy, label, Some(v4)).await?;
+    }
+    if ip_failure_count(&mut conn, &store, label, Some(mapped)).await? != 0 {
+      return Err(TestError::Unexpected(
+        "the mapped twin must not share the IPv4 bucket".to_owned(),
+      ));
+    }
+    if !is_locked_out(&mut conn, &store, policy, label, Some(v4)).await? {
+      return Err(TestError::Unexpected(
+        "the IPv4 bucket must lock".to_owned(),
+      ));
+    }
+    if is_locked_out(&mut conn, &store, policy, label, Some(mapped)).await? {
+      return Err(TestError::Unexpected(
+        "the mapped twin must stay unlocked".to_owned(),
+      ));
+    }
+    Ok(())
+  }
+
+  /// T3-C1 admin sweep: `unlock_login` pages past one `SCAN` batch (`COUNT`
+  /// 100), removes the account key plus every composite bucket, and never
+  /// touches the label-less unknown buckets.
+  #[tokio::test]
+  async fn lockout_unlock_sweeps_more_than_scan_page() -> TestResult {
+    let (_guard, mut conn, store) = harness().await?;
+    let policy = LockoutPolicy::new(5, 1_000_000, Duration::from_secs(900))
+      .ok_or(TestError::Unexpected("invalid policy".to_owned()))?;
+    let label = "paged-sweep@example.test";
+    const SOURCES: usize = 150;
+    for index in 0..SOURCES {
+      let source = IpAddr::V4(Ipv4Addr::new(
+        10,
+        9,
+        (index >> 8) as u8,
+        (index & 0xff) as u8,
+      ));
+      register_login_failure(&mut conn, &store, policy, label, Some(source)).await?;
+    }
+    let before = scan_login_fail(&mut conn, &store).await?;
+    if before.len() != SOURCES + 1 {
+      return Err(TestError::Unexpected(format!(
+        "150 sources plus the account key must mint 151 keys, found {}",
+        before.len()
+      )));
+    }
+    register_unknown_failure(&mut conn, &store, policy, Some(attacker_ip())).await?;
+    let removed = unlock_login(&mut conn, &store, label).await?;
+    if removed != (SOURCES as u64) + 1 {
+      return Err(TestError::Unexpected(format!(
+        "the sweep must remove all 151 keys, removed {removed}"
+      )));
+    }
+    let after = scan_login_fail(&mut conn, &store).await?;
+    if after.len() != 1 {
+      return Err(TestError::Unexpected(format!(
+        "only the unknown bucket must survive the sweep, found {}",
+        after.len()
+      )));
+    }
+    if unknown_failure_count(&mut conn, &store, Some(attacker_ip())).await? != 1 {
+      return Err(TestError::Unexpected(
+        "the sweep must leave the unknown bucket untouched".to_owned(),
+      ));
+    }
+    let sample = IpAddr::V4(Ipv4Addr::new(10, 9, 0, 3));
+    if is_locked_out(&mut conn, &store, policy, label, Some(sample)).await? {
+      return Err(TestError::Unexpected(
+        "the sweep must release every bucket".to_owned(),
+      ));
+    }
+    Ok(())
+  }
+}

+ 20 - 9
src/middleware/ratelimit.rs

@@ -2,7 +2,7 @@
 //!
 //!
 //! A fixed-window limiter over the sibling `RedisConn`: every request to a
 //! A fixed-window limiter over the sibling `RedisConn`: every request to a
 //! protected endpoint increments one counter per identity (peer IP and, when
 //! protected endpoint increments one counter per identity (peer IP and, when
-//! the request carries a `client_id`, the client). Counters live under the
+//! the request carries a `client_id`, the `(peer_ip, client_id)` composite). Counters live under the
 //! configured rate-limit prefix (`oauth:{rl}:`, enforced by config validation)
 //! configured rate-limit prefix (`oauth:{rl}:`, enforced by config validation)
 //! so they never share the token-state keyspace and always carry the `{rl}`
 //! so they never share the token-state keyspace and always carry the `{rl}`
 //! hash tag that keeps a multi-key write in one Cluster slot.
 //! hash tag that keeps a multi-key write in one Cluster slot.
@@ -40,7 +40,7 @@ use tower::{Layer, Service};
 
 
 use crate::config::RateLimitConfig;
 use crate::config::RateLimitConfig;
 use crate::state::AppState;
 use crate::state::AppState;
-use identity::{counter_key, extract_client_id, peer_ip};
+use identity::{client_bucket_raw, client_ip, counter_key, extract_client_id};
 
 
 #[cfg(test)]
 #[cfg(test)]
 mod test_support;
 mod test_support;
@@ -90,7 +90,7 @@ impl Rule {
   /// segment such as `/register/{client_id}` is classified by its prefix and
   /// segment such as `/register/{client_id}` is classified by its prefix and
   /// a sensitive route can never fall through to an unrated handler.
   /// a sensitive route can never fall through to an unrated handler.
   /// `/health`, `/.well-known`, and `/assets` are deliberately unrated.
   /// `/health`, `/.well-known`, and `/assets` are deliberately unrated.
-  pub(crate) fn for_path(limits: RateLimitConfig, path: &str) -> Option<Self> {
+  pub(crate) fn for_path(limits: &RateLimitConfig, path: &str) -> Option<Self> {
     let normalized = path
     let normalized = path
       .split('?')
       .split('?')
       .next()
       .next()
@@ -203,7 +203,7 @@ where
 
 
     Box::pin(async move {
     Box::pin(async move {
       let path = request.uri().path().to_owned();
       let path = request.uri().path().to_owned();
-      let Some(rule) = Rule::for_path(state.config.rate_limit, &path) else {
+      let Some(rule) = Rule::for_path(&state.config.rate_limit, &path) else {
         return inner.call(request).await;
         return inner.call(request).await;
       };
       };
 
 
@@ -212,9 +212,12 @@ where
         return inner.call(request).await;
         return inner.call(request).await;
       }
       }
 
 
-      // Without a peer address there is no identity to count against; the
-      // endpoint is protected, so this is a misconfiguration, not a pass.
-      let Some(ip) = peer_ip(&request) else {
+      // The attributed client IP is the verified TCP peer unless the peer
+      // is a configured trusted proxy (T3-C3): only then is the
+      // rightmost-untrusted `X-Forwarded-For` entry honored, and only for
+      // rate-limit bucket selection. Without any identity the endpoint is
+      // protected, so this is a misconfiguration, not a pass.
+      let Some(ip) = client_ip(&request, &state.config.rate_limit.trusted_proxies) else {
         tracing::error!(%path, "rate limit: no peer address; failing closed");
         tracing::error!(%path, "rate limit: no peer address; failing closed");
         return Ok(service_unavailable(1));
         return Ok(service_unavailable(1));
       };
       };
@@ -241,7 +244,12 @@ where
 /// The hashed counter keys and limits for one request, in evaluation order.
 /// The hashed counter keys and limits for one request, in evaluation order.
 ///
 ///
 /// `limit == 0` disables that bucket (a documented "off" switch); the IP
 /// `limit == 0` disables that bucket (a documented "off" switch); the IP
-/// bucket is always present because `peer_ip` already proved it.
+/// bucket is always present because `client_ip` already proved an identity. The client
+/// bucket is keyed by `(peer_ip, client_id)` (T3-C2): `client_id` arrives
+/// before client authentication, so a global per-`client_id` bucket would let
+/// an attacker exhaust a victim client's budget or rotate fake ids to evade
+/// the limit. Scoping the client bucket to the peer keeps the IP bucket as
+/// the primary control while preserving per-client accounting per peer.
 fn counter_keys(
 fn counter_keys(
   state: &AppState,
   state: &AppState,
   rule: Rule,
   rule: Rule,
@@ -256,7 +264,10 @@ fn counter_keys(
   if let Some(limit) = rule.per_client.filter(|limit| *limit > 0)
   if let Some(limit) = rule.per_client.filter(|limit| *limit > 0)
     && let Some(client_id) = client_id
     && let Some(client_id) = client_id
   {
   {
-    buckets.push((counter_key(prefix, "client", client_id), limit));
+    buckets.push((
+      counter_key(prefix, "client", &client_bucket_raw(&ip, client_id)),
+      limit,
+    ));
   }
   }
   buckets
   buckets
 }
 }

+ 288 - 0
src/middleware/ratelimit/identity.rs

@@ -11,10 +11,17 @@ use axum::extract::ConnectInfo;
 use axum::http::{Request, header};
 use axum::http::{Request, header};
 use sha2::{Digest, Sha256};
 use sha2::{Digest, Sha256};
 
 
+use crate::config::types::TrustedProxyNet;
+
 /// Largest form body buffered to recover a `client_id`; anything larger is
 /// Largest form body buffered to recover a `client_id`; anything larger is
 /// left untouched and simply yields no client identity.
 /// left untouched and simply yields no client identity.
 const MAX_FORM_BYTES: usize = 64 * 1024;
 const MAX_FORM_BYTES: usize = 64 * 1024;
 
 
+/// Largest `X-Forwarded-For` chain honored for rate-limit attribution; only
+/// the rightmost entries are considered so header growth cannot grow the
+/// parse or let a planted leftmost claim survive the cap.
+const MAX_XFF_ENTRIES: usize = 32;
+
 /// The peer address recorded by axum's `ConnectInfo` (present whenever the
 /// The peer address recorded by axum's `ConnectInfo` (present whenever the
 /// listener is served with `into_make_service_with_connect_info`).
 /// listener is served with `into_make_service_with_connect_info`).
 pub(super) fn peer_ip(request: &Request<Body>) -> Option<IpAddr> {
 pub(super) fn peer_ip(request: &Request<Body>) -> Option<IpAddr> {
@@ -24,6 +31,66 @@ pub(super) fn peer_ip(request: &Request<Body>) -> Option<IpAddr> {
     .map(|info| info.0.ip())
     .map(|info| info.0.ip())
 }
 }
 
 
+/// The client IP attributed to a request for rate-limit bucket selection
+/// (T3-C3; paired T6-X2 decision).
+///
+/// The verified TCP peer is always the identity unless it belongs to the
+/// configured `trusted_proxies` allowlist. Only then is the `X-Forwarded-For`
+/// chain consulted, and only its rightmost-untrusted entry is used: addresses appended by downstream trusted proxies are skipped so a
+/// spoofed leftmost claim planted before the first trusted hop cannot pick a
+/// foreign bucket. A header from an untrusted peer is ignored outright, and
+/// an absent header — or one with no parseable bare-IP entry — falls back
+/// to the verified peer — the
+/// request is still counted, never passed unattributed.
+///
+/// This trust is narrowly scoped to rate-limit IP attribution: it never
+/// authenticates a client and never carries a client certificate (mTLS still
+/// requires L4/TCP passthrough). RFC 7239 `Forwarded` is never consulted.
+pub(super) fn client_ip(request: &Request<Body>, trusted: &[TrustedProxyNet]) -> Option<IpAddr> {
+  let peer = peer_ip(request)?;
+  if !trusted.iter().any(|net| net.contains(peer)) {
+    return Some(peer);
+  }
+  let Some(chain) = forwarded_chain(request) else {
+    return Some(peer);
+  };
+  Some(rightmost_untrusted(&chain, trusted))
+}
+
+/// Parse the `X-Forwarded-For` header into its address chain, leftmost-first.
+/// `None` when the header is absent or holds no bare-IP entry: unparseable
+/// entries (ports, brackets, `unknown`, garbage) are skipped — never trusted
+/// entry-by-entry — and only the rightmost [`MAX_XFF_ENTRIES`] raw entries
+/// are considered. Trusted proxies must emit bare IPs.
+fn forwarded_chain(request: &Request<Body>) -> Option<Vec<IpAddr>> {
+  let header = request.headers().get("x-forwarded-for")?.to_str().ok()?;
+  let raw: Vec<&str> = header.split(',').map(|entry| entry.trim()).collect();
+  let tail = if raw.len() > MAX_XFF_ENTRIES {
+    &raw[raw.len() - MAX_XFF_ENTRIES..]
+  } else {
+    raw.as_slice()
+  };
+  let chain: Vec<IpAddr> = tail
+    .iter()
+    .filter(|entry| !entry.is_empty())
+    .filter_map(|entry| entry.parse::<IpAddr>().ok())
+    .collect();
+  if chain.is_empty() { None } else { Some(chain) }
+}
+
+/// The rightmost chain entry outside the trusted set; when every hop is
+/// trusted, the leftmost entry (the originating client as seen through
+/// trusted hops only).
+fn rightmost_untrusted(chain: &[IpAddr], trusted: &[TrustedProxyNet]) -> IpAddr {
+  let is_trusted = |ip: &IpAddr| trusted.iter().any(|net| net.contains(*ip));
+  chain
+    .iter()
+    .rev()
+    .find(|ip| !is_trusted(ip))
+    .copied()
+    .unwrap_or(chain[0])
+}
+
 /// Recover a `client_id` from the query string, or (for form posts) the body.
 /// Recover a `client_id` from the query string, or (for form posts) the body.
 ///
 ///
 /// A form body is buffered and restored so the handler still sees it; a body
 /// A form body is buffered and restored so the handler still sees it; a body
@@ -50,10 +117,28 @@ pub(super) async fn extract_client_id(request: &mut Request<Body>) -> Option<Str
 }
 }
 
 
 /// `{prefix}{kind}:{sha256-hex}` — the raw identity never reaches Redis.
 /// `{prefix}{kind}:{sha256-hex}` — the raw identity never reaches Redis.
+///
+/// The `raw` value for a client bucket is built with [`client_bucket_raw`]:
+/// the composite is hashed as one unit, so the separator never reaches Redis
+/// either.
 pub(super) fn counter_key(prefix: &str, kind: &str, raw: &str) -> String {
 pub(super) fn counter_key(prefix: &str, kind: &str, raw: &str) -> String {
   format!("{prefix}{kind}:{}", sha256_hex(raw.as_bytes()))
   format!("{prefix}{kind}:{}", sha256_hex(raw.as_bytes()))
 }
 }
 
 
+/// Composite raw identity for the per-client bucket: `(peer_ip, client_id)`.
+///
+/// `client_id` is recovered before client authentication (T3-C2), so it must
+/// never select a global bucket on its own: an attacker could exhaust a
+/// victim client's budget or rotate fake ids to evade the limit. Joining the
+/// peer IP with a NUL separator keeps each peer's client accounting distinct
+/// while the per-IP bucket stays the primary control. The separator is
+/// unambiguous even though `%00` form-decodes to NUL inside a `client_id`:
+/// the IP rendering never contains NUL, so the first NUL always delimits the
+/// IP and distinct pairs always render distinctly.
+pub(super) fn client_bucket_raw(ip: &IpAddr, client_id: &str) -> String {
+  format!("{ip}\0{client_id}")
+}
+
 /// Lowercase hex of the SHA-256 digest.
 /// Lowercase hex of the SHA-256 digest.
 fn sha256_hex(input: &[u8]) -> String {
 fn sha256_hex(input: &[u8]) -> String {
   const HEX: &[u8; 16] = b"0123456789abcdef";
   const HEX: &[u8; 16] = b"0123456789abcdef";
@@ -121,3 +206,206 @@ fn hex_value(byte: u8) -> Option<u8> {
     _ => None,
     _ => None,
   }
   }
 }
 }
+
+#[cfg(test)]
+mod identity_tests {
+  use std::error::Error;
+  use std::net::{IpAddr, SocketAddr};
+  use std::str::FromStr as _;
+
+  use axum::body::Body;
+  use axum::extract::ConnectInfo;
+  use axum::http::Request;
+
+  use super::client_ip;
+  use crate::config::types::TrustedProxyNet;
+
+  /// Test body result (no `unwrap`/`expect` under the deny lints).
+  type TestResult = Result<(), Box<dyn Error>>;
+
+  /// Request carrying `peer` as the recorded TCP peer plus an optional
+  /// `X-Forwarded-For` header.
+  fn request_with(
+    peer: Option<SocketAddr>,
+    xff: Option<&str>,
+  ) -> Result<Request<Body>, Box<dyn Error>> {
+    let mut builder = Request::builder().uri("/token");
+    if let Some(value) = xff {
+      builder = builder.header("x-forwarded-for", value);
+    }
+    let mut request = builder.body(Body::empty())?;
+    if let Some(addr) = peer {
+      request.extensions_mut().insert(ConnectInfo(addr));
+    }
+    Ok(request)
+  }
+
+  /// Parse-to-trusted helper for the test allowlists below.
+  fn trusted(entries: &[&str]) -> Result<Vec<TrustedProxyNet>, Box<dyn Error>> {
+    entries
+      .iter()
+      .map(|entry| TrustedProxyNet::from_str(entry).map_err(Box::<dyn Error>::from))
+      .collect()
+  }
+
+  /// A trusted peer yields the client IP from `X-Forwarded-For`.
+  #[test]
+  fn ratelimit_trusted_proxy_honors_xff_from_trusted_peer() -> TestResult {
+    let allow = trusted(&["10.0.0.0/8"])?;
+    let peer = SocketAddr::from(([10, 0, 0, 1], 40_000));
+    let request = request_with(Some(peer), Some("198.51.100.7"))?;
+    assert_eq!(
+      client_ip(&request, &allow),
+      Some(IpAddr::from_str("198.51.100.7")?)
+    );
+    Ok(())
+  }
+
+  /// A spoofed header from an untrusted peer is ignored: the peer owns the
+  /// bucket, so rotating the header cannot evade the limit.
+  #[test]
+  fn ratelimit_trusted_proxy_ignores_xff_from_untrusted_peer() -> TestResult {
+    let allow = trusted(&["10.0.0.0/8"])?;
+    let peer = SocketAddr::from(([127, 0, 0, 1], 40_000));
+    let request = request_with(Some(peer), Some("198.51.100.7"))?;
+    assert_eq!(client_ip(&request, &allow), Some(peer.ip()));
+    Ok(())
+  }
+
+  /// A header with no parseable bare-IP entry from a trusted peer fails
+  /// closed onto the verified peer: the request is still counted, never
+  /// passed unattributed. Partially valid chains attribute their valid
+  /// entries (see the skip tests below).
+  #[test]
+  fn ratelimit_trusted_proxy_malformed_xff_falls_back_to_peer() -> TestResult {
+    let allow = trusted(&["10.0.0.1"])?;
+    let peer = SocketAddr::from(([10, 0, 0, 1], 40_000));
+    for xff in ["not-an-ip", "", "unknown", "1.2.3.4:5678", "[2001:db8::1]"] {
+      let request = request_with(Some(peer), Some(xff))?;
+      assert_eq!(client_ip(&request, &allow), Some(peer.ip()), "xff={xff:?}");
+    }
+    Ok(())
+  }
+
+  /// Rightmost-untrusted selection: trusted hops appended by downstream
+  /// proxies are skipped, the first untrusted address wins.
+  #[test]
+  fn ratelimit_trusted_proxy_selects_rightmost_untrusted() -> TestResult {
+    let allow = trusted(&["10.0.0.0/8"])?;
+    let peer = SocketAddr::from(([10, 0, 0, 1], 40_000));
+    let request = request_with(Some(peer), Some("198.51.100.7, 10.0.0.5, 10.0.0.9"))?;
+    assert_eq!(
+      client_ip(&request, &allow),
+      Some(IpAddr::from_str("198.51.100.7")?)
+    );
+    Ok(())
+  }
+
+  /// An all-trusted chain yields its leftmost entry (the originating client
+  /// as seen through trusted hops only).
+  #[test]
+  fn ratelimit_trusted_proxy_all_trusted_chain_yields_leftmost() -> TestResult {
+    let allow = trusted(&["10.0.0.0/8"])?;
+    let peer = SocketAddr::from(([10, 0, 0, 1], 40_000));
+    let request = request_with(Some(peer), Some("10.0.0.5, 10.0.0.6"))?;
+    assert_eq!(
+      client_ip(&request, &allow),
+      Some(IpAddr::from_str("10.0.0.5")?)
+    );
+    Ok(())
+  }
+
+  /// Default empty allowlist: every header is ignored, the peer is used.
+  #[test]
+  fn ratelimit_trusted_proxy_empty_allowlist_ignores_xff() -> TestResult {
+    let allow: Vec<TrustedProxyNet> = Vec::new();
+    let peer = SocketAddr::from(([203, 0, 113, 9], 40_000));
+    let request = request_with(Some(peer), Some("198.51.100.7"))?;
+    assert_eq!(client_ip(&request, &allow), Some(peer.ip()));
+    Ok(())
+  }
+
+  /// No recorded peer means no identity: `None` so the caller fails closed.
+  #[test]
+  fn ratelimit_trusted_proxy_missing_peer_yields_no_identity() -> TestResult {
+    let allow = trusted(&["10.0.0.0/8"])?;
+    let request = request_with(None, Some("198.51.100.7"))?;
+    assert_eq!(client_ip(&request, &allow), None);
+    Ok(())
+  }
+
+  /// A partially spoofable chain still attributes its valid entries: bad
+  /// entries are skipped, rightmost-untrusted wins over what remains.
+  #[test]
+  fn ratelimit_trusted_proxy_partial_chain_skips_bad_entry() -> TestResult {
+    let allow = trusted(&["10.0.0.0/8"])?;
+    let peer = SocketAddr::from(([10, 0, 0, 1], 40_000));
+    let request = request_with(Some(peer), Some("198.51.100.7, not-an-ip"))?;
+    assert_eq!(
+      client_ip(&request, &allow),
+      Some(IpAddr::from_str("198.51.100.7")?)
+    );
+    let request = request_with(Some(peer), Some("not-an-ip, 198.51.100.8"))?;
+    assert_eq!(
+      client_ip(&request, &allow),
+      Some(IpAddr::from_str("198.51.100.8")?)
+    );
+    Ok(())
+  }
+
+  /// Entries carrying a port, brackets, or `unknown` are not bare IPs and are
+  /// skipped; the remaining valid entry still attributes (previously the
+  /// whole chain collapsed to the peer).
+  #[test]
+  fn ratelimit_trusted_proxy_xff_with_port_still_attributes() -> TestResult {
+    let allow = trusted(&["10.0.0.0/8"])?;
+    let peer = SocketAddr::from(([10, 0, 0, 1], 40_000));
+    let request = request_with(Some(peer), Some("198.51.100.7:4123, 198.51.100.8"))?;
+    assert_eq!(
+      client_ip(&request, &allow),
+      Some(IpAddr::from_str("198.51.100.8")?)
+    );
+    let request = request_with(Some(peer), Some("[2001:db8::1], 198.51.100.9"))?;
+    assert_eq!(
+      client_ip(&request, &allow),
+      Some(IpAddr::from_str("198.51.100.9")?)
+    );
+    let request = request_with(Some(peer), Some("unknown, 198.51.100.10"))?;
+    assert_eq!(
+      client_ip(&request, &allow),
+      Some(IpAddr::from_str("198.51.100.10")?)
+    );
+    Ok(())
+  }
+
+  /// An oversize chain is capped to the rightmost 32 entries: a leading
+  /// entry pushed past the cap cannot select the bucket.
+  #[test]
+  fn ratelimit_trusted_proxy_oversize_chain_capped() -> TestResult {
+    let allow = trusted(&["10.0.0.0/8"])?;
+    let peer = SocketAddr::from(([10, 0, 0, 1], 40_000));
+    let mut chain = vec!["198.51.100.7".to_string()];
+    for _ in 0..40 {
+      chain.push("10.0.0.5".to_string());
+    }
+    let header = chain.join(", ");
+    let request = request_with(Some(peer), Some(header.as_str()))?;
+    assert_eq!(
+      client_ip(&request, &allow),
+      Some(IpAddr::from_str("10.0.0.5")?)
+    );
+    Ok(())
+  }
+  #[test]
+  fn ratelimit_trusted_proxy_exact_ip_entry_trusts_only_that_peer() -> TestResult {
+    let allow = trusted(&["10.0.0.1"])?;
+    let trusted_peer = SocketAddr::from(([10, 0, 0, 1], 40_000));
+    let other_peer = SocketAddr::from(([10, 0, 0, 2], 40_000));
+    let expected = IpAddr::from_str("198.51.100.7")?;
+    let via_trusted = request_with(Some(trusted_peer), Some("198.51.100.7"))?;
+    assert_eq!(client_ip(&via_trusted, &allow), Some(expected));
+    let via_other = request_with(Some(other_peer), Some("198.51.100.7"))?;
+    assert_eq!(client_ip(&via_other, &allow), Some(other_peer.ip()));
+    Ok(())
+  }
+}

+ 303 - 10
src/middleware/ratelimit/tests.rs

@@ -11,7 +11,7 @@ use serde_json::json;
 use tower::Layer;
 use tower::Layer;
 
 
 use super::RateLimitLayer;
 use super::RateLimitLayer;
-use super::identity::counter_key;
+use super::identity::{client_bucket_raw, counter_key};
 use super::test_support::{
 use super::test_support::{
   Extras, Passthrough, TestResult, live_state, peer, redis_container, retry_after, scan_keys, send,
   Extras, Passthrough, TestResult, live_state, peer, redis_container, retry_after, scan_keys, send,
   send_with, test_config, unavailable_state,
   send_with, test_config, unavailable_state,
@@ -60,10 +60,11 @@ async fn ratelimit_token_per_ip_rejects_over_budget_with_retry_after() -> TestRe
   Ok(())
   Ok(())
 }
 }
 
 
-/// The per-client budget is enforced across distinct source IPs, proving the
-/// client bucket (parsed from the form body) is keyed independently of the IP.
+/// The per-client budget is enforced per `(peer_ip, client_id)`: repeating
+/// the same client from the same peer past its budget is limited, while the
+/// same client from a different peer owns a separate bucket (T3-C2).
 #[tokio::test]
 #[tokio::test]
-async fn ratelimit_token_per_client_spans_source_ips() -> TestResult {
+async fn ratelimit_token_per_client_enforced_per_peer() -> TestResult {
   let (_redis, url) = redis_container().await?;
   let (_redis, url) = redis_container().await?;
   let config = test_config(
   let config = test_config(
     &url,
     &url,
@@ -72,13 +73,13 @@ async fn ratelimit_token_per_client_spans_source_ips() -> TestResult {
   let state = live_state(config, &url).await?;
   let state = live_state(config, &url).await?;
   let service = RateLimitLayer::new(state).layer(Passthrough);
   let service = RateLimitLayer::new(state).layer(Passthrough);
 
 
-  for octet in [1_u8, 2_u8] {
+  for _ in 0..2 {
     let response = send(
     let response = send(
       service.clone(),
       service.clone(),
       "POST",
       "POST",
       "/token",
       "/token",
       "grant_type=authorization_code&client_id=client-x",
       "grant_type=authorization_code&client_id=client-x",
-      peer(octet),
+      peer(1),
     )
     )
     .await?;
     .await?;
     assert_eq!(response.status(), StatusCode::OK);
     assert_eq!(response.status(), StatusCode::OK);
@@ -89,13 +90,27 @@ async fn ratelimit_token_per_client_spans_source_ips() -> TestResult {
     "POST",
     "POST",
     "/token",
     "/token",
     "grant_type=authorization_code&client_id=client-x",
     "grant_type=authorization_code&client_id=client-x",
-    peer(3),
+    peer(1),
   )
   )
   .await?;
   .await?;
   assert_eq!(
   assert_eq!(
     blocked.status(),
     blocked.status(),
     StatusCode::TOO_MANY_REQUESTS,
     StatusCode::TOO_MANY_REQUESTS,
-    "third request for the same client from a new IP must be limited"
+    "third request for the same client from the same peer must be limited"
+  );
+
+  let other_peer = send(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code&client_id=client-x",
+    peer(2),
+  )
+  .await?;
+  assert_eq!(
+    other_peer.status(),
+    StatusCode::OK,
+    "the same client from a different peer must own a separate bucket"
   );
   );
   Ok(())
   Ok(())
 }
 }
@@ -443,8 +458,16 @@ async fn ratelimit_per_ip_budget_blocks_fresh_client_from_same_ip() -> TestResul
   let keys = scan_keys(&url, "oauth:{rl}:*").await?;
   let keys = scan_keys(&url, "oauth:{rl}:*").await?;
   for expected in [
   for expected in [
     counter_key(&prefix, "ip", "127.0.0.1"),
     counter_key(&prefix, "ip", "127.0.0.1"),
-    counter_key(&prefix, "client", "client-a"),
-    counter_key(&prefix, "client", "client-b"),
+    counter_key(
+      &prefix,
+      "client",
+      &client_bucket_raw(&peer(1).ip(), "client-a"),
+    ),
+    counter_key(
+      &prefix,
+      "client",
+      &client_bucket_raw(&peer(1).ip(), "client-b"),
+    ),
   ] {
   ] {
     assert!(
     assert!(
       keys.contains(&expected),
       keys.contains(&expected),
@@ -535,6 +558,276 @@ async fn ratelimit_disabled_never_limits_and_writes_no_keys() -> TestResult {
   Ok(())
   Ok(())
 }
 }
 
 
+/// T3-C2 (a): an unauthenticated `client_id` is not a global bucket. An
+/// attacker at one peer that floods a victim's `client_id` must not exhaust
+/// the victim's own bucket at a different peer: the client bucket is keyed by
+/// `(peer_ip, client_id)`, so the victim's request still passes.
+#[tokio::test]
+async fn ratelimit_client_bucket_is_scoped_to_peer_ip() -> TestResult {
+  let (_redis, url) = redis_container().await?;
+  let config = test_config(
+    &url,
+    json!({"enabled": true, "window_secs": 60, "per_ip": 1000, "token_per_client": 2}),
+  )?;
+  let state = live_state(config, &url).await?;
+  let service = RateLimitLayer::new(state).layer(Passthrough);
+
+  for _ in 0..2 {
+    let response = send(
+      service.clone(),
+      "POST",
+      "/token",
+      "grant_type=authorization_code&client_id=victim-client",
+      peer(9),
+    )
+    .await?;
+    assert_eq!(response.status(), StatusCode::OK);
+  }
+  let attacker_blocked = send(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code&client_id=victim-client",
+    peer(9),
+  )
+  .await?;
+  assert_eq!(
+    attacker_blocked.status(),
+    StatusCode::TOO_MANY_REQUESTS,
+    "attacker peer must hit its own client budget"
+  );
+
+  let victim = send(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code&client_id=victim-client",
+    peer(1),
+  )
+  .await?;
+  assert_eq!(
+    victim.status(),
+    StatusCode::OK,
+    "victim peer with the same client_id must own a separate bucket"
+  );
+  Ok(())
+}
+
+/// T3-C2 (b): rotating fake `client_id` values from one peer must not evade
+/// the limit. The per-IP bucket is the primary control and counts every
+/// request regardless of the claimed client identity.
+#[tokio::test]
+async fn ratelimit_rotating_fake_client_ids_does_not_evade_ip_budget() -> TestResult {
+  let (_redis, url) = redis_container().await?;
+  let config = test_config(
+    &url,
+    json!({"enabled": true, "window_secs": 60, "per_ip": 2, "token_per_client": 1000}),
+  )?;
+  let state = live_state(config, &url).await?;
+  let service = RateLimitLayer::new(state).layer(Passthrough);
+
+  for (index, client_id) in ["fake-1", "fake-2"].iter().enumerate() {
+    let response = send(
+      service.clone(),
+      "POST",
+      "/token",
+      &format!("grant_type=authorization_code&client_id={client_id}"),
+      peer(1),
+    )
+    .await?;
+    assert_eq!(
+      response.status(),
+      StatusCode::OK,
+      "request {index} within the IP budget must pass"
+    );
+  }
+
+  let blocked = send(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code&client_id=fake-3",
+    peer(1),
+  )
+  .await?;
+  assert_eq!(
+    blocked.status(),
+    StatusCode::TOO_MANY_REQUESTS,
+    "a rotated fake client_id from an exhausted peer must hit the per-IP budget"
+  );
+  Ok(())
+}
+
+/// T3-C3 end to end: `trusted_proxies` flows from config through
+/// `RateLimitLayer` into Redis. Requests from the trusted proxy peer are
+/// attributed to their `X-Forwarded-For` client: repeating the same claimed
+/// client exhausts its bucket, while a different claimed client through the
+/// same proxy owns a fresh bucket, and no counter is ever keyed by the proxy
+/// peer itself.
+#[tokio::test]
+async fn ratelimit_trusted_proxy_end_to_end_attributes_xff_bucket() -> TestResult {
+  let (_redis, url) = redis_container().await?;
+  let config = test_config(
+    &url,
+    json!({
+      "enabled": true,
+      "window_secs": 60,
+      "per_ip": 1,
+      "token_per_client": 1000,
+      "trusted_proxies": ["10.0.0.1"],
+    }),
+  )?;
+  let state = live_state(config, &url).await?;
+  let prefix = state.config.redis.ratelimit_key_prefix.clone();
+  let service = RateLimitLayer::new(state).layer(Passthrough);
+  let proxy = SocketAddr::from(([10, 0, 0, 1], 40_000));
+
+  let first = send_with(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    Extras {
+      peer: Some(proxy),
+      headers: &[("x-forwarded-for", "198.51.100.7")],
+    },
+  )
+  .await?;
+  assert_eq!(first.status(), StatusCode::OK);
+
+  let blocked = send_with(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    Extras {
+      peer: Some(proxy),
+      headers: &[("x-forwarded-for", "198.51.100.7")],
+    },
+  )
+  .await?;
+  assert_eq!(
+    blocked.status(),
+    StatusCode::TOO_MANY_REQUESTS,
+    "repeating the same XFF client through the trusted proxy must exhaust its bucket"
+  );
+
+  let other_client = send_with(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    Extras {
+      peer: Some(proxy),
+      headers: &[("x-forwarded-for", "198.51.100.8")],
+    },
+  )
+  .await?;
+  assert_eq!(
+    other_client.status(),
+    StatusCode::OK,
+    "a different XFF client through the same proxy must own a fresh bucket"
+  );
+
+  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  for expected in [
+    counter_key(&prefix, "ip", "198.51.100.7"),
+    counter_key(&prefix, "ip", "198.51.100.8"),
+  ] {
+    assert!(
+      keys.contains(&expected),
+      "missing attributed client bucket {expected}: {keys:?}"
+    );
+  }
+  assert!(
+    !keys.contains(&counter_key(&prefix, "ip", "10.0.0.1")),
+    "the proxy peer itself must never own a bucket: {keys:?}"
+  );
+  assert_eq!(
+    keys.len(),
+    2,
+    "only the two attributed client buckets may exist: {keys:?}"
+  );
+  Ok(())
+}
+
+/// T3-C3 end to end: bracketed, port-suffixed, and `unknown` XFF entries are
+/// never bare IPs, so from a trusted peer they fall back to the verified peer
+/// bucket through the full layer instead of minting a foreign bucket.
+#[tokio::test]
+async fn ratelimit_trusted_proxy_unparseable_xff_falls_back_to_peer() -> TestResult {
+  let (_redis, url) = redis_container().await?;
+  let config = test_config(
+    &url,
+    json!({
+      "enabled": true,
+      "window_secs": 60,
+      "per_ip": 1,
+      "token_per_client": 1000,
+      "trusted_proxies": ["10.0.0.1"],
+    }),
+  )?;
+  let state = live_state(config, &url).await?;
+  let prefix = state.config.redis.ratelimit_key_prefix.clone();
+  let service = RateLimitLayer::new(state).layer(Passthrough);
+  let proxy = SocketAddr::from(([10, 0, 0, 1], 40_000));
+
+  let first = send_with(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    Extras {
+      peer: Some(proxy),
+      headers: &[("x-forwarded-for", "[2001:db8::1]")],
+    },
+  )
+  .await?;
+  assert_eq!(first.status(), StatusCode::OK);
+
+  let blocked = send_with(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    Extras {
+      peer: Some(proxy),
+      headers: &[("x-forwarded-for", "198.51.100.9:4123")],
+    },
+  )
+  .await?;
+  assert_eq!(
+    blocked.status(),
+    StatusCode::TOO_MANY_REQUESTS,
+    "an unparseable XFF entry must fall back to the peer bucket, not evade it"
+  );
+
+  let unknown = send_with(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    Extras {
+      peer: Some(proxy),
+      headers: &[("x-forwarded-for", "unknown")],
+    },
+  )
+  .await?;
+  assert_eq!(
+    unknown.status(),
+    StatusCode::TOO_MANY_REQUESTS,
+    "`unknown` must likewise count against the peer bucket"
+  );
+
+  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  assert_eq!(
+    keys,
+    vec![counter_key(&prefix, "ip", "10.0.0.1")],
+    "only the verified peer bucket may exist: {keys:?}"
+  );
+  Ok(())
+}
+
 /// A request with no recorded peer fails closed: `503` with the contractual
 /// A request with no recorded peer fails closed: `503` with the contractual
 /// `Retry-After: 1`, and no counter key is written because no identity exists
 /// `Retry-After: 1`, and no counter key is written because no identity exists
 /// to count.
 /// to count.

+ 253 - 4
src/protocol/purge.rs

@@ -2,7 +2,8 @@
 //!
 //!
 //! [`purge_subject`] deletes every Redis record whose `subject` field names
 //! [`purge_subject`] deletes every Redis record whose `subject` field names
 //! the user (canonical email or user-id string): login sessions (plus their
 //! the user (canonical email or user-id string): login sessions (plus their
-//! `:meta` sidecars), the lockout counter, authorization codes, device records
+//! `:meta` sidecars), the lockout counters (the account key plus every
+//! per-IP composite key), authorization codes, device records
 //! (plus `:cnf`/`:poll` sidecars and `user_code` mappings), and token grants
 //! (plus `:cnf`/`:poll` sidecars and `user_code` mappings), and token grants
 //! (cascade-revoked through [`grants::revoke`] so member token keys, the
 //! (cascade-revoked through [`grants::revoke`] so member token keys, the
 //! member set, and the barrier stay correct, plus standalone access/refresh
 //! member set, and the barrier stay correct, plus standalone access/refresh
@@ -30,7 +31,7 @@ use crate::store::{Store, StoreError, grants, sha256_hex};
 pub struct PurgedSubject {
 pub struct PurgedSubject {
   /// Login session records deleted (each also removed its `:meta` sidecar).
   /// Login session records deleted (each also removed its `:meta` sidecar).
   pub sessions: u64,
   pub sessions: u64,
-  /// Lockout counters deleted (at most one: the email's `loginfail` key).
+  /// Lockout counters deleted (the account key plus every per-IP composite).
   pub lockouts: u64,
   pub lockouts: u64,
   /// Authorization-code records deleted.
   /// Authorization-code records deleted.
   pub codes: u64,
   pub codes: u64,
@@ -89,6 +90,28 @@ pub async fn purge_subject(
   if removed > 0 {
   if removed > 0 {
     summary.lockouts += 1;
     summary.lockouts += 1;
   }
   }
+  let account_hash = sha256_hex(normalize_email(email).as_bytes());
+  let composite_pattern = format!("{}*:{account_hash}", store.login_fail_ip_prefix());
+  let mut cursor = "0".to_owned();
+  loop {
+    let (next, batch): (String, Vec<String>) = redis::cmd("SCAN")
+      .arg(&cursor)
+      .arg("MATCH")
+      .arg(&composite_pattern)
+      .arg("COUNT")
+      .arg(100)
+      .query_async(conn)
+      .await
+      .map_err(store_mapped)?;
+    for key in &batch {
+      del_key(conn, key).await?;
+      summary.lockouts += 1;
+    }
+    if next == "0" {
+      break;
+    }
+    cursor = next;
+  }
 
 
   for key in scan_keys(conn, &store.code_key_prefix()).await? {
   for key in scan_keys(conn, &store.code_key_prefix()).await? {
     if is_subject(&read_fields(conn, &key).await?, email, user_id) {
     if is_subject(&read_fields(conn, &key).await?, email, user_id) {
@@ -176,6 +199,47 @@ pub async fn purge_subject(
   Ok(summary)
   Ok(summary)
 }
 }
 
 
+/// Delete every login session (plus `:meta` sidecar) whose `subject` is
+/// `email` or `user_id`, except the session named by `keep_session_id`.
+///
+/// The credential-change path (`change_password` / `confirm_totp` in
+/// `src/ui/login.rs`) calls this before the database update and before
+/// re-issuing the current session, so a password or TOTP change revokes the
+/// subject's other sessions while the session in hand survives. The sweep
+/// runs first so a Redis outage fails before any credential is written (the
+/// password stays unchanged); a later database failure then costs only
+/// re-authentication. Tokens, codes,
+/// and grants are deliberately untouched: ending the login sessions forces
+/// re-authentication, while live OAuth tokens keep working until they expire
+/// or the account is disabled (which runs the full [`purge_subject`]).
+///
+/// # Errors
+///
+/// Returns a fail-closed store failure when Redis is unavailable; callers
+/// must surface the error instead of reporting success with siblings intact.
+pub async fn purge_sibling_sessions(
+  conn: &mut RedisConn,
+  store: &Store,
+  email: &str,
+  user_id: &str,
+  keep_session_id: &str,
+) -> Result<u64, AppError> {
+  let keep_key = store.session_key(keep_session_id);
+  let mut revoked = 0;
+  let session_prefix = store.session_key_prefix();
+  for key in scan_keys(conn, &session_prefix).await? {
+    if key.ends_with(":meta") || key == keep_key {
+      continue;
+    }
+    if is_subject(&read_fields(conn, &key).await?, email, user_id) {
+      del_key(conn, &key).await?;
+      del_key(conn, &format!("{key}:meta")).await?;
+      revoked += 1;
+    }
+  }
+  Ok(revoked)
+}
+
 /// Whether a record's `subject` field names this user by email or id.
 /// Whether a record's `subject` field names this user by email or id.
 fn is_subject(fields: &HashMap<String, String>, email: &str, user_id: &str) -> bool {
 fn is_subject(fields: &HashMap<String, String>, email: &str, user_id: &str) -> bool {
   fields
   fields
@@ -245,8 +309,14 @@ mod tests {
     runners::AsyncRunner,
     runners::AsyncRunner,
   };
   };
 
 
-  use super::{PurgedSubject, purge_subject};
-  use crate::{config::DurabilityMode, state::RedisConn, store::Store, test_guard::ContainerGuard};
+  use super::{PurgedSubject, purge_sibling_sessions, purge_subject};
+  use crate::{
+    config::DurabilityMode,
+    error::{AppError, OAuthErrorCode},
+    state::RedisConn,
+    store::Store,
+    test_guard::ContainerGuard,
+  };
 
 
   #[derive(Debug, thiserror::Error)]
   #[derive(Debug, thiserror::Error)]
   enum TestError {
   enum TestError {
@@ -555,4 +625,183 @@ mod tests {
     }
     }
     Ok(())
     Ok(())
   }
   }
+
+  async fn dead_conn() -> Result<RedisConn, TestError> {
+    let client = redis::Client::open("redis://127.0.0.1:1")?;
+    let manager = redis::aio::ConnectionManager::new_lazy_with_config(
+      client,
+      redis::aio::ConnectionManagerConfig::new()
+        .set_number_of_retries(0)
+        .set_response_timeout(Some(Duration::from_millis(200)))
+        .set_connection_timeout(Some(Duration::from_millis(200))),
+    )?;
+    Ok(RedisConn::Single(manager))
+  }
+
+  fn require_unavailable(error: AppError) -> TestResult {
+    if error.code() != OAuthErrorCode::TemporarilyUnavailable {
+      return Err(TestError::Unexpected(format!(
+        "a store outage must be temporarily_unavailable, got {:?}",
+        error.code()
+      )));
+    }
+    if error.status().as_u16() != 503 {
+      return Err(TestError::Unexpected(format!(
+        "a store outage must render 503, got {}",
+        error.status()
+      )));
+    }
+    Ok(())
+  }
+
+  #[tokio::test]
+  async fn purge_sibling_sessions_deletes_meta_sidecar_and_keeps_current() -> TestResult {
+    let (_guard, mut conn, store) = harness().await?;
+    let email = "sibling-meta@example.test";
+    let user_id = uuid::Uuid::new_v4().to_string();
+    let current_key = store.session_key("sess-current");
+    hset(
+      &mut conn,
+      &current_key,
+      &[("subject", email), ("mfa_verified", "true")],
+    )
+    .await?;
+    hset(&mut conn, &format!("{current_key}:meta"), &[("csrf", "a")]).await?;
+    let sibling_key = store.session_key("sess-sibling");
+    hset(
+      &mut conn,
+      &sibling_key,
+      &[("subject", email), ("mfa_verified", "true")],
+    )
+    .await?;
+    hset(&mut conn, &format!("{sibling_key}:meta"), &[("csrf", "b")]).await?;
+
+    let revoked =
+      purge_sibling_sessions(&mut conn, &store, email, &user_id, "sess-current").await?;
+    if revoked != 1 {
+      return Err(TestError::Unexpected(format!(
+        "exactly one sibling must be revoked, got {revoked}"
+      )));
+    }
+    require_present(&mut conn, &current_key).await?;
+    require_present(&mut conn, &format!("{current_key}:meta")).await?;
+    require_gone(&mut conn, &sibling_key).await?;
+    require_gone(&mut conn, &format!("{sibling_key}:meta")).await?;
+    Ok(())
+  }
+
+  #[tokio::test]
+  async fn purge_sibling_sessions_leaves_other_subjects_tokens_and_grants() -> TestResult {
+    let (_guard, mut conn, store) = harness().await?;
+    let email = "sibling-scope@example.test";
+    let user_id = uuid::Uuid::new_v4().to_string();
+    let other = "stranger@example.test";
+    let victim_key = store.session_key("sess-victim");
+    hset(&mut conn, &victim_key, &[("subject", email)]).await?;
+    let other_key = store.session_key("sess-other");
+    hset(&mut conn, &other_key, &[("subject", other)]).await?;
+    hset(&mut conn, &format!("{other_key}:meta"), &[("csrf", "c")]).await?;
+    let grant_id = "grant-survivor";
+    hset(
+      &mut conn,
+      &store.grant_key(grant_id),
+      &[("subject", email), ("scope", "orders:read")],
+    )
+    .await?;
+    redis::cmd("SADD")
+      .arg(store.grant_tokens_key(grant_id))
+      .arg("memberhash")
+      .query_async::<()>(&mut conn)
+      .await?;
+    let access_key = store.access_key("survivoraccess");
+    hset(&mut conn, &access_key, &[("subject", email)]).await?;
+    set(&mut conn, &format!("{access_key}:act"), "jkt").await?;
+    let refresh_key = store.refresh_key("survivorrefresh");
+    hset(&mut conn, &refresh_key, &[("subject", email)]).await?;
+    let code_key = store.code_key("survivorcode");
+    hset(&mut conn, &code_key, &[("subject", email)]).await?;
+
+    let revoked = purge_sibling_sessions(&mut conn, &store, email, &user_id, "sess-victim").await?;
+    if revoked != 0 {
+      return Err(TestError::Unexpected(format!(
+        "keeping the only own session must revoke nothing, got {revoked}"
+      )));
+    }
+    for key in [
+      victim_key,
+      other_key.clone(),
+      format!("{other_key}:meta"),
+      store.grant_key(grant_id),
+      store.grant_tokens_key(grant_id),
+      access_key.clone(),
+      format!("{access_key}:act"),
+      refresh_key,
+      code_key,
+    ] {
+      require_present(&mut conn, &key).await?;
+    }
+    Ok(())
+  }
+
+  #[tokio::test]
+  async fn purge_sibling_sessions_matches_subject_by_user_id() -> TestResult {
+    let (_guard, mut conn, store) = harness().await?;
+    let email = "sibling-id@example.test";
+    let user_id = uuid::Uuid::new_v4().to_string();
+    let current_key = store.session_key("sess-id-current");
+    hset(&mut conn, &current_key, &[("subject", user_id.as_str())]).await?;
+    let sibling_key = store.session_key("sess-id-sibling");
+    hset(&mut conn, &sibling_key, &[("subject", user_id.as_str())]).await?;
+
+    let revoked =
+      purge_sibling_sessions(&mut conn, &store, email, &user_id, "sess-id-current").await?;
+    if revoked != 1 {
+      return Err(TestError::Unexpected(format!(
+        "a user-id-keyed sibling must be revoked, got {revoked}"
+      )));
+    }
+    require_present(&mut conn, &current_key).await?;
+    require_gone(&mut conn, &sibling_key).await?;
+    Ok(())
+  }
+
+  #[tokio::test]
+  async fn purge_subject_fails_closed_on_store_outage() -> TestResult {
+    let mut conn = dead_conn().await?;
+    let store = Store::from_parts(
+      "purge-outage:{state}:".to_owned(),
+      "oauth:{rl}:".to_owned(),
+      DurabilityMode::WaitAof,
+    );
+    match purge_subject(&mut conn, &store, "outage@example.test", "outage-id").await {
+      Err(error) => require_unavailable(error),
+      Ok(_) => Err(TestError::Unexpected(
+        "purge_subject on a dead store must fail, never report success".to_owned(),
+      )),
+    }
+  }
+
+  #[tokio::test]
+  async fn purge_sibling_sessions_fails_closed_on_store_outage() -> TestResult {
+    let mut conn = dead_conn().await?;
+    let store = Store::from_parts(
+      "purge-outage:{state}:".to_owned(),
+      "oauth:{rl}:".to_owned(),
+      DurabilityMode::WaitAof,
+    );
+    match purge_sibling_sessions(
+      &mut conn,
+      &store,
+      "outage@example.test",
+      "outage-id",
+      "sess-keep",
+    )
+    .await
+    {
+      Err(error) => require_unavailable(error),
+      Ok(_) => Err(TestError::Unexpected(
+        "purge_sibling_sessions on a dead store must fail, never report success".to_owned(),
+      )),
+    }
+  }
 }
 }

+ 52 - 5
src/routes.rs

@@ -15,6 +15,10 @@
 //! 3. [`middleware::ratelimit::RateLimitLayer`] enforces per-IP/per-client
 //! 3. [`middleware::ratelimit::RateLimitLayer`] enforces per-IP/per-client
 //!    budgets on every endpoint except health and metadata (those paths have
 //!    budgets on every endpoint except health and metadata (those paths have
 //!    no rule and pass through to handlers that fail closed on their own).
 //!    no rule and pass through to handlers that fail closed on their own).
+//!    The IP bucket uses the verified TCP peer unless the peer is a
+//!    configured `rate_limit.trusted_proxies` entry, in which case the
+//!    rightmost-untrusted `X-Forwarded-For` address is attributed instead
+//!    (rate-limit bucket selection only; never client authentication).
 //! 4. [`tower_http::limit::RequestBodyLimitLayer`] caps form bodies at
 //! 4. [`tower_http::limit::RequestBodyLimitLayer`] caps form bodies at
 //!    [`MAX_BODY_BYTES`].
 //!    [`MAX_BODY_BYTES`].
 //! 5. [`middleware::security_headers::SecurityHeadersLayer`] decorates
 //! 5. [`middleware::security_headers::SecurityHeadersLayer`] decorates
@@ -44,11 +48,11 @@ use std::collections::HashMap;
 use std::net::SocketAddr;
 use std::net::SocketAddr;
 use std::time::Duration;
 use std::time::Duration;
 
 
-use axum::Form;
 use axum::extract::{ConnectInfo, Query, State};
 use axum::extract::{ConnectInfo, Query, State};
 use axum::http::{HeaderMap, StatusCode, header};
 use axum::http::{HeaderMap, StatusCode, header};
 use axum::response::{IntoResponse, Response};
 use axum::response::{IntoResponse, Response};
 use axum::routing::{get, post};
 use axum::routing::{get, post};
+use axum::{Extension, Form};
 use axum_extra::extract::CookieJar;
 use axum_extra::extract::CookieJar;
 use rustls::ServerConfig;
 use rustls::ServerConfig;
 use tower_http::limit::RequestBodyLimitLayer;
 use tower_http::limit::RequestBodyLimitLayer;
@@ -66,6 +70,7 @@ use crate::state::{AppState, health_router};
 use crate::store::pending::PendingAuth;
 use crate::store::pending::PendingAuth;
 use crate::store::{Store, pending};
 use crate::store::{Store, pending};
 use crate::ui::device::DeviceForm;
 use crate::ui::device::DeviceForm;
+use crate::ui::login::{ConfirmForm, LoginForm};
 use crate::ui::{assets, consent, device as device_page, login};
 use crate::ui::{assets, consent, device as device_page, login};
 
 
 /// Largest accepted request body (2 MiB; DCR may carry an inline `jwks`).
 /// Largest accepted request body (2 MiB; DCR may carry an inline `jwks`).
@@ -129,7 +134,7 @@ pub fn build_router_with_options(state: AppState, options: RouterOptions) -> axu
       "/device",
       "/device",
       get(device_page::get_device).post(post_device_entry),
       get(device_page::get_device).post(post_device_entry),
     )
     )
-    .route("/login", get(login::get_login).post(login::post_login))
+    .route("/login", get(login::get_login).post(post_login_entry))
     .route("/login/success", get(login::get_login_success))
     .route("/login/success", get(login::get_login_success))
     .route(
     .route(
       "/login/change-password",
       "/login/change-password",
@@ -138,7 +143,7 @@ pub fn build_router_with_options(state: AppState, options: RouterOptions) -> axu
     .route("/login/totp/setup", get(login::get_totp_setup))
     .route("/login/totp/setup", get(login::get_totp_setup))
     .route(
     .route(
       "/login/totp/confirm",
       "/login/totp/confirm",
-      get(login::get_totp_confirm).post(login::post_totp_confirm),
+      get(login::get_totp_confirm).post(post_totp_confirm_entry),
     )
     )
     .route("/logout", get(login::get_logout).post(login::post_logout))
     .route("/logout", get(login::get_logout).post(login::post_logout))
     .route(
     .route(
@@ -292,6 +297,48 @@ async fn post_device_entry(
   device_page::post_device(State(state), jar, Some(peer), Form(form)).await
   device_page::post_device(State(state), jar, Some(peer), Form(form)).await
 }
 }
 
 
+/// `POST /login` adapter: [`ui::login::post_login`] takes
+/// `Option<ConnectInfo<_>>` (not a handler extractor), so this thin entry
+/// takes the required extractor and forwards it as `Some` for composite
+/// lockout keying. Served with `into_make_service_with_connect_info` the
+/// peer is always present; without it the request is rejected before the
+/// handler runs.
+async fn post_login_entry(
+  State(state): State<AppState>,
+  Extension(request_id): Extension<RequestId>,
+  peer: ConnectInfo<SocketAddr>,
+  jar: CookieJar,
+  Form(form): Form<LoginForm>,
+) -> Response {
+  login::post_login(
+    State(state),
+    Extension(request_id),
+    jar,
+    Form(form),
+    Some(peer),
+  )
+  .await
+}
+
+/// `POST /login/totp/confirm` adapter: mirrors [`post_login_entry`] for the
+/// enrollment-confirmation lockout path.
+async fn post_totp_confirm_entry(
+  State(state): State<AppState>,
+  Extension(request_id): Extension<RequestId>,
+  peer: ConnectInfo<SocketAddr>,
+  jar: CookieJar,
+  Form(form): Form<ConfirmForm>,
+) -> Response {
+  login::post_totp_confirm(
+    State(state),
+    Extension(request_id),
+    jar,
+    Form(form),
+    Some(peer),
+  )
+  .await
+}
+
 #[cfg(test)]
 #[cfg(test)]
 mod router_tests {
 mod router_tests {
   use std::net::SocketAddr;
   use std::net::SocketAddr;
@@ -884,7 +931,7 @@ mod router_tests {
       "/introspect",
       "/introspect",
       "/revoke",
       "/revoke",
     ] {
     ] {
-      if crate::middleware::ratelimit::Rule::for_path(config.rate_limit, path).is_none() {
+      if crate::middleware::ratelimit::Rule::for_path(&config.rate_limit, path).is_none() {
         return Err(format!("sensitive route {path} must have a rate-limit rule").into());
         return Err(format!("sensitive route {path} must have a rate-limit rule").into());
       }
       }
     }
     }
@@ -894,7 +941,7 @@ mod router_tests {
       "/health/ready",
       "/health/ready",
       "/assets/app.css",
       "/assets/app.css",
     ] {
     ] {
-      if crate::middleware::ratelimit::Rule::for_path(config.rate_limit, path).is_some() {
+      if crate::middleware::ratelimit::Rule::for_path(&config.rate_limit, path).is_some() {
         return Err(format!("exempt route {path} must not have a rate-limit rule").into());
         return Err(format!("exempt route {path} must not have a rate-limit rule").into());
       }
       }
     }
     }

+ 33 - 0
src/store/mod.rs

@@ -419,6 +419,39 @@ impl Store {
     self.token_key("loginfail", account_hash)
     self.token_key("loginfail", account_hash)
   }
   }
 
 
+  /// Composite failed-login counter key for one source IP and one account.
+  ///
+  /// Both halves are hashes (never a raw IP or email). The `ip:` infix keeps
+  /// these keys disjoint from the per-account `loginfail:<hash>` keys so an
+  /// admin sweep can match `loginfail:ip:*:<account-hash>`.
+  #[must_use]
+  pub fn login_fail_ip_key(&self, ip_hash: &str, account_hash: &str) -> String {
+    format!("{}ip:{ip_hash}:{account_hash}", self.login_fail_prefix())
+  }
+
+  /// Unknown-label failed-login bucket key for one source IP (never a label).
+  ///
+  /// Attempts for absent accounts share this single per-IP bucket instead of
+  /// minting one key per probed label, so label enumeration cannot spray the
+  /// keyspace.
+  #[must_use]
+  pub fn login_fail_unknown_key(&self, ip_hash: &str) -> String {
+    format!("{}unknown:{ip_hash}", self.login_fail_prefix())
+  }
+
+  /// Key prefix covering every failed-login counter (account, composite, and
+  /// unknown buckets).
+  #[must_use]
+  pub(crate) fn login_fail_prefix(&self) -> String {
+    format!("{}loginfail:", self.token_prefix)
+  }
+
+  /// Key prefix covering the composite per-IP counters.
+  #[must_use]
+  pub(crate) fn login_fail_ip_prefix(&self) -> String {
+    format!("{}ip:", self.login_fail_prefix())
+  }
+
   /// TOTP used-code replay-marker key for a fingerprint hash (task 25).
   /// TOTP used-code replay-marker key for a fingerprint hash (task 25).
   ///
   ///
   /// Genuine gap noted by the login task: the replay cache needs a
   /// Genuine gap noted by the login task: the replay cache needs a

+ 107 - 0
src/store/sessions.rs

@@ -171,6 +171,113 @@ pub async fn clear_failed_logins(
   Ok(())
   Ok(())
 }
 }
 
 
+/// Record a failed login for one source IP and account pair and return the
+/// in-window composite count.
+///
+/// The IP and the label are hashed before keying so neither raw value ever
+/// becomes a key. Like [`record_failed_login`] this is a single non-retried
+/// script invocation.
+pub async fn record_ip_failure(
+  conn: &mut RedisConn,
+  store: &Store,
+  ip_label: &str,
+  account_label: &str,
+  window: Duration,
+) -> Result<u64, StoreError> {
+  let key = store.login_fail_ip_key(
+    &sha256_hex(ip_label.as_bytes()),
+    &sha256_hex(account_label.as_bytes()),
+  );
+  let mut invocation = super::SCRIPT_RATELIMIT_INCR.prepare_invoke();
+  invocation.key(key.as_str());
+  invocation.arg(window.as_millis() as u64);
+  let count: u64 = invocation
+    .invoke_async(conn)
+    .await
+    .map_err(StoreError::Redis)?;
+  Ok(count)
+}
+
+/// Read the in-window composite count for one source IP and account pair.
+pub async fn ip_failure_count(
+  conn: &mut RedisConn,
+  store: &Store,
+  ip_label: &str,
+  account_label: &str,
+) -> Result<u64, StoreError> {
+  let key = store.login_fail_ip_key(
+    &sha256_hex(ip_label.as_bytes()),
+    &sha256_hex(account_label.as_bytes()),
+  );
+  let budget = store.retry_budget();
+  retry_op!(conn, budget, async {
+    redis::cmd("GET")
+      .arg(&key)
+      .query_async::<Option<u64>>(conn)
+      .await
+  })
+  .map(|count| count.unwrap_or(0))
+  .map_err(StoreError::Redis)
+}
+
+/// Clear the composite counter for one source IP and account pair; the
+/// per-account counter and other IPs' buckets are untouched.
+pub async fn clear_ip_failures(
+  conn: &mut RedisConn,
+  store: &Store,
+  ip_label: &str,
+  account_label: &str,
+) -> Result<(), StoreError> {
+  let key = store.login_fail_ip_key(
+    &sha256_hex(ip_label.as_bytes()),
+    &sha256_hex(account_label.as_bytes()),
+  );
+  let _: () = redis::cmd("DEL")
+    .arg(&key)
+    .query_async(conn)
+    .await
+    .map_err(StoreError::Redis)?;
+  Ok(())
+}
+
+/// Record a failed login for an absent account label without minting a
+/// per-label key: the attempt counts toward the source IP's single shared
+/// unknown bucket and returns its in-window count.
+pub async fn record_unknown_failure(
+  conn: &mut RedisConn,
+  store: &Store,
+  ip_label: &str,
+  window: Duration,
+) -> Result<u64, StoreError> {
+  let key = store.login_fail_unknown_key(&sha256_hex(ip_label.as_bytes()));
+  let mut invocation = super::SCRIPT_RATELIMIT_INCR.prepare_invoke();
+  invocation.key(key.as_str());
+  invocation.arg(window.as_millis() as u64);
+  let count: u64 = invocation
+    .invoke_async(conn)
+    .await
+    .map_err(StoreError::Redis)?;
+  Ok(count)
+}
+
+/// Read the in-window unknown-label count for one source IP.
+pub async fn unknown_failure_count(
+  conn: &mut RedisConn,
+  store: &Store,
+  ip_label: &str,
+) -> Result<u64, StoreError> {
+  let key = store.login_fail_unknown_key(&sha256_hex(ip_label.as_bytes()));
+  let budget = store.retry_budget();
+  retry_op!(conn, budget, async {
+    redis::cmd("GET")
+      .arg(&key)
+      .query_async::<Option<u64>>(conn)
+      .await
+  })
+  .map(|count| count.unwrap_or(0))
+  .map_err(StoreError::Redis)
+}
+
 fn sessions_redis(error: StoreError) -> redis::RedisError {
 fn sessions_redis(error: StoreError) -> redis::RedisError {
   match error {
   match error {
     StoreError::Redis(inner) => inner,
     StoreError::Redis(inner) => inner,

+ 35 - 0
src/store/tests_store.rs

@@ -1276,6 +1276,39 @@ async fn store_login_failure_counter_ttl_within_lockout_window() -> TestResult {
   assert_ttl_within(pttl_ms(&mut conn, &key).await?, window)
   assert_ttl_within(pttl_ms(&mut conn, &key).await?, window)
 }
 }
 
 
+/// The composite helpers scope to one source: clearing one IP's bucket
+/// leaves the account counter and every other IP's bucket intact.
+#[tokio::test]
+async fn store_ip_failure_clear_is_scoped_to_source() -> TestResult {
+  let (_container, url) = redis_container(Vec::new()).await?;
+  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let window = crate::ui::login::LOCKOUT_WINDOW;
+  let account = unique("alice@example.test");
+
+  sessions::record_failed_login(&mut conn, &store, &account, window).await?;
+  sessions::record_ip_failure(&mut conn, &store, "ip-a", &account, window).await?;
+  sessions::record_ip_failure(&mut conn, &store, "ip-a", &account, window).await?;
+  sessions::record_ip_failure(&mut conn, &store, "ip-b", &account, window).await?;
+
+  sessions::clear_ip_failures(&mut conn, &store, "ip-a", &account).await?;
+  if sessions::ip_failure_count(&mut conn, &store, "ip-a", &account).await? != 0 {
+    return Err(TestError::Unexpected(
+      "the cleared source bucket must read zero".to_owned(),
+    ));
+  }
+  if sessions::ip_failure_count(&mut conn, &store, "ip-b", &account).await? != 1 {
+    return Err(TestError::Unexpected(
+      "the other source bucket must survive the clear".to_owned(),
+    ));
+  }
+  if sessions::failed_login_count(&mut conn, &store, &account).await? != 1 {
+    return Err(TestError::Unexpected(
+      "the account counter must survive a scoped clear".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
 /// docs/REDIS.md: the DPoP replay marker is bounded by the proof window
 /// docs/REDIS.md: the DPoP replay marker is bounded by the proof window
 /// ([`crate::jose::dpop::DPOP_JTI_TTL`]); the `private_key_jwt` marker is
 /// ([`crate::jose::dpop::DPOP_JTI_TTL`]); the `private_key_jwt` marker is
 /// bounded by the assertion's remaining validity (at most 360 s).
 /// bounded by the assertion's remaining validity (at most 360 s).
@@ -1332,6 +1365,8 @@ fn store_keys_share_hash_tag_and_keyspaces_split() -> TestResult {
     store.client_assertion_jti_key("j"),
     store.client_assertion_jti_key("j"),
     store.rotation_map_key("r"),
     store.rotation_map_key("r"),
     store.login_fail_key("l"),
     store.login_fail_key("l"),
+    store.login_fail_ip_key("i", "l"),
+    store.login_fail_unknown_key("i"),
   ] {
   ] {
     if !key.contains("{state}") {
     if !key.contains("{state}") {
       return Err(TestError::Unexpected(
       return Err(TestError::Unexpected(

+ 3 - 11
src/ui/consent.rs

@@ -40,7 +40,7 @@ use crate::{
   },
   },
   state::{AppState, RedisConn},
   state::{AppState, RedisConn},
   store::{self, Store, pending::PendingAuth},
   store::{self, Store, pending::PendingAuth},
-  ui::login::{self, LiveSession, SESSION_COOKIE, form_token, load_session, verify_form_token},
+  ui::login::{self, LiveSession, form_token, load_session, verify_form_token},
 };
 };
 
 
 /// Form-token action prefix; the bound `request_id` is appended so a token
 /// Form-token action prefix; the bound `request_id` is appended so a token
@@ -432,7 +432,7 @@ pub async fn get_consent(
 ) -> Response {
 ) -> Response {
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
-  let session_id = session_id_of(&jar);
+  let session_id = login::session_id_from_jar(&state.config, &jar);
   let mut deps = ConsentDeps {
   let mut deps = ConsentDeps {
     pool: &state.pool,
     pool: &state.pool,
     conn: &mut conn,
     conn: &mut conn,
@@ -475,7 +475,7 @@ pub async fn post_consent(
   };
   };
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
-  let session_id = session_id_of(&jar);
+  let session_id = login::session_id_from_jar(&state.config, &jar);
   let mut deps = ConsentDeps {
   let mut deps = ConsentDeps {
     pool: &state.pool,
     pool: &state.pool,
     conn: &mut conn,
     conn: &mut conn,
@@ -499,14 +499,6 @@ pub async fn post_consent(
   }
   }
 }
 }
 
 
-/// Session id from the cookie, or `""` when absent.
-fn session_id_of(jar: &CookieJar) -> String {
-  jar
-    .get(SESSION_COOKIE)
-    .map(|cookie| cookie.value().to_owned())
-    .unwrap_or_default()
-}
-
 /// `302 Found` to `location`; an invalid header fails closed to a `500`.
 /// `302 Found` to `location`; an invalid header fails closed to a `500`.
 fn redirect(location: &str) -> Response {
 fn redirect(location: &str) -> Response {
   match header::HeaderValue::from_str(location) {
   match header::HeaderValue::from_str(location) {

+ 5 - 12
src/ui/device.rs

@@ -44,8 +44,6 @@ use crate::{
   store::{self, Store},
   store::{self, Store},
 };
 };
 
 
-/// Session cookie name (owned by the login flow; see module docs).
-pub const SESSION_COOKIE: &str = "session_id";
 /// Where anonymous users are sent to authenticate first.
 /// Where anonymous users are sent to authenticate first.
 pub const LOGIN_PATH: &str = "/login";
 pub const LOGIN_PATH: &str = "/login";
 /// Guess window for the user-code brute-force counter.
 /// Guess window for the user-code brute-force counter.
@@ -139,16 +137,14 @@ pub async fn check_guess_budget(
 pub async fn authenticated_subject(
 pub async fn authenticated_subject(
   conn: &mut RedisConn,
   conn: &mut RedisConn,
   store: &Store,
   store: &Store,
+  config: &crate::config::Config,
   jar: &CookieJar,
   jar: &CookieJar,
 ) -> Result<Option<String>, AppError> {
 ) -> Result<Option<String>, AppError> {
-  let Some(cookie) = jar.get(SESSION_COOKIE) else {
-    return Ok(None);
-  };
-  let session_id = cookie.value();
+  let session_id = crate::ui::login::session_id_from_jar(config, jar);
   if session_id.is_empty() {
   if session_id.is_empty() {
     return Ok(None);
     return Ok(None);
   }
   }
-  let record = store::sessions::get(conn, store, session_id).await?;
+  let record = store::sessions::get(conn, store, &session_id).await?;
   Ok(
   Ok(
     record
     record
       .filter(|session| !session.subject.is_empty() && session.mfa_verified && !session.restricted)
       .filter(|session| !session.subject.is_empty() && session.mfa_verified && !session.restricted)
@@ -382,14 +378,11 @@ pub async fn post_device(
   peer: Option<ConnectInfo<SocketAddr>>,
   peer: Option<ConnectInfo<SocketAddr>>,
   Form(form): Form<DeviceForm>,
   Form(form): Form<DeviceForm>,
 ) -> Response {
 ) -> Response {
-  let session_cookie = jar
-    .get(SESSION_COOKIE)
-    .map(|cookie| cookie.value().to_owned())
-    .unwrap_or_default();
+  let session_cookie = crate::ui::login::session_id_from_jar(&state.config, &jar);
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
 
 
-  let subject = match authenticated_subject(&mut conn, &store, &jar).await {
+  let subject = match authenticated_subject(&mut conn, &store, &state.config, &jar).await {
     Ok(Some(subject)) => subject,
     Ok(Some(subject)) => subject,
     Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
     Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
     Err(error) => return error.into_response(),
     Err(error) => return error.into_response(),

+ 206 - 85
src/ui/login.rs

@@ -21,12 +21,15 @@
 //! pending `request_id` lives only in that sidecar after `GET /login`
 //! pending `request_id` lives only in that sidecar after `GET /login`
 //! validates it, so `POST /login` cannot be steered off-record.
 //! validates it, so `POST /login` cannot be steered off-record.
 
 
-use std::time::Duration;
+use std::{
+  net::{IpAddr, SocketAddr},
+  time::Duration,
+};
 
 
 use askama::Template;
 use askama::Template;
 use axum::{
 use axum::{
   Extension, Form,
   Extension, Form,
-  extract::{Query, State},
+  extract::{ConnectInfo, Query, State},
   http::{StatusCode, header},
   http::{StatusCode, header},
   response::{IntoResponse, Redirect, Response},
   response::{IntoResponse, Redirect, Response},
 };
 };
@@ -56,13 +59,26 @@ use crate::{
   error::AppError,
   error::AppError,
   keys::KeyRing,
   keys::KeyRing,
   middleware::{audit::AuditEvent, request_id::RequestId},
   middleware::{audit::AuditEvent, request_id::RequestId},
+  protocol::purge::purge_sibling_sessions,
   protocol::redirect_repo as redirect_match,
   protocol::redirect_repo as redirect_match,
   state::{AppState, RedisConn},
   state::{AppState, RedisConn},
   store::{self, Store, StoreError},
   store::{self, Store, StoreError},
 };
 };
 
 
-/// Session cookie name (also the rendezvous for task 26/29 readers).
-pub const SESSION_COOKIE: &str = "session_id";
+/// Secure session cookie name (also the rendezvous for task 26/29 readers).
+///
+/// The `__Host-` prefix (RFC 6265bis) binds the browser-side contract:
+/// `Secure`, `Path=/`, and no `Domain`. The builders below never emit
+/// `Domain`, and configuration rejects any `cookie_domain`, so the prefix
+/// guarantee holds for every response that sets or clears this cookie.
+pub const SESSION_COOKIE: &str = "__Host-session_id";
+/// Insecure development session cookie name, used only when
+/// [`Config::cookie_secure`] is disabled (development-only; production
+/// refuses to load without `Secure`). Browsers reject a `__Host-` cookie
+/// without `Secure`, so the prefix is dropped here and the cookie stays a
+/// plain host-only cookie (`Path=/`, `HttpOnly`, `SameSite=Lax`, no
+/// `Domain`).
+pub const INSECURE_SESSION_COOKIE: &str = "session_id";
 /// Login form page.
 /// Login form page.
 pub const LOGIN_PATH: &str = "/login";
 pub const LOGIN_PATH: &str = "/login";
 /// Landing page for logins with no bound authorization request.
 /// Landing page for logins with no bound authorization request.
@@ -77,8 +93,12 @@ pub const TOTP_CONFIRM_PATH: &str = "/login/totp/confirm";
 pub const LOGOUT_PATH: &str = "/logout";
 pub const LOGOUT_PATH: &str = "/logout";
 /// Post-login target for a bound authorization request (owned by task 26).
 /// Post-login target for a bound authorization request (owned by task 26).
 pub const CONSENT_PATH: &str = "/consent";
 pub const CONSENT_PATH: &str = "/consent";
-/// Failed logins within [`LOCKOUT_WINDOW`] that lock an account.
+/// Failed logins from one IP for one account within [`LOCKOUT_WINDOW`] that
+/// lock that IP's composite bucket.
 pub const LOCKOUT_THRESHOLD: u64 = 5;
 pub const LOCKOUT_THRESHOLD: u64 = 5;
+/// Failed logins from any IPs within [`LOCKOUT_WINDOW`] that lock the account
+/// globally (the distributed-burst cap above the per-IP threshold).
+pub const ACCOUNT_LOCKOUT_THRESHOLD: u64 = 20;
 /// Window that doubles as the lock duration.
 /// Window that doubles as the lock duration.
 pub const LOCKOUT_WINDOW: Duration = Duration::from_secs(15 * 60);
 pub const LOCKOUT_WINDOW: Duration = Duration::from_secs(15 * 60);
 /// Minimum accepted password length on change.
 /// Minimum accepted password length on change.
@@ -141,6 +161,19 @@ pub struct LoginAttempt {
   pub code: String,
   pub code: String,
   /// Presented login-CSRF token.
   /// Presented login-CSRF token.
   pub csrf: String,
   pub csrf: String,
+  /// Source IP for composite lockout keying (`None` when the peer address is
+  /// absent; enforcement then degrades to the per-account counter only).
+  pub peer_ip: Option<IpAddr>,
+}
+
+/// The login lockout policy: a per-(IP, account) threshold plus the global
+/// per-account cap over the same window.
+fn lockout_policy() -> user_repo::LockoutPolicy {
+  user_repo::LockoutPolicy {
+    threshold: LOCKOUT_THRESHOLD,
+    account_threshold: ACCOUNT_LOCKOUT_THRESHOLD,
+    lock_duration: LOCKOUT_WINDOW,
+  }
 }
 }
 
 
 /// Outcome of [`attempt_login`].
 /// Outcome of [`attempt_login`].
@@ -181,43 +214,101 @@ pub fn restricted_allows_path(path: &str) -> bool {
   )
   )
 }
 }
 
 
-/// Build the session cookie: `HttpOnly`, `SameSite=Lax`, `Secure` exactly
-/// when [`Config::cookie_secure`] is set (disabled for localhost development).
+/// Session cookie name for the runtime configuration: [`SESSION_COOKIE`]
+/// when [`Config::cookie_secure`] is set, [`INSECURE_SESSION_COOKIE`]
+/// otherwise (browsers reject `__Host-` without `Secure`).
+#[must_use]
+pub fn session_cookie_name(config: &Config) -> &'static str {
+  if config.cookie_secure {
+    SESSION_COOKIE
+  } else {
+    INSECURE_SESSION_COOKIE
+  }
+}
+
+/// Session id from the request cookies.
+///
+/// Production ([`Config::cookie_secure`]) honors [`SESSION_COOKIE`]
+/// (`__Host-session_id`) **alone**: a plain `session_id` set by a sibling
+/// subdomain is never read, so it cannot fixate a victim's login onto the
+/// attacker's pending presession. The insecure development variant additionally
+/// falls back to [`SESSION_COOKIE`] for a cookie left over from before a config
+/// flip (harmless: it is only read when the configured name is the plain one).
+#[must_use]
+pub fn session_id_from_jar(config: &Config, jar: &CookieJar) -> String {
+  jar
+    .get(session_cookie_name(config))
+    .or_else(|| {
+      if config.cookie_secure {
+        return None;
+      }
+      jar.get(SESSION_COOKIE)
+    })
+    .map(|cookie| cookie.value().to_owned())
+    .unwrap_or_default()
+}
+
+/// Build the session cookie: `__Host-` prefixed, `HttpOnly`, `Path=/`,
+/// `SameSite=Lax`, and `Secure` exactly when [`Config::cookie_secure`] is set
+/// (disabled for localhost development only; production refuses to load
+/// without `Secure`). No `Domain` is ever emitted in either variant, so the
+/// cookie stays host-only. Without `Secure` the name drops the `__Host-`
+/// prefix ([`INSECURE_SESSION_COOKIE`]): browsers reject a `__Host-` cookie
+/// without `Secure`, so the prefixed name would never persist the session.
 #[must_use]
 #[must_use]
 pub fn session_cookie(config: &Config, session_id: &str) -> Cookie<'static> {
 pub fn session_cookie(config: &Config, session_id: &str) -> Cookie<'static> {
-  let mut builder = Cookie::build((SESSION_COOKIE, session_id.to_owned()))
+  let mut builder = Cookie::build((session_cookie_name(config), session_id.to_owned()))
     .path("/")
     .path("/")
     .http_only(true)
     .http_only(true)
     .same_site(SameSite::Lax);
     .same_site(SameSite::Lax);
   if config.cookie_secure {
   if config.cookie_secure {
     builder = builder.secure(true);
     builder = builder.secure(true);
   }
   }
-  if let Some(domain) = config.cookie_domain.clone() {
-    builder = builder.domain(domain);
-  }
   builder.build()
   builder.build()
 }
 }
 
 
-/// Build an expired session cookie for logout, mirroring every attribute of
-/// [`session_cookie`] (`Path`, `HttpOnly`, `SameSite`, `Secure`, `Domain`) so
-/// the clearing cookie overwrites the session cookie in the browser. A bare
-/// removal cookie without those attributes would leave the session cookie
-/// alive under the stricter attribute set.
+/// Build a clearing cookie for one session-cookie name, mirroring every
+/// attribute of [`session_cookie`] (`Path`, `HttpOnly`, `SameSite`, `Secure`)
+/// so the clearing cookie overwrites the session cookie in the browser. A
+/// `__Host-` name always carries `Secure` (RFC 6265bis; the secure runtime uses
+/// exactly that name); the plain development name never does. No `Domain` is
+/// ever emitted, so the cookie stays host-only.
 #[must_use]
 #[must_use]
-pub fn expired_session_cookie(config: &Config) -> Cookie<'static> {
-  let mut builder = Cookie::build((SESSION_COOKIE, String::new()))
+fn clearing_cookie(name: &'static str) -> Cookie<'static> {
+  let mut builder = Cookie::build((name, String::new()))
     .path("/")
     .path("/")
     .http_only(true)
     .http_only(true)
     .same_site(SameSite::Lax);
     .same_site(SameSite::Lax);
-  if config.cookie_secure {
+  if name == SESSION_COOKIE {
     builder = builder.secure(true);
     builder = builder.secure(true);
   }
   }
-  if let Some(domain) = config.cookie_domain.clone() {
-    builder = builder.domain(domain);
-  }
   builder.removal().build()
   builder.removal().build()
 }
 }
 
 
+/// Every clearing cookie for logout: one per session-cookie name the reader
+/// honors ([`session_id_from_jar`]), so a cookie the reader would accept cannot
+/// survive a sign-out. Production clears only `__Host-session_id`; the insecure
+/// development variant also clears the plain `session_id` fallback. Ordering
+/// puts the configured name first, so the first `Set-Cookie` mirrors
+/// [`session_cookie`].
+#[must_use]
+pub fn expired_session_cookies(config: &Config) -> Vec<Cookie<'static>> {
+  let mut cookies = vec![clearing_cookie(session_cookie_name(config))];
+  if !config.cookie_secure {
+    cookies.push(clearing_cookie(SESSION_COOKIE));
+  }
+  cookies
+}
+
+/// Add every clearing cookie the reader honors to `jar`.
+fn clear_session_cookies(jar: CookieJar, config: &Config) -> CookieJar {
+  let mut jar = jar;
+  for cookie in expired_session_cookies(config) {
+    jar = jar.add(cookie);
+  }
+  jar
+}
+
 /// Mint the stateless pre-login CSRF token: HMAC under the active token
 /// Mint the stateless pre-login CSRF token: HMAC under the active token
 /// pepper over `session_id || "|login"`. No server-side CSRF state is stored.
 /// pepper over `session_id || "|login"`. No server-side CSRF state is stored.
 ///
 ///
@@ -386,11 +477,8 @@ pub async fn attempt_login(
     return Err(AppError::invalid_request("invalid form submission"));
     return Err(AppError::invalid_request("invalid form submission"));
   }
   }
   let email = normalize_email(&attempt.email);
   let email = normalize_email(&attempt.email);
-  let policy = user_repo::LockoutPolicy {
-    threshold: LOCKOUT_THRESHOLD,
-    lock_duration: LOCKOUT_WINDOW,
-  };
-  if user_repo::is_locked_out(deps.conn, deps.store, policy, &email).await? {
+  let policy = lockout_policy();
+  if user_repo::is_locked_out(deps.conn, deps.store, policy, &email, attempt.peer_ip).await? {
     audit_failure(None, None, "locked");
     audit_failure(None, None, "locked");
     return Ok(LoginOutcome::Rejected);
     return Ok(LoginOutcome::Rejected);
   }
   }
@@ -406,17 +494,26 @@ pub async fn attempt_login(
   match user_repo::verify_password(deps.pool, deps.keys, &email, &attempt.password).await? {
   match user_repo::verify_password(deps.pool, deps.keys, &email, &attempt.password).await? {
     user_repo::PasswordVerdict::Unknown => {
     user_repo::PasswordVerdict::Unknown => {
       burn_timing(deps.config, &attempt.password)?;
       burn_timing(deps.config, &attempt.password)?;
-      user_repo::register_login_failure(deps.conn, deps.store, policy, &email).await?;
-      audit_failure(pending_id.as_deref(), None, "bad_credentials");
+      // Absent labels share the source IP's single unknown bucket instead of
+      // minting one key per probed label; the rejection stays uniform either
+      // way, so enumeration learns nothing.
+      if user_repo::is_unknown_locked_out(deps.conn, deps.store, policy, attempt.peer_ip).await? {
+        audit_failure(pending_id.as_deref(), None, "locked");
+      } else {
+        user_repo::register_unknown_failure(deps.conn, deps.store, policy, attempt.peer_ip).await?;
+        audit_failure(pending_id.as_deref(), None, "bad_credentials");
+      }
       return Ok(LoginOutcome::Rejected);
       return Ok(LoginOutcome::Rejected);
     }
     }
     user_repo::PasswordVerdict::Disabled => {
     user_repo::PasswordVerdict::Disabled => {
-      user_repo::register_login_failure(deps.conn, deps.store, policy, &email).await?;
+      user_repo::register_login_failure(deps.conn, deps.store, policy, &email, attempt.peer_ip)
+        .await?;
       audit_failure(pending_id.as_deref(), Some(&email), "disabled");
       audit_failure(pending_id.as_deref(), Some(&email), "disabled");
       return Ok(LoginOutcome::Rejected);
       return Ok(LoginOutcome::Rejected);
     }
     }
     user_repo::PasswordVerdict::Mismatched => {
     user_repo::PasswordVerdict::Mismatched => {
-      user_repo::register_login_failure(deps.conn, deps.store, policy, &email).await?;
+      user_repo::register_login_failure(deps.conn, deps.store, policy, &email, attempt.peer_ip)
+        .await?;
       audit_failure(pending_id.as_deref(), Some(&email), "bad_credentials");
       audit_failure(pending_id.as_deref(), Some(&email), "bad_credentials");
       return Ok(LoginOutcome::Rejected);
       return Ok(LoginOutcome::Rejected);
     }
     }
@@ -429,13 +526,14 @@ pub async fn attempt_login(
   let mut mfa_done = false;
   let mut mfa_done = false;
   if user.totp_enabled {
   if user.totp_enabled {
     if !verify_second_factor(deps, user.id, &attempt.code).await? {
     if !verify_second_factor(deps, user.id, &attempt.code).await? {
-      user_repo::register_login_failure(deps.conn, deps.store, policy, &email).await?;
+      user_repo::register_login_failure(deps.conn, deps.store, policy, &email, attempt.peer_ip)
+        .await?;
       audit_failure(pending_id.as_deref(), Some(&email), "bad_code");
       audit_failure(pending_id.as_deref(), Some(&email), "bad_code");
       return Ok(LoginOutcome::Rejected);
       return Ok(LoginOutcome::Rejected);
     }
     }
     mfa_done = true;
     mfa_done = true;
   }
   }
-  user_repo::clear_login_failures(deps.conn, deps.store, &email).await?;
+  user_repo::clear_login_failures(deps.conn, deps.store, &email, attempt.peer_ip).await?;
   // Bind strictly: the pending record must still exist at login time, and the
   // Bind strictly: the pending record must still exist at login time, and the
   // redirect leaves from the stored record — never from client input.
   // redirect leaves from the stored record — never from client input.
   let redirect_to = match pending_id.as_deref() {
   let redirect_to = match pending_id.as_deref() {
@@ -517,7 +615,9 @@ pub enum PasswordOutcome {
 ///
 ///
 /// Clears `must_change_password` in the database. When TOTP is already
 /// Clears `must_change_password` in the database. When TOTP is already
 /// enrolled the session is re-issued as full and the caller redirects on;
 /// enrolled the session is re-issued as full and the caller redirects on;
-/// otherwise the session stays restricted for the enrollment step.
+/// otherwise the session stays restricted for the enrollment step. A
+/// successful change ends the subject's other sessions; the current one
+/// survives.
 ///
 ///
 /// # Errors
 /// # Errors
 ///
 ///
@@ -548,6 +648,18 @@ pub async fn change_password(
     return Err(AppError::invalid_request("the login session has expired"));
     return Err(AppError::invalid_request("the login session has expired"));
   };
   };
   let params = secret_params(deps.config)?;
   let params = secret_params(deps.config)?;
+  // The credential is about to change: end the subject's other sessions
+  // before writing the new password. A Redis outage fails the call (503)
+  // with the password untouched, so the caller retries; a later database
+  // failure costs only re-authentication (the siblings are already gone).
+  purge_sibling_sessions(
+    deps.conn,
+    deps.store,
+    &live.record.subject,
+    &user.id.to_string(),
+    session_id,
+  )
+  .await?;
   let hash = hash_secret(new_password, &params).map_err(AppError::internal)?;
   let hash = hash_secret(new_password, &params).map_err(AppError::internal)?;
   if !user_repo::set_password(deps.pool, user.id, &hash).await? {
   if !user_repo::set_password(deps.pool, user.id, &hash).await? {
     return Err(AppError::server_error());
     return Err(AppError::server_error());
@@ -637,7 +749,8 @@ pub enum ConfirmOutcome {
 
 
 /// Confirm TOTP enrollment with an authenticator code (TOTP only: recovery
 /// Confirm TOTP enrollment with an authenticator code (TOTP only: recovery
 /// codes never confirm enrollment). Failed attempts increment the shared
 /// codes never confirm enrollment). Failed attempts increment the shared
-/// login lockout counter; a successful confirmation clears it.
+/// login lockout counter; a successful confirmation clears it. A successful
+/// confirmation ends the subject's other sessions; the current one survives.
 ///
 ///
 /// # Errors
 /// # Errors
 ///
 ///
@@ -649,6 +762,7 @@ pub async fn confirm_totp(
   deps: &mut LoginDeps<'_>,
   deps: &mut LoginDeps<'_>,
   session_id: &str,
   session_id: &str,
   code: &str,
   code: &str,
+  peer_ip: Option<IpAddr>,
 ) -> Result<ConfirmOutcome, AppError> {
 ) -> Result<ConfirmOutcome, AppError> {
   let Some(live) = load_session(deps.conn, deps.store, session_id).await? else {
   let Some(live) = load_session(deps.conn, deps.store, session_id).await? else {
     return Err(AppError::invalid_request("the login session has expired"));
     return Err(AppError::invalid_request("the login session has expired"));
@@ -660,11 +774,8 @@ pub async fn confirm_totp(
   // uses, keyed by the session subject. Checked before verification so a
   // uses, keyed by the session subject. Checked before verification so a
   // locked account rejects even a correct code, indistinguishably from a
   // locked account rejects even a correct code, indistinguishably from a
   // wrong one, until the window expires.
   // wrong one, until the window expires.
-  let policy = user_repo::LockoutPolicy {
-    threshold: LOCKOUT_THRESHOLD,
-    lock_duration: LOCKOUT_WINDOW,
-  };
-  if user_repo::is_locked_out(deps.conn, deps.store, policy, &live.record.subject).await? {
+  let policy = lockout_policy();
+  if user_repo::is_locked_out(deps.conn, deps.store, policy, &live.record.subject, peer_ip).await? {
     return Ok(ConfirmOutcome::Rejected);
     return Ok(ConfirmOutcome::Rejected);
   }
   }
   let Some(user) = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject).await?
   let Some(user) = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject).await?
@@ -672,7 +783,8 @@ pub async fn confirm_totp(
     return Err(AppError::invalid_request("the login session has expired"));
     return Err(AppError::invalid_request("the login session has expired"));
   };
   };
   let Some(blob) = user_repo::totp_secret_blob(deps.pool, user.id).await? else {
   let Some(blob) = user_repo::totp_secret_blob(deps.pool, user.id).await? else {
-    user_repo::register_login_failure(deps.conn, deps.store, policy, &live.record.subject).await?;
+    user_repo::register_login_failure(deps.conn, deps.store, policy, &live.record.subject, peer_ip)
+      .await?;
     return Ok(ConfirmOutcome::Rejected);
     return Ok(ConfirmOutcome::Rejected);
   };
   };
   let secret = totp::decrypt_secret(deps.keys, &blob)?;
   let secret = totp::decrypt_secret(deps.keys, &blob)?;
@@ -688,7 +800,8 @@ pub async fn confirm_totp(
     ttl: totp::USED_CODE_TTL,
     ttl: totp::USED_CODE_TTL,
   };
   };
   if !totp::verify_and_claim_with_ring(deps.conn, deps.keys, key_for, &claim).await? {
   if !totp::verify_and_claim_with_ring(deps.conn, deps.keys, key_for, &claim).await? {
-    user_repo::register_login_failure(deps.conn, deps.store, policy, &live.record.subject).await?;
+    user_repo::register_login_failure(deps.conn, deps.store, policy, &live.record.subject, peer_ip)
+      .await?;
     audit_failure(
     audit_failure(
       live.meta.pending_request_id.as_deref(),
       live.meta.pending_request_id.as_deref(),
       Some(&live.record.subject),
       Some(&live.record.subject),
@@ -696,7 +809,18 @@ pub async fn confirm_totp(
     );
     );
     return Ok(ConfirmOutcome::Rejected);
     return Ok(ConfirmOutcome::Rejected);
   }
   }
-  user_repo::clear_login_failures(deps.conn, deps.store, &live.record.subject).await?;
+  user_repo::clear_login_failures(deps.conn, deps.store, &live.record.subject, peer_ip).await?;
+  // The authenticator changed: end the subject's other sessions before
+  // promoting. A Redis outage fails the call (503) rather than reporting
+  // success with siblings intact.
+  purge_sibling_sessions(
+    deps.conn,
+    deps.store,
+    &live.record.subject,
+    &user.id.to_string(),
+    session_id,
+  )
+  .await?;
   let fresh = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject).await?;
   let fresh = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject).await?;
   if fresh.is_some_and(|user| !user.must_change_password) {
   if fresh.is_some_and(|user| !user.must_change_password) {
     let promoted = promote_to_full(deps, session_id, &live).await?;
     let promoted = promote_to_full(deps, session_id, &live).await?;
@@ -1229,9 +1353,12 @@ pub async fn get_login(
 ) -> Response {
 ) -> Response {
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
-  let presented = jar
-    .get(SESSION_COOKIE)
-    .map(|cookie| cookie.value().to_owned());
+  let presented_id = session_id_from_jar(&state.config, &jar);
+  let presented = if presented_id.is_empty() {
+    None
+  } else {
+    Some(presented_id)
+  };
   let (presession_id, fresh) = {
   let (presession_id, fresh) = {
     let mut deps = deps_of(&state, &mut conn, &store);
     let mut deps = deps_of(&state, &mut conn, &store);
     match ensure_presession(&mut deps, presented.as_deref()).await {
     match ensure_presession(&mut deps, presented.as_deref()).await {
@@ -1302,24 +1429,28 @@ pub async fn get_login(
 }
 }
 
 
 /// `POST /login`: verify credentials, rotate the session, redirect strictly.
 /// `POST /login`: verify credentials, rotate the session, redirect strictly.
+///
+/// `Option<ConnectInfo<_>>` is not a handler extractor, so the route mounts a
+/// thin entry adapter (see `routes.rs`) that forwards the required peer as
+/// `Some`; a `None` peer degrades lockout enforcement to the per-account
+/// counter only.
 pub async fn post_login(
 pub async fn post_login(
   State(state): State<AppState>,
   State(state): State<AppState>,
   Extension(request_id): Extension<RequestId>,
   Extension(request_id): Extension<RequestId>,
   jar: CookieJar,
   jar: CookieJar,
   Form(form): Form<LoginForm>,
   Form(form): Form<LoginForm>,
+  peer: Option<ConnectInfo<SocketAddr>>,
 ) -> Response {
 ) -> Response {
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
-  let presession_id = jar
-    .get(SESSION_COOKIE)
-    .map(|cookie| cookie.value().to_owned())
-    .unwrap_or_default();
+  let presession_id = session_id_from_jar(&state.config, &jar);
   let attempt = LoginAttempt {
   let attempt = LoginAttempt {
     presession_id,
     presession_id,
     email: form.email.clone(),
     email: form.email.clone(),
     password: form.password,
     password: form.password,
     code: form.code,
     code: form.code,
     csrf: form.csrf,
     csrf: form.csrf,
+    peer_ip: peer.map(|info| info.0.ip()),
   };
   };
   let mut deps = deps_of(&state, &mut conn, &store);
   let mut deps = deps_of(&state, &mut conn, &store);
   match attempt_login(&mut deps, attempt).await {
   match attempt_login(&mut deps, attempt).await {
@@ -1368,10 +1499,7 @@ pub async fn post_change_password(
 ) -> Response {
 ) -> Response {
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
-  let session_id = jar
-    .get(SESSION_COOKIE)
-    .map(|cookie| cookie.value().to_owned())
-    .unwrap_or_default();
+  let session_id = session_id_from_jar(&state.config, &jar);
   let live = match load_session(&mut conn, &store, &session_id).await {
   let live = match load_session(&mut conn, &store, &session_id).await {
     Ok(Some(live)) => live,
     Ok(Some(live)) => live,
     Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
     Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
@@ -1406,10 +1534,7 @@ pub async fn get_totp_setup(
 ) -> Response {
 ) -> Response {
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
-  let session_id = jar
-    .get(SESSION_COOKIE)
-    .map(|cookie| cookie.value().to_owned())
-    .unwrap_or_default();
+  let session_id = session_id_from_jar(&state.config, &jar);
   let live = match load_session(&mut conn, &store, &session_id).await {
   let live = match load_session(&mut conn, &store, &session_id).await {
     Ok(Some(live)) => live,
     Ok(Some(live)) => live,
     Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
     Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
@@ -1448,18 +1573,19 @@ pub async fn get_totp_confirm(State(state): State<AppState>, jar: CookieJar) ->
 }
 }
 
 
 /// `POST /login/totp/confirm`: verify the authenticator code, promote or stay.
 /// `POST /login/totp/confirm`: verify the authenticator code, promote or stay.
+///
+/// Peer handling mirrors [`post_login`]: the route entry forwards the
+/// required peer as `Some`, and `None` degrades to the per-account counter.
 pub async fn post_totp_confirm(
 pub async fn post_totp_confirm(
   State(state): State<AppState>,
   State(state): State<AppState>,
   Extension(request_id): Extension<RequestId>,
   Extension(request_id): Extension<RequestId>,
   jar: CookieJar,
   jar: CookieJar,
   Form(form): Form<ConfirmForm>,
   Form(form): Form<ConfirmForm>,
+  peer: Option<ConnectInfo<SocketAddr>>,
 ) -> Response {
 ) -> Response {
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
-  let session_id = jar
-    .get(SESSION_COOKIE)
-    .map(|cookie| cookie.value().to_owned())
-    .unwrap_or_default();
+  let session_id = session_id_from_jar(&state.config, &jar);
   let live = match load_session(&mut conn, &store, &session_id).await {
   let live = match load_session(&mut conn, &store, &session_id).await {
     Ok(Some(live)) => live,
     Ok(Some(live)) => live,
     Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
     Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
@@ -1469,7 +1595,14 @@ pub async fn post_totp_confirm(
     return AppError::invalid_request("invalid form submission").into_response();
     return AppError::invalid_request("invalid form submission").into_response();
   }
   }
   let mut deps = deps_of(&state, &mut conn, &store);
   let mut deps = deps_of(&state, &mut conn, &store);
-  match confirm_totp(&mut deps, &session_id, &form.code).await {
+  match confirm_totp(
+    &mut deps,
+    &session_id,
+    &form.code,
+    peer.map(|info| info.0.ip()),
+  )
+  .await
+  {
     Ok(ConfirmOutcome::Completed {
     Ok(ConfirmOutcome::Completed {
       session_id,
       session_id,
       redirect_to,
       redirect_to,
@@ -1492,12 +1625,12 @@ pub async fn post_totp_confirm(
 }
 }
 
 
 /// `POST /logout`: verify the session-bound form token, then destroy the
 /// `POST /logout`: verify the session-bound form token, then destroy the
-/// session and expire the cookie.
+/// session and expire every session cookie the reader honors.
 ///
 ///
 /// A missing or invalid token is rejected with `invalid_request` and the
 /// A missing or invalid token is rejected with `invalid_request` and the
 /// session is left intact, so a cross-site form post cannot sign the user
 /// session is left intact, so a cross-site form post cannot sign the user
-/// out. With no session the call is idempotent: the cookie is still expired
-/// and the caller lands on login.
+/// out. With no session the call is idempotent: every honored cookie is still
+/// expired and the caller lands on login.
 pub async fn post_logout(
 pub async fn post_logout(
   State(state): State<AppState>,
   State(state): State<AppState>,
   Extension(request_id): Extension<RequestId>,
   Extension(request_id): Extension<RequestId>,
@@ -1506,15 +1639,12 @@ pub async fn post_logout(
 ) -> Response {
 ) -> Response {
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
-  let session_id = jar
-    .get(SESSION_COOKIE)
-    .map(|cookie| cookie.value().to_owned())
-    .unwrap_or_default();
+  let session_id = session_id_from_jar(&state.config, &jar);
   let live = match load_session(&mut conn, &store, &session_id).await {
   let live = match load_session(&mut conn, &store, &session_id).await {
     Ok(Some(live)) => live,
     Ok(Some(live)) => live,
     Ok(None) => {
     Ok(None) => {
       return (
       return (
-        jar.add(expired_session_cookie(&state.config)),
+        clear_session_cookies(jar, &state.config),
         Redirect::to(LOGIN_PATH),
         Redirect::to(LOGIN_PATH),
       )
       )
         .into_response();
         .into_response();
@@ -1532,7 +1662,7 @@ pub async fn post_logout(
         return AppError::server_error().into_response();
         return AppError::server_error().into_response();
       }
       }
       return (
       return (
-        jar.add(expired_session_cookie(&state.config)),
+        clear_session_cookies(jar, &state.config),
         error.into_response(),
         error.into_response(),
       )
       )
         .into_response();
         .into_response();
@@ -1545,7 +1675,7 @@ pub async fn post_logout(
     Some(uri) => Redirect::to(&uri).into_response(),
     Some(uri) => Redirect::to(&uri).into_response(),
     None => render(SignedOutTemplate),
     None => render(SignedOutTemplate),
   };
   };
-  (jar.add(expired_session_cookie(&state.config)), response).into_response()
+  (clear_session_cookies(jar, &state.config), response).into_response()
 }
 }
 
 
 /// `GET /logout`: read-only sign-out confirmation.
 /// `GET /logout`: read-only sign-out confirmation.
@@ -1564,10 +1694,7 @@ pub async fn post_logout(
 pub async fn get_logout(State(state): State<AppState>, jar: CookieJar) -> Response {
 pub async fn get_logout(State(state): State<AppState>, jar: CookieJar) -> Response {
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
-  let session_id = jar
-    .get(SESSION_COOKIE)
-    .map(|cookie| cookie.value().to_owned())
-    .unwrap_or_default();
+  let session_id = session_id_from_jar(&state.config, &jar);
   match load_session(&mut conn, &store, &session_id).await {
   match load_session(&mut conn, &store, &session_id).await {
     Ok(Some(live)) => render(LogoutConfirmTemplate {
     Ok(Some(live)) => render(LogoutConfirmTemplate {
       csrf: form_token(&live.meta.csrf_secret, LOGOUT_ACTION),
       csrf: form_token(&live.meta.csrf_secret, LOGOUT_ACTION),
@@ -1628,10 +1755,7 @@ async fn resolve_post_logout_target(
 pub async fn get_login_success(State(state): State<AppState>, jar: CookieJar) -> Response {
 pub async fn get_login_success(State(state): State<AppState>, jar: CookieJar) -> Response {
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
-  let session_id = jar
-    .get(SESSION_COOKIE)
-    .map(|cookie| cookie.value().to_owned())
-    .unwrap_or_default();
+  let session_id = session_id_from_jar(&state.config, &jar);
   match load_session(&mut conn, &store, &session_id).await {
   match load_session(&mut conn, &store, &session_id).await {
     Ok(Some(live)) if !live.record.subject.is_empty() && !live.record.restricted => {
     Ok(Some(live)) if !live.record.subject.is_empty() && !live.record.restricted => {
       render(SuccessTemplate {
       render(SuccessTemplate {
@@ -1667,10 +1791,7 @@ async fn gated_form(
 ) -> Response {
 ) -> Response {
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
-  let session_id = jar
-    .get(SESSION_COOKIE)
-    .map(|cookie| cookie.value().to_owned())
-    .unwrap_or_default();
+  let session_id = session_id_from_jar(&state.config, jar);
   let live = match load_session(&mut conn, &store, &session_id).await {
   let live = match load_session(&mut conn, &store, &session_id).await {
     Ok(Some(live)) => live,
     Ok(Some(live)) => live,
     Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
     Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),

+ 11 - 2
src/ui/login/tests.rs

@@ -3,10 +3,12 @@
 //! Child suites (`auth`, `enroll`) drive the public [`super`] API through
 //! Child suites (`auth`, `enroll`) drive the public [`super`] API through
 //! these helpers; every test owns a fresh database and Redis container.
 //! these helpers; every test owns a fresh database and Redis container.
 
 
-use std::time::Duration;
-
 use serde_json::json;
 use serde_json::json;
 use sqlx::PgPool;
 use sqlx::PgPool;
+use std::{
+  net::{IpAddr, Ipv4Addr, SocketAddr},
+  time::Duration,
+};
 use testcontainers::{
 use testcontainers::{
   GenericImage, ImageExt,
   GenericImage, ImageExt,
   core::{IntoContainerPort, WaitFor},
   core::{IntoContainerPort, WaitFor},
@@ -32,6 +34,12 @@ use crate::{
 pub(super) mod auth;
 pub(super) mod auth;
 pub(super) mod enroll;
 pub(super) mod enroll;
 
 
+/// Default source IP for harness login attempts: the composite lockout path
+/// keys on the peer, so attempts without an explicit IP share this one.
+pub(super) const TEST_PEER_IP: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
+/// Socket form of [`TEST_PEER_IP`] for handler-level `ConnectInfo` values.
+pub(super) const TEST_PEER_SOCK: SocketAddr = SocketAddr::new(TEST_PEER_IP, 5000);
+
 /// Test body result (no `unwrap`/`expect` under the deny lints).
 /// Test body result (no `unwrap`/`expect` under the deny lints).
 pub(super) type TestResult = Result<(), TestError>;
 pub(super) type TestResult = Result<(), TestError>;
 
 
@@ -295,6 +303,7 @@ pub(super) fn attempt(
     password: user.password.clone(),
     password: user.password.clone(),
     code: code.to_owned(),
     code: code.to_owned(),
     csrf: csrf.to_owned(),
     csrf: csrf.to_owned(),
+    peer_ip: Some(TEST_PEER_IP),
   }
   }
 }
 }
 
 

+ 409 - 24
src/ui/login/tests/auth.rs

@@ -1,15 +1,17 @@
 //! `test(login)` authentication suite: cookies, uniform rejects, replay, binding.
 //! `test(login)` authentication suite: cookies, uniform rejects, replay, binding.
 
 
 use axum::Extension;
 use axum::Extension;
-use axum::extract::{Form, Query, State};
+use axum::extract::{ConnectInfo, Form, Query, State};
 use axum::http::{StatusCode, header};
 use axum::http::{StatusCode, header};
-use axum_extra::extract::CookieJar;
+use axum_extra::extract::{CookieJar, cookie::Cookie};
 use sqlx::Row as _;
 use sqlx::Row as _;
+use std::net::{IpAddr, Ipv4Addr};
 
 
 use super::super::LOGOUT_ACTION;
 use super::super::LOGOUT_ACTION;
 use super::{
 use super::{
-  Harness, LoginAttempt, TestError, TestResult, TestUser, app_state, attempt, cookie_header,
-  create_user, current_code, deps_of, harness, pending_request, presession, wrong_code,
+  Harness, LoginAttempt, TEST_PEER_SOCK, TestError, TestResult, TestUser, app_state, attempt,
+  cookie_header, create_user, current_code, deps_of, harness, pending_request, presession,
+  wrong_code,
 };
 };
 use crate::{
 use crate::{
   db::{
   db::{
@@ -21,9 +23,10 @@ use crate::{
   middleware::request_id::RequestId,
   middleware::request_id::RequestId,
   store::{pending, sessions},
   store::{pending, sessions},
   ui::login::{
   ui::login::{
-    CONSENT_PATH, LOCKOUT_THRESHOLD, LOCKOUT_WINDOW, LOGIN_SUCCESS_PATH, LoginForm, LoginOutcome,
-    LoginPageQuery, LogoutForm, attempt_login, bind_pending_request, expired_session_cookie,
-    form_token, get_login, get_logout, load_session, post_login, post_logout, session_cookie,
+    ACCOUNT_LOCKOUT_THRESHOLD, CONSENT_PATH, LOCKOUT_THRESHOLD, LOCKOUT_WINDOW, LOGIN_SUCCESS_PATH,
+    LoginForm, LoginOutcome, LoginPageQuery, LogoutForm, attempt_login, bind_pending_request,
+    expired_session_cookies, form_token, get_login, get_logout, load_session, post_login,
+    post_logout, session_cookie, session_id_from_jar,
   },
   },
 };
 };
 
 
@@ -96,16 +99,21 @@ async fn login_success_sets_session_cookie_and_bound_redirect() -> TestResult {
   }
   }
   let header = cookie_header(&h, &session_id);
   let header = cookie_header(&h, &session_id);
   for needle in [
   for needle in [
-    "session_id=",
+    "__Host-session_id=",
     "Path=/",
     "Path=/",
     "HttpOnly",
     "HttpOnly",
     "Secure",
     "Secure",
     "SameSite=Lax",
     "SameSite=Lax",
   ] {
   ] {
     if !header.contains(needle) {
     if !header.contains(needle) {
-      return Err(unexpected("the session cookie must be HttpOnly/Secure/Lax"));
+      return Err(unexpected(
+        "the session cookie must be __Host-/HttpOnly/Secure/Lax",
+      ));
     }
     }
   }
   }
+  if header.contains("Domain=") {
+    return Err(unexpected("the __Host- cookie must never carry Domain"));
+  }
   if !header.contains(&session_id) {
   if !header.contains(&session_id) {
     return Err(unexpected("the cookie must carry the session id"));
     return Err(unexpected("the cookie must carry the session id"));
   }
   }
@@ -162,7 +170,7 @@ async fn post_logout_destroys_session_and_clears_cookie() -> TestResult {
     .get(header::SET_COOKIE)
     .get(header::SET_COOKIE)
     .and_then(|value| value.to_str().ok())
     .and_then(|value| value.to_str().ok())
     .unwrap_or_default();
     .unwrap_or_default();
-  if !set_cookie.contains("session_id=") || !set_cookie.contains("Max-Age=0") {
+  if !set_cookie.contains("__Host-session_id=") || !set_cookie.contains("Max-Age=0") {
     return Err(unexpected("POST /logout must expire the session cookie"));
     return Err(unexpected("POST /logout must expire the session cookie"));
   }
   }
   if cookie_attr_names(&cookie_header(&h, &session_id)) != cookie_attr_names(set_cookie) {
   if cookie_attr_names(&cookie_header(&h, &session_id)) != cookie_attr_names(set_cookie) {
@@ -354,7 +362,7 @@ async fn post_logout_wrong_content_type_fails_closed() -> TestResult {
     .header(axum::http::header::CONTENT_TYPE, "application/json")
     .header(axum::http::header::CONTENT_TYPE, "application/json")
     .header(
     .header(
       axum::http::header::COOKIE,
       axum::http::header::COOKIE,
-      format!("session_id={session_id}"),
+      format!("__Host-session_id={session_id}"),
     )
     )
     .body(axum::body::Body::from(body))
     .body(axum::body::Body::from(body))
     .map_err(|_| unexpected("request build failed"))?;
     .map_err(|_| unexpected("request build failed"))?;
@@ -399,7 +407,7 @@ async fn post_logout_without_session_is_idempotent() -> TestResult {
     .get(header::SET_COOKIE)
     .get(header::SET_COOKIE)
     .and_then(|value| value.to_str().ok())
     .and_then(|value| value.to_str().ok())
     .unwrap_or_default();
     .unwrap_or_default();
-  if !set_cookie.contains("session_id=") || !set_cookie.contains("Max-Age=0") {
+  if !set_cookie.contains("__Host-session_id=") || !set_cookie.contains("Max-Age=0") {
     return Err(unexpected(
     return Err(unexpected(
       "an anonymous sign-out must still expire the cookie",
       "an anonymous sign-out must still expire the cookie",
     ));
     ));
@@ -725,9 +733,10 @@ async fn login_locked_account_rejected_uniformly() -> TestResult {
   }
   }
   let policy = user_repo::LockoutPolicy {
   let policy = user_repo::LockoutPolicy {
     threshold: LOCKOUT_THRESHOLD,
     threshold: LOCKOUT_THRESHOLD,
+    account_threshold: LOCKOUT_THRESHOLD,
     lock_duration: LOCKOUT_WINDOW,
     lock_duration: LOCKOUT_WINDOW,
   };
   };
-  if !user_repo::is_locked_out(&mut h.conn, &h.store, policy, &user.email).await? {
+  if !user_repo::is_locked_out(&mut h.conn, &h.store, policy, &user.email, None).await? {
     return Err(unexpected("the threshold must lock the account"));
     return Err(unexpected("the threshold must lock the account"));
   }
   }
   let (pre, csrf) = presession(&mut h).await?;
   let (pre, csrf) = presession(&mut h).await?;
@@ -868,27 +877,121 @@ async fn login_expired_pending_denied_without_redirect() -> TestResult {
 async fn login_cookie_secure_config_gated() -> TestResult {
 async fn login_cookie_secure_config_gated() -> TestResult {
   let mut h = harness().await?;
   let mut h = harness().await?;
   let header = cookie_header(&h, "session-abc");
   let header = cookie_header(&h, "session-abc");
-  if !header.contains("Secure") {
-    return Err(unexpected("Secure must be set by default"));
+  if !header.contains("__Host-session_id=") || !header.contains("Secure") {
+    return Err(unexpected(
+      "Secure must be set by default on the __Host- cookie",
+    ));
   }
   }
   let config = std::sync::Arc::get_mut(&mut h.config)
   let config = std::sync::Arc::get_mut(&mut h.config)
     .ok_or_else(|| unexpected("the harness config must be uniquely owned"))?;
     .ok_or_else(|| unexpected("the harness config must be uniquely owned"))?;
   config.cookie_secure = false;
   config.cookie_secure = false;
-  config.cookie_domain = Some("auth.example.test".to_owned());
   let header = cookie_header(&h, "session-abc");
   let header = cookie_header(&h, "session-abc");
   if header.contains("Secure") {
   if header.contains("Secure") {
     return Err(unexpected("Secure must follow the config gate"));
     return Err(unexpected("Secure must follow the config gate"));
   }
   }
-  for needle in [
-    "HttpOnly",
-    "SameSite=Lax",
-    "Path=/",
-    "Domain=auth.example.test",
-  ] {
+  // Without `Secure` the `__Host-` prefix is unusable (browsers reject a
+  // `__Host-` cookie without `Secure`), so the insecure development variant
+  // drops the prefix and stays a plain host-only cookie.
+  if header.contains("__Host-") {
+    return Err(unexpected(
+      "the insecure cookie must drop the __Host- prefix",
+    ));
+  }
+  for needle in ["session_id=", "HttpOnly", "SameSite=Lax", "Path=/"] {
     if !header.contains(needle) {
     if !header.contains(needle) {
-      return Err(unexpected("domain/HttpOnly/Lax/Path must survive"));
+      return Err(unexpected("HttpOnly/Lax/Path/name must survive"));
     }
     }
   }
   }
+  if header.contains("Domain=") {
+    return Err(unexpected("the session cookie must never carry Domain"));
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn session_id_from_jar_secure_honors_only_host_variant() -> TestResult {
+  let mut h = harness().await?;
+  // Production (`cookie_secure`) must read `__Host-session_id` alone. A plain
+  // `session_id` planted by a sibling subdomain must never be honored, or a
+  // victim's login could be bound to the attacker's pending presession.
+  let planted = CookieJar::new().add(Cookie::new("session_id", "attacker-planted"));
+  if !session_id_from_jar(&h.config, &planted).is_empty() {
+    return Err(unexpected(
+      "production must never read a plain session_id (session fixation)",
+    ));
+  }
+  let host = CookieJar::new().add(Cookie::new("__Host-session_id", "legit-host"));
+  if session_id_from_jar(&h.config, &host) != "legit-host" {
+    return Err(unexpected("the __Host- session cookie must resolve"));
+  }
+  let both = CookieJar::new()
+    .add(Cookie::new("session_id", "attacker-planted"))
+    .add(Cookie::new("__Host-session_id", "legit-host"));
+  if session_id_from_jar(&h.config, &both) != "legit-host" {
+    return Err(unexpected(
+      "the __Host- cookie must take precedence over the planted plain one",
+    ));
+  }
+  // The development variant (configured name is the plain `session_id`) may
+  // still fall back to `__Host-session_id` for a cookie left over from before
+  // a config flip; this fallback is harmless because it is read only here.
+  let config = std::sync::Arc::get_mut(&mut h.config)
+    .ok_or_else(|| unexpected("the harness config must be uniquely owned"))?;
+  config.cookie_secure = false;
+  if session_id_from_jar(&h.config, &host) != "legit-host" {
+    return Err(unexpected(
+      "the insecure variant may fall back to the __Host- name",
+    ));
+  }
+  let plain = CookieJar::new().add(Cookie::new("session_id", "legit-plain"));
+  if session_id_from_jar(&h.config, &plain) != "legit-plain" {
+    return Err(unexpected(
+      "the insecure variant must read its configured plain name",
+    ));
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn get_login_ignores_planted_plain_session_cookie() -> TestResult {
+  let mut h = harness().await?;
+  // The attacker owns a live pending presession and plants a plain, non-
+  // `__Host-` cookie naming it: the sibling-subdomain fixation vector.
+  let (planted, _) = presession(&mut h).await?;
+  let jar = CookieJar::new().add(Cookie::new("session_id", planted.clone()));
+  let response = get_login(
+    State(app_state(&h)),
+    jar,
+    Query(LoginPageQuery { request_id: None }),
+  )
+  .await;
+  // Production must ignore the planted cookie and mint a fresh presession, so
+  // the victim's login can never be bound to the attacker's pending request.
+  // The jar re-emits the request cookie as the first `Set-Cookie`; only a scan
+  // of every header can distinguish the freshly minted `__Host-` cookie from
+  // that echo, so a first-header-only read would assert on attacker bytes.
+  let cookies: Vec<String> = response
+    .headers()
+    .get_all(header::SET_COOKIE)
+    .iter()
+    .filter_map(|value| value.to_str().ok())
+    .map(str::to_owned)
+    .collect();
+  let fresh = cookies
+    .iter()
+    .find_map(|header| header.strip_prefix("__Host-session_id="))
+    .and_then(|rest| rest.split(';').next())
+    .unwrap_or_default();
+  if fresh.is_empty() {
+    return Err(unexpected(
+      "GET /login must mint a fresh __Host- presession, ignoring the planted cookie",
+    ));
+  }
+  if fresh == planted {
+    return Err(unexpected(
+      "GET /login must never adopt the attacker-planted presession",
+    ));
+  }
   Ok(())
   Ok(())
 }
 }
 
 
@@ -1237,6 +1340,7 @@ async fn login_corrupt_totp_ciphertext_fails_closed_with_503() -> TestResult {
     Extension(RequestId::new()),
     Extension(RequestId::new()),
     jar,
     jar,
     Form(form),
     Form(form),
+    Some(ConnectInfo(TEST_PEER_SOCK)),
   )
   )
   .await;
   .await;
   if response.status() != StatusCode::SERVICE_UNAVAILABLE {
   if response.status() != StatusCode::SERVICE_UNAVAILABLE {
@@ -1315,7 +1419,11 @@ async fn get_logout_leaves_session_intact_and_renders_confirm() -> TestResult {
 async fn logout_clearing_cookie_mirrors_session_cookie_attrs() -> TestResult {
 async fn logout_clearing_cookie_mirrors_session_cookie_attrs() -> TestResult {
   let h = harness().await?;
   let h = harness().await?;
   let session = session_cookie(&h.config, "logout-attr-session").to_string();
   let session = session_cookie(&h.config, "logout-attr-session").to_string();
-  let clearing = expired_session_cookie(&h.config).to_string();
+  let clearing = expired_session_cookies(&h.config)
+    .into_iter()
+    .next()
+    .ok_or_else(|| unexpected("there must be a clearing cookie"))?
+    .to_string();
   if !clearing.contains("Max-Age=0") {
   if !clearing.contains("Max-Age=0") {
     return Err(unexpected("the clearing cookie must expire immediately"));
     return Err(unexpected("the clearing cookie must expire immediately"));
   }
   }
@@ -1326,3 +1434,280 @@ async fn logout_clearing_cookie_mirrors_session_cookie_attrs() -> TestResult {
   }
   }
   Ok(())
   Ok(())
 }
 }
+
+#[tokio::test]
+async fn post_logout_without_session_clears_every_honored_name_in_development() -> TestResult {
+  let mut h = harness().await?;
+  {
+    let config = std::sync::Arc::get_mut(&mut h.config)
+      .ok_or_else(|| unexpected("the harness config must be uniquely owned"))?;
+    config.cookie_secure = false;
+  }
+  let response = post_logout(
+    State(app_state(&h)),
+    Extension(RequestId::new()),
+    CookieJar::new(),
+    Form(LogoutForm {
+      csrf: String::new(),
+    }),
+  )
+  .await;
+  // The development reader honors both the plain and `__Host-` names, so a
+  // logout must clear both or an injected cookie could survive it. Names are
+  // matched exactly: `__Host-session_id=` contains `session_id=` as a
+  // substring, so a substring check cannot prove the plain cookie is cleared.
+  let names = response
+    .headers()
+    .get_all(header::SET_COOKIE)
+    .iter()
+    .filter_map(|value| value.to_str().ok())
+    .filter_map(|header| header.split('=').next())
+    .collect::<Vec<_>>();
+  if !names.contains(&"session_id") || !names.contains(&"__Host-session_id") {
+    return Err(unexpected(
+      "logout must clear every session-cookie name the reader honors",
+    ));
+  }
+  let joined = response
+    .headers()
+    .get_all(header::SET_COOKIE)
+    .iter()
+    .filter_map(|value| value.to_str().ok())
+    .collect::<Vec<_>>()
+    .join("\n");
+  if !joined.contains("Max-Age=0") {
+    return Err(unexpected("every clearing cookie must expire immediately"));
+  }
+  Ok(())
+}
+
+/// T3-C1 (a) at the login layer: a burst of wrong passwords from an attacker
+/// IP locks that IP's composite bucket only; the victim's correct credentials
+/// from a different IP still authenticate.
+#[tokio::test]
+async fn login_attacker_ip_burst_does_not_lock_victim_from_other_ip() -> TestResult {
+  const ATTACKER: IpAddr = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 44));
+  const VICTIM_ADDR: IpAddr = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 45));
+  let mut h = harness().await?;
+  let user = create_user(
+    &mut h,
+    "victim-scope@example.test",
+    "correct-horse-90",
+    false,
+    true,
+  )
+  .await?;
+  let code = current_code(&user, &h)?;
+  for _ in 0..LOCKOUT_THRESHOLD {
+    let (pre, csrf) = presession(&mut h).await?;
+    let bad = LoginAttempt {
+      password: "wrong-password-xyz".to_owned(),
+      peer_ip: Some(ATTACKER),
+      ..attempt(&pre, &csrf, &user, &code)
+    };
+    match try_login(&mut h, bad).await? {
+      LoginOutcome::Rejected => {}
+      _ => return Err(unexpected("wrong passwords must be rejected")),
+    }
+  }
+  let (pre, csrf) = presession(&mut h).await?;
+  let good = LoginAttempt {
+    peer_ip: Some(VICTIM_ADDR),
+    ..attempt(&pre, &csrf, &user, &code)
+  };
+  match try_login(&mut h, good).await? {
+    LoginOutcome::Authenticated { .. } => Ok(()),
+    _ => Err(unexpected(
+      "a burst from an attacker IP must not lock the victim from another IP",
+    )),
+  }
+}
+
+/// T3-C1 (b) at the login layer: repeated unknown-email attempts from one IP
+/// stay rejected yet share a single bounded bucket instead of spraying one
+/// Redis key per label.
+#[tokio::test]
+async fn login_unknown_labels_do_not_spray_redis_keys() -> TestResult {
+  const PROBER: IpAddr = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 46));
+  let mut h = harness().await?;
+  for index in 0..10 {
+    let (pre, csrf) = presession(&mut h).await?;
+    let ghost = TestUser {
+      id: uuid::Uuid::new_v4(),
+      email: format!("ghost-spray-{index}@example.test"),
+      password: "some-password-01".to_owned(),
+      totp_secret: None,
+      recovery_codes: Vec::new(),
+    };
+    let probe = LoginAttempt {
+      peer_ip: Some(PROBER),
+      ..attempt(&pre, &csrf, &ghost, "000000")
+    };
+    match try_login(&mut h, probe).await? {
+      LoginOutcome::Rejected => {}
+      _ => {
+        return Err(unexpected(
+          "an unknown email must be rejected like a wrong password",
+        ));
+      }
+    }
+  }
+  let pattern = format!("{}*", h.store.login_fail_prefix());
+  let mut cursor = "0".to_owned();
+  let mut keys = Vec::new();
+  loop {
+    let (next, batch): (String, Vec<String>) = redis::cmd("SCAN")
+      .arg(&cursor)
+      .arg("MATCH")
+      .arg(&pattern)
+      .arg("COUNT")
+      .arg(100)
+      .query_async(&mut h.conn)
+      .await?;
+    keys.extend(batch);
+    if next == "0" {
+      break;
+    }
+    cursor = next;
+  }
+  if keys.len() > 2 {
+    return Err(unexpected(
+      "unknown labels must share bounded buckets instead of spraying keys",
+    ));
+  }
+  Ok(())
+}
+
+/// T3-C1 peer-less path at the login layer: attempts without a source IP
+/// count toward the account counter only, stay uniformly rejected below the
+/// cap, and a correct login still authenticates and resets the counter.
+#[tokio::test]
+async fn login_peerless_failures_count_toward_account_only() -> TestResult {
+  let mut h = harness().await?;
+  let user = create_user(
+    &mut h,
+    "peerless-scope@example.test",
+    "correct-horse-91",
+    false,
+    true,
+  )
+  .await?;
+  let code = current_code(&user, &h)?;
+  for _ in 0..3 {
+    let (pre, csrf) = presession(&mut h).await?;
+    let bad = LoginAttempt {
+      password: "wrong-password-xyz".to_owned(),
+      peer_ip: None,
+      ..attempt(&pre, &csrf, &user, &code)
+    };
+    match try_login(&mut h, bad).await? {
+      LoginOutcome::Rejected => {}
+      _ => return Err(unexpected("wrong passwords must be rejected")),
+    }
+  }
+  if user_repo::login_failure_count(&mut h.conn, &h.store, &user.email).await? != 3 {
+    return Err(unexpected(
+      "peer-less failures must count toward the account counter",
+    ));
+  }
+  if user_repo::ip_failure_count(&mut h.conn, &h.store, &user.email, None).await? != 0 {
+    return Err(unexpected(
+      "peer-less failures must not mint a composite bucket",
+    ));
+  }
+  let (pre, csrf) = presession(&mut h).await?;
+  let good = LoginAttempt {
+    peer_ip: None,
+    ..attempt(&pre, &csrf, &user, &code)
+  };
+  match try_login(&mut h, good).await? {
+    LoginOutcome::Authenticated { .. } => {}
+    _ => {
+      return Err(unexpected(
+        "sub-threshold peer-less failures must not lock the account",
+      ));
+    }
+  }
+  if user_repo::login_failure_count(&mut h.conn, &h.store, &user.email).await? != 0 {
+    return Err(unexpected(
+      "a successful login must reset the account counter",
+    ));
+  }
+  Ok(())
+}
+
+/// T3-C1 scoped clear at the login layer: a successful login releases only
+/// the calling IP's bucket; an attacker's locked bucket for the same account
+/// survives.
+#[tokio::test]
+async fn login_success_releases_only_calling_ip_bucket() -> TestResult {
+  const ATTACKER: IpAddr = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 47));
+  const VICTIM_ADDR: IpAddr = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 48));
+  let mut h = harness().await?;
+  let user = create_user(
+    &mut h,
+    "scoped-clear@example.test",
+    "correct-horse-92",
+    false,
+    true,
+  )
+  .await?;
+  let code = current_code(&user, &h)?;
+  for _ in 0..LOCKOUT_THRESHOLD {
+    let (pre, csrf) = presession(&mut h).await?;
+    let bad = LoginAttempt {
+      password: "wrong-password-xyz".to_owned(),
+      peer_ip: Some(ATTACKER),
+      ..attempt(&pre, &csrf, &user, &code)
+    };
+    match try_login(&mut h, bad).await? {
+      LoginOutcome::Rejected => {}
+      _ => return Err(unexpected("wrong passwords must be rejected")),
+    }
+  }
+  let (pre, csrf) = presession(&mut h).await?;
+  let bad = LoginAttempt {
+    password: "wrong-password-xyz".to_owned(),
+    peer_ip: Some(VICTIM_ADDR),
+    ..attempt(&pre, &csrf, &user, &code)
+  };
+  match try_login(&mut h, bad).await? {
+    LoginOutcome::Rejected => {}
+    _ => return Err(unexpected("wrong passwords must be rejected")),
+  }
+  let (pre, csrf) = presession(&mut h).await?;
+  let good = LoginAttempt {
+    peer_ip: Some(VICTIM_ADDR),
+    ..attempt(&pre, &csrf, &user, &code)
+  };
+  match try_login(&mut h, good).await? {
+    LoginOutcome::Authenticated { .. } => {}
+    _ => {
+      return Err(unexpected(
+        "the victim IP must still authenticate below its own threshold",
+      ));
+    }
+  }
+  let policy = user_repo::LockoutPolicy {
+    threshold: LOCKOUT_THRESHOLD,
+    account_threshold: ACCOUNT_LOCKOUT_THRESHOLD,
+    lock_duration: LOCKOUT_WINDOW,
+  };
+  if !user_repo::is_locked_out(&mut h.conn, &h.store, policy, &user.email, Some(ATTACKER)).await? {
+    return Err(unexpected(
+      "a successful login must not release another IP bucket",
+    ));
+  }
+  if user_repo::is_locked_out(
+    &mut h.conn,
+    &h.store,
+    policy,
+    &user.email,
+    Some(VICTIM_ADDR),
+  )
+  .await?
+  {
+    return Err(unexpected("the calling IP bucket must be released"));
+  }
+  Ok(())
+}

+ 332 - 15
src/ui/login/tests/enroll.rs

@@ -1,20 +1,21 @@
 //! `test(login)` enrollment suite: bootstrap cycle, gating, password change.
 //! `test(login)` enrollment suite: bootstrap cycle, gating, password change.
 
 
 use axum::Extension;
 use axum::Extension;
-use axum::extract::{Form, State};
+use axum::extract::{ConnectInfo, Form, State};
 use axum::http::StatusCode;
 use axum::http::StatusCode;
 use axum_extra::extract::CookieJar;
 use axum_extra::extract::CookieJar;
 
 
 use super::super::TOTP_CONFIRM_ACTION;
 use super::super::TOTP_CONFIRM_ACTION;
 use super::{
 use super::{
-  Harness, LoginAttempt, TestError, TestResult, app_state, attempt, create_user, current_code,
-  deps_of, harness, pending_request, presession,
+  Harness, LoginAttempt, LoginDeps, TEST_PEER_IP, TEST_PEER_SOCK, TestError, TestResult, app_state,
+  attempt, create_user, current_code, deps_of, harness, pending_request, presession,
 };
 };
 use crate::{
 use crate::{
   crypto::totp,
   crypto::totp,
   db::user_repo,
   db::user_repo,
   error::OAuthErrorCode,
   error::OAuthErrorCode,
   middleware::request_id::RequestId,
   middleware::request_id::RequestId,
+  state::RedisConn,
   store::sessions,
   store::sessions,
   ui::login::{
   ui::login::{
     CHANGE_PASSWORD_PATH, CONSENT_PATH, ConfirmForm, ConfirmOutcome, LOCKOUT_THRESHOLD,
     CHANGE_PASSWORD_PATH, CONSENT_PATH, ConfirmForm, ConfirmOutcome, LOCKOUT_THRESHOLD,
@@ -58,6 +59,7 @@ async fn restricted_login(
     password: password.to_owned(),
     password: password.to_owned(),
     code: String::new(),
     code: String::new(),
     csrf,
     csrf,
+    peer_ip: Some(TEST_PEER_IP),
   };
   };
   let mut deps = deps_of(harness);
   let mut deps = deps_of(harness);
   match attempt_login(&mut deps, attempt).await? {
   match attempt_login(&mut deps, attempt).await? {
@@ -101,7 +103,7 @@ async fn login_enrollment_wrong_confirmation_rejected() -> TestResult {
   let bad = wrong_for_secret(&offer.secret, h.counter)?;
   let bad = wrong_for_secret(&offer.secret, h.counter)?;
   let outcome = {
   let outcome = {
     let mut deps = deps_of(&mut h);
     let mut deps = deps_of(&mut h);
-    confirm_totp(&mut deps, &restricted, &bad).await?
+    confirm_totp(&mut deps, &restricted, &bad, Some(TEST_PEER_IP)).await?
   };
   };
   if !matches!(outcome, ConfirmOutcome::Rejected) {
   if !matches!(outcome, ConfirmOutcome::Rejected) {
     return Err(unexpected("a wrong confirmation code must be rejected"));
     return Err(unexpected("a wrong confirmation code must be rejected"));
@@ -166,7 +168,7 @@ async fn login_bootstrap_full_cycle_clears_restriction() -> TestResult {
     .to_string();
     .to_string();
   let (full, redirect_to) = {
   let (full, redirect_to) = {
     let mut deps = deps_of(&mut h);
     let mut deps = deps_of(&mut h);
-    match confirm_totp(&mut deps, &restricted, &code).await? {
+    match confirm_totp(&mut deps, &restricted, &code, Some(TEST_PEER_IP)).await? {
       ConfirmOutcome::Completed {
       ConfirmOutcome::Completed {
         session_id,
         session_id,
         redirect_to,
         redirect_to,
@@ -245,7 +247,7 @@ async fn login_enrollment_requires_subject_session() -> TestResult {
   }
   }
   {
   {
     let mut deps = deps_of(&mut h);
     let mut deps = deps_of(&mut h);
-    match confirm_totp(&mut deps, &pre, "000000").await {
+    match confirm_totp(&mut deps, &pre, "000000", Some(TEST_PEER_IP)).await {
       Err(error) if error.code() == OAuthErrorCode::InvalidRequest => Ok(()),
       Err(error) if error.code() == OAuthErrorCode::InvalidRequest => Ok(()),
       Err(_) => Err(unexpected("a pre-session confirm must be invalid_request")),
       Err(_) => Err(unexpected("a pre-session confirm must be invalid_request")),
       Ok(_) => Err(unexpected("a pre-session must never confirm")),
       Ok(_) => Err(unexpected("a pre-session must never confirm")),
@@ -266,7 +268,7 @@ async fn login_confirm_without_provision_rejected() -> TestResult {
   .await?;
   .await?;
   let restricted = restricted_login(&mut h, &user.email, &user.password, None).await?;
   let restricted = restricted_login(&mut h, &user.email, &user.password, None).await?;
   let mut deps = deps_of(&mut h);
   let mut deps = deps_of(&mut h);
-  match confirm_totp(&mut deps, &restricted, "000000").await? {
+  match confirm_totp(&mut deps, &restricted, "000000", Some(TEST_PEER_IP)).await? {
     ConfirmOutcome::Rejected => Ok(()),
     ConfirmOutcome::Rejected => Ok(()),
     _ => Err(unexpected("confirmation without a secret must be rejected")),
     _ => Err(unexpected("confirmation without a secret must be rejected")),
   }
   }
@@ -406,7 +408,7 @@ async fn login_totp_enrollment() -> TestResult {
   let bad = wrong_for_secret(&offer.secret, h.counter)?;
   let bad = wrong_for_secret(&offer.secret, h.counter)?;
   {
   {
     let mut deps = deps_of(&mut h);
     let mut deps = deps_of(&mut h);
-    match confirm_totp(&mut deps, &restricted, &bad).await? {
+    match confirm_totp(&mut deps, &restricted, &bad, Some(TEST_PEER_IP)).await? {
       ConfirmOutcome::Rejected => {}
       ConfirmOutcome::Rejected => {}
       _ => return Err(unexpected("a wrong confirmation code must be rejected")),
       _ => return Err(unexpected("a wrong confirmation code must be rejected")),
     }
     }
@@ -418,7 +420,7 @@ async fn login_totp_enrollment() -> TestResult {
     .to_string();
     .to_string();
   let full = {
   let full = {
     let mut deps = deps_of(&mut h);
     let mut deps = deps_of(&mut h);
-    match confirm_totp(&mut deps, &restricted, &code).await? {
+    match confirm_totp(&mut deps, &restricted, &code, Some(TEST_PEER_IP)).await? {
       ConfirmOutcome::Completed {
       ConfirmOutcome::Completed {
         session_id,
         session_id,
         redirect_to,
         redirect_to,
@@ -452,6 +454,206 @@ async fn login_totp_enrollment() -> TestResult {
   Ok(())
   Ok(())
 }
 }
 
 
+/// `change_password` must revoke the subject's sibling sessions while the
+/// current session survives (re-issued by promotion).
+#[tokio::test]
+async fn login_password_change_revokes_sibling_sessions() -> TestResult {
+  let mut h = harness().await?;
+  let user = create_user(
+    &mut h,
+    "pw-sweep@example.test",
+    "correct-horse-31",
+    false,
+    true,
+  )
+  .await?;
+  let code = current_code(&user, &h)?;
+  let (pre_a, csrf_a) = presession(&mut h).await?;
+  let current = {
+    let mut deps = deps_of(&mut h);
+    match attempt_login(&mut deps, attempt(&pre_a, &csrf_a, &user, &code)).await? {
+      LoginOutcome::Authenticated { session_id, .. } => session_id,
+      _ => return Err(unexpected("correct credentials must authenticate")),
+    }
+  };
+  // A sibling session via a recovery code (the TOTP code is already claimed).
+  let sibling_code = user
+    .recovery_codes
+    .first()
+    .ok_or_else(|| unexpected("the user must hold recovery codes"))?
+    .clone();
+  let (pre_b, csrf_b) = presession(&mut h).await?;
+  let sibling = {
+    let mut deps = deps_of(&mut h);
+    match attempt_login(&mut deps, attempt(&pre_b, &csrf_b, &user, &sibling_code)).await? {
+      LoginOutcome::Authenticated { session_id, .. } => session_id,
+      _ => return Err(unexpected("a recovery code must authenticate")),
+    }
+  };
+  let promoted = {
+    let mut deps = deps_of(&mut h);
+    match change_password(
+      &mut deps,
+      &current,
+      "new-strong-password-31",
+      "new-strong-password-31",
+    )
+    .await?
+    {
+      PasswordOutcome::Promoted { session_id, .. } => session_id,
+      PasswordOutcome::StillRestricted { .. } => {
+        return Err(unexpected("an enrolled user must promote"));
+      }
+    }
+  };
+  if load_session(&mut h.conn, &h.store, &sibling)
+    .await?
+    .is_some()
+  {
+    return Err(unexpected("a password change must kill sibling sessions"));
+  }
+  let live = load_session(&mut h.conn, &h.store, &promoted)
+    .await?
+    .ok_or_else(|| unexpected("the current session must survive"))?;
+  if live.record.subject != user.email || !live.record.mfa_verified || live.record.restricted {
+    return Err(unexpected("the surviving session must stay full"));
+  }
+  Ok(())
+}
+
+/// `change_password` on a still-restricted session (TOTP enrollment pending)
+/// must still revoke the subject's sibling sessions: the sweep runs before
+/// the promotion branch, so `StillRestricted` is not a revocation bypass.
+#[tokio::test]
+async fn login_password_change_restricted_still_revokes_siblings() -> TestResult {
+  let mut h = harness().await?;
+  let user = create_user(
+    &mut h,
+    "pw-restricted-sweep@example.test",
+    "temp-password-50",
+    true,
+    false,
+  )
+  .await?;
+  let current = restricted_login(&mut h, &user.email, &user.password, None).await?;
+  let sibling = restricted_login(&mut h, &user.email, &user.password, None).await?;
+  if load_session(&mut h.conn, &h.store, &sibling)
+    .await?
+    .is_none()
+  {
+    return Err(unexpected("the sibling session must start live"));
+  }
+  {
+    let mut deps = deps_of(&mut h);
+    match change_password(
+      &mut deps,
+      &current,
+      "new-strong-password-50",
+      "new-strong-password-50",
+    )
+    .await?
+    {
+      PasswordOutcome::StillRestricted { session_id } => {
+        if session_id != current {
+          return Err(unexpected("a restricted change must keep the session id"));
+        }
+      }
+      PasswordOutcome::Promoted { .. } => {
+        return Err(unexpected("promotion needs TOTP enrollment first"));
+      }
+    }
+  }
+  if load_session(&mut h.conn, &h.store, &sibling)
+    .await?
+    .is_some()
+  {
+    return Err(unexpected(
+      "a restricted password change must kill sibling sessions",
+    ));
+  }
+  let sibling_meta = format!("{}:meta", h.store.session_key(&sibling));
+  let meta_left: i64 = redis::cmd("EXISTS")
+    .arg(&sibling_meta)
+    .query_async(&mut h.conn)
+    .await?;
+  if meta_left != 0 {
+    return Err(unexpected(
+      "a restricted password change must delete the sibling :meta sidecar",
+    ));
+  }
+  let live = load_session(&mut h.conn, &h.store, &current)
+    .await?
+    .ok_or_else(|| unexpected("the current session must survive"))?;
+  if !live.record.restricted {
+    return Err(unexpected("the surviving session must stay restricted"));
+  }
+  if live.record.subject != user.email {
+    return Err(unexpected("the surviving session must keep its subject"));
+  }
+  Ok(())
+}
+
+/// `confirm_totp` must revoke the subject's sibling sessions while the
+/// current session survives (re-issued by promotion).
+#[tokio::test]
+async fn login_confirm_totp_revokes_sibling_sessions() -> TestResult {
+  let mut h = harness().await?;
+  let user = create_user(
+    &mut h,
+    "confirm-sweep@example.test",
+    "temp-password-32",
+    true,
+    false,
+  )
+  .await?;
+  let current = restricted_login(&mut h, &user.email, &user.password, None).await?;
+  let sibling = restricted_login(&mut h, &user.email, &user.password, None).await?;
+  {
+    let mut deps = deps_of(&mut h);
+    match change_password(
+      &mut deps,
+      &current,
+      "new-strong-password-32",
+      "new-strong-password-32",
+    )
+    .await?
+    {
+      PasswordOutcome::StillRestricted { .. } => {}
+      PasswordOutcome::Promoted { .. } => {
+        return Err(unexpected("promotion needs TOTP enrollment first"));
+      }
+    }
+  }
+  let offer = {
+    let mut deps = deps_of(&mut h);
+    provision_totp(&mut deps, &current).await?
+  };
+  let verifier = totp::build_totp(&offer.secret)?;
+  let code = verifier
+    .generate(h.counter * totp::TOTP_STEP_SECS)
+    .to_string();
+  let promoted = {
+    let mut deps = deps_of(&mut h);
+    match confirm_totp(&mut deps, &current, &code, Some(TEST_PEER_IP)).await? {
+      ConfirmOutcome::Completed { session_id, .. } => session_id,
+      _ => return Err(unexpected("a correct confirmation must complete")),
+    }
+  };
+  if load_session(&mut h.conn, &h.store, &sibling)
+    .await?
+    .is_some()
+  {
+    return Err(unexpected("a TOTP confirmation must kill sibling sessions"));
+  }
+  let live = load_session(&mut h.conn, &h.store, &promoted)
+    .await?
+    .ok_or_else(|| unexpected("the current session must survive"))?;
+  if live.record.subject != user.email || !live.record.mfa_verified || live.record.restricted {
+    return Err(unexpected("the surviving session must be full"));
+  }
+  Ok(())
+}
+
 /// Drive one `POST /login/totp/confirm` against a restricted session, returning
 /// Drive one `POST /login/totp/confirm` against a restricted session, returning
 /// the status and body bytes of the rendered page.
 /// the status and body bytes of the rendered page.
 async fn post_confirm(
 async fn post_confirm(
@@ -469,6 +671,7 @@ async fn post_confirm(
       code: code.to_owned(),
       code: code.to_owned(),
       csrf: csrf.to_owned(),
       csrf: csrf.to_owned(),
     }),
     }),
+    Some(ConnectInfo(TEST_PEER_SOCK)),
   )
   )
   .await;
   .await;
   let status = response.status();
   let status = response.status();
@@ -498,7 +701,7 @@ async fn login_confirm_burst_locks_and_rejects_correct_code() -> TestResult {
   for _ in 0..LOCKOUT_THRESHOLD {
   for _ in 0..LOCKOUT_THRESHOLD {
     let outcome = {
     let outcome = {
       let mut deps = deps_of(&mut h);
       let mut deps = deps_of(&mut h);
-      confirm_totp(&mut deps, &restricted, &bad).await?
+      confirm_totp(&mut deps, &restricted, &bad, Some(TEST_PEER_IP)).await?
     };
     };
     if !matches!(outcome, ConfirmOutcome::Rejected) {
     if !matches!(outcome, ConfirmOutcome::Rejected) {
       return Err(unexpected("a wrong confirmation code must be rejected"));
       return Err(unexpected("a wrong confirmation code must be rejected"));
@@ -506,9 +709,10 @@ async fn login_confirm_burst_locks_and_rejects_correct_code() -> TestResult {
   }
   }
   let policy = user_repo::LockoutPolicy {
   let policy = user_repo::LockoutPolicy {
     threshold: LOCKOUT_THRESHOLD,
     threshold: LOCKOUT_THRESHOLD,
+    account_threshold: LOCKOUT_THRESHOLD,
     lock_duration: LOCKOUT_WINDOW,
     lock_duration: LOCKOUT_WINDOW,
   };
   };
-  if !user_repo::is_locked_out(&mut h.conn, &h.store, policy, &user.email).await? {
+  if !user_repo::is_locked_out(&mut h.conn, &h.store, policy, &user.email, None).await? {
     return Err(unexpected("a burst of wrong codes must lock the account"));
     return Err(unexpected("a burst of wrong codes must lock the account"));
   }
   }
   let verifier = totp::build_totp(&offer.secret)?;
   let verifier = totp::build_totp(&offer.secret)?;
@@ -517,7 +721,7 @@ async fn login_confirm_burst_locks_and_rejects_correct_code() -> TestResult {
     .to_string();
     .to_string();
   let outcome = {
   let outcome = {
     let mut deps = deps_of(&mut h);
     let mut deps = deps_of(&mut h);
-    confirm_totp(&mut deps, &restricted, &correct).await?
+    confirm_totp(&mut deps, &restricted, &correct, Some(TEST_PEER_IP)).await?
   };
   };
   if !matches!(outcome, ConfirmOutcome::Rejected) {
   if !matches!(outcome, ConfirmOutcome::Rejected) {
     return Err(unexpected(
     return Err(unexpected(
@@ -563,7 +767,7 @@ async fn login_confirm_success_clears_failures() -> TestResult {
   for _ in 1..LOCKOUT_THRESHOLD {
   for _ in 1..LOCKOUT_THRESHOLD {
     let outcome = {
     let outcome = {
       let mut deps = deps_of(&mut h);
       let mut deps = deps_of(&mut h);
-      confirm_totp(&mut deps, &restricted, &bad).await?
+      confirm_totp(&mut deps, &restricted, &bad, Some(TEST_PEER_IP)).await?
     };
     };
     if !matches!(outcome, ConfirmOutcome::Rejected) {
     if !matches!(outcome, ConfirmOutcome::Rejected) {
       return Err(unexpected("a wrong confirmation code must be rejected"));
       return Err(unexpected("a wrong confirmation code must be rejected"));
@@ -579,7 +783,7 @@ async fn login_confirm_success_clears_failures() -> TestResult {
     .to_string();
     .to_string();
   let outcome = {
   let outcome = {
     let mut deps = deps_of(&mut h);
     let mut deps = deps_of(&mut h);
-    confirm_totp(&mut deps, &restricted, &correct).await?
+    confirm_totp(&mut deps, &restricted, &correct, Some(TEST_PEER_IP)).await?
   };
   };
   match outcome {
   match outcome {
     ConfirmOutcome::Completed { .. } => {}
     ConfirmOutcome::Completed { .. } => {}
@@ -625,9 +829,10 @@ async fn login_confirm_locked_page_matches_wrong_code() -> TestResult {
   }
   }
   let policy = user_repo::LockoutPolicy {
   let policy = user_repo::LockoutPolicy {
     threshold: LOCKOUT_THRESHOLD,
     threshold: LOCKOUT_THRESHOLD,
+    account_threshold: LOCKOUT_THRESHOLD,
     lock_duration: LOCKOUT_WINDOW,
     lock_duration: LOCKOUT_WINDOW,
   };
   };
-  if !user_repo::is_locked_out(&mut h.conn, &h.store, policy, &user.email).await? {
+  if !user_repo::is_locked_out(&mut h.conn, &h.store, policy, &user.email, None).await? {
     return Err(unexpected("the confirm burst must lock the account"));
     return Err(unexpected("the confirm burst must lock the account"));
   }
   }
   let (locked_status, locked_body) = post_confirm(&h, &restricted, &csrf, &bad).await?;
   let (locked_status, locked_body) = post_confirm(&h, &restricted, &csrf, &bad).await?;
@@ -643,3 +848,115 @@ async fn login_confirm_locked_page_matches_wrong_code() -> TestResult {
   }
   }
   Ok(())
   Ok(())
 }
 }
+
+/// A Redis write outage during `change_password` must fail closed with `503`
+/// and leave the password unchanged (purge-before-write): the Postgres write
+/// runs only after the sibling sweep succeeds, so a sweep failure costs only
+/// a retry. The outage principal may read sessions (the load and the sweep
+/// scan succeed) but may not delete them, so the first sweep `DEL` fails with
+/// `NOPERM` exactly like a write outage at that step.
+#[tokio::test]
+async fn change_password_redis_outage_leaves_password_unchanged() -> TestResult {
+  use std::time::Duration;
+
+  let mut h = harness().await?;
+  let user = create_user(
+    &mut h,
+    "pw-outage@example.test",
+    "correct-horse-41",
+    true,
+    false,
+  )
+  .await?;
+  let current = restricted_login(&mut h, &user.email, &user.password, None).await?;
+  let sibling = restricted_login(&mut h, &user.email, &user.password, None).await?;
+  let acl_password = "outage-pw-01";
+  let _: () = redis::cmd("ACL")
+    .arg("SETUSER")
+    .arg("outage-user")
+    .arg("on")
+    .arg(format!(">{acl_password}"))
+    .arg("~*")
+    .arg("+@all")
+    .arg("-@write")
+    .query_async(&mut h.conn)
+    .await?;
+  let port = h._redis.get_host_port_ipv4(6379).await?;
+  let url = format!("redis://outage-user:{acl_password}@127.0.0.1:{port}");
+  let client = redis::Client::open(url.as_str())?;
+  let manager = redis::aio::ConnectionManager::new_lazy_with_config(
+    client,
+    redis::aio::ConnectionManagerConfig::new()
+      .set_number_of_retries(0)
+      .set_response_timeout(Some(Duration::from_millis(500)))
+      .set_connection_timeout(Some(Duration::from_millis(500))),
+  )?;
+  let mut outage = RedisConn::Single(manager);
+  {
+    let mut deps = LoginDeps {
+      pool: &h.pool,
+      conn: &mut outage,
+      store: &h.store,
+      keys: &h.config.keys,
+      config: &h.config,
+      now: h.now,
+    };
+    match change_password(
+      &mut deps,
+      &current,
+      "new-strong-password-41",
+      "new-strong-password-41",
+    )
+    .await
+    {
+      Err(error)
+        if error.code() == OAuthErrorCode::TemporarilyUnavailable
+          && error.status().as_u16() == 503 => {}
+      Err(_) => {
+        return Err(unexpected(
+          "a write outage during password change must fail closed with 503",
+        ));
+      }
+      Ok(_) => {
+        return Err(unexpected(
+          "a write outage must never report a password change",
+        ));
+      }
+    }
+  }
+  match user_repo::verify_password(&h.pool, &h.config.keys, &user.email, "correct-horse-41").await?
+  {
+    user_repo::PasswordVerdict::Matched => {}
+    _ => return Err(unexpected("the old password must still verify")),
+  }
+  match user_repo::verify_password(
+    &h.pool,
+    &h.config.keys,
+    &user.email,
+    "new-strong-password-41",
+  )
+  .await?
+  {
+    user_repo::PasswordVerdict::Mismatched => {}
+    _ => return Err(unexpected("the new password must not verify")),
+  }
+  let fresh = user_repo::find_by_email(&h.pool, &h.config.keys, &user.email)
+    .await?
+    .ok_or_else(|| unexpected("the user must exist"))?;
+  if !fresh.must_change_password {
+    return Err(unexpected("a failed change must keep the must-change flag"));
+  }
+  if load_session(&mut h.conn, &h.store, &current)
+    .await?
+    .is_none()
+  {
+    return Err(unexpected("a failed change must keep the current session"));
+  }
+  if load_session(&mut h.conn, &h.store, &sibling)
+    .await?
+    .is_none()
+  {
+    return Err(unexpected("a failed change must keep sibling sessions"));
+  }
+  Ok(())
+}

+ 1 - 1
tests/e2e_flow.rs

@@ -44,7 +44,7 @@ const SCOPE: &str = "read";
 /// `state` echoed from authorize to the code redirect (unreserved chars only).
 /// `state` echoed from authorize to the code redirect (unreserved chars only).
 const STATE: &str = "e2e-flow-state";
 const STATE: &str = "e2e-flow-state";
 /// Session cookie name (production `src/ui/login.rs`: `SESSION_COOKIE`).
 /// Session cookie name (production `src/ui/login.rs`: `SESSION_COOKIE`).
-const SESSION_COOKIE: &str = "session_id";
+const SESSION_COOKIE: &str = "__Host-session_id";
 /// Uniform credential-rejection page text (`INVALID_CREDENTIALS`).
 /// Uniform credential-rejection page text (`INVALID_CREDENTIALS`).
 const INVALID_CREDENTIALS: &str = "Invalid email, password, or code.";
 const INVALID_CREDENTIALS: &str = "Invalid email, password, or code.";
 
 

+ 12 - 11
tests/user_repo.rs

@@ -290,13 +290,13 @@ async fn user_repo_scope_and_entitlement_round_trip() -> TestResult {
 #[tokio::test]
 #[tokio::test]
 async fn user_repo_lockout_locks_after_threshold_and_clears() -> TestResult {
 async fn user_repo_lockout_locks_after_threshold_and_clears() -> TestResult {
   let (mut conn, store) = redis_store().await?;
   let (mut conn, store) = redis_store().await?;
-  let policy = LockoutPolicy::new(3, Duration::from_millis(600)).ok_or("invalid policy")?;
+  let policy = LockoutPolicy::new(3, 3, Duration::from_millis(600)).ok_or("invalid policy")?;
   let label = "Locked@Example.com";
   let label = "Locked@Example.com";
 
 
   for _ in 0..3 {
   for _ in 0..3 {
-    user_repo::register_login_failure(&mut conn, &store, policy, label).await?;
+    user_repo::register_login_failure(&mut conn, &store, policy, label, None).await?;
   }
   }
-  assert!(user_repo::is_locked_out(&mut conn, &store, policy, label).await?);
+  assert!(user_repo::is_locked_out(&mut conn, &store, policy, label, None).await?);
   assert_eq!(
   assert_eq!(
     user_repo::login_failure_count(&mut conn, &store, "locked@example.com").await?,
     user_repo::login_failure_count(&mut conn, &store, "locked@example.com").await?,
     3,
     3,
@@ -309,24 +309,24 @@ async fn user_repo_lockout_locks_after_threshold_and_clears() -> TestResult {
   );
   );
 
 
   // Clear-on-success unlocks immediately.
   // Clear-on-success unlocks immediately.
-  user_repo::clear_login_failures(&mut conn, &store, label).await?;
+  user_repo::clear_login_failures(&mut conn, &store, label, None).await?;
   assert_eq!(
   assert_eq!(
     user_repo::login_failure_count(&mut conn, &store, label).await?,
     user_repo::login_failure_count(&mut conn, &store, label).await?,
     0
     0
   );
   );
-  assert!(!user_repo::is_locked_out(&mut conn, &store, policy, label).await?);
+  assert!(!user_repo::is_locked_out(&mut conn, &store, policy, label, None).await?);
 
 
   // TTL expiry (unlock policy) also releases the lock.
   // TTL expiry (unlock policy) also releases the lock.
   for _ in 0..3 {
   for _ in 0..3 {
-    user_repo::register_login_failure(&mut conn, &store, policy, label).await?;
+    user_repo::register_login_failure(&mut conn, &store, policy, label, None).await?;
   }
   }
-  assert!(user_repo::is_locked_out(&mut conn, &store, policy, label).await?);
+  assert!(user_repo::is_locked_out(&mut conn, &store, policy, label, None).await?);
   tokio::time::sleep(Duration::from_millis(800)).await;
   tokio::time::sleep(Duration::from_millis(800)).await;
   assert_eq!(
   assert_eq!(
     user_repo::login_failure_count(&mut conn, &store, label).await?,
     user_repo::login_failure_count(&mut conn, &store, label).await?,
     0
     0
   );
   );
-  assert!(!user_repo::is_locked_out(&mut conn, &store, policy, label).await?);
+  assert!(!user_repo::is_locked_out(&mut conn, &store, policy, label, None).await?);
   Ok(())
   Ok(())
 }
 }
 
 
@@ -344,13 +344,14 @@ async fn user_repo_lockout_gate_rejects_correct_password() -> TestResult {
     &new_user("gina@example.com", "correct", false, false)?,
     &new_user("gina@example.com", "correct", false, false)?,
   )
   )
   .await?;
   .await?;
-  let policy = LockoutPolicy::new(2, Duration::from_secs(60)).ok_or("invalid policy")?;
+  let policy = LockoutPolicy::new(2, 2, Duration::from_secs(60)).ok_or("invalid policy")?;
 
 
   for _ in 0..2 {
   for _ in 0..2 {
-    user_repo::register_login_failure(&mut conn, &store, policy, "gina@example.com").await?;
+    user_repo::register_login_failure(&mut conn, &store, policy, "gina@example.com", None).await?;
   }
   }
 
 
-  let locked = user_repo::is_locked_out(&mut conn, &store, policy, "gina@example.com").await?;
+  let locked =
+    user_repo::is_locked_out(&mut conn, &store, policy, "gina@example.com", None).await?;
   let verdict = user_repo::verify_password(&pool, &keys, "gina@example.com", "correct").await?;
   let verdict = user_repo::verify_password(&pool, &keys, "gina@example.com", "correct").await?;
   assert_eq!(
   assert_eq!(
     verdict,
     verdict,