浏览代码

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
   values are refused at load (`WeakKeyMaterial`).
 - `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
   (`invalid_grant`).
 - 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
   `--kind authorize|post_logout` (default `authorize`); `post_logout` URIs are
   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)).
 - Declarative apply: `config sync --file` and `config check`.
 - 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).
-- DCR initial access: `initial-access-token issue`.
+- DCR initial access: `initial-access-token issue [--show-secret]`.
 - Utilities: `keygen` and `healthcheck`.
 
 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.
 4. Resilience: sheds excess load, caps concurrency, and times out slow
    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.
 
 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
 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`,
 `totp_encryption_key_file`, and `pii_encryption_key_file` (environment
 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. |
 | `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. |
-| `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. |
 | `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). |
@@ -242,9 +251,30 @@ iteration count and parallelism.
 | `rate_limit.per_client` | `300` |
 | `rate_limit.login_per_ip` | `20` |
 | `rate_limit.token_per_client` | `60` |
+| `rate_limit.trusted_proxies` | empty |
 
 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` 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
   `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
 
 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
 never destroys the session or writes cookies. Only `POST /logout`, carrying
 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
 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

+ 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 enroll-totp --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 revoke-scope --email <email> --scope <scope>
 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`.
   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
-  `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
   sessions and their `:meta` sidecars, access/refresh tokens and their grants,
   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
   stays enabled (fail closed). Disabling alone does not touch PAR records,
   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.
 - `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
 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)
 
 ```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;
-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.
 
 ### Key generation and health
@@ -354,7 +375,12 @@ survives a Redis loss.
 - Rotating the token pepper without retaining the old key invalidates every
   live opaque token. Plan the overlap.
 - `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
 

+ 16 - 2
docs/REDIS.md

@@ -38,7 +38,9 @@ differ.
 | `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}: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:{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:
 
 - `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.
 - `device:<hash>` records plus their `:cnf`/`:poll` sidecars and the
   `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
 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
 
 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 plaintext health listener carries no OAuth routes.
 - 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
 
@@ -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`). |
 | 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. |
+| 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,
 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
   `frame-ancestors 'none'`. They use server-side sessions, CSRF protection, and
   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
   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.
@@ -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
   `id_token_hint`.
 - 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`,
   `/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
   and policy: a failed code increments it, success clears it, and a locked
   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
   refresh rejects it with `invalid_grant` before minting; `user disable` first
   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
   standalone access/refresh records), then flips Postgres `disabled`. A Redis
   outage fails closed and leaves the user enabled. Records with no subject
   (`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.
 - DCR is protected by a one-time initial-access token; open registration is off
   by default.

+ 66 - 7
src/cli.rs

@@ -8,7 +8,9 @@
 //! 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
 //! 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
 //! Postgres advisory lock; `serve` never migrates. Mutations are audited on the
@@ -167,6 +169,11 @@ pub enum UserCommand {
     /// User email.
     #[arg(long)]
     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 {
@@ -174,6 +181,12 @@ pub enum UserCommand {
     #[arg(long)]
     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.
   GrantScope {
     /// User email.
@@ -372,6 +385,11 @@ pub enum InitialAccessTokenCommand {
     /// Operator label for audit (never a secret).
     #[arg(long, default_value = "cli")]
     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,
     } => {
       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?;
-      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(())
     }
     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::RevokeScope { email, scope } => user::revoke_scope(context, &email, &scope).await,
     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> {
   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?;
-      println!("initial access token (shown once): {token}");
+      println!("initial access token (shown once): {}", token.as_str());
       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 sqlx::{PgConnection, PgPool};
 use uuid::Uuid;
+use zeroize::Zeroizing;
 
 use super::context::{CliError, audit, require_nonempty};
 use super::secret;
@@ -40,10 +41,11 @@ const GENERATED_BYTES: usize = 32;
 
 /// 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 {
-  /// 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,
 }
@@ -51,7 +53,11 @@ pub enum BootstrapPassword {
 impl fmt::Debug for BootstrapPassword {
   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     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"),
     }
   }
@@ -67,7 +73,7 @@ pub struct BootstrapOutcome {
   /// Canonical email.
   pub email: String,
   /// The generated password, when [`BootstrapPassword::Generate`] was used.
-  pub generated_password: Option<String>,
+  pub generated_password: Option<Zeroizing<String>>,
 }
 
 impl fmt::Debug for BootstrapOutcome {
@@ -75,9 +81,13 @@ impl fmt::Debug for BootstrapOutcome {
     f.debug_struct("BootstrapOutcome")
       .field("id", &self.id)
       .field("email", &self.email)
+      .field("generated_password", &"[REDACTED]")
       .field(
-        "generated_password",
-        &self.generated_password.as_ref().map(|_| "[REDACTED]"),
+        "generated_password_len",
+        &self
+          .generated_password
+          .as_ref()
+          .map(|password| password.len()),
       )
       .finish()
   }
@@ -94,14 +104,11 @@ impl fmt::Debug for BootstrapOutcome {
 /// Returns [`CliError::Io`] when the file/stdin read fails.
 pub fn resolve_password(password_file: Option<&Path>) -> Result<BootstrapPassword, CliError> {
   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));
   }
-  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)
   } else {
     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).
 ///
+/// The password is held in [`Zeroizing`] so it is wiped on drop.
+///
 /// # Errors
 /// 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()
-    .fill(&mut bytes)
+    .fill(&mut bytes[..])
     .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
@@ -152,7 +163,7 @@ pub async fn bootstrap(
   // 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
   // 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
   // pooled connections on the database lock while the winner still needs a
@@ -191,7 +202,7 @@ async fn bootstrap_locked(
   email: &str,
   password_hash: &str,
   force: bool,
-  generated_password: Option<String>,
+  generated_password: Option<Zeroizing<String>>,
 ) -> Result<BootstrapOutcome, CliError> {
   let existing: i64 =
     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 sqlx::PgPool;
+use zeroize::Zeroizing;
 
 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
 /// `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
-/// 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) {
     (true, Some(_)) => Err(CliError::Input(
       "--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) => {
-      let mut raw = String::new();
+      let mut raw = Zeroizing::new(String::new());
       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.
 ///
 /// # Errors
@@ -175,6 +213,10 @@ pub fn require_nonempty(secret: &str, what: &str) -> Result<(), CliError> {
   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 tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
+use zeroize::Zeroizing;
 
 use super::context::{CliError, audit, open_pool};
 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.
 ///
-/// 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
 /// Returns [`CliError`] for invalid budgets, config/state failures, or a store
@@ -126,7 +127,7 @@ pub async fn issue_initial_access_token(
   uses: u64,
   ttl_secs: u64,
   label: &str,
-) -> Result<String, CliError> {
+) -> Result<Zeroizing<String>, CliError> {
   let config = Arc::new(Config::load()?);
   issue_with(config, uses, ttl_secs, label).await
 }
@@ -141,7 +142,7 @@ pub async fn issue_with(
   uses: u64,
   ttl_secs: u64,
   label: &str,
-) -> Result<String, CliError> {
+) -> Result<Zeroizing<String>, CliError> {
   if uses == 0 {
     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 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 mut conn = state.redis.clone();
   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?;
   println!("bootstrapped admin {} (id {})", outcome.email, outcome.id);
   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");
   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 {
     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];
   SystemRandom::new()
     .fill(&mut nonce_bytes)
     .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()))?;
   let sealing = LessSafeKey::new(unbound);
   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).
 ///
+/// The secret is held in [`Zeroizing`] so it is wiped on drop.
+///
 /// # Errors
 /// 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()
-    .fill(&mut bytes)
+    .fill(&mut bytes[..])
     .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
-  Ok(base32::encode(
+  Ok(Zeroizing::new(base32::encode(
     base32::Alphabet::Rfc4648 { padding: false },
-    &bytes,
-  ))
+    &bytes[..],
+  )))
 }
 
 /// 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.
   #[error("redis error: {0}")]
   Redis(#[from] redis::RedisError),
+  /// A lockout-counter operation failed.
+  #[error("store error: {0}")]
+  Store(#[from] crate::store::StoreError),
   /// A required value was unexpectedly absent.
   #[error("missing value: {0}")]
   Missing(&'static str),

+ 359 - 26
src/cli/tests.rs

@@ -1,7 +1,7 @@
 //! `test(cli)` suite: config validation, user provisioning, token issuance,
 //! 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 clap::Parser as _;
@@ -20,7 +20,8 @@ use testcontainers::{
 use time::OffsetDateTime;
 
 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},
   test_support::{self, TestError},
   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> {
   let harness = test_support::harness().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 mut conn = AppState::connect(harness.config.clone())
@@ -182,7 +183,7 @@ async fn cli_initial_access_token_stores_only_hash() -> Result<(), TestError> {
     .await?;
   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")
     .arg(&raw_key)
     .query_async(&mut conn)
@@ -193,7 +194,7 @@ async fn cli_initial_access_token_stores_only_hash() -> Result<(), TestError> {
     .arg(&key)
     .query_async(&mut conn)
     .await?;
-  assert!(fields.iter().all(|field| !field.contains(&token)));
+  assert!(fields.iter().all(|field| !field.contains(token.as_str())));
   Ok(())
 }
 
@@ -223,7 +224,7 @@ async fn cli_user_enroll_totp_stores_encrypted_secret() -> Result<(), TestError>
     "plaintext TOTP secret found in stored 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/"));
   Ok(())
 }
@@ -235,31 +236,46 @@ async fn cli_totp_v3_cross_decrypts() -> Result<(), TestError> {
   let plaintext = secret::generate_totp_secret()?;
 
   // 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!(
     cli_blob.first().copied(),
     Some(3),
     "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.
-  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.
-  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!(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.
-  let legacy = secret::encrypt_totp(config, &plaintext, false)?;
+  let legacy = secret::encrypt_totp(config, plaintext.as_str(), false)?;
   assert_eq!(
     legacy.first().copied(),
     Some(2),
     "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
   // 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 rewrapped = rewrap_v3(&cli_blob, kid.as_str(), material, target.as_str())?;
   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.
   let truncated = &cli_blob[..12];
@@ -842,7 +861,7 @@ async fn admin_bootstrap_creates_first_admin_must_change() -> Result<(), TestErr
     &config,
     &pool,
     &email,
-    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned()),
+    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned().into()),
     false,
   )
   .await?;
@@ -898,7 +917,7 @@ async fn admin_bootstrap_concurrent_creates_exactly_one() -> Result<(), TestErro
         &config,
         &pool,
         &email,
-        admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned()),
+        admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned().into()),
         false,
       )
       .await
@@ -938,7 +957,7 @@ async fn admin_bootstrap_second_refuses_and_force_is_audited() -> Result<(), Tes
     &config,
     &pool,
     &first,
-    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned()),
+    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned().into()),
     false,
   )
   .await?;
@@ -948,7 +967,7 @@ async fn admin_bootstrap_second_refuses_and_force_is_audited() -> Result<(), Tes
     &config,
     &pool,
     &second,
-    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned()),
+    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned().into()),
     false,
   )
   .await;
@@ -972,7 +991,7 @@ async fn admin_bootstrap_second_refuses_and_force_is_audited() -> Result<(), Tes
     &config,
     &pool,
     &second,
-    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned()),
+    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned().into()),
     true,
   )
   .await?;
@@ -1010,16 +1029,16 @@ async fn admin_bootstrap_generated_password_is_256_bit_and_unlogged() -> Result<
     .generated_password
     .ok_or(TestError::Missing("generated password"))?;
   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"))?;
   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);
 
   let log = test_support::captured(&capture.buffer);
   assert!(
-    !log.contains(&generated),
+    !log.contains(generated.as_str()),
     "generated password leaked into logs"
   );
   assert!(
@@ -1053,7 +1072,7 @@ async fn admin_bootstrap_first_login_restricted_until_rotation() -> Result<(), T
     &config,
     &pool,
     &email,
-    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned()),
+    admin::BootstrapPassword::Provided(BOOTSTRAP_PASSWORD.to_owned().into()),
     false,
   )
   .await?;
@@ -1090,6 +1109,7 @@ async fn admin_bootstrap_first_login_restricted_until_rotation() -> Result<(), T
       password: BOOTSTRAP_PASSWORD.to_owned(),
       code: String::new(),
       csrf,
+      peer_ip: None,
     },
   )
   .await?
@@ -1148,7 +1168,7 @@ async fn admin_bootstrap_first_login_restricted_until_rotation() -> Result<(), T
   let code = totp::build_totp(&offer.secret)?
     .generate(counter * totp::TOTP_STEP_SECS)
     .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,
     _ => {
       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");
 }
 
+#[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]
 async fn admin_bootstrap_migrate_seeds_no_admin() -> Result<(), TestError> {
   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");
   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 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.
   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,
     "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.
   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 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();
   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.
 
+use std::fmt;
+
 use uuid::Uuid;
+use zeroize::Zeroizing;
 
 use super::{
   context::{CliError, Context, audit, require_nonempty},
@@ -9,11 +12,25 @@ use super::{
 use crate::{db::user_repo, error::AppError};
 
 /// 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 {
-  /// 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.
@@ -63,7 +80,7 @@ pub async fn enroll_totp(context: &Context, email: &str) -> Result<Enrollment, C
   let totp_secret = secret::generate_totp_secret()?;
   let blob = secret::encrypt_totp(
     context.config(),
-    &totp_secret,
+    totp_secret.as_str(),
     !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);
   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,
   })
 }
@@ -115,6 +136,36 @@ pub async fn disable(context: &Context, email: &str) -> Result<(), CliError> {
   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.
 ///
 /// # Errors

+ 84 - 1
src/config.rs

@@ -42,7 +42,9 @@ pub use db::DbConfig;
 pub use policy::{FailMode, FailurePolicy, RetryableClass};
 pub use redis::{DurabilityMode, RedisConfig, RedisTopology};
 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_*`).
 pub const ENV_PREFIX: &str = "D_OAUTH";
@@ -184,6 +186,68 @@ pub(crate) fn env_source() -> ::config::Environment {
     .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.
 #[derive(Debug, Error)]
 pub enum ConfigError {
@@ -300,6 +364,13 @@ pub enum ConfigError {
   /// The rate-limit window was zero.
   #[error("invalid rate-limit window")]
   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.
   #[error("invalid token TTL: every lifetime must be non-zero")]
   InvalidTtl,
@@ -328,6 +399,18 @@ pub enum ConfigError {
     /// The rejected value.
     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.

+ 24 - 2
src/config/build.rs

@@ -48,6 +48,11 @@ impl Config {
       .into_iter()
       .map(ScopeDefinition::from_raw)
       .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
       .token_exchange_allowlist
       .into_iter()
@@ -67,8 +72,8 @@ impl Config {
       ttl,
       argon2,
       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,
       pii_dual_write: raw.pii_dual_write,
       dcr_redirect_policy: raw.dcr_redirect_policy,
@@ -276,3 +281,20 @@ fn validate_timeout_ladder(db: &DbConfig, policy: &FailurePolicy) -> Result<(),
     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 allow_open_registration: bool,
   pub dcr_redirect_policy: DcrRedirectPolicy,
+  #[serde(default, deserialize_with = "crate::config::deserialize_string_or_vec")]
   pub cors_allowed_origins: Vec<String>,
   pub scope_registry: Vec<RawScopeDefinition>,
   pub allow_token_exchange: bool,

+ 2 - 0
src/config/redis.rs

@@ -266,10 +266,12 @@ impl RedisConfig {
 #[serde(default)]
 pub(crate) struct RawRedisConfig {
   pub url: Option<String>,
+  #[serde(default, deserialize_with = "crate::config::deserialize_string_or_vec")]
   pub nodes: Vec<String>,
   pub master_name: Option<String>,
   pub sentinel_auth: Option<String>,
   pub data_auth: Option<String>,
+  #[serde(default, deserialize_with = "crate::config::deserialize_string_or_vec")]
   pub seeds: Vec<String>,
   pub auth: Option<String>,
   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  ");
   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
 //! 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 serde::Deserialize;
@@ -191,7 +193,7 @@ impl Default for RawArgon2 {
 }
 
 /// 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 {
   /// Whether rate limiting is enforced.
   pub enabled: bool,
@@ -205,6 +207,12 @@ pub struct RateLimitConfig {
   pub login_per_ip: u32,
   /// Per-client token-request budget per window.
   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 {
@@ -216,6 +224,7 @@ impl Default for RateLimitConfig {
       per_client: 300,
       login_per_ip: 20,
       token_per_client: 60,
+      trusted_proxies: Vec::new(),
     }
   }
 }
@@ -225,6 +234,16 @@ impl RateLimitConfig {
     if raw.window_secs == 0 {
       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 {
       enabled: raw.enabled,
       window: Duration::from_secs(raw.window_secs),
@@ -232,6 +251,7 @@ impl RateLimitConfig {
       per_client: raw.per_client,
       login_per_ip: raw.login_per_ip,
       token_per_client: raw.token_per_client,
+      trusted_proxies,
     })
   }
 }
@@ -246,6 +266,8 @@ pub(crate) struct RawRateLimit {
   pub per_client: u32,
   pub login_per_ip: 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 {
@@ -258,6 +280,98 @@ impl Default for RawRateLimit {
       per_client: r.per_client,
       login_per_ip: r.login_per_ip,
       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;
 
 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
 //! primitive is [`crate::store::sessions`]; this module owns the policy
 //! (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::{
   state::RedisConn,
@@ -15,35 +23,58 @@ use crate::{
 
 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)]
 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,
+  /// Failures from any IPs within the window that lock the account globally.
+  pub account_threshold: u64,
   /// Fixed window that doubles as the lock duration.
   pub lock_duration: Duration,
 }
 
 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]
-  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,
+      account_threshold,
       lock_duration,
     })
   }
 
-  /// Whether `count` failures meet or exceed the lock threshold.
+  /// Whether `count` failures meet or exceed the per-IP lock threshold.
   #[must_use]
   pub fn is_locked(&self, count: u64) -> bool {
     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
 /// Returns a [`StoreError`] when Redis is unavailable.
@@ -52,16 +83,41 @@ pub async fn register_login_failure(
   store: &Store,
   policy: LockoutPolicy,
   account_label: &str,
+  peer_ip: Option<IpAddr>,
 ) -> 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,
     store,
-    &normalize_email(account_label),
+    &ip_label(Some(peer)),
+    &canonical,
     policy.lock_duration,
   )
   .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.
 ///
 /// # Errors
@@ -74,7 +130,45 @@ pub async fn login_failure_count(
   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
 /// Returns a [`StoreError`] when Redis is unavailable.
@@ -83,12 +177,39 @@ pub async fn is_locked_out(
   store: &Store,
   policy: LockoutPolicy,
   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> {
-  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))
 }
 
-/// 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
 /// Returns a [`StoreError`] when Redis is unavailable.
@@ -96,8 +217,64 @@ pub async fn clear_login_failures(
   conn: &mut RedisConn,
   store: &Store,
   account_label: &str,
+  peer_ip: Option<IpAddr>,
 ) -> 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
@@ -120,3 +297,419 @@ pub async fn lockout_remaining(
     .map_err(StoreError::Redis)?;
   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
 //! 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)
 //! 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.
@@ -40,7 +40,7 @@ use tower::{Layer, Service};
 
 use crate::config::RateLimitConfig;
 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)]
 mod test_support;
@@ -90,7 +90,7 @@ impl Rule {
   /// segment such as `/register/{client_id}` is classified by its prefix and
   /// a sensitive route can never fall through to an unrated handler.
   /// `/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
       .split('?')
       .next()
@@ -203,7 +203,7 @@ where
 
     Box::pin(async move {
       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;
       };
 
@@ -212,9 +212,12 @@ where
         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");
         return Ok(service_unavailable(1));
       };
@@ -241,7 +244,12 @@ where
 /// The hashed counter keys and limits for one request, in evaluation order.
 ///
 /// `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(
   state: &AppState,
   rule: Rule,
@@ -256,7 +264,10 @@ fn counter_keys(
   if let Some(limit) = rule.per_client.filter(|limit| *limit > 0)
     && 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
 }

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

@@ -11,10 +11,17 @@ use axum::extract::ConnectInfo;
 use axum::http::{Request, header};
 use sha2::{Digest, Sha256};
 
+use crate::config::types::TrustedProxyNet;
+
 /// Largest form body buffered to recover a `client_id`; anything larger is
 /// left untouched and simply yields no client identity.
 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
 /// listener is served with `into_make_service_with_connect_info`).
 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())
 }
 
+/// 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.
 ///
 /// 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.
+///
+/// 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 {
   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.
 fn sha256_hex(input: &[u8]) -> String {
   const HEX: &[u8; 16] = b"0123456789abcdef";
@@ -121,3 +206,206 @@ fn hex_value(byte: u8) -> Option<u8> {
     _ => 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 super::RateLimitLayer;
-use super::identity::counter_key;
+use super::identity::{client_bucket_raw, counter_key};
 use super::test_support::{
   Extras, Passthrough, TestResult, live_state, peer, redis_container, retry_after, scan_keys, send,
   send_with, test_config, unavailable_state,
@@ -60,10 +60,11 @@ async fn ratelimit_token_per_ip_rejects_over_budget_with_retry_after() -> TestRe
   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]
-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 config = test_config(
     &url,
@@ -72,13 +73,13 @@ async fn ratelimit_token_per_client_spans_source_ips() -> TestResult {
   let state = live_state(config, &url).await?;
   let service = RateLimitLayer::new(state).layer(Passthrough);
 
-  for octet in [1_u8, 2_u8] {
+  for _ in 0..2 {
     let response = send(
       service.clone(),
       "POST",
       "/token",
       "grant_type=authorization_code&client_id=client-x",
-      peer(octet),
+      peer(1),
     )
     .await?;
     assert_eq!(response.status(), StatusCode::OK);
@@ -89,13 +90,27 @@ async fn ratelimit_token_per_client_spans_source_ips() -> TestResult {
     "POST",
     "/token",
     "grant_type=authorization_code&client_id=client-x",
-    peer(3),
+    peer(1),
   )
   .await?;
   assert_eq!(
     blocked.status(),
     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(())
 }
@@ -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?;
   for expected in [
     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!(
       keys.contains(&expected),
@@ -535,6 +558,276 @@ async fn ratelimit_disabled_never_limits_and_writes_no_keys() -> TestResult {
   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
 /// `Retry-After: 1`, and no counter key is written because no identity exists
 /// to count.

+ 253 - 4
src/protocol/purge.rs

@@ -2,7 +2,8 @@
 //!
 //! [`purge_subject`] deletes every Redis record whose `subject` field names
 //! 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
 //! (cascade-revoked through [`grants::revoke`] so member token keys, the
 //! 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 {
   /// Login session records deleted (each also removed its `:meta` sidecar).
   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,
   /// Authorization-code records deleted.
   pub codes: u64,
@@ -89,6 +90,28 @@ pub async fn purge_subject(
   if removed > 0 {
     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? {
     if is_subject(&read_fields(conn, &key).await?, email, user_id) {
@@ -176,6 +199,47 @@ pub async fn purge_subject(
   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.
 fn is_subject(fields: &HashMap<String, String>, email: &str, user_id: &str) -> bool {
   fields
@@ -245,8 +309,14 @@ mod tests {
     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)]
   enum TestError {
@@ -555,4 +625,183 @@ mod tests {
     }
     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
 //!    budgets on every endpoint except health and metadata (those paths have
 //!    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
 //!    [`MAX_BODY_BYTES`].
 //! 5. [`middleware::security_headers::SecurityHeadersLayer`] decorates
@@ -44,11 +48,11 @@ use std::collections::HashMap;
 use std::net::SocketAddr;
 use std::time::Duration;
 
-use axum::Form;
 use axum::extract::{ConnectInfo, Query, State};
 use axum::http::{HeaderMap, StatusCode, header};
 use axum::response::{IntoResponse, Response};
 use axum::routing::{get, post};
+use axum::{Extension, Form};
 use axum_extra::extract::CookieJar;
 use rustls::ServerConfig;
 use tower_http::limit::RequestBodyLimitLayer;
@@ -66,6 +70,7 @@ use crate::state::{AppState, health_router};
 use crate::store::pending::PendingAuth;
 use crate::store::{Store, pending};
 use crate::ui::device::DeviceForm;
+use crate::ui::login::{ConfirmForm, LoginForm};
 use crate::ui::{assets, consent, device as device_page, login};
 
 /// 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",
       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/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/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(
@@ -292,6 +297,48 @@ async fn post_device_entry(
   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)]
 mod router_tests {
   use std::net::SocketAddr;
@@ -884,7 +931,7 @@ mod router_tests {
       "/introspect",
       "/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());
       }
     }
@@ -894,7 +941,7 @@ mod router_tests {
       "/health/ready",
       "/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());
       }
     }

+ 33 - 0
src/store/mod.rs

@@ -419,6 +419,39 @@ impl Store {
     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).
   ///
   /// 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(())
 }
 
+/// 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 {
   match error {
     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)
 }
 
+/// 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
 /// ([`crate::jose::dpop::DPOP_JTI_TTL`]); the `private_key_jwt` marker is
 /// 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.rotation_map_key("r"),
     store.login_fail_key("l"),
+    store.login_fail_ip_key("i", "l"),
+    store.login_fail_unknown_key("i"),
   ] {
     if !key.contains("{state}") {
       return Err(TestError::Unexpected(

+ 3 - 11
src/ui/consent.rs

@@ -40,7 +40,7 @@ use crate::{
   },
   state::{AppState, RedisConn},
   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
@@ -432,7 +432,7 @@ pub async fn get_consent(
 ) -> Response {
   let mut conn = state.redis.clone();
   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 {
     pool: &state.pool,
     conn: &mut conn,
@@ -475,7 +475,7 @@ pub async fn post_consent(
   };
   let mut conn = state.redis.clone();
   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 {
     pool: &state.pool,
     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`.
 fn redirect(location: &str) -> Response {
   match header::HeaderValue::from_str(location) {

+ 5 - 12
src/ui/device.rs

@@ -44,8 +44,6 @@ use crate::{
   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.
 pub const LOGIN_PATH: &str = "/login";
 /// Guess window for the user-code brute-force counter.
@@ -139,16 +137,14 @@ pub async fn check_guess_budget(
 pub async fn authenticated_subject(
   conn: &mut RedisConn,
   store: &Store,
+  config: &crate::config::Config,
   jar: &CookieJar,
 ) -> 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() {
     return Ok(None);
   }
-  let record = store::sessions::get(conn, store, session_id).await?;
+  let record = store::sessions::get(conn, store, &session_id).await?;
   Ok(
     record
       .filter(|session| !session.subject.is_empty() && session.mfa_verified && !session.restricted)
@@ -382,14 +378,11 @@ pub async fn post_device(
   peer: Option<ConnectInfo<SocketAddr>>,
   Form(form): Form<DeviceForm>,
 ) -> 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 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(None) => return Redirect::to(LOGIN_PATH).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`
 //! 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 axum::{
   Extension, Form,
-  extract::{Query, State},
+  extract::{ConnectInfo, Query, State},
   http::{StatusCode, header},
   response::{IntoResponse, Redirect, Response},
 };
@@ -56,13 +59,26 @@ use crate::{
   error::AppError,
   keys::KeyRing,
   middleware::{audit::AuditEvent, request_id::RequestId},
+  protocol::purge::purge_sibling_sessions,
   protocol::redirect_repo as redirect_match,
   state::{AppState, RedisConn},
   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.
 pub const LOGIN_PATH: &str = "/login";
 /// 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";
 /// Post-login target for a bound authorization request (owned by task 26).
 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;
+/// 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.
 pub const LOCKOUT_WINDOW: Duration = Duration::from_secs(15 * 60);
 /// Minimum accepted password length on change.
@@ -141,6 +161,19 @@ pub struct LoginAttempt {
   pub code: String,
   /// Presented login-CSRF token.
   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`].
@@ -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]
 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("/")
     .http_only(true)
     .same_site(SameSite::Lax);
   if config.cookie_secure {
     builder = builder.secure(true);
   }
-  if let Some(domain) = config.cookie_domain.clone() {
-    builder = builder.domain(domain);
-  }
   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]
-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("/")
     .http_only(true)
     .same_site(SameSite::Lax);
-  if config.cookie_secure {
+  if name == SESSION_COOKIE {
     builder = builder.secure(true);
   }
-  if let Some(domain) = config.cookie_domain.clone() {
-    builder = builder.domain(domain);
-  }
   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
 /// 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"));
   }
   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");
     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? {
     user_repo::PasswordVerdict::Unknown => {
       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);
     }
     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");
       return Ok(LoginOutcome::Rejected);
     }
     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");
       return Ok(LoginOutcome::Rejected);
     }
@@ -429,13 +526,14 @@ pub async fn attempt_login(
   let mut mfa_done = false;
   if user.totp_enabled {
     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");
       return Ok(LoginOutcome::Rejected);
     }
     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
   // redirect leaves from the stored record — never from client input.
   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
 /// 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
 ///
@@ -548,6 +648,18 @@ pub async fn change_password(
     return Err(AppError::invalid_request("the login session has expired"));
   };
   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)?;
   if !user_repo::set_password(deps.pool, user.id, &hash).await? {
     return Err(AppError::server_error());
@@ -637,7 +749,8 @@ pub enum ConfirmOutcome {
 
 /// Confirm TOTP enrollment with an authenticator code (TOTP only: recovery
 /// 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
 ///
@@ -649,6 +762,7 @@ pub async fn confirm_totp(
   deps: &mut LoginDeps<'_>,
   session_id: &str,
   code: &str,
+  peer_ip: Option<IpAddr>,
 ) -> Result<ConfirmOutcome, AppError> {
   let Some(live) = load_session(deps.conn, deps.store, session_id).await? else {
     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
   // locked account rejects even a correct code, indistinguishably from a
   // 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);
   }
   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"));
   };
   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);
   };
   let secret = totp::decrypt_secret(deps.keys, &blob)?;
@@ -688,7 +800,8 @@ pub async fn confirm_totp(
     ttl: totp::USED_CODE_TTL,
   };
   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(
       live.meta.pending_request_id.as_deref(),
       Some(&live.record.subject),
@@ -696,7 +809,18 @@ pub async fn confirm_totp(
     );
     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?;
   if fresh.is_some_and(|user| !user.must_change_password) {
     let promoted = promote_to_full(deps, session_id, &live).await?;
@@ -1229,9 +1353,12 @@ pub async fn get_login(
 ) -> Response {
   let mut conn = state.redis.clone();
   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 mut deps = deps_of(&state, &mut conn, &store);
     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.
+///
+/// `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(
   State(state): State<AppState>,
   Extension(request_id): Extension<RequestId>,
   jar: CookieJar,
   Form(form): Form<LoginForm>,
+  peer: Option<ConnectInfo<SocketAddr>>,
 ) -> Response {
   let mut conn = state.redis.clone();
   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 {
     presession_id,
     email: form.email.clone(),
     password: form.password,
     code: form.code,
     csrf: form.csrf,
+    peer_ip: peer.map(|info| info.0.ip()),
   };
   let mut deps = deps_of(&state, &mut conn, &store);
   match attempt_login(&mut deps, attempt).await {
@@ -1368,10 +1499,7 @@ pub async fn post_change_password(
 ) -> Response {
   let mut conn = state.redis.clone();
   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 {
     Ok(Some(live)) => live,
     Ok(None) => return Redirect::to(LOGIN_PATH).into_response(),
@@ -1406,10 +1534,7 @@ pub async fn get_totp_setup(
 ) -> Response {
   let mut conn = state.redis.clone();
   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 {
     Ok(Some(live)) => live,
     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.
+///
+/// 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(
   State(state): State<AppState>,
   Extension(request_id): Extension<RequestId>,
   jar: CookieJar,
   Form(form): Form<ConfirmForm>,
+  peer: Option<ConnectInfo<SocketAddr>>,
 ) -> Response {
   let mut conn = state.redis.clone();
   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 {
     Ok(Some(live)) => live,
     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();
   }
   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 {
       session_id,
       redirect_to,
@@ -1492,12 +1625,12 @@ pub async fn post_totp_confirm(
 }
 
 /// `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
 /// 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(
   State(state): State<AppState>,
   Extension(request_id): Extension<RequestId>,
@@ -1506,15 +1639,12 @@ pub async fn post_logout(
 ) -> Response {
   let mut conn = state.redis.clone();
   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 {
     Ok(Some(live)) => live,
     Ok(None) => {
       return (
-        jar.add(expired_session_cookie(&state.config)),
+        clear_session_cookies(jar, &state.config),
         Redirect::to(LOGIN_PATH),
       )
         .into_response();
@@ -1532,7 +1662,7 @@ pub async fn post_logout(
         return AppError::server_error().into_response();
       }
       return (
-        jar.add(expired_session_cookie(&state.config)),
+        clear_session_cookies(jar, &state.config),
         error.into_response(),
       )
         .into_response();
@@ -1545,7 +1675,7 @@ pub async fn post_logout(
     Some(uri) => Redirect::to(&uri).into_response(),
     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.
@@ -1564,10 +1694,7 @@ pub async fn post_logout(
 pub async fn get_logout(State(state): State<AppState>, jar: CookieJar) -> Response {
   let mut conn = state.redis.clone();
   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 {
     Ok(Some(live)) => render(LogoutConfirmTemplate {
       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 {
   let mut conn = state.redis.clone();
   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 {
     Ok(Some(live)) if !live.record.subject.is_empty() && !live.record.restricted => {
       render(SuccessTemplate {
@@ -1667,10 +1791,7 @@ async fn gated_form(
 ) -> Response {
   let mut conn = state.redis.clone();
   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 {
     Ok(Some(live)) => live,
     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
 //! these helpers; every test owns a fresh database and Redis container.
 
-use std::time::Duration;
-
 use serde_json::json;
 use sqlx::PgPool;
+use std::{
+  net::{IpAddr, Ipv4Addr, SocketAddr},
+  time::Duration,
+};
 use testcontainers::{
   GenericImage, ImageExt,
   core::{IntoContainerPort, WaitFor},
@@ -32,6 +34,12 @@ use crate::{
 pub(super) mod auth;
 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).
 pub(super) type TestResult = Result<(), TestError>;
 
@@ -295,6 +303,7 @@ pub(super) fn attempt(
     password: user.password.clone(),
     code: code.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.
 
 use axum::Extension;
-use axum::extract::{Form, Query, State};
+use axum::extract::{ConnectInfo, Form, Query, State};
 use axum::http::{StatusCode, header};
-use axum_extra::extract::CookieJar;
+use axum_extra::extract::{CookieJar, cookie::Cookie};
 use sqlx::Row as _;
+use std::net::{IpAddr, Ipv4Addr};
 
 use super::super::LOGOUT_ACTION;
 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::{
   db::{
@@ -21,9 +23,10 @@ use crate::{
   middleware::request_id::RequestId,
   store::{pending, sessions},
   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);
   for needle in [
-    "session_id=",
+    "__Host-session_id=",
     "Path=/",
     "HttpOnly",
     "Secure",
     "SameSite=Lax",
   ] {
     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) {
     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)
     .and_then(|value| value.to_str().ok())
     .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"));
   }
   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::COOKIE,
-      format!("session_id={session_id}"),
+      format!("__Host-session_id={session_id}"),
     )
     .body(axum::body::Body::from(body))
     .map_err(|_| unexpected("request build failed"))?;
@@ -399,7 +407,7 @@ async fn post_logout_without_session_is_idempotent() -> TestResult {
     .get(header::SET_COOKIE)
     .and_then(|value| value.to_str().ok())
     .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(
       "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 {
     threshold: LOCKOUT_THRESHOLD,
+    account_threshold: LOCKOUT_THRESHOLD,
     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"));
   }
   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 {
   let mut h = harness().await?;
   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)
     .ok_or_else(|| unexpected("the harness config must be uniquely owned"))?;
   config.cookie_secure = false;
-  config.cookie_domain = Some("auth.example.test".to_owned());
   let header = cookie_header(&h, "session-abc");
   if header.contains("Secure") {
     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) {
-      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(())
 }
 
@@ -1237,6 +1340,7 @@ async fn login_corrupt_totp_ciphertext_fails_closed_with_503() -> TestResult {
     Extension(RequestId::new()),
     jar,
     Form(form),
+    Some(ConnectInfo(TEST_PEER_SOCK)),
   )
   .await;
   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 {
   let h = harness().await?;
   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") {
     return Err(unexpected("the clearing cookie must expire immediately"));
   }
@@ -1326,3 +1434,280 @@ async fn logout_clearing_cookie_mirrors_session_cookie_attrs() -> TestResult {
   }
   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.
 
 use axum::Extension;
-use axum::extract::{Form, State};
+use axum::extract::{ConnectInfo, Form, State};
 use axum::http::StatusCode;
 use axum_extra::extract::CookieJar;
 
 use super::super::TOTP_CONFIRM_ACTION;
 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::{
   crypto::totp,
   db::user_repo,
   error::OAuthErrorCode,
   middleware::request_id::RequestId,
+  state::RedisConn,
   store::sessions,
   ui::login::{
     CHANGE_PASSWORD_PATH, CONSENT_PATH, ConfirmForm, ConfirmOutcome, LOCKOUT_THRESHOLD,
@@ -58,6 +59,7 @@ async fn restricted_login(
     password: password.to_owned(),
     code: String::new(),
     csrf,
+    peer_ip: Some(TEST_PEER_IP),
   };
   let mut deps = deps_of(harness);
   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 outcome = {
     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) {
     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();
   let (full, redirect_to) = {
     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 {
         session_id,
         redirect_to,
@@ -245,7 +247,7 @@ async fn login_enrollment_requires_subject_session() -> TestResult {
   }
   {
     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(_) => Err(unexpected("a pre-session confirm must be invalid_request")),
       Ok(_) => Err(unexpected("a pre-session must never confirm")),
@@ -266,7 +268,7 @@ async fn login_confirm_without_provision_rejected() -> TestResult {
   .await?;
   let restricted = restricted_login(&mut h, &user.email, &user.password, None).await?;
   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(()),
     _ => 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 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 => {}
       _ => return Err(unexpected("a wrong confirmation code must be rejected")),
     }
@@ -418,7 +420,7 @@ async fn login_totp_enrollment() -> TestResult {
     .to_string();
   let full = {
     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 {
         session_id,
         redirect_to,
@@ -452,6 +454,206 @@ async fn login_totp_enrollment() -> TestResult {
   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
 /// the status and body bytes of the rendered page.
 async fn post_confirm(
@@ -469,6 +671,7 @@ async fn post_confirm(
       code: code.to_owned(),
       csrf: csrf.to_owned(),
     }),
+    Some(ConnectInfo(TEST_PEER_SOCK)),
   )
   .await;
   let status = response.status();
@@ -498,7 +701,7 @@ async fn login_confirm_burst_locks_and_rejects_correct_code() -> TestResult {
   for _ in 0..LOCKOUT_THRESHOLD {
     let outcome = {
       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) {
       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 {
     threshold: LOCKOUT_THRESHOLD,
+    account_threshold: LOCKOUT_THRESHOLD,
     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"));
   }
   let verifier = totp::build_totp(&offer.secret)?;
@@ -517,7 +721,7 @@ async fn login_confirm_burst_locks_and_rejects_correct_code() -> TestResult {
     .to_string();
   let outcome = {
     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) {
     return Err(unexpected(
@@ -563,7 +767,7 @@ async fn login_confirm_success_clears_failures() -> TestResult {
   for _ in 1..LOCKOUT_THRESHOLD {
     let outcome = {
       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) {
       return Err(unexpected("a wrong confirmation code must be rejected"));
@@ -579,7 +783,7 @@ async fn login_confirm_success_clears_failures() -> TestResult {
     .to_string();
   let outcome = {
     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 {
     ConfirmOutcome::Completed { .. } => {}
@@ -625,9 +829,10 @@ async fn login_confirm_locked_page_matches_wrong_code() -> TestResult {
   }
   let policy = user_repo::LockoutPolicy {
     threshold: LOCKOUT_THRESHOLD,
+    account_threshold: LOCKOUT_THRESHOLD,
     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"));
   }
   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(())
 }
+
+/// 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).
 const STATE: &str = "e2e-flow-state";
 /// 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`).
 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]
 async fn user_repo_lockout_locks_after_threshold_and_clears() -> TestResult {
   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";
 
   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!(
     user_repo::login_failure_count(&mut conn, &store, "locked@example.com").await?,
     3,
@@ -309,24 +309,24 @@ async fn user_repo_lockout_locks_after_threshold_and_clears() -> TestResult {
   );
 
   // 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!(
     user_repo::login_failure_count(&mut conn, &store, label).await?,
     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.
   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;
   assert_eq!(
     user_repo::login_failure_count(&mut conn, &store, label).await?,
     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(())
 }
 
@@ -344,13 +344,14 @@ async fn user_repo_lockout_gate_rejects_correct_password() -> TestResult {
     &new_user("gina@example.com", "correct", false, false)?,
   )
   .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 {
-    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?;
   assert_eq!(
     verdict,