Efren Yevale Varela 3 недель назад
Родитель
Сommit
a95ffc0220
12 измененных файлов с 154 добавлено и 40 удалено
  1. 5 3
      AGENTS.md
  2. 6 0
      README.md
  3. 1 1
      RUNBOOK.md
  4. 7 2
      docs/ARCHITECTURE.md
  5. 19 0
      docs/CHANGELOG.md
  6. 7 1
      docs/CONTRIBUTING.md
  7. 4 1
      docs/DATABASE.md
  8. 20 12
      docs/DEPLOYMENT.md
  9. 31 8
      docs/OAUTH.md
  10. 13 3
      docs/REDIS.md
  11. 39 8
      docs/SECURITY.md
  12. 2 1
      src/routes.rs

+ 5 - 3
AGENTS.md

@@ -21,7 +21,7 @@ the admin surface.
 | JOSE verification (DPoP, JAR, `private_key_jwt`) | `src/jose/` | Reject fail-closed with the exact error code. |
 | Postgres query or repository | `src/db/` | `query!`/`query_as!` macros; regenerate `.sqlx`. |
 | Redis record or Lua script | `src/store/` | Keep every key under `{state}`; update [docs/REDIS.md](docs/REDIS.md). |
-| OAuth endpoint or grant | `src/protocol/` | Cite the RFC section in a comment. Update [docs/OAUTH.md](docs/OAUTH.md). |
+| OAuth endpoint or grant | `src/protocol/` | Cite the RFC section in a comment. Update [docs/OAUTH.md](docs/OAUTH.md). A subject-scoped Redis purge lives at `src/protocol/purge.rs` (`purge_subject`), used by `user disable`; keep every key under `{state}` and update docs/REDIS.md. |
 | Login, consent, or device page | `src/ui/` | Keep CSP and CSRF intact. |
 | Cross-cutting HTTP concern | `src/middleware/` | Mind the layer order in `src/routes.rs`. |
 | Endpoint or layer order | `src/routes.rs` | Update [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). |
@@ -67,6 +67,8 @@ Before finishing any change, confirm:
 - [ ] Only hashed or encrypted secrets are stored; no raw token at rest.
 - [ ] A sender-constrained grant re-verifies a fresh proof on every use.
 - [ ] `private_key_jwt` is treated as authentication only, never as a binding.
+- [ ] Master keys (pepper, TOTP/PII keys, and retained previous keys) carry at least 32 bytes; shorter material is rejected at load (`WeakKeyMaterial`).
+- [ ] A disabled subject is dead everywhere: introspection reports inactive, refresh rejects with `invalid_grant` before minting, and refresh re-checks current entitlements; `user disable` purges the subject's Redis state (sessions, codes, devices, grants, standalone tokens, lockout) before flipping `users.disabled`, failing closed on a Redis error.
 - [ ] The change does not introduce a wildcard scope, redirect URI, or CORS
       origin.
 
@@ -125,8 +127,8 @@ updates the relevant guide in the same commit:
   `docs/SECURITY.md`.
 - Modules, lifecycle, topology: `docs/ARCHITECTURE.md`.
 - Config keys and defaults: `docs/CONFIGURATION.md`.
-- Schema and migrations: `docs/DATABASE.md`.
-- Redis keys, TTLs, durability: `docs/REDIS.md`.
+- Schema and migrations, disabled-user re-check: `docs/DATABASE.md`.
+- Redis keys, TTLs, durability, subject purge: `docs/REDIS.md`.
 - Image, manifests, probes: `docs/DEPLOYMENT.md`.
 - CLI and runbooks: `docs/OPERATIONS.md`, `RUNBOOK.md`.
 - This file, when the module map or rules change.

+ 6 - 0
README.md

@@ -236,6 +236,12 @@ certificate.
 - The token pepper, TOTP encryption key, and PII field-encryption key are
   required; losing the pepper invalidates every live opaque token, and losing
   the PII key makes the sealed directory unreadable.
+- 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
+  user's live tokens then introspect inactive and refuse refresh
+  (`invalid_grant`).
 - Production requires `issuer` on `https` and a database URL with
   `sslmode=verify-full`.
 - Redis must run with `noeviction` and persistence. The server asserts this at

+ 1 - 1
RUNBOOK.md

@@ -85,7 +85,7 @@ admin-gated actions deny it until both steps complete.
 - [ ] Create a named second admin:
       `d-oauth-server user create --admin --email <name>@example.com`
 - [ ] Log in as the named admin and verify full access.
-- [ ] Disable the bootstrap account: `d-oauth-server user disable --email ops@example.com`
+- [ ] Disable the bootstrap account: `d-oauth-server user disable --email ops@example.com`. `user disable` purges the account's Redis sessions, tokens, grants, codes, device records, and lockout counter before flipping the Postgres `disabled` flag, and fails closed (leaving the user enabled) if Redis is unavailable. Records with no subject (PAR, pending, replay markers, rotation map, TOTP replay, rate-limit counters) are untouched. See docs/OPERATIONS.md.
 - [ ] Rotate/expire the bootstrap secret: delete the mounted
       `/run/secrets/bootstrap-password` material and revoke any copy of a
       generated one-time password.

+ 7 - 2
docs/ARCHITECTURE.md

@@ -27,7 +27,7 @@ the library.
 | `src/jose/` | DPoP proof verification, JAR request objects, `private_key_jwt` client assertions, and JWK parsing. |
 | `src/db/` | Postgres repositories for the six domain tables. See [DATABASE.md](DATABASE.md). |
 | `src/store/` | Redis ephemeral state and the durability barrier. See [REDIS.md](REDIS.md). |
-| `src/protocol/` | OAuth endpoints and grants. See [OAUTH.md](OAUTH.md). |
+| `src/protocol/` | OAuth endpoints and grants, plus the subject-scoped Redis purge (`src/protocol/purge.rs`, `purge_subject`, used by `user disable`). |
 | `src/ui/` | Server-rendered login, consent, and device pages (Askama templates) plus the embedded same-origin stylesheet served at `/assets/app.css`. |
 | `src/middleware/` | Request id, tracing and redaction, rate limiting, security headers, and audit. |
 | `src/resilience.rs` | Load-shed, concurrency-limit, and timeout guard between CORS and rate limiting. |
@@ -86,7 +86,12 @@ The split is strict and drives the whole design:
 - PostgreSQL holds durable domain state only: `oauth_clients`, `users`,
   `scopes`, `user_scopes`, `redirect_uris`, `client_redirect_uris`, plus sqlx's
   `_sqlx_migrations`. Nothing is written to Postgres per request. A failed login
-  does not touch Postgres; the counter lives in Redis.
+  does not touch Postgres; the counter lives in Redis. Introspection and refresh
+  do re-read Postgres on every use: a non-empty subject resolving to a disabled
+  user yields inactive (introspection) or `invalid_grant` (refresh) without
+  minting, and refresh re-reads the user's current entitlements; an empty or
+  unknown subject stays live. `user disable` purges the user's Redis state
+  before flipping `users.disabled`, failing closed on a Redis error.
 - Redis holds every short-lived value: authorization codes, access and refresh
   tokens, grant chains, sessions, PAR request URIs, device codes, DCR
   initial-access tokens, DPoP and `private_key_jwt` replay markers, and

+ 19 - 0
docs/CHANGELOG.md

@@ -60,6 +60,11 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
   rejects one), and `automountServiceAccountToken` is `false`. Remote JAR
   `request_uri` is documented as unsupported under the default-deny, with the
   Cilium FQDN opt-in path in `docs/DEPLOYMENT.md`.
+- `user disable` now purges all Redis state for the subject (sessions and their
+  meta, codes, devices with their sidecars and mappings, grants via cascade
+  revoke, standalone tokens, and the lockout counter) before flipping
+  `users.disabled`; a Redis failure aborts before the flag changes, so the
+  command fails closed.
 
 ### Changed
 
@@ -79,6 +84,10 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
   security-header layer) now sets `Cache-Control: no-store` and
   `X-Content-Type-Options: nosniff` on its own probe responses, with a
   regression test pinning both.
+- Forwarded client-cert and IP headers are never trusted; the dead
+  proxy-header fallback is removed.
+- The login lockout now also covers TOTP enrollment confirmation, and the rate
+  limiter classifies requests by their full path.
 
 ### Security
 
@@ -87,6 +96,16 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
   emits a `pii.decrypt_failure` audit event carrying only the request id, never
   a value. Full-blob substitution by an attacker with database write access
   remains out of the threat model and is documented in `docs/SECURITY.md`.
+- Introspection and refresh re-check `users.disabled`, and refresh re-checks
+  the subject's current entitlements; a disabled subject introspects inactive
+  and refresh rejects with `invalid_grant` before any token is minted.
+- Master keys (the token pepper, TOTP key, PII key, and any retained previous
+  key) shorter than 32 bytes are rejected at load with `WeakKeyMaterial`.
+- `private_key_jwt` assertion lifetime is capped and its `jti` replay marker
+  now tracks the remaining validity; `PS256` is added to the assertion
+  algorithm allowlist.
+- DCR `open` redirect policy now accepts only `https` or RFC 8252 loopback
+  URIs, and the SSRF guard pins the fetch to the addresses it pre-validated.
 
 ### Fixed
 

+ 7 - 1
docs/CONTRIBUTING.md

@@ -34,7 +34,12 @@ cargo build --locked --release  # release (thin LTO, stripped)
 
 The build is offline-safe for SQL: with `SQLX_OFFLINE=true` the `query!` macros
 compile against the committed `.sqlx/` cache, so no database is needed at build
-time. The Docker build relies on exactly this.
+time. The Docker build relies on exactly this. When a query or a migration
+changes, regenerate and commit the cache:
+
+```bash
+cargo sqlx prepare --workspace
+```
 
 ## Quality gates
 
@@ -47,6 +52,7 @@ cargo nextest run --all-targets
 cargo test --doc
 cargo audit
 cargo deny check
+python3 scripts/check_doc_links.py
 ```
 
 Start the shared test services before `cargo nextest run --all-targets`:

+ 4 - 1
docs/DATABASE.md

@@ -85,7 +85,7 @@ One row per resource owner.
 | `is_admin` | `bool` | Default false. Gates privileged actions. |
 | `must_change_password` | `bool` | Default false; true for a bootstrapped admin. |
 | `password_changed_at` | `timestamptz` | Stamped on password change. |
-| `disabled` | `bool` | Default false. |
+| `disabled` | `bool` | Default false. Introspection and refresh re-check this flag on every use via the subject account-status lookup: a disabled subject is inactive / `invalid_grant`, while an empty or unknown subject stays live; a datastore failure propagates and never reports active. |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 
 `ux_users_email_index` is a partial unique index on `email_index` where it is
@@ -265,6 +265,9 @@ during a maintenance window with the app tier quiesced.
   Postgres, so RFC 7592 client management survives a Redis loss.
 - Foreign keys with `ON DELETE CASCADE` keep the join tables consistent, and a
   scope referenced by `user_scopes` is disabled rather than deleted.
+- `user disable` purges the subject's Redis state before the
+  `UPDATE users SET disabled` runs; a Redis failure aborts before the flag
+  flips.
 
 ## Access hardening
 

+ 20 - 12
docs/DEPLOYMENT.md

@@ -264,6 +264,7 @@ loader at them:
 ```text
 D_OAUTH_TOKEN_PEPPER_FILE=/run/secrets/token-pepper
 D_OAUTH_TOTP_ENCRYPTION_KEY_FILE=/run/secrets/totp-encryption-key
+D_OAUTH_PII_ENCRYPTION_KEY_FILE=/run/secrets/pii-encryption-key
 D_OAUTH_DATABASE__PASSWORD_FILE=/run/secrets/database-password
 D_OAUTH_REDIS__PASSWORD_FILE=/run/secrets/redis-password
 ```
@@ -277,20 +278,22 @@ describe` output, or in a crash dump — a `kubectl exec` into the container
 reads nothing useful from the environment. `scripts/check_manifests.py` fails
 the build if a workload injects a secret through a `secretKeyRef` env, or if a
 ConfigMap DSN embeds a password. The inline `D_OAUTH_TOKEN_PEPPER` /
-`D_OAUTH_TOTP_ENCRYPTION_KEY` forms remain supported and take precedence when
-both are set (`src/config/build.rs`), so an existing environment-based
-deployment still starts; the shipped manifests deliberately never set them.
+`D_OAUTH_TOTP_ENCRYPTION_KEY` / `D_OAUTH_PII_ENCRYPTION_KEY` forms remain
+supported and take precedence when the inline forms are set
+(`src/config/build.rs`), so an existing environment-based deployment still
+starts; the shipped manifests deliberately never set them.
 The migrate Job mounts the same files. To rotate a password, update it in
 Postgres/Redis and the Secret together, then roll the replicas; see
 [CONFIGURATION.md](CONFIGURATION.md) and [OPERATIONS.md](OPERATIONS.md).
 
 ### Master key custody and KMS/HSM
 
-The token pepper and the TOTP-encryption key are the server's master keys. The
-binary never fetches a key and carries no cloud KMS or HSM SDK; it reads the
-material from `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`
-(or the inline forms), so custody is entirely the deployment's responsibility.
-Treat this as a hard requirement, not a preference:
+The token pepper, the TOTP-encryption key, and the PII field-encryption key are
+the server's master keys. The binary never fetches a key and carries no cloud
+KMS or HSM SDK; it reads the material from `D_OAUTH_TOKEN_PEPPER_FILE` /
+`D_OAUTH_TOTP_ENCRYPTION_KEY_FILE` / `D_OAUTH_PII_ENCRYPTION_KEY_FILE` (or the
+inline forms), so custody is entirely the deployment's responsibility. Treat
+this as a hard requirement, not a preference:
 
 - **Never co-locate the key with the database.** The keys must not live in a
   Postgres table, in the database backup, or in any archive that also carries a
@@ -309,6 +312,10 @@ Treat this as a hard requirement, not a preference:
   key is a second secret and is custodied under the same rule, apart from the
   backup it protects.
 
+Every resolved key value — pepper, TOTP, PII, active or retained previous —
+must be at least 32 bytes (256 bits); shorter values are refused at load
+(`ConfigError::WeakKeyMaterial`).
+
 Rotation keeps the chain honest: retaining the old pepper key id is what lets
 live tokens verify until they expire, and retaining the old TOTP key id is only
 long enough to re-enroll users. The full contract, the forbidden locations, and
@@ -483,9 +490,9 @@ The image pipeline is gated on:
   surface to the binary.
 - Non-root with a read-only root filesystem.
 - Secrets and CA material are mounted, never baked into the image or manifests.
-- Every secret — token pepper, TOTP key, and the database and Redis passwords —
-  is a `0400` tmpfs file, not an environment variable, and the pod carries no
-  service-account token.
+- Every secret — token pepper, TOTP key, PII key, and the database and Redis
+  passwords — is a `0400` tmpfs file, not an environment variable, and the pod
+  carries no service-account token.
 - `kubectl exec` / `docker exec` reach a non-root process with a read-only
   rootfs, no capabilities, and no shell, but still bypass the application's own
   auth; restrict `pods/exec` with RBAC and audit it (see
@@ -546,7 +553,8 @@ d-oauth-server healthcheck --url http://127.0.0.1:8081/health/live
   weekly rebuild.
 - `deploy/k8s/` for the cluster manifests: `namespace.yaml`, `configmap.yaml`
   (non-secret settings only, including the passwordless DSNs), `secret.yaml`
-  (placeholder secrets — pepper, TOTP key, database and Redis passwords — all
+  (placeholder secrets — pepper, TOTP key, PII key, database and Redis
+  passwords — all
   file-mounted and replaced by the secret manager), `service.yaml` (public TLS
   port only, never the health port), `deployment.yaml` (3 stateless replicas,
   probes on the plaintext health listener, PDB + topology spread,

+ 31 - 8
docs/OAUTH.md

@@ -132,6 +132,14 @@ JSON response; an unknown `grant_type` is `unsupported_grant_type`. The DCR
 routes map onto `protocol::dcr::register_handler` and
 `protocol::dcr::manage::{read,update,delete}_handler` (RFC 7591 / RFC 7592).
 
+### Dynamic client registration
+
+Dynamic registration redirect policy (`dcr_redirect_policy`): `catalog` attaches
+only catalog-registered URIs; `catalog_loopback` additionally upserts RFC 8252
+loopback URIs; `open` additionally upserts any absolute `https` URI with a
+non-empty host and no userinfo, fragment, or whitespace. Loopback URIs are
+always allowed. Anything else is `invalid_redirect_uri`.
+
 ## The five grants
 
 The advertised set is `authorization_code`, `refresh_token`,
@@ -159,14 +167,17 @@ Reference: RFC 6749 §6, RFC 9700 §2.2.2 and §4.14, RFC 9449 §5.
 
 Parameters: `client_id`, `refresh_token`, optional `requested_scope` (which may
 narrow but never widen), and a freshly verified sender proof. The effective
-scope is re-checked against the client's current registration and the user's
-current entitlements, so a revoked scope or entitlement drops on refresh.
+scope is re-checked against the current `enabled` catalog and the client's
+current registration and the user's current entitlements (fresh Postgres
+reads), so a revoked scope or entitlement drops on refresh.
 
 Behavior: rotation on every use. The presented token is tombstoned with a
 `rotated_to` member. Presenting a rotated token again is reuse: the whole grant
 family is revoked and the request fails with `invalid_grant`. A lost reply does
 not double-rotate, because the rotation is keyed by a deterministic request id
-and a retry returns the same tokens.
+and a retry returns the same tokens. If the refresh token's non-empty subject
+resolves to a disabled user, the request fails `invalid_grant` before minting or
+rotating.
 
 ### client_credentials
 
@@ -286,7 +297,10 @@ on every use.
 `private_key_jwt` is client authentication only. It produces no confirmation and
 never satisfies sender constraint. For token exchange, the proof must match the
 subject token's confirmation. See [SECURITY.md](SECURITY.md) for the full matrix
-and the confirmation propagation rule.
+and the confirmation propagation rule. It accepts `RS256`, `PS256`, `ES256`, and
+`EdDSA` only (`none` and HMAC are rejected); `exp` may lie at most 300 s past
+`now` plus 30 s clock skew, and the single-use `jti` marker lives for the
+assertion's remaining validity, clamped to `[1 s, 360 s]`.
 
 ## Metadata
 
@@ -300,7 +314,9 @@ than advertising an unknown list.
 The document carries no `jwks_uri`: the server signs no artifact of its own.
 `code_challenge_methods_supported` advertises only `S256`.
 `authorization_response_iss_parameter_supported` is true, so the authorization
-response carries `iss` (RFC 9207).
+response carries `iss` (RFC 9207). The document advertises
+`token_endpoint_auth_signing_alg_values_supported = [ES256, EdDSA, RS256,
+PS256]`; `dpop_signing_alg_values_supported` stays `[ES256, EdDSA, RS256]`.
 
 ## JAR and PAR
 
@@ -314,7 +330,9 @@ Reference: RFC 9101 §4.1 and §5.2, RFC 9126 §2 and §3.
   algorithm. Conflicts between the object and the query string are rejected.
 - A `request_uri` is a one-time, client-bound PAR handle or an exactly
   pre-registered URI. It is fetched only with the SSRF guard: https only, no
-  redirects, global IPs only, size and time capped.
+  redirects, global IPs only, size and time capped. The connection is pinned to
+  the addresses validated before the fetch, so a rebinding hostname cannot steer
+  it to a blocked address.
 - A remote (pre-registered, non-PAR) `request_uri` is fetched over outbound
   HTTPS, which the shipped cluster network policy default-denies. It is
   available only when the deployment adds a scoped egress allowance for the
@@ -331,8 +349,11 @@ Reference: RFC 9101 §4.1 and §5.2, RFC 9126 §2 and §3.
 
 ## Edge cases
 
-- A refresh request that omits `scope` keeps the prior scope; a request that
-  asks for a scope the client was not granted fails.
+- A refresh request that omits `scope` keeps the prior grant only while still
+  enabled, then narrows to the still-registered ∩ still-entitled intersection
+  (possibly empty, from fresh Postgres reads of the client registration, the
+  user entitlements, and the `enabled` catalog); an explicit `scope` must be a
+  subset of the prior grant and all three current sets.
 - A second authorization code made from the same request is a distinct code;
   each is single-use.
 - A DPoP proof outside the `iat` freshness window, or with a replayed `jti`, is
@@ -350,6 +371,8 @@ curl -s https://auth.example.com/.well-known/oauth-authorization-server | jq .
 
 - A code flow starts with `GET /authorize?response_type=code&client_id=...&redirect_uri=...&scope=...&state=...&code_challenge=...&code_challenge_method=S256`.
 - Introspect a token: `POST /introspect` with `token` and client authentication.
+  A token whose subject is a disabled user returns `{"active": false}` like an
+  unknown token.
 - Revoke a token: `POST /revoke` with `token` and an optional `token_type_hint`.
 - If every security endpoint returns `503`, check `/health/ready` and the
   Postgres and Redis connections.

+ 13 - 3
docs/REDIS.md

@@ -22,20 +22,23 @@ differ.
 | --- | --- | --- |
 | `oauth:{state}:code:<hash>` | Single-use authorization code, PKCE-bound. | `ttl.code` (60s) |
 | `oauth:{state}:access:<hash>` | Access-token record. | `ttl.access` (300s) |
+| `oauth:{state}:access:<hash>:act` | Token-exchange `act` claim sidecar. | Lives with the access record |
 | `oauth:{state}:refresh:<hash>` | Refresh-token record or tombstone. | `ttl.refresh` (30d) |
 | `oauth:{state}:grant:<id>` | Grant-chain record. | Refresh lifetime |
 | `oauth:{state}:grant:<id>:tokens` | Set of member token hashes for cascade revoke. | Lives with the grant |
 | `oauth:{state}:session:<id>` | Server-side login session. | `ttl.session` (12h) |
 | `oauth:{state}:session:<id>:meta` | Session sidecar: `auth_time`, CSRF secret, bound `request_id`, and the bound post-logout client/URI. | `ttl.session` |
 | `oauth:{state}:par:<id>` | PAR `request_uri` record. | `ttl.par` (90s) |
-| `oauth:{state}:pending:<id>` | Validated pending-auth record. | Short, request-scoped |
+| `oauth:{state}:pending:<id>` | Validated pending-auth record. | `routes::PENDING_TTL` (300s) |
 | `oauth:{state}:device:<hash>` | Device-code record. | `ttl.device` (600s) |
+| `oauth:{state}:device:<hash>:cnf` | Device sender-binding sidecar. | Lives with the device record |
+| `oauth:{state}:device:<hash>:poll` | Device poll-throttle marker. | Lives with the device record |
 | `oauth:{state}:user_code:<code-hash>` | User code to device-hash mapping. | `ttl.device` |
 | `oauth:{state}:dcr_iat:<hash>` | DCR initial-access token budget. | CLI `--ttl` |
 | `oauth:{state}:dpop_jti:<hash>` | DPoP `jti` replay marker. | Proof freshness window |
-| `oauth:{state}:client_assertion_jti:<hash>` | `private_key_jwt` `jti` replay marker. | Assertion 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). | Rate-limit window |
+| `oauth:{state}:loginfail:<sha256>` | Failed-login counter (hashed label). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:totp_replay:<fingerprint>` | TOTP used-code replay marker. | Time step window |
 | `oauth:{rl}:ratelimit:<bucket-hash>` | Fixed-window rate-limit counter. | `rate_limit.window` (60s) |
 
@@ -57,6 +60,13 @@ token state. This is what keeps two promises:
 The server asserts both properties at startup and refuses a prefix that lacks
 its tag.
 
+## Record writes
+
+Every hash record is written through one atomic Lua script (`hset_record` →
+`HSET_TTL`): the fields and the `PEXPIRE` happen in a single script, so no
+record can ever be observed — or survive a crash — without its TTL. Replay
+markers are `SET NX PX` plus a durability barrier.
+
 ## Rotation, tombstones, and idempotency
 
 Refresh rotation is a Lua compare-and-set keyed by a deterministic

+ 39 - 8
docs/SECURITY.md

@@ -64,7 +64,7 @@ class, or runtime setting).
 | 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. |
 
-Last reviewed: 2026-10-05. File-mounted DB/Redis passwords, digest pinning,
+Last reviewed: 2026-10-09. File-mounted DB/Redis passwords, digest pinning,
 NetworkPolicy, `automountServiceAccountToken: false`, and scheduled fuzzing are
 implemented in the shipped manifests and workflows.
 
@@ -74,6 +74,11 @@ implemented in the shipped manifests and workflows.
 - `unwrap`, `expect`, and `panic` are denied by lint in request paths.
 - A raw token, secret, password, OTP, or client secret never appears in
   Postgres, Redis, logs, an error body, or an evidence file.
+- Log redaction masks `authorization`, `cookie`, `dpop`, `code`, `token`,
+  `client_secret`, `code_verifier`, `password`, `user_code`, `device_code`, and
+  `request_uri`, plus any `*_token`/`*_secret`/`*_password`/`*_code` key, in
+  headers, JSON, form bodies, and query strings; `code_challenge` and
+  `request_id` stay visible.
 - Secret comparisons are constant time.
 
 ## Browser and intermediary caching
@@ -237,8 +242,13 @@ Reference: AES-256-GCM (`ring`), HKDF-SHA256, HMAC-SHA256.
   the inline value wins (`resolve_secret` in `src/config/build.rs`). The key ids
   default to `v1` through `token_pepper_id` / `totp_key_id` / `pii_key_id`
   (`src/config/raw.rs`). Each purpose may retain one previous key
-  (`*_previous_key` / `*_previous_key_file`) with an explicit, distinct
+  (  `*_previous_key` / `*_previous_key_file`) with an explicit, distinct
   `*_previous_key_id`; a previous key without its active key is a config error.
+- **Minimum length.** Every resolved master-key value — `token_pepper`,
+  `totp_encryption_key`, `pii_encryption_key`, and each retained
+  `*_previous_key`, inline or `*_FILE` — must be at least 32 bytes (256 bits).
+  Shorter material is rejected at load with `ConfigError::WeakKeyMaterial`,
+  which names the offending key.
 - The shipped cluster manifests never use the inline or environment forms: the
   Secret file-mounts the keys `0400` on a tmpfs volume at `/run/secrets`, and
   the loader reads them through `D_OAUTH_TOKEN_PEPPER_FILE`,
@@ -293,7 +303,8 @@ configure one as fail-open is a load-time error.
 - A device code is consumed atomically on approval.
 - DPoP and `private_key_jwt` `jti` values are recorded with `SET NX`, so a
   replayed proof or assertion is rejected. A `private_key_jwt` marker lives for
-  the assertion's full remaining validity, bounded by the 300 s lifetime cap.
+  the assertion's full remaining validity, bounded by the 360 s bound (a 300 s
+  maximum assertion lifetime plus 2×30 s clock skew).
 - A TOTP code is recorded in a replay cache for its time-step window.
 
 ## Input and output safety
@@ -302,9 +313,12 @@ configure one as fail-open is a load-time error.
   There are no wildcards and no prefix matches.
 - PKCE is `S256` only. A verifier cannot be omitted to downgrade.
 - Scope is intersected: `requested ∩ client-registered ∩ user-entitled ∩
-  enabled`. Refresh can only narrow and re-checks the current client-registered
-  and user-entitled sets (a fresh Postgres read), so revoking a scope or an
-  entitlement drops it on the next refresh.
+  enabled`. Refresh can only narrow and re-checks the current `enabled` catalog
+  plus the current client-registered and user-entitled sets (fresh Postgres
+  reads): an omitted `scope` keeps the prior grant only while still enabled,
+  then silently narrows to the still-registered ∩ still-entitled intersection
+  (possibly empty); an explicit `scope` must be a subset of the prior grant and
+  of all three current sets.
 - 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.
@@ -320,8 +334,25 @@ configure one as fail-open is a load-time error.
   `id_token_hint`.
 - Failed-login and lockout counters live in Redis, not Postgres, so a login
   flood does not write rows.
-- Rate limiting is per IP and per client. Counters live in a Redis keyspace
-  separated from token state.
+- 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`.
+- 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
+  expires.
+- 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` +
+  `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.
 - 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.

+ 2 - 1
src/routes.rs

@@ -1,7 +1,8 @@
 //! Router assembly: public listener routes plus the middleware chain.
 //!
 //! [`build_router`] mounts every endpoint behind one [`tower::ServiceBuilder`]
-//! whose execution order is exactly {request-id → tracing/redaction →
+//! whose execution order (outer-to-inner) is exactly {request-id → CORS (when
+//! configured) → resilience (load-shed → concurrency-limit → timeout) →
 //! rate-limit → body limit → security headers}:
 //!
 //! 1. [`middleware::request_id::RequestIdLayer`] mints/propagates