Просмотр исходного кода

Hardened security and updated documentation

Efren Yevale Varela 1 месяц назад
Родитель
Сommit
53c0c685b3

+ 72 - 4
RUNBOOK.md

@@ -1,8 +1,9 @@
-# Admin Bootstrap Operator Runbook
+# Operator Runbook
 
 
-How to create the first administrator of a fresh `d-oauth` install, and how
-to replace that bootstrap account immediately. There is no HTTP bootstrap
-endpoint: provisioning is CLI/Job only.
+How to create the first administrator of a fresh `d-oauth` install, how to
+replace that bootstrap account immediately, and how to respond to a full
+database disclosure. There is no HTTP bootstrap endpoint: provisioning is
+CLI/Job only.
 
 
 > Task 48 owns `SECURITY.md`; this file is the task-47 operator runbook.
 > Task 48 owns `SECURITY.md`; this file is the task-47 operator runbook.
 > The threat model and the replace-immediately rationale live there.
 > The threat model and the replace-immediately rationale live there.
@@ -84,3 +85,70 @@ and TOTP-enrolled outcomes are emitted by the login flow itself.
 - The bootstrap password is never accepted via argv, never logged, and never
 - The bootstrap password is never accepted via argv, never logged, and never
   stored (only its Argon2id hash reaches Postgres).
   stored (only its Argon2id hash reaches Postgres).
 - Bootstrap is CLI/Job only; no HTTP endpoint can create an admin.
 - Bootstrap is CLI/Job only; no HTTP endpoint can create an admin.
+
+## 5. Full-database-disclosure response
+
+Use this when a PostgreSQL dump, backup, or read replica has left your control
+on unknown terms. The blast radius and the rationale for the order below are in
+[docs/SECURITY.md](docs/SECURITY.md); this is the operator checklist. Preserve
+evidence first: capture the audit stream and keep the `X-Request-Id`
+correlation. No step below writes a secret to a log.
+
+### Rotation order and commands
+
+- [ ] Confirm whether the key store leaked with the dump. The token pepper and
+      the TOTP encryption key live outside the database
+      (`D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOKEN_PEPPER_FILE`,
+      `D_OAUTH_TOTP_ENCRYPTION_KEY` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`); if it
+      is not provably untouched, assume it leaked and continue.
+- [ ] **1. Rotate the token pepper.** Point `D_OAUTH_TOKEN_PEPPER` (or
+      `D_OAUTH_TOKEN_PEPPER_FILE`) at a new value, bump
+      `D_OAUTH_TOKEN_PEPPER_ID`, and perform a rolling restart. Dropping the old
+      id invalidates every live opaque access and refresh token, so clients
+      re-authenticate.
+- [ ] Confirm opaque tokens are dead by re-authenticating a test client. Expect
+      `invalid_grant` for a token minted before the rotation.
+- [ ] **2. Rotate the TOTP encryption key.** Point
+      `D_OAUTH_TOTP_ENCRYPTION_KEY` (or `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`) at a
+      new value, bump `D_OAUTH_TOTP_KEY_ID`, and roll the replicas. Retain the
+      old key only until every user has re-enrolled.
+- [ ] Re-enroll every TOTP user before dropping the old key:
+      `d-oauth-server user enroll-totp --email <email>`
+      (prints the secret and `otpauth://` URI exactly once).
+- [ ] **3. Rotate every confidential client secret.** Read the current metadata,
+      then re-submit it through RFC 7592 `PUT /register/{client_id}` to mint a
+      new secret and a new registration token:
+
+      ```sh
+      curl -sS https://<issuer>/register/<client_id> \
+        -H "Authorization: Bearer <current-registration-access-token>"
+
+      curl -sS -X PUT https://<issuer>/register/<client_id> \
+        -H "Authorization: Bearer <current-registration-access-token>" \
+        -H 'Content-Type: application/json' \
+        --data @client-metadata.json
+      ```
+
+      The `PUT` response carries `client_secret` and
+      `registration_access_token` exactly once. Store both before the process
+      exits; the previous registration token immediately answers `403`.
+- [ ] **4. Rotate every DCR registration token.** Every successful `PUT` above
+      already rotated the registration token. If you also need new
+      registrations after the breach, issue a fresh single-use initial-access
+      token:
+      `d-oauth-server initial-access-token issue --uses <n> --ttl <seconds>`
+      (the raw token is printed once).
+- [ ] **5. Verify fail-closed behavior.** Readiness must report ready only with
+      both dependencies up:
+      `d-oauth-server healthcheck --url http://127.0.0.1:8081/health/ready`.
+      A dependency outage during recovery must yield `503`, never a fail-open
+      success.
+
+### What not to do
+
+- Do not rotate the TOTP key before the pepper: the pepper step is the only one
+  that retires live opaque tokens the dump cannot reconstruct.
+- Do not remove the old TOTP key before every user has re-enrolled.
+- Do not relax the fail-closed policy to keep the security endpoints up.
+- Do not paste the leaked registration tokens, client secrets, or the pepper
+  into a ticket, chat, or log.

+ 36 - 5
compose.yaml

@@ -21,7 +21,13 @@
 #
 #
 # mTLS is OFF here (no `D_OAUTH_TLS__CLIENT_CA_PATH`): the L4-passthrough
 # mTLS is OFF here (no `D_OAUTH_TLS__CLIENT_CA_PATH`): the L4-passthrough
 # constraint is a cluster concern, documented in docs/DEPLOYMENT.md.
 # constraint is a cluster concern, documented in docs/DEPLOYMENT.md.
-# Secrets below are DEV-ONLY placeholders; cluster secrets are never baked.
+#
+# Secrets mirror the cluster's file-mount pattern: the
+# one-shot `gensecrets` service materializes DEV-ONLY placeholder values as
+# 0400 files owned by 65532 in the `devsecrets` volume, mounted at
+# /run/secrets; the app and migrate services read them through
+# D_OAUTH_TOKEN_PEPPER_FILE / D_OAUTH_TOTP_ENCRYPTION_KEY_FILE, so no key
+# material enters the process environment. Cluster secrets are never baked.
 
 
 name: d-oauth
 name: d-oauth
 
 
@@ -71,6 +77,24 @@ services:
         chmod 600 /certs/tls.key
         chmod 600 /certs/tls.key
         chmod 644 /certs/tls.crt
         chmod 644 /certs/tls.crt
 
 
+  # One-shot: DEV-ONLY pepper/TOTP key files, shaped like the cluster's tmpfs
+  # secret volume (0400, owned by 65532). Values are throwaway placeholders;
+  # never reuse them outside this local compose file.
+  gensecrets:
+    image: alpine:3.21
+    volumes:
+      - devsecrets:/run/secrets
+    command:
+      - sh
+      - -ec
+      - |
+        if [ ! -f /run/secrets/token-pepper ]; then
+          printf '%s' 'dev-only-pepper-ROTATE-IN-PROD-000000' > /run/secrets/token-pepper
+          printf '%s' 'dev-only-totp-key-ROTATE-IN-PROD-000' > /run/secrets/totp-encryption-key
+        fi
+        chown 65532:65532 /run/secrets/token-pepper /run/secrets/totp-encryption-key
+        chmod 400 /run/secrets/token-pepper /run/secrets/totp-encryption-key
+
   # Exactly-once migration (sqlx advisory lock). Replicas never migrate.
   # Exactly-once migration (sqlx advisory lock). Replicas never migrate.
   migrate:
   migrate:
     build:
     build:
@@ -82,6 +106,8 @@ services:
     depends_on:
     depends_on:
       gencerts:
       gencerts:
         condition: service_completed_successfully
         condition: service_completed_successfully
+      gensecrets:
+        condition: service_completed_successfully
       db:
       db:
         condition: service_healthy
         condition: service_healthy
       redis:
       redis:
@@ -96,10 +122,11 @@ services:
       D_OAUTH_DATABASE__URL: postgres://oauth:oauth-dev-only@db:5432/oauth?sslmode=disable
       D_OAUTH_DATABASE__URL: postgres://oauth:oauth-dev-only@db:5432/oauth?sslmode=disable
       D_OAUTH_REDIS__URL: redis://redis:6379
       D_OAUTH_REDIS__URL: redis://redis:6379
       D_OAUTH_REDIS__DURABILITY: waitaof
       D_OAUTH_REDIS__DURABILITY: waitaof
-      D_OAUTH_TOKEN_PEPPER: dev-only-pepper-ROTATE-IN-PROD-000000
-      D_OAUTH_TOTP_ENCRYPTION_KEY: dev-only-totp-key-ROTATE-IN-PROD-000
+      D_OAUTH_TOKEN_PEPPER_FILE: /run/secrets/token-pepper
+      D_OAUTH_TOTP_ENCRYPTION_KEY_FILE: /run/secrets/totp-encryption-key
     volumes:
     volumes:
       - certs:/certs:ro
       - certs:/certs:ro
+      - devsecrets:/run/secrets:ro
 
 
   app:
   app:
     build:
     build:
@@ -109,6 +136,8 @@ services:
     depends_on:
     depends_on:
       migrate:
       migrate:
         condition: service_completed_successfully
         condition: service_completed_successfully
+      gensecrets:
+        condition: service_completed_successfully
       db:
       db:
         condition: service_healthy
         condition: service_healthy
       redis:
       redis:
@@ -123,8 +152,8 @@ services:
       D_OAUTH_DATABASE__URL: postgres://oauth:oauth-dev-only@db:5432/oauth?sslmode=disable
       D_OAUTH_DATABASE__URL: postgres://oauth:oauth-dev-only@db:5432/oauth?sslmode=disable
       D_OAUTH_REDIS__URL: redis://redis:6379
       D_OAUTH_REDIS__URL: redis://redis:6379
       D_OAUTH_REDIS__DURABILITY: waitaof
       D_OAUTH_REDIS__DURABILITY: waitaof
-      D_OAUTH_TOKEN_PEPPER: dev-only-pepper-ROTATE-IN-PROD-000000
-      D_OAUTH_TOTP_ENCRYPTION_KEY: dev-only-totp-key-ROTATE-IN-PROD-000
+      D_OAUTH_TOKEN_PEPPER_FILE: /run/secrets/token-pepper
+      D_OAUTH_TOTP_ENCRYPTION_KEY_FILE: /run/secrets/totp-encryption-key
     expose:
     expose:
       - 8080
       - 8080
       - 8081
       - 8081
@@ -142,8 +171,10 @@ services:
       start_period: 10s
       start_period: 10s
     volumes:
     volumes:
       - certs:/certs:ro
       - certs:/certs:ro
+      - devsecrets:/run/secrets:ro
 
 
 volumes:
 volumes:
   pgdata:
   pgdata:
   redisdata:
   redisdata:
   certs:
   certs:
+  devsecrets:

+ 4 - 1
deploy/k8s/configmap.yaml

@@ -3,7 +3,10 @@
 # Every key maps to a `D_OAUTH_*` environment variable read by
 # Every key maps to a `D_OAUTH_*` environment variable read by
 # `Config::load` (prefix `D_OAUTH`, nested separator `__`). Secret material
 # `Config::load` (prefix `D_OAUTH`, nested separator `__`). Secret material
 # (database/redis URLs, token pepper, TOTP key) lives in `secret.yaml` and
 # (database/redis URLs, token pepper, TOTP key) lives in `secret.yaml` and
-# is NEVER added here.
+# is NEVER added here: the pepper and TOTP key are file-mounted by the
+# Deployment, and their `*_FILE` paths are declared in `deployment.yaml`,
+# not in this ConfigMap (`scripts/check_manifests.py` rejects a
+# secret-shaped key here).
 apiVersion: v1
 apiVersion: v1
 kind: ConfigMap
 kind: ConfigMap
 metadata:
 metadata:

+ 58 - 14
deploy/k8s/deployment.yaml

@@ -10,6 +10,25 @@
 #   default `0.0.0.0:8081`); the public listener stays TLS-only.
 #   default `0.0.0.0:8081`); the public listener stays TLS-only.
 # - `terminationGracePeriodSeconds: 45` covers the server's 1s propagation
 # - `terminationGracePeriodSeconds: 45` covers the server's 1s propagation
 #   grace + 30s drain budget (`src/serve.rs`: PROPAGATION_GRACE, DRAIN_DEADLINE).
 #   grace + 30s drain budget (`src/serve.rs`: PROPAGATION_GRACE, DRAIN_DEADLINE).
+# - Host hardening: non-root 65532, read-only rootfs, ALL capabilities
+#   dropped, `allowPrivilegeEscalation: false`, `RuntimeDefault` seccomp, and
+#   `automountServiceAccountToken: false` (the server never calls the
+#   Kubernetes API). `networkpolicy.yaml` default-denies pod egress except DNS
+#   + Postgres + Redis.
+# - Secret material is file-mounted, never env-injected: the token pepper and
+#   TOTP key are projected from `d-oauth-secrets` into a tmpfs volume at
+#   `/run/secrets` (`defaultMode: 0400`, group 65532 via `fsGroup`) and read
+#   through `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`.
+#   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`), but this deployment never sets them, so no key
+#   material enters the process environment (`/proc/self/environ`, children,
+#   crash dumps, `docker inspect`).
+# - The image is digest-pinned. The all-zero digest below is a deliberate
+#   placeholder: the `pin-digest` job in `.github/workflows/supply-chain.yml`
+#   writes the real signed-build digest into the `images` entry of
+#   `kustomization.yaml`, which overrides this field on every workload.
+#   `:latest` is never a shippable reference.
 apiVersion: apps/v1
 apiVersion: apps/v1
 kind: Deployment
 kind: Deployment
 metadata:
 metadata:
@@ -37,18 +56,28 @@ spec:
         app.kubernetes.io/part-of: d-oauth
         app.kubernetes.io/part-of: d-oauth
     spec:
     spec:
       terminationGracePeriodSeconds: 45
       terminationGracePeriodSeconds: 45
+      # The app never talks to the Kubernetes API; the auto-mounted service
+      # account token is pure attack surface, so it stays disabled.
+      automountServiceAccountToken: false
       securityContext:
       securityContext:
         runAsNonRoot: true
         runAsNonRoot: true
         runAsUser: 65532
         runAsUser: 65532
         runAsGroup: 65532
         runAsGroup: 65532
+        # Secret volumes are tmpfs; fsGroup makes the 0400 files readable by
+        # the runtime group so the pepper/TOTP keys can be mounted as files.
+        fsGroup: 65532
+        fsGroupChangePolicy: OnRootMismatch
         seccompProfile:
         seccompProfile:
           type: RuntimeDefault
           type: RuntimeDefault
       containers:
       containers:
         - name: server
         - name: server
-          # Release pinning: CI resolves this tag to an immutable digest
-          # (`kustomize edit set image ...@sha256:<digest>`) before rollout;
-          # see `.github/workflows/supply-chain.yml` and `kustomization.yaml`.
-          image: ghcr.io/d-oauth/d-oauth-server:latest
+          # Digest-pinned reference. The all-zero digest
+          # is a deliberate placeholder, not a shippable reference: it fails
+          # closed instead of resolving a mutable tag. The `pin-digest` job in
+          # `.github/workflows/supply-chain.yml` writes the real signed-build
+          # digest into the `images` entry of `kustomization.yaml`; kustomize
+          # then applies that digest to this container and the migrate Job.
+          image: ghcr.io/d-oauth/d-oauth-server@sha256:0000000000000000000000000000000000000000000000000000000000000000
           imagePullPolicy: IfNotPresent
           imagePullPolicy: IfNotPresent
           ports:
           ports:
             - name: public-tls
             - name: public-tls
@@ -71,16 +100,15 @@ spec:
                 secretKeyRef:
                 secretKeyRef:
                   name: d-oauth-secrets
                   name: d-oauth-secrets
                   key: redis-url
                   key: redis-url
-            - name: D_OAUTH_TOKEN_PEPPER
-              valueFrom:
-                secretKeyRef:
-                  name: d-oauth-secrets
-                  key: token-pepper
-            - name: D_OAUTH_TOTP_ENCRYPTION_KEY
-              valueFrom:
-                secretKeyRef:
-                  name: d-oauth-secrets
-                  key: totp-encryption-key
+            # File-mounted key material: the values stay
+            # in the tmpfs secret volume, never in the process environment.
+            # The inline D_OAUTH_TOKEN_PEPPER / D_OAUTH_TOTP_ENCRYPTION_KEY
+            # forms are still supported and take precedence when both are set
+            # (`src/config/build.rs`); this deployment deliberately uses files.
+            - name: D_OAUTH_TOKEN_PEPPER_FILE
+              value: /run/secrets/token-pepper
+            - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
+              value: /run/secrets/totp-encryption-key
           # Liveness is dependency-free by construction (`/health/live`
           # Liveness is dependency-free by construction (`/health/live`
           # touches no Postgres/Redis), so a dependency outage never
           # touches no Postgres/Redis), so a dependency outage never
           # triggers a restart storm — readiness absorbs it instead.
           # triggers a restart storm — readiness absorbs it instead.
@@ -125,11 +153,27 @@ spec:
             - name: certs
             - name: certs
               mountPath: /certs
               mountPath: /certs
               readOnly: true
               readOnly: true
+            - name: secrets
+              mountPath: /run/secrets
+              readOnly: true
       volumes:
       volumes:
         - name: certs
         - name: certs
           secret:
           secret:
             secretName: d-oauth-tls
             secretName: d-oauth-tls
             defaultMode: 0o400
             defaultMode: 0o400
+        # tmpfs-backed secret volume. `defaultMode:
+        # 0400` plus `fsGroup: 65532` leaves the projected files readable only
+        # by the runtime user/group. Only the two key files are projected;
+        # database-url and redis-url remain env secretKeyRefs by design.
+        - name: secrets
+          secret:
+            secretName: d-oauth-secrets
+            defaultMode: 0o400
+            items:
+              - key: token-pepper
+                path: token-pepper
+              - key: totp-encryption-key
+                path: totp-encryption-key
       topologySpreadConstraints:
       topologySpreadConstraints:
         - maxSkew: 1
         - maxSkew: 1
           topologyKey: topology.kubernetes.io/zone
           topologyKey: topology.kubernetes.io/zone

+ 16 - 6
deploy/k8s/kustomization.yaml

@@ -3,10 +3,14 @@
 # Validate: `kustomize build deploy/k8s`
 # Validate: `kustomize build deploy/k8s`
 # Dry-run against a cluster: `kubectl apply --dry-run=server -k deploy/k8s`
 # Dry-run against a cluster: `kubectl apply --dry-run=server -k deploy/k8s`
 #
 #
-# Release pinning: CI resolves the floating tags below to immutable digests
-# (`kustomize edit set image d-oauth-server=ghcr.io/...@sha256:<digest>`)
-# after `docker buildx bake` pushes, so a rollout can never float. The
-# committed file intentionally carries tags; the pinned build is what ships.
+# Release pinning: the `images` entry below carries a
+# placeholder digest, never a floating tag. The `pin-digest` job in
+# `.github/workflows/supply-chain.yml` overwrites `images[].digest` with the
+# digest of the signed multi-arch build (`docker buildx bake`) after every
+# push to main and re-runs `scripts/check_manifests.py --strict-digest`. The
+# per-workload `image:` fields in `deployment.yaml` / `migrate-job.yaml` also
+# carry the placeholder; this entry overrides them in `kustomize build`.
+# `:latest` is never a shippable reference.
 apiVersion: kustomize.config.k8s.io/v1beta1
 apiVersion: kustomize.config.k8s.io/v1beta1
 kind: Kustomization
 kind: Kustomization
 namespace: d-oauth
 namespace: d-oauth
@@ -18,12 +22,18 @@ resources:
   - deployment.yaml
   - deployment.yaml
   - migrate-job.yaml
   - migrate-job.yaml
   - pdb.yaml
   - pdb.yaml
+  - networkpolicy.yaml
 images:
 images:
   - name: ghcr.io/d-oauth/d-oauth-server
   - name: ghcr.io/d-oauth/d-oauth-server
     newName: ghcr.io/d-oauth/d-oauth-server
     newName: ghcr.io/d-oauth/d-oauth-server
-    newTag: latest
+    # Placeholder (all zeroes), not a pullable reference: a rollout without
+    # the pin step fails closed instead of silently floating on a tag. CI
+    # replaces this with the released `sha256:<digest>` after signing.
+    digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
 # NOTE: no Ingress resource is shipped on purpose. In-process mTLS
 # NOTE: no Ingress resource is shipped on purpose. In-process mTLS
 # verification needs L4/TCP passthrough (NLB gateway / TLSRoute); an L7
 # verification needs L4/TCP passthrough (NLB gateway / TLSRoute); an L7
 # ingress that terminates TLS strips the client certificate. Adding an
 # ingress that terminates TLS strips the client certificate. Adding an
 # Ingress while `D_OAUTH_TLS__CLIENT_CA_PATH` is set fails
 # Ingress while `D_OAUTH_TLS__CLIENT_CA_PATH` is set fails
-# `scripts/check_manifests.py`. See docs/DEPLOYMENT.md.
+# `scripts/check_manifests.py`. See docs/DEPLOYMENT.md. Pod egress and ingress
+# are constrained by `networkpolicy.yaml` (default-deny egress except DNS +
+# Postgres + Redis).

+ 31 - 12
deploy/k8s/migrate-job.yaml

@@ -7,6 +7,13 @@
 # database; per-replica migration races and double-applies). The Deployment's
 # database; per-replica migration races and double-applies). The Deployment's
 # readiness gate (`_sqlx_migrations` version match) keeps new replicas out of
 # readiness gate (`_sqlx_migrations` version match) keeps new replicas out of
 # traffic until this Job succeeds.
 # traffic until this Job succeeds.
+#
+# Hardened like the Deployment: non-root 65532,
+# read-only rootfs, ALL capabilities dropped, `automountServiceAccountToken:
+# false`, and the token pepper / TOTP key mounted as 0400 tmpfs files under
+# `/run/secrets` (read through the `*_FILE` variables) instead of env vars.
+# The image carries the same placeholder digest; `pin-digest` rewrites the
+# real digest in `kustomization.yaml` for every workload.
 apiVersion: batch/v1
 apiVersion: batch/v1
 kind: Job
 kind: Job
 metadata:
 metadata:
@@ -27,16 +34,20 @@ spec:
         app.kubernetes.io/component: migrate
         app.kubernetes.io/component: migrate
     spec:
     spec:
       restartPolicy: Never
       restartPolicy: Never
+      automountServiceAccountToken: false
       securityContext:
       securityContext:
         runAsNonRoot: true
         runAsNonRoot: true
         runAsUser: 65532
         runAsUser: 65532
         runAsGroup: 65532
         runAsGroup: 65532
+        fsGroup: 65532
+        fsGroupChangePolicy: OnRootMismatch
         seccompProfile:
         seccompProfile:
           type: RuntimeDefault
           type: RuntimeDefault
       containers:
       containers:
         - name: migrate
         - name: migrate
-          # Same pinning rule as the Deployment: CI resolves to a digest.
-          image: ghcr.io/d-oauth/d-oauth-server:latest
+          # Same pinning rule as the Deployment: placeholder digest, never a
+          # shippable `:latest`; CI resolves it via `kustomization.yaml`.
+          image: ghcr.io/d-oauth/d-oauth-server@sha256:0000000000000000000000000000000000000000000000000000000000000000
           imagePullPolicy: IfNotPresent
           imagePullPolicy: IfNotPresent
           # Overrides the image ENTRYPOINT (["/d-oauth-server","serve"]).
           # Overrides the image ENTRYPOINT (["/d-oauth-server","serve"]).
           command: ["/d-oauth-server", "migrate"]
           command: ["/d-oauth-server", "migrate"]
@@ -54,16 +65,12 @@ spec:
                 secretKeyRef:
                 secretKeyRef:
                   name: d-oauth-secrets
                   name: d-oauth-secrets
                   key: redis-url
                   key: redis-url
-            - name: D_OAUTH_TOKEN_PEPPER
-              valueFrom:
-                secretKeyRef:
-                  name: d-oauth-secrets
-                  key: token-pepper
-            - name: D_OAUTH_TOTP_ENCRYPTION_KEY
-              valueFrom:
-                secretKeyRef:
-                  name: d-oauth-secrets
-                  key: totp-encryption-key
+            # File-mounted key material, same pattern as the Deployment; the
+            # inline forms remain supported by `Config` but are not used here.
+            - name: D_OAUTH_TOKEN_PEPPER_FILE
+              value: /run/secrets/token-pepper
+            - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
+              value: /run/secrets/totp-encryption-key
           resources:
           resources:
             requests:
             requests:
               cpu: 50m
               cpu: 50m
@@ -81,8 +88,20 @@ spec:
             - name: certs
             - name: certs
               mountPath: /certs
               mountPath: /certs
               readOnly: true
               readOnly: true
+            - name: secrets
+              mountPath: /run/secrets
+              readOnly: true
       volumes:
       volumes:
         - name: certs
         - name: certs
           secret:
           secret:
             secretName: d-oauth-tls
             secretName: d-oauth-tls
             defaultMode: 0o400
             defaultMode: 0o400
+        - name: secrets
+          secret:
+            secretName: d-oauth-secrets
+            defaultMode: 0o400
+            items:
+              - key: token-pepper
+                path: token-pepper
+              - key: totp-encryption-key
+                path: totp-encryption-key

+ 67 - 0
deploy/k8s/networkpolicy.yaml

@@ -0,0 +1,67 @@
+# NetworkPolicy (task 46). Default-deny for the app pods.
+#
+# Selecting the pods below with BOTH policy types makes this a default-deny
+# for them in any CNI that enforces NetworkPolicy (Calico, Cilium, ...): only
+# the flows listed here are admitted, everything else is dropped. A CNI that
+# ignores NetworkPolicy does not apply this control; verify the cluster CNI.
+#
+# Egress:
+# - DNS only, to the `kube-system` namespace on port 53 (UDP + TCP). Adjust if
+#   CoreDNS runs elsewhere; NodeLocal DNSCache also lives in `kube-system`, so
+#   it is covered.
+# - Postgres (5432) and Redis (6379) anywhere else, with link-local
+#   `169.254.0.0/16` excluded so the cloud metadata endpoint
+#   (`169.254.169.254`) can never be reached. If Postgres/Redis run in-cluster,
+#   tighten the port-scoped `ipBlock` to their namespace/pod selector.
+# - Everything else, including the Kubernetes API and arbitrary HTTPS
+#   exfiltration, is denied. The app never calls the API and runs with
+#   `automountServiceAccountToken: false`.
+#
+# Ingress: only the public TLS port (8080) and the plaintext health port
+# (8081) are reachable. NetworkPolicy cannot name a Service (the Service is
+# virtual, and traffic arrives from the L4 gateway or the kubelet), so the
+# source is unrestricted but the ports are not. The Service keeps publishing
+# 8080 only; 8081 must never be internet-facing.
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+  name: d-oauth-server
+  namespace: d-oauth
+  labels:
+    app.kubernetes.io/name: d-oauth-server
+    app.kubernetes.io/part-of: d-oauth
+spec:
+  podSelector:
+    matchLabels:
+      app.kubernetes.io/name: d-oauth-server
+  policyTypes:
+    - Ingress
+    - Egress
+  ingress:
+    - ports:
+        - port: 8080
+          protocol: TCP
+        - port: 8081
+          protocol: TCP
+  egress:
+    # DNS (kube-dns / CoreDNS in kube-system).
+    - to:
+        - namespaceSelector:
+            matchLabels:
+              kubernetes.io/metadata.name: kube-system
+      ports:
+        - port: 53
+          protocol: UDP
+        - port: 53
+          protocol: TCP
+    # Postgres + Redis ports only; link-local (metadata) excluded.
+    - to:
+        - ipBlock:
+            cidr: 0.0.0.0/0
+            except:
+              - 169.254.0.0/16
+      ports:
+        - port: 5432
+          protocol: TCP
+        - port: 6379
+          protocol: TCP

+ 12 - 5
deploy/k8s/secret.yaml

@@ -1,15 +1,22 @@
 # App secrets (task 46). PLACEHOLDERS ONLY — never commit real values.
 # App secrets (task 46). PLACEHOLDERS ONLY — never commit real values.
 #
 #
+# Consumption: the Deployment and the migrate Job
+# project `token-pepper` and `totp-encryption-key` from this Secret into a
+# tmpfs volume at `/run/secrets` (`defaultMode: 0400`, group 65532 via the
+# pod `fsGroup`) and read them through `D_OAUTH_TOKEN_PEPPER_FILE` /
+# `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`, so the key material never enters the
+# process environment. `database-url` and `redis-url` are still injected as
+# `secretKeyRef` environment variables.
+#
 # Production rule: this file's keys are replaced by the secret manager
 # Production rule: this file's keys are replaced by the secret manager
 # (external-secrets / sealed-secrets / vault agent). The manifests only ever
 # (external-secrets / sealed-secrets / vault agent). The manifests only ever
-# reference keys via `secretKeyRef`; `scripts/check_manifests.py` fails the
-# build if a value still looks like a placeholder reached a release, and
-# fails if any secret-looking value appears in a ConfigMap, Deployment, or
-# Job field.
+# reference keys, never values; `scripts/check_manifests.py` fails the build
+# if a placeholder leaks outside this file, if a PEM block appears in any
+# manifest, or if a secret-shaped key lands in the ConfigMap.
 #
 #
 # To provision: `kubectl create secret generic d-oauth-secrets -n d-oauth
 # To provision: `kubectl create secret generic d-oauth-secrets -n d-oauth
 #   --from-literal=database-url='postgres://...' ...` (or the external-secret
 #   --from-literal=database-url='postgres://...' ...` (or the external-secret
-# equivalent), then delete this placeholder file from the applied set.
+#   equivalent), then delete this placeholder file from the applied set.
 apiVersion: v1
 apiVersion: v1
 kind: Secret
 kind: Secret
 metadata:
 metadata:

+ 6 - 3
docs/ARCHITECTURE.md

@@ -29,6 +29,7 @@ the library.
 | `src/protocol/` | OAuth endpoints and grants. See [OAUTH.md](OAUTH.md). |
 | `src/protocol/` | OAuth endpoints and grants. See [OAUTH.md](OAUTH.md). |
 | `src/ui/` | Server-rendered login, consent, and device pages (Askama templates) plus the embedded same-origin stylesheet served at `/assets/app.css`. |
 | `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/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. |
 | `src/routes.rs` | Router assembly and the middleware order. |
 | `src/routes.rs` | Router assembly and the middleware order. |
 | `src/serve.rs`, `src/tls.rs` | Listener bootstrap and the custom TLS accept loop. |
 | `src/serve.rs`, `src/tls.rs` | Listener bootstrap and the custom TLS accept loop. |
 | `src/state.rs`, `src/state/` | `AppState`, the topology-aware Redis connection, health endpoints. |
 | `src/state.rs`, `src/state/` | `AppState`, the topology-aware Redis connection, health endpoints. |
@@ -51,10 +52,12 @@ is fixed in `build_router_with_options` in `src/routes.rs`:
    them. Without a configured proxy, proxy evidence is never trusted.
    them. Without a configured proxy, proxy evidence is never trusted.
 3. CORS: answers preflights from the explicit allowlist. Absent when the
 3. CORS: answers preflights from the explicit allowlist. Absent when the
    allowlist is empty, which is the default.
    allowlist is empty, which is the default.
-4. Rate limit: enforces the per-IP and per-client budgets. Health and metadata
+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.
    have no rule and pass through; those handlers fail closed on their own.
-5. Body limit: caps request bodies at 2 MiB.
-6. Security headers: adds `Referrer-Policy`, `X-Content-Type-Options`, a CSP
+6. Body limit: caps request bodies at 2 MiB.
+7. Security headers: adds `Referrer-Policy`, `X-Content-Type-Options`, a CSP
    with `frame-ancestors 'none'`, HSTS on the TLS listener, and
    with `frame-ancestors 'none'`, HSTS on the TLS listener, and
    `Cache-Control: no-store` on every response except `/.well-known/…` metadata.
    `Cache-Control: no-store` on every response except `/.well-known/…` metadata.
 
 

+ 14 - 0
docs/CHANGELOG.md

@@ -32,9 +32,23 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
   QR code beside the text secret and URI, so users can scan instead of
   QR code beside the text secret and URI, so users can scan instead of
   hand-typing. The new `qrcode` dependency is pinned SVG-only (no `image`
   hand-typing. The new `qrcode` dependency is pinned SVG-only (no `image`
   codecs); the text secret, URI, and one-time recovery codes are unchanged.
   codecs); the text secret, URI, and one-time recovery codes are unchanged.
+- A full-database-disclosure response procedure: `docs/SECURITY.md` gains the
+  dump blast-radius table and the ordered rotation (token pepper, TOTP key,
+  client secrets, DCR registration tokens), and `RUNBOOK.md` gains the
+  matching operator checklist with concrete commands.
+- Cluster hardening in `deploy/k8s`: the token pepper and TOTP key mount as
+  `0400` tmpfs files under `/run/secrets` (read through
+  `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`, mirrored
+  in `compose.yaml`), the image reference is digest-pinned, a NetworkPolicy
+  default-denies pod egress except DNS plus Postgres/Redis, and
+  `automountServiceAccountToken` is `false`.
 
 
 ### Changed
 ### Changed
 
 
+- TOTP secrets now encrypt under HKDF-SHA256 (info `totp-gcm-v2`) instead of
+  bare `SHA256(totp_key)`. New blobs carry format version 2; version-1 blobs
+  keep decrypting through a legacy fallback, so no re-enrollment is needed.
+
 - `GET /.well-known/oauth-authorization-server` now carries an explicit
 - `GET /.well-known/oauth-authorization-server` now carries an explicit
   `Cache-Control: public, max-age=3600` instead of relying on browser
   `Cache-Control: public, max-age=3600` instead of relying on browser
   heuristic freshness, so metadata caching is intentional and tunable. The
   heuristic freshness, so metadata caching is intentional and tunable. The

+ 17 - 3
docs/CONFIGURATION.md

@@ -36,20 +36,28 @@ with every `.` replaced by `__`, uppercased. For example:
 A secret may be given inline or as a mounted file. Inline wins when both are
 A secret may be given inline or as a mounted file. Inline wins when both are
 set. Mounted files are read and trimmed of trailing newlines.
 set. Mounted files are read and trimmed of trailing newlines.
 
 
+The key ring follows the same pattern: `token_pepper_file` and
+`totp_encryption_key_file` (environment spellings `D_OAUTH_TOKEN_PEPPER_FILE`
+and `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`) name mounted files. The cluster
+Deployment uses those file forms so the material never enters the process
+environment; mount them `0400` on a tmpfs (the shipped manifests project them
+at `/run/secrets` with group `65532`). The inline forms are retained and win
+when both are set, so an existing environment-based deployment still starts.
+
 ## Top-level keys
 ## Top-level keys
 
 
 | Key | Default | Rule |
 | Key | Default | Rule |
 | --- | --- | --- |
 | --- | --- | --- |
 | `issuer` | required | Absolute `https` URL with no trailing slash and no whitespace in the authority. |
 | `issuer` | required | Absolute `https` URL with no trailing slash and no whitespace in the authority. |
 | `bind_addr` | `0.0.0.0:8080` | A socket address. The TLS public listener. |
 | `bind_addr` | `0.0.0.0:8080` | A socket address. The TLS public listener. |
-| `health_addr` | `0.0.0.0:8081` | A socket address, never loopback. The plaintext health listener. |
+| `health_addr` | `0.0.0.0:8081` | A socket address. `serve` refuses a loopback address; `config check` does not. The plaintext health listener. |
 | `environment` | `production` | `production` or `development`. Development relaxes the database TLS requirement. |
 | `environment` | `production` | `production` or `development`. Development relaxes the database TLS requirement. |
 | `cookie_domain` | none (host-only) | Optional cookie domain. |
 | `cookie_domain` | none (host-only) | Optional cookie domain. |
 | `cookie_secure` | `true` | Adds the `Secure` attribute. Set `false` only for localhost testing. |
 | `cookie_secure` | `true` | Adds the `Secure` attribute. Set `false` only for localhost testing. |
 | `trusted_proxy` | none | An IP. When set, that proxy's forwarded client-cert and client-IP headers are honored. When unset, those headers are stripped. |
 | `trusted_proxy` | none | An IP. When set, that proxy's forwarded client-cert and client-IP headers are honored. When unset, those headers are stripped. |
 | `allow_open_registration` | `false` | When false, DCR requires a valid initial-access token. |
 | `allow_open_registration` | `false` | When false, DCR requires a valid initial-access token. |
 | `dcr_redirect_policy` | `catalog` | `catalog`, `catalog_loopback`, or `open`. See below. |
 | `dcr_redirect_policy` | `catalog` | `catalog`, `catalog_loopback`, or `open`. See below. |
-| `cors_allowed_origins` | empty | An explicit origin allowlist. Empty means no CORS layer. A lone `*` is refused. |
+| `cors_allowed_origins` | empty | An explicit origin allowlist. Empty means no CORS layer. A lone `*` is silently dropped (no CORS layer, no error). |
 | `allow_token_exchange` | `false` | Enables the token-exchange grant. |
 | `allow_token_exchange` | `false` | Enables the token-exchange grant. |
 | `token_exchange_allowlist` | empty | Rules of `{caller, subject, scopes[]}` for token exchange. |
 | `token_exchange_allowlist` | empty | Rules of `{caller, subject, scopes[]}` for token exchange. |
 
 
@@ -129,7 +137,9 @@ rejected.
 | `totp_key_id` | `v1` | Version id carried in the encrypted TOTP blob. |
 | `totp_key_id` | `v1` | Version id carried in the encrypted TOTP blob. |
 
 
 The token pepper is required: without it the key ring cannot be built and the
 The token pepper is required: without it the key ring cannot be built and the
-server refuses to start.
+server refuses to start. The shipped cluster manifests set
+`D_OAUTH_TOKEN_PEPPER_FILE` and `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE` rather than
+the inline keys; see [DEPLOYMENT.md](DEPLOYMENT.md).
 
 
 ## Failure policy keys
 ## Failure policy keys
 
 
@@ -218,12 +228,16 @@ the statement.
 - `trusted_proxy` is unset by default, so forwarded client-cert headers from an
 - `trusted_proxy` is unset by default, so forwarded client-cert headers from an
   untrusted hop are stripped before any handler reads them.
   untrusted hop are stripped before any handler reads them.
 - CORS and open registration are off by default.
 - CORS and open registration are off by default.
+- File-backed pepper/TOTP keys are read from a `0400` tmpfs file, never from
+  the process environment.
 
 
 ## Edge cases
 ## Edge cases
 
 
 - `D_OAUTH_CONFIG` pointing at a missing file is not an error; the file is
 - `D_OAUTH_CONFIG` pointing at a missing file is not an error; the file is
   optional.
   optional.
 - An inline `token_pepper` of only whitespace is treated as absent.
 - An inline `token_pepper` of only whitespace is treated as absent.
+- An unreadable `*_file` path is a load-time error (`ConfigError::SecretFile`),
+  never a silent fallback to an absent key.
 - `scope delete` and `redirect-uri delete` disable a referenced row instead of
 - `scope delete` and `redirect-uri delete` disable a referenced row instead of
   deleting it. See [OPERATIONS.md](OPERATIONS.md).
   deleting it. See [OPERATIONS.md](OPERATIONS.md).
 - A deliberate mismatch of `dcr_redirect_policy = open` and mTLS is a deployment
 - A deliberate mismatch of `dcr_redirect_policy = open` and mTLS is a deployment

+ 4 - 1
docs/DATABASE.md

@@ -40,6 +40,7 @@ One row per registered client (RFC 7591 / RFC 7592).
 | `software_id` | `text` | Metadata only. |
 | `software_id` | `text` | Metadata only. |
 | `active` | `bool` | Soft disable, default true. |
 | `active` | `bool` | Soft disable, default true. |
 | `registration_access_token_hash` | `text` | Keyed hash of the RFC 7592 management token. |
 | `registration_access_token_hash` | `text` | Keyed hash of the RFC 7592 management token. |
+| `dpop_bound_access_tokens` | `bool` | Whether this client's tokens require DPoP proof (`migrations/1791244800_client_dpop_bound.sql`). |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 
 
 Two partial unique indexes enforce that a client certificate authenticates at
 Two partial unique indexes enforce that a client certificate authenticates at
@@ -198,7 +199,7 @@ D_OAUTH_DATABASE__URL='postgres://oauth@db.example.com/oauth?sslmode=verify-full
   cargo run --bin d-oauth-server -- migrate
   cargo run --bin d-oauth-server -- migrate
 ```
 ```
 
 
-The command prints `applied N migration(s)`. Run it as a cluster Job and let
+The command prints `schema current: N known migration(s)`. Run it as a cluster Job and let
 readiness gate the Deployment. To confirm a fresh install, check that the six
 readiness gate the Deployment. To confirm a fresh install, check that the six
 tables exist and are empty:
 tables exist and are empty:
 
 
@@ -212,6 +213,8 @@ SELECT version, success FROM _sqlx_migrations ORDER BY version;
 
 
 - `migrations/1791072000_oauth_clients.sql` for `oauth_clients`.
 - `migrations/1791072000_oauth_clients.sql` for `oauth_clients`.
 - `migrations/1791158400_users.sql` for the other five tables and `citext`.
 - `migrations/1791158400_users.sql` for the other five tables and `citext`.
+- `migrations/1791244800_client_dpop_bound.sql` for `oauth_clients.dpop_bound_access_tokens`.
+- `migrations/1791331200_redirect_uri_kind.sql` for `redirect_uris.kind`.
 - `src/db/` for the repositories.
 - `src/db/` for the repositories.
 - `src/state.rs` for `MIGRATOR`.
 - `src/state.rs` for `MIGRATOR`.
 - `src/cli/ops.rs` for the `migrate` command.
 - `src/cli/ops.rs` for the `migrate` command.

+ 77 - 11
docs/DEPLOYMENT.md

@@ -218,7 +218,9 @@ A correct Deployment has these properties:
 - Stateless replicas, three or more. Every replica shares the same external
 - Stateless replicas, three or more. Every replica shares the same external
   Postgres and Redis, so no replica holds state another needs.
   Postgres and Redis, so no replica holds state another needs.
 - `runAsNonRoot`, `runAsUser: 65532`, `readOnlyRootFilesystem`, a
 - `runAsNonRoot`, `runAsUser: 65532`, `readOnlyRootFilesystem`, a
-  `seccompProfile` of `RuntimeDefault`, and `capabilities.drop: ["ALL"]`.
+  `seccompProfile` of `RuntimeDefault`, `capabilities.drop: ["ALL"]`, and
+  `automountServiceAccountToken: false`: the server never calls the Kubernetes
+  API, so the projected service-account token would be pure attack surface.
 - Resource requests and limits.
 - Resource requests and limits.
 - A `startupProbe` and `livenessProbe` on `/health/live`, and a
 - A `startupProbe` and `livenessProbe` on `/health/live`, and a
   `readinessProbe` on `/health/ready`, all an HTTP GET against the plaintext
   `readinessProbe` on `/health/ready`, all an HTTP GET against the plaintext
@@ -229,7 +231,14 @@ A correct Deployment has these properties:
 - A `PodDisruptionBudget` with `minAvailable: 2`, and
 - A `PodDisruptionBudget` with `minAvailable: 2`, and
   `topologySpreadConstraints` so replicas spread across nodes.
   `topologySpreadConstraints` so replicas spread across nodes.
 - A `Service`, a `ConfigMap` for non-secret settings, and a `Secret` for
 - A `Service`, a `ConfigMap` for non-secret settings, and a `Secret` for
-  secret material. Secrets are mounted or injected, never baked into the image.
+  secret material. Secrets are mounted or injected, never baked into the
+  image. The token pepper and TOTP key are file-mounted under `/run/secrets`
+  (see below), not injected into the environment.
+- A `NetworkPolicy` that default-denies pod egress except DNS, Postgres, and
+  Redis, and admits ingress only on the two pod ports (see below).
+- A digest-pinned image reference. The release pipeline replaces the committed
+  placeholder digest in `kustomization.yaml` with the signed build digest;
+  `:latest` is never a shippable reference.
 - Client-CA material mounted read-only if in-process mTLS is used.
 - Client-CA material mounted read-only if in-process mTLS is used.
 
 
 ### Migrations run once
 ### Migrations run once
@@ -243,6 +252,51 @@ migration that is not yet in `_sqlx_migrations` reports not ready.
 Migrations must be expand/contract so version N-1 and N replicas serve during a
 Migrations must be expand/contract so version N-1 and N replicas serve during a
 rollout. See [DATABASE.md](DATABASE.md).
 rollout. See [DATABASE.md](DATABASE.md).
 
 
+### Secret material as files
+
+The Deployment does not put the token pepper or the TOTP encryption key in the
+environment. It projects the two keys of the `d-oauth-secrets` Secret into a
+tmpfs volume at `/run/secrets`, `defaultMode: 0400`, with the pod's `fsGroup`
+set to `65532`, and points the loader at them:
+
+```text
+D_OAUTH_TOKEN_PEPPER_FILE=/run/secrets/token-pepper
+D_OAUTH_TOTP_ENCRYPTION_KEY_FILE=/run/secrets/totp-encryption-key
+```
+
+Only the runtime user/group can read the files, so the key material never
+appears in `/proc/self/environ`, in child processes, in `docker inspect` /
+`kubectl describe` output, or in a crash dump. `database.url` and `redis.url`
+are still `secretKeyRef` environment values; prefer `database.password_file`
+where the deployment can split the credential from the URL. 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. The migrate Job mounts the same files. To rotate, replace the
+Secret and roll the replicas; see [CONFIGURATION.md](CONFIGURATION.md).
+
+### Network policy
+
+`networkpolicy.yaml` selects pods labeled
+`app.kubernetes.io/name: d-oauth-server` (the app replicas and the migrate Job)
+and enables both `Ingress` and `Egress` policy types, which makes it a
+default-deny for those pods on any CNI that enforces NetworkPolicy (Calico,
+Cilium, and similar). A CNI that ignores NetworkPolicy gets no benefit from the
+manifest; verify the cluster CNI applies it.
+
+- Egress admits DNS to the `kube-system` namespace on port 53 (UDP and TCP)
+  and the Postgres (5432) and Redis (6379) ports to any address except
+  `169.254.0.0/16`. The link-local exclusion means a cloud metadata endpoint
+  such as `169.254.169.254` can never be reached, closing the SSRF-to-metadata
+  and arbitrary-exfiltration paths.
+- Ingress admits only the public TLS port (8080) and the plaintext health port
+  (8081). The Service still publishes only 8080.
+- If Postgres and Redis run in-cluster behind known labels, tighten the
+  port-scoped `ipBlock` to their `namespaceSelector`/`podSelector`. If CoreDNS
+  runs outside `kube-system`, adjust the DNS rule. A gateway in another
+  namespace is still admitted because the ingress rule constrains ports, not
+  sources.
+
 ### Multi-replica validation
 ### Multi-replica validation
 
 
 A local compose file is the intended way to prove shared-state behavior: three
 A local compose file is the intended way to prove shared-state behavior: three
@@ -302,6 +356,10 @@ The image pipeline is gated on:
   surface to the binary.
   surface to the binary.
 - Non-root with a read-only root filesystem.
 - Non-root with a read-only root filesystem.
 - Secrets and CA material are mounted, never baked into the image or manifests.
 - Secrets and CA material are mounted, never baked into the image or manifests.
+- The token pepper and TOTP key are `0400` tmpfs files, not environment
+  variables, and the pod carries no service-account token.
+- A NetworkPolicy default-denies pod egress except DNS, Postgres, and Redis,
+  and admits ingress only on the two pod ports.
 - Health routes are unreachable from the public listener, so the probe port is
 - Health routes are unreachable from the public listener, so the probe port is
   not a second API surface.
   not a second API surface.
 - Liveness never touches a dependency, so a dependency outage does not trigger
 - Liveness never touches a dependency, so a dependency outage does not trigger
@@ -316,6 +374,10 @@ The image pipeline is gated on:
   until the schema matches.
   until the schema matches.
 - A replica that has no shared state is a bug: token state is in Redis, not in
 - A replica that has no shared state is a bug: token state is in Redis, not in
   the process.
   the process.
+- A CNI that does not enforce NetworkPolicy leaves pod egress unrestricted;
+  verify the cluster CNI applies the policy before relying on it.
+- Shipping the committed placeholder digest without running the release
+  pipeline fails closed: the image cannot be pulled. Run `pin-digest` first.
 
 
 ## Operational runbook
 ## Operational runbook
 
 
@@ -350,15 +412,19 @@ d-oauth-server healthcheck --url http://127.0.0.1:8081/health/live
   cosign signing, digest pinning of `deploy/k8s/kustomization.yaml`, and the
   cosign signing, digest pinning of `deploy/k8s/kustomization.yaml`, and the
   weekly rebuild.
   weekly rebuild.
 - `deploy/k8s/` for the cluster manifests: `namespace.yaml`, `configmap.yaml`
 - `deploy/k8s/` for the cluster manifests: `namespace.yaml`, `configmap.yaml`
-  (non-secret settings only), `secret.yaml` (placeholder keys, 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), `migrate-job.yaml` (exactly-once
-  schema migration, never per replica), `pdb.yaml` (`minAvailable: 2`),
-  `kustomization.yaml` (CI pins the image digest here at release), and
-  `tls-secret.example.yaml` (how to provision `/certs`, never applied).
-  No Ingress is shipped: mTLS requires L4/TCP passthrough.
+  (non-secret settings only), `secret.yaml` (placeholder keys, file-mounted by
+  the workloads 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,
+  `automountServiceAccountToken: false`, pepper/TOTP file mounts),
+  `migrate-job.yaml` (exactly-once schema migration, never per replica),
+  `networkpolicy.yaml` (default-deny egress except DNS/Postgres/Redis, ingress
+  to the two pod ports), `pdb.yaml` (`minAvailable: 2`), `kustomization.yaml`
+  (committed placeholder digest; the release pipeline pins the real image
+  digest here), and `tls-secret.example.yaml` (how to provision `/certs`,
+  never applied). No Ingress is shipped: mTLS requires L4/TCP passthrough.
 - `compose.yaml` for the local 3-replica proof (one Postgres + one Redis,
 - `compose.yaml` for the local 3-replica proof (one Postgres + one Redis,
-  one-shot `migrate`, random host ports so `--scale app=3` never collides).
+  one-shot `migrate`, file-mounted dev secrets under `/run/secrets`, random
+  host ports so `--scale app=3` never collides).
 - `scripts/check_manifests.py` for the manifest policy lint, including the
 - `scripts/check_manifests.py` for the manifest policy lint, including the
   L7-ingress-with-client-cert-auth rejection.
   L7-ingress-with-client-cert-auth rejection.

+ 2 - 1
docs/OPERATIONS.md

@@ -35,7 +35,8 @@ d-oauth-server config check
   durability contract, and runs the TLS public listener plus the plaintext
   durability contract, and runs the TLS public listener plus the plaintext
   health listener. It never migrates. It drains on `SIGTERM`.
   health listener. It never migrates. It drains on `SIGTERM`.
 - `migrate` applies forward-only migrations under sqlx's advisory lock and
 - `migrate` applies forward-only migrations under sqlx's advisory lock and
-  prints `applied N migration(s)`.
+  prints `schema current: N known migration(s)` (`N` is the embedded count,
+  not the delta just applied).
 - `config check` validates configuration and prints `config ok: issuer=...`.
 - `config check` validates configuration and prints `config ok: issuer=...`.
 
 
 ### Users
 ### Users

+ 62 - 5
docs/SECURITY.md

@@ -56,13 +56,16 @@ class, or runtime setting).
 | Application-level RCE in a parser (form bodies, DPoP/JAR/`private_key_jwt` JWTs, DCR JSON, `jwks_uri`/`request_uri` fetches) | `#![forbid(unsafe_code)]`, no `unwrap`/`panic` in request paths, rustls/ring (no OpenSSL), fuzz targets for token-form/DCR-JSON/JAR-claims | Fuzzing is not exhaustive. |
 | Application-level RCE in a parser (form bodies, DPoP/JAR/`private_key_jwt` JWTs, DCR JSON, `jwks_uri`/`request_uri` fetches) | `#![forbid(unsafe_code)]`, no `unwrap`/`panic` in request paths, rustls/ring (no OpenSSL), fuzz targets for token-form/DCR-JSON/JAR-claims | Fuzzing is not exhaustive. |
 | Dependency / supply-chain compromise | Committed `Cargo.lock`, `cargo deny`/`cargo audit` in CI, SBOM + provenance + keyless cosign, digest-pinned bases | A zero-day in a trusted crate. |
 | Dependency / supply-chain compromise | Committed `Cargo.lock`, `cargo deny`/`cargo audit` in CI, SBOM + provenance + keyless cosign, digest-pinned bases | A zero-day in a trusted crate. |
 | Container escape via kernel or runtime bug | Non-root UID 65532, read-only root filesystem, all capabilities dropped, `allowPrivilegeEscalation: false`, `seccompProfile: RuntimeDefault` | A kernel LPE or runc bug, mitigated but not eliminated. |
 | Container escape via kernel or runtime bug | Non-root UID 65532, read-only root filesystem, all capabilities dropped, `allowPrivilegeEscalation: false`, `seccompProfile: RuntimeDefault` | A kernel LPE or runc bug, mitigated but not eliminated. |
-| Secret exfiltration from the process environment | Secrets are injected at runtime, not baked into image layers; the pepper/TOTP key live outside the database | An attacker with code execution reads env vars directly (see `docs/TODO.md` item 7). |
+| Secret exfiltration from the process environment | Secrets are injected at runtime, not baked into image layers; the pepper/TOTP key live outside the database; the Deployment mounts the pepper and TOTP key as `0400` tmpfs files under `/run/secrets` and reads them through `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`, so no key material enters the environment | `database-url` and `redis-url` are still `secretKeyRef` environment values; an attacker with code execution reads those. |
 | Exposed management surface | The plaintext health listener (8081) is unauthenticated but not published in the Service (only 8080 is) | A misconfigured Service/Ingress could expose it; it must never be internet-facing. |
 | Exposed management surface | The plaintext health listener (8081) is unauthenticated but not published in the Service (only 8080 is) | A misconfigured Service/Ingress could expose it; it must never be internet-facing. |
 | Host access via mounted volumes | `/certs` is mounted read-only at mode `0400`; no Docker socket is mounted | Operator misconfiguration. |
 | Host access via mounted volumes | `/certs` is mounted read-only at mode `0400`; no Docker socket is mounted | Operator misconfiguration. |
-| Image/registry compromise | Digest pinning and cosign verification in CI | The base manifest still uses a mutable `:latest` tag that CI rewrites to a digest before rollout (see `docs/TODO.md` item 8). |
+| Unrestricted pod egress (SSRF to metadata, exfiltration) | `networkpolicy.yaml` default-denies egress except DNS (53 to `kube-system`) and Postgres/Redis (5432/6379), with link-local `169.254.0.0/16` excluded; ingress is limited to pod ports 8080/8081 | Enforcement depends on a NetworkPolicy-capable CNI; a cluster that ignores NetworkPolicy gets no benefit. |
+| 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-04. The mitigations to close the residual risks are
-tracked in `docs/TODO.md`.
+Last reviewed: 2026-10-04. File-mounted key material, digest pinning,
+NetworkPolicy, and `automountServiceAccountToken: false` are implemented in
+the shipped manifests; the remaining mitigations are tracked in `docs/TODO.md`.
 
 
 ## Toplevel rules
 ## Toplevel rules
 
 
@@ -274,6 +277,60 @@ security rationale and checklist.
 5. Preserve logs. The `X-Request-Id` header correlates a request to its logs;
 5. Preserve logs. The `X-Request-Id` header correlates a request to its logs;
    audit events never contain a secret.
    audit events never contain a secret.
 
 
+### Full-database-disclosure blast radius
+
+A full dump is worse than one leaked token: it exposes the whole directory and
+every hash or ciphertext at once. The token pepper and the TOTP encryption key
+are supplied outside the database (environment variable or mounted file), so a
+pure dump does not carry them; treat them as compromised anyway unless the key
+store can be shown untouched.
+
+| Dumped class | Protection at rest | What the dump yields |
+| --- | --- | --- |
+| User passwords | Argon2id PHC, salted | Offline cracking only. |
+| Client secrets | Argon2id PHC | Offline cracking only. |
+| TOTP secrets | AES-256-GCM under HKDF-SHA256 (info `totp-gcm-v2`; pre-migration v1 blobs under bare `SHA256(totp_key)` still decrypt) | Readable only if the TOTP key also leaks. |
+| TOTP recovery codes | Argon2id PHC | Offline cracking only. |
+| DCR registration access tokens | Keyed HMAC (`registration_access_token_hash`) | Opaque while the pepper stays outside the dump. |
+| Emails, client names, contacts, redirect URIs, scopes, entitlements | Plaintext | Full disclosure. |
+| Client public keys (`jwks`) | Plaintext | Public by design. |
+| `tls_client_auth_subject_dn` and certificate thumbprints | Plaintext | Identifier disclosure. |
+| `is_admin`, `disabled`, `must_change_password`, timestamps | Plaintext | Metadata disclosure. |
+
+If the key store leaked too, the dump is a full compromise: TOTP secrets
+decrypt, the Argon2id hashes are the only barrier left on passwords and client
+secrets, and registration-access-token hashes can be recomputed. Respond in the
+order below.
+
+### Full-database-disclosure rotation order
+
+Rotate in this order. Each step retires a class of credential the dump already
+exposed; do not skip ahead because a later step looks cheaper.
+
+1. **Token pepper first.** Make a new pepper active and remove the old key id.
+   Removing the old id kills every live opaque access and refresh token, so
+   every grant is forced back through authentication. This is the only step
+   that retires tokens the dump cannot reconstruct. Retain the old id only to
+   keep live tokens verifying, which is the opposite of what a breach response
+   wants; a full disclosure drops it.
+2. **TOTP encryption key second.** Make a new key active. Existing TOTP blobs
+   stay readable only while the old key is retained; once it is removed every
+   user must re-enroll. Re-enroll with `user enroll-totp` before the old key is
+   dropped, or the user cannot complete TOTP.
+3. **Client secrets third.** Rotate every confidential client secret through
+   RFC 7592 `PUT /register/{client_id}`, which mints a fresh secret when the
+   method is secret-based and returns it once.
+4. **DCR registration tokens last.** The same `PUT /register/{client_id}` call
+   rotates the registration access token on every update, and the response
+   carries the only copy. Do not re-issue DCR tokens by replaying an
+   initial-access token; it is single-use and consumed at registration.
+
+The seven security endpoints stay fail-closed throughout. A Postgres or Redis
+outage while responding returns `503 temporarily_unavailable` with
+`Retry-After: 1`, never a fail-open success; do not relax the policy to keep
+service up mid-rotation. The operator checklist with concrete commands is in
+[RUNBOOK.md](../RUNBOOK.md).
+
 ## Edge cases
 ## Edge cases
 
 
 - A Redis flush loses ephemeral tokens but not clients, users, scopes, or
 - A Redis flush loses ephemeral tokens but not clients, users, scopes, or
@@ -298,4 +355,4 @@ security rationale and checklist.
 - `src/config/policy.rs` for the fail-closed policy.
 - `src/config/policy.rs` for the fail-closed policy.
 - `src/middleware/` for redaction, rate limiting, and security headers.
 - `src/middleware/` for redaction, rate limiting, and security headers.
 - `src/cli/admin.rs` for bootstrap.
 - `src/cli/admin.rs` for bootstrap.
-- `RUNBOOK.md` for the operator bootstrap procedure.
+- `RUNBOOK.md` for the operator runbook (admin bootstrap, full-database-disclosure response).

+ 19 - 81
docs/TODO.md

@@ -19,7 +19,7 @@ is the full accounting.
 | --- | --- | --- |
 | --- | --- | --- |
 | User passwords | Argon2id PHC, salted (`src/db/user_repo.rs`) | Offline cracking only. |
 | User passwords | Argon2id PHC, salted (`src/db/user_repo.rs`) | Offline cracking only. |
 | Client secrets | Argon2id PHC (`src/db/client_repo.rs`) | Offline cracking only. |
 | Client secrets | Argon2id PHC (`src/db/client_repo.rs`) | Offline cracking only. |
-| TOTP secrets | AES-256-GCM under `SHA256(totp_key)` (`src/crypto/totp.rs`) | Safe only if the key is not in the dump. |
+| TOTP secrets | AES-256-GCM under HKDF-SHA256 (`src/crypto/totp.rs`, info `totp-gcm-v2`; pre-migration v1 blobs under bare `SHA256(totp_key)` still decrypt) | Safe only if the key is not in the dump. |
 | TOTP recovery codes | Argon2id PHC (`src/db/user_repo/totp.rs`) | Offline cracking only. |
 | TOTP recovery codes | Argon2id PHC (`src/db/user_repo/totp.rs`) | Offline cracking only. |
 | DCR registration access tokens | Keyed HMAC hash (`src/db/client_repo.rs`) | Safe; the pepper is not in the database. |
 | DCR registration access tokens | Keyed HMAC hash (`src/db/client_repo.rs`) | Safe; the pepper is not in the database. |
 | Emails, client names, contacts, redirect URIs, scopes, entitlements | Plaintext | Full disclosure. |
 | Emails, client names, contacts, redirect URIs, scopes, entitlements | Plaintext | Full disclosure. |
@@ -51,17 +51,7 @@ list is deliberately short and ordered by value.
 - **Files.** `src/db/user_repo.rs`, `src/db/client_repo.rs`,
 - **Files.** `src/db/user_repo.rs`, `src/db/client_repo.rs`,
   `src/db/redirect_repo.rs`, `src/keys.rs`, `migrations/`.
   `src/db/redirect_repo.rs`, `src/keys.rs`, `migrations/`.
 
 
-### 2. Strengthen TOTP key derivation
-
-- **Risk.** The TOTP encryption key is derived with a bare `SHA256(totp_key)`
-  (`src/crypto/totp.rs`) and no KDF stretching. If the key file leaks alongside
-  the dump, every TOTP secret falls immediately. A low-entropy passphrase is
-  cheap to brute-force when a single hash is all that stands in the way.
-- **Proposed change.** Derive the AES key with Argon2id or HKDF from a
-  high-entropy key, so a leaked passphrase costs an attacker real work.
-- **Files.** `src/crypto/totp.rs`, `src/cli/secret.rs`.
-
-### 3. Envelope encryption with per-row DEKs
+### 2. Envelope encryption with per-row DEKs
 
 
 - **Risk.** One static key encrypts every TOTP secret. There is no per-row data
 - **Risk.** One static key encrypts every TOTP secret. There is no per-row data
   key, so there is no way to rotate the master key without re-encrypting the
   key, so there is no way to rotate the master key without re-encrypting the
@@ -70,28 +60,17 @@ list is deliberately short and ordered by value.
   key, which makes rotation incremental and limits a single leak's blast radius.
   key, which makes rotation incremental and limits a single leak's blast radius.
 - **Files.** `src/crypto/totp.rs`, `src/keys.rs`.
 - **Files.** `src/crypto/totp.rs`, `src/keys.rs`.
 
 
-### 4. KMS/HSM-backed master key
+### 3. KMS/HSM-backed master key
 
 
-- **Risk.** The master keys are plain environment variables or files. Where they
-  are stored, who can read them, and whether they ever end up in the same backup
-  as the database is undocumented and unenforced.
+- **Risk.** The master keys live in environment variables, files, or a
+  secrets manager, but nothing enforces where they are stored or keeps them
+  out of the same backup as the database.
 - **Proposed change.** Document, and optionally support, a master key held in a
 - **Proposed change.** Document, and optionally support, a master key held in a
   KMS or HSM. At a minimum, document the hard requirement that the key never
   KMS or HSM. At a minimum, document the hard requirement that the key never
   lives with the database backup.
   lives with the database backup.
 - **Files.** `src/config/`, `docs/DEPLOYMENT.md`, `docs/SECURITY.md`.
 - **Files.** `src/config/`, `docs/DEPLOYMENT.md`, `docs/SECURITY.md`.
 
 
-### 5. Breach-response runbook
-
-- **Risk.** There is no documented procedure for what to rotate after a dump, or
-  in what order. The [SECURITY.md](SECURITY.md) operational runbook covers an
-  incident in general terms but not a full database disclosure.
-- **Proposed change.** Document the blast radius and the rotation order: rotate
-  the token pepper first so all opaque tokens die, then rotate the TOTP key so
-  users re-enroll, then rotate client secrets, then rotate DCR registration
-  tokens.
-- **Files.** `docs/SECURITY.md`, `RUNBOOK.md`.
-
-### 6. Database access hardening
+### 4. Database access hardening
 
 
 - **Risk.** There is no tamper-evidence or audit trail for database access, and
 - **Risk.** There is no tamper-evidence or audit trail for database access, and
   no guidance on encrypted backups or Postgres-level encryption such as TDE or
   no guidance on encrypted backups or Postgres-level encryption such as TDE or
@@ -117,50 +96,14 @@ the mitigations to implement.
 
 
 ### Mitigations to plan and implement
 ### Mitigations to plan and implement
 
 
-### 7. Move secrets from environment variables to files
-
-- **Risk.** Environment variables are readable by the process
-  (`/proc/self/environ`), appear in `docker inspect`, are inherited by children,
-  and leak into logs and crash dumps; an attacker with code execution reads the
-  token pepper and TOTP key directly.
-- **Proposed change.** Mount the pepper and TOTP key as files (tmpfs, mode
-  `0400`, owned by 65532) and use the already-supported
-  `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE` variables
-  (`src/config/raw.rs`, `src/config/build.rs`).
-- **Files.** `deploy/k8s/deployment.yaml`, `deploy/k8s/secret.yaml`,
-  `compose.yaml`, `docs/DEPLOYMENT.md`, `docs/CONFIGURATION.md`.
-
-### 8. Pin the image by digest in the committed manifest
-
-- **Risk.** The base manifest uses a mutable `:latest` tag with
-  `imagePullPolicy: IfNotPresent`; a compromised registry tag could be pulled if
-  the CI digest-rewrite step does not run.
-- **Proposed change.** Commit the digest-pinned image reference.
-- **Files.** `deploy/k8s/deployment.yaml`, `deploy/k8s/kustomization.yaml`.
-
-### 9. Add a NetworkPolicy
-
-- **Risk.** Unrestricted pod egress allows SSRF-to-metadata and data
-  exfiltration.
-- **Proposed change.** Restrict egress to Postgres and Redis only, and ingress
-  to the Service.
-- **Files.** `deploy/k8s/` (new `networkpolicy.yaml`), `docs/DEPLOYMENT.md`.
-
-### 10. Disable the service-account token
-
-- **Risk.** The app never calls the Kubernetes API, so the auto-mounted service
-  account token is pure attack surface.
-- **Proposed change.** Set `automountServiceAccountToken: false` on the pod.
-- **Files.** `deploy/k8s/deployment.yaml`.
-
-### 11. Strengthen runtime isolation
+### 5. Strengthen runtime isolation
 
 
 - **Risk.** A kernel or runtime escape is possible after code execution.
 - **Risk.** A kernel or runtime escape is possible after code execution.
 - **Proposed change.** Consider a sandboxed runtime class (gVisor/Kata) and a
 - **Proposed change.** Consider a sandboxed runtime class (gVisor/Kata) and a
   custom seccomp allowlist instead of `RuntimeDefault`.
   custom seccomp allowlist instead of `RuntimeDefault`.
 - **Files.** `deploy/k8s/deployment.yaml`, `docs/DEPLOYMENT.md`.
 - **Files.** `deploy/k8s/deployment.yaml`, `docs/DEPLOYMENT.md`.
 
 
-### 12. Continuous fuzzing and dependency review
+### 6. Continuous fuzzing and dependency review
 
 
 - **Risk.** CI-only fuzzing and `cargo deny` do not catch a zero-day in a
 - **Risk.** CI-only fuzzing and `cargo deny` do not catch a zero-day in a
   trusted crate.
   trusted crate.
@@ -168,7 +111,7 @@ the mitigations to implement.
   `cargo vet`/`cargo crev` review.
   `cargo vet`/`cargo crev` review.
 - **Files.** `.github/workflows/`, `fuzz/`, `deny.toml`.
 - **Files.** `.github/workflows/`, `fuzz/`, `deny.toml`.
 
 
-### 13. Restrict and audit container exec
+### 7. Restrict and audit container exec
 
 
 - **Risk.** `docker exec` / `kubectl exec` into the running container bypasses
 - **Risk.** `docker exec` / `kubectl exec` into the running container bypasses
   every image control.
   every image control.
@@ -179,23 +122,18 @@ the mitigations to implement.
 
 
 Ranked by value against effort:
 Ranked by value against effort:
 
 
-- **High value, low effort:** items 1, 2, 5.
-- **Medium:** items 3, 4.
-- **Lower, mostly operational:** item 6.
-- **Container/host hardening:** items 7, 8, 9, 10 are high value and low effort;
-  items 11, 12, 13 are medium/operational.
+- **Medium:** items 1, 2, 3. Item 1 is medium/high effort because it needs a
+  migration plus a blind index with uniqueness and rotation handling.
+- **Lower, mostly operational:** item 4.
+- **Container/host hardening:** items 5, 6, 7 are medium/operational.
 
 
-Recommendation: implement 1, 2, 5, 7, 8, 9, and 10 first. Item 1 closes the
-largest plaintext disclosure, item 2 removes an easy offline win if the key file
-leaks, item 5 is mostly writing down a procedure the team already needs, and
-items 7 through 10 are high-value, low-effort container and host controls.
+Recommendation: implement item 1 next. It closes the largest plaintext
+disclosure; items 2 and 3 then reduce the cost of a future key compromise.
 
 
 ## Open questions
 ## Open questions
 
 
-- Is the token pepper and TOTP key supplied through environment variables,
-  mounted files, or a secrets manager in the target deployment? This decides
-  whether envelope encryption (item 3) is worth building at all.
 - Should PII encryption (item 1) be mandatory or gated behind configuration?
 - Should PII encryption (item 1) be mandatory or gated behind configuration?
-- Should the pepper and TOTP key move to file-based secrets now, or wait for the
-  envelope-encryption work (item 3)?
+- Is the token pepper and TOTP key held in mounted files or a secrets manager
+  in the target deployment? This decides whether envelope encryption (item 2)
+  is worth building at all.
 - Is a sandboxed runtime class (gVisor/Kata) available in the target cluster?
 - Is a sandboxed runtime class (gVisor/Kata) available in the target cluster?

+ 174 - 3
scripts/check_manifests.py

@@ -12,6 +12,15 @@ contract WITHOUT a live cluster:
 - Migration Job: exactly one, runs `migrate`, `restartPolicy: Never`.
 - Migration Job: exactly one, runs `migrate`, `restartPolicy: Never`.
 - PDB: `minAvailable: 2`.
 - PDB: `minAvailable: 2`.
 - Service: never exposes the health port (8081).
 - Service: never exposes the health port (8081).
+- Hardening: `automountServiceAccountToken: false` and `fsGroup: 65532`; the
+  token pepper and TOTP key must be file-mounted from `d-oauth-secrets` at
+  `/run/secrets` (`defaultMode: 0400`) and referenced with
+  `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`; the inline
+  `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` envs FAIL.
+- NetworkPolicy: must select `app.kubernetes.io/name: d-oauth-server`, enable
+  `Ingress` + `Egress`, admit ingress only on 8080/8081, and allow egress only
+  on 53 (DNS), 5432 (Postgres), and 6379 (Redis) with `169.254.0.0/16`
+  excluded (cloud metadata).
 - No baked secrets: no PEM blocks in manifests; `REPLACE_ME` placeholders
 - No baked secrets: no PEM blocks in manifests; `REPLACE_ME` placeholders
   only inside `secret.yaml`; no secret-shaped keys in the ConfigMap.
   only inside `secret.yaml`; no secret-shaped keys in the ConfigMap.
 - L7-vs-L4 mTLS constraint: an L7 Ingress that terminates TLS while
 - L7-vs-L4 mTLS constraint: an L7 Ingress that terminates TLS while
@@ -53,6 +62,18 @@ CLIENT_CERT_ANNOTATIONS = (
   "traefik.ingress.kubernetes.io/router.tls.options",
   "traefik.ingress.kubernetes.io/router.tls.options",
 )
 )
 
 
+SECRET_NAME = "d-oauth-secrets"
+SECRETS_MOUNT_PATH = "/run/secrets"
+FS_GROUP = 65532
+SECRET_VOLUME_MODE = 0o400
+FILE_SECRET_ENV_KEYS = {
+  "D_OAUTH_TOKEN_PEPPER_FILE": "token-pepper",
+  "D_OAUTH_TOTP_ENCRYPTION_KEY_FILE": "totp-encryption-key",
+}
+INLINE_SECRET_ENVS = ("D_OAUTH_TOKEN_PEPPER", "D_OAUTH_TOTP_ENCRYPTION_KEY")
+REQUIRED_EGRESS_PORTS = (53, 5432, 6379)
+METADATA_CIDR = "169.254.0.0/16"
+
 
 
 class Failures:
 class Failures:
   def __init__(self) -> None:
   def __init__(self) -> None:
@@ -118,6 +139,131 @@ def mtls_enabled(docs: list[dict]) -> bool:
   return False
   return False
 
 
 
 
+def octal_mode(value: object) -> int | None:
+  """Parse a YAML file mode (`0400`, `0o400`, `256`) into an int, or None."""
+  if isinstance(value, bool) or value is None:
+    return None
+  if isinstance(value, int):
+    return value
+  if isinstance(value, str):
+    text = value.strip().lower().replace("_", "")
+    try:
+      if text.startswith("0o"):
+        return int(text[2:], 8)
+      if text.startswith("0") and len(text) > 1:
+        return int(text, 8)
+      return int(text, 10)
+    except ValueError:
+      return None
+  return None
+
+
+def env_by_name(container: dict) -> dict[str, dict]:
+  envs: dict[str, dict] = {}
+  for entry in container.get("env") or []:
+    if isinstance(entry, dict) and entry.get("name"):
+      envs[entry["name"]] = entry
+  return envs
+
+
+def check_file_mounted_secrets(
+  workload: str, tpl: dict, container: dict, fails: Failures
+) -> None:
+  """Item 7: pepper/TOTP are 0400 files under /run/secrets, never inline env."""
+  cname = container.get("name", "?")
+  envs = env_by_name(container)
+  for inline in INLINE_SECRET_ENVS:
+    if inline in envs:
+      fails.add(
+        f"{workload}/{cname}: {inline} must not be injected as an env value; "
+        "use the file-mounted *_FILE form"
+      )
+  mount = next(
+    (
+      m
+      for m in container.get("volumeMounts") or []
+      if isinstance(m, dict) and m.get("mountPath") == SECRETS_MOUNT_PATH
+    ),
+    None,
+  )
+  if mount is None:
+    fails.add(
+      f"{workload}/{cname}: must mount the secret volume at {SECRETS_MOUNT_PATH}"
+    )
+  elif mount.get("readOnly") is not True:
+    fails.add(f"{workload}/{cname}: {SECRETS_MOUNT_PATH} must be readOnly")
+  items: dict[str, str] = {}
+  mode: int | None = None
+  for volume in tpl.get("volumes") or []:
+    if not isinstance(volume, dict):
+      continue
+    secret = volume.get("secret") or {}
+    if secret.get("secretName") != SECRET_NAME:
+      continue
+    mode = octal_mode(secret.get("defaultMode"))
+    for item in secret.get("items") or []:
+      if isinstance(item, dict) and item.get("key") and item.get("path"):
+        items[item["key"]] = item["path"]
+  if mode != SECRET_VOLUME_MODE:
+    fails.add(f"{workload}: {SECRET_NAME} volume defaultMode must be 0400")
+  for env_name, key in FILE_SECRET_ENV_KEYS.items():
+    if key not in items:
+      fails.add(f"{workload}: {SECRET_NAME} volume must project key {key!r}")
+      continue
+    expected = f"{SECRETS_MOUNT_PATH}/{items[key]}"
+    entry = envs.get(env_name) or {}
+    if (entry.get("value") or "").strip() != expected:
+      fails.add(f"{workload}/{cname}: {env_name} must point at {expected}")
+
+
+def policy_ports(rule: dict) -> set[int]:
+  ports: set[int] = set()
+  for port in rule.get("ports") or []:
+    if isinstance(port, dict) and isinstance(port.get("port"), int):
+      ports.add(port["port"])
+  return ports
+
+
+def check_networkpolicy(doc: dict, fails: Failures) -> None:
+  """Item 9: default-deny ingress/egress for the app pods."""
+  name = doc.get("metadata", {}).get("name", "?")
+  spec = doc.get("spec") or {}
+  selector = (spec.get("podSelector") or {}).get("matchLabels") or {}
+  if selector.get("app.kubernetes.io/name") != "d-oauth-server":
+    fails.add(
+      f"networkpolicy/{name}: podSelector must match "
+      "app.kubernetes.io/name: d-oauth-server"
+    )
+  if not {"Ingress", "Egress"} <= set(spec.get("policyTypes") or []):
+    fails.add(f"networkpolicy/{name}: policyTypes must include Ingress and Egress")
+  ingress_ports: set[int] = set()
+  for rule in spec.get("ingress") or []:
+    if isinstance(rule, dict):
+      ingress_ports |= policy_ports(rule)
+  if not {PUBLIC_PORT, HEALTH_PORT} <= ingress_ports:
+    fails.add(
+      f"networkpolicy/{name}: ingress must admit ports "
+      f"{PUBLIC_PORT} and {HEALTH_PORT}"
+    )
+  egress_ports: set[int] = set()
+  metadata_excluded = False
+  for rule in spec.get("egress") or []:
+    if not isinstance(rule, dict):
+      continue
+    egress_ports |= policy_ports(rule)
+    for peer in rule.get("to") or []:
+      block = (peer or {}).get("ipBlock") or {}
+      if METADATA_CIDR in (block.get("except") or []):
+        metadata_excluded = True
+  for port in REQUIRED_EGRESS_PORTS:
+    if port not in egress_ports:
+      fails.add(f"networkpolicy/{name}: egress must allow port {port}")
+  if not metadata_excluded:
+    fails.add(
+      f"networkpolicy/{name}: egress must exclude {METADATA_CIDR} (cloud metadata)"
+    )
+
+
 def check_deployment(doc: dict, fails: Failures, strict_digest: bool) -> None:
 def check_deployment(doc: dict, fails: Failures, strict_digest: bool) -> None:
   name = doc.get("metadata", {}).get("name", "?")
   name = doc.get("metadata", {}).get("name", "?")
   spec = doc.get("spec", {})
   spec = doc.get("spec", {})
@@ -133,6 +279,13 @@ def check_deployment(doc: dict, fails: Failures, strict_digest: bool) -> None:
     fails.add(f"deployment/{name}: runAsUser must be 65532")
     fails.add(f"deployment/{name}: runAsUser must be 65532")
   if (pod_sc.get("seccompProfile") or {}).get("type") != "RuntimeDefault":
   if (pod_sc.get("seccompProfile") or {}).get("type") != "RuntimeDefault":
     fails.add(f"deployment/{name}: seccompProfile.type must be RuntimeDefault")
     fails.add(f"deployment/{name}: seccompProfile.type must be RuntimeDefault")
+  if tpl.get("automountServiceAccountToken") is not False:
+    fails.add(f"deployment/{name}: automountServiceAccountToken must be false")
+  if pod_sc.get("fsGroup") != FS_GROUP:
+    fails.add(
+      f"deployment/{name}: fsGroup must be {FS_GROUP} so the 0400 secret "
+      "files are readable by the runtime user"
+    )
   if not tpl.get("topologySpreadConstraints"):
   if not tpl.get("topologySpreadConstraints"):
     fails.add(f"deployment/{name}: topologySpreadConstraints required")
     fails.add(f"deployment/{name}: topologySpreadConstraints required")
   for container in tpl.get("containers", []):
   for container in tpl.get("containers", []):
@@ -169,6 +322,7 @@ def check_deployment(doc: dict, fails: Failures, strict_digest: bool) -> None:
     image = container.get("image", "")
     image = container.get("image", "")
     if strict_digest and not DIGEST_RE.search(image):
     if strict_digest and not DIGEST_RE.search(image):
       fails.add(f"deployment/{name}/{cname}: image must be digest-pinned")
       fails.add(f"deployment/{name}/{cname}: image must be digest-pinned")
+    check_file_mounted_secrets(f"deployment/{name}", tpl, container, fails)
 
 
 
 
 def check_job(doc: dict, fails: Failures, strict_digest: bool) -> None:
 def check_job(doc: dict, fails: Failures, strict_digest: bool) -> None:
@@ -176,6 +330,13 @@ def check_job(doc: dict, fails: Failures, strict_digest: bool) -> None:
   spec = doc.get("spec", {}).get("template", {}).get("spec", {})
   spec = doc.get("spec", {}).get("template", {}).get("spec", {})
   if spec.get("restartPolicy") != "Never":
   if spec.get("restartPolicy") != "Never":
     fails.add(f"job/{name}: restartPolicy must be Never")
     fails.add(f"job/{name}: restartPolicy must be Never")
+  if spec.get("automountServiceAccountToken") is not False:
+    fails.add(f"job/{name}: automountServiceAccountToken must be false")
+  if (spec.get("securityContext") or {}).get("fsGroup") != FS_GROUP:
+    fails.add(
+      f"job/{name}: fsGroup must be {FS_GROUP} so the 0400 secret files are "
+      "readable by the runtime user"
+    )
   containers = spec.get("containers", [])
   containers = spec.get("containers", [])
   if not any(
   if not any(
     "migrate" in " ".join(str(a) for a in (list(c.get("command", [])) + list(c.get("args", []))))
     "migrate" in " ".join(str(a) for a in (list(c.get("command", [])) + list(c.get("args", []))))
@@ -185,6 +346,7 @@ def check_job(doc: dict, fails: Failures, strict_digest: bool) -> None:
   for container in containers:
   for container in containers:
     if strict_digest and not DIGEST_RE.search(container.get("image", "")):
     if strict_digest and not DIGEST_RE.search(container.get("image", "")):
       fails.add(f"job/{name}: image must be digest-pinned")
       fails.add(f"job/{name}: image must be digest-pinned")
+    check_file_mounted_secrets(f"job/{name}", spec, container, fails)
 
 
 
 
 def check_pdb(doc: dict, fails: Failures) -> None:
 def check_pdb(doc: dict, fails: Failures) -> None:
@@ -229,10 +391,17 @@ def main() -> int:
   fails.items.extend(load_fails.items)
   fails.items.extend(load_fails.items)
 
 
   kinds = {d.get("kind") for d in docs}
   kinds = {d.get("kind") for d in docs}
-  for required in ("Deployment", "Service", "ConfigMap", "Secret", "Job", "PodDisruptionBudget"):
-    kind_name = "Job" if required == "Job" else required
+  for required in (
+    "Deployment",
+    "Service",
+    "ConfigMap",
+    "Secret",
+    "Job",
+    "PodDisruptionBudget",
+    "NetworkPolicy",
+  ):
     if required not in kinds:
     if required not in kinds:
-      fails.add(f"missing required kind: {required} ({kind_name})")
+      fails.add(f"missing required kind: {required}")
 
 
   check_pem_and_placeholders(docs, fails)
   check_pem_and_placeholders(docs, fails)
   for doc in by_kind(docs, "ConfigMap"):
   for doc in by_kind(docs, "ConfigMap"):
@@ -245,6 +414,8 @@ def main() -> int:
     check_pdb(doc, fails)
     check_pdb(doc, fails)
   for doc in by_kind(docs, "Service"):
   for doc in by_kind(docs, "Service"):
     check_service(doc, fails)
     check_service(doc, fails)
+  for doc in by_kind(docs, "NetworkPolicy"):
+    check_networkpolicy(doc, fails)
   check_l7_mtls(docs, fails)
   check_l7_mtls(docs, fails)
 
 
   if fails.ok():
   if fails.ok():

+ 1 - 1
src/cli.rs

@@ -298,7 +298,7 @@ pub enum ClientCommand {
 pub enum ConfigCommand {
 pub enum ConfigCommand {
   /// Apply a declarative file idempotently (scopes, redirect URIs, entitlements).
   /// Apply a declarative file idempotently (scopes, redirect URIs, entitlements).
   Sync {
   Sync {
-    /// YAML/JSON declarative file.
+    /// JSON declarative file.
     #[arg(long, value_name = "PATH")]
     #[arg(long, value_name = "PATH")]
     file: PathBuf,
     file: PathBuf,
   },
   },

+ 4 - 1
src/cli/ops.rs

@@ -43,7 +43,10 @@ pub async fn migrate() -> Result<(), CliError> {
   let pool = open_pool(&config).await?;
   let pool = open_pool(&config).await?;
   MIGRATOR.run(&pool).await?;
   MIGRATOR.run(&pool).await?;
   audit("migrate", "schema");
   audit("migrate", "schema");
-  println!("applied {} migration(s)", MIGRATOR.migrations.len());
+  println!(
+    "schema current: {} known migration(s)",
+    MIGRATOR.migrations.len()
+  );
   Ok(())
   Ok(())
 }
 }
 
 

+ 65 - 13
src/cli/secret.rs

@@ -3,8 +3,10 @@
 //!
 //!
 //! Nothing here ever logs or prints a secret. TOTP secrets are encrypted with
 //! Nothing here ever logs or prints a secret. TOTP secrets are encrypted with
 //! AES-256-GCM under a key derived from the active [`crate::keys::KeyRing`]
 //! AES-256-GCM under a key derived from the active [`crate::keys::KeyRing`]
-//! TOTP key; the stored blob carries the key id so retained keys keep
-//! decrypting after rotation. `keygen` emits an ES256 keypair for test fixtures
+//! TOTP key via HKDF-SHA256 (info `totp-gcm-v2`); the stored blob carries the
+//! key id so retained keys keep decrypting after rotation. Version-1 blobs
+//! (bare `SHA256(totp_key)`) still decrypt via a legacy fallback. `keygen`
+//! emits an ES256 keypair for test fixtures
 //! and client-side DPoP keys only — never an AS signing key.
 //! and client-side DPoP keys only — never an AS signing key.
 
 
 use base64::{
 use base64::{
@@ -12,17 +14,24 @@ use base64::{
 };
 };
 use ring::{
 use ring::{
   aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
   aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
+  hkdf,
   rand::{SecureRandom as _, SystemRandom},
   rand::{SecureRandom as _, SystemRandom},
   signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair as _},
   signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair as _},
 };
 };
 use secrecy::ExposeSecret as _;
 use secrecy::ExposeSecret as _;
+#[cfg(test)]
 use sha2::{Digest as _, Sha256};
 use sha2::{Digest as _, Sha256};
 
 
 use super::context::CliError;
 use super::context::CliError;
 use crate::config::Config;
 use crate::config::Config;
 
 
-/// Current TOTP-blob format version.
-const TOTP_BLOB_VERSION: u8 = 1;
+/// Current TOTP-blob format version (HKDF-SHA256 writes).
+const TOTP_BLOB_VERSION: u8 = 2;
+/// Legacy blob version (bare-`SHA256(totp_key)` writes): decrypt-only.
+#[cfg(test)]
+const TOTP_BLOB_V1: u8 = 1;
+/// HKDF domain-separation label binding the derived key to TOTP AES-GCM use.
+const HKDF_INFO: &[u8] = b"totp-gcm-v2";
 /// AES-GCM nonce length.
 /// AES-GCM nonce length.
 const NONCE_LEN: usize = 12;
 const NONCE_LEN: usize = 12;
 /// AES-256 key length.
 /// AES-256 key length.
@@ -75,7 +84,7 @@ pub fn token_hash(config: &Config, token: &str) -> Result<String, CliError> {
 
 
 /// Encrypt a TOTP secret with the active TOTP-encryption key.
 /// Encrypt a TOTP secret with the active TOTP-encryption key.
 ///
 ///
-/// Blob layout: `version(1) || kid_len(1) || kid || nonce(12) || ciphertext+tag`.
+/// Blob layout: `version(2) || kid_len(1) || kid || nonce(12) || ciphertext+tag`.
 /// The key id travels with the blob so retained keys decrypt after rotation.
 /// The key id travels with the blob so retained keys decrypt after rotation.
 ///
 ///
 /// # Errors
 /// # Errors
@@ -90,7 +99,8 @@ pub fn encrypt_totp(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliErro
   if kid.len() > u8::MAX as usize {
   if kid.len() > u8::MAX as usize {
     return Err(CliError::Crypto("TOTP key id is too long".to_owned()));
     return Err(CliError::Crypto("TOTP key id is too long".to_owned()));
   }
   }
-  let key = derive_key(secret.expose_secret().as_bytes());
+  let key = derive_key(secret.expose_secret().as_bytes())
+    .map_err(|_| CliError::Crypto("TOTP key derivation failed".to_owned()))?;
   let mut nonce_bytes = [0u8; NONCE_LEN];
   let mut nonce_bytes = [0u8; NONCE_LEN];
   SystemRandom::new()
   SystemRandom::new()
     .fill(&mut nonce_bytes)
     .fill(&mut nonce_bytes)
@@ -119,6 +129,10 @@ pub fn encrypt_totp(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliErro
 
 
 /// Decrypt a TOTP secret previously produced by [`encrypt_totp`].
 /// Decrypt a TOTP secret previously produced by [`encrypt_totp`].
 ///
 ///
+/// Version-2 blobs open under the HKDF-SHA256 key; legacy version-1 blobs
+/// first try the HKDF key, then fall back to the bare-`SHA256(totp_key)` key,
+/// so rows written before the HKDF migration keep decrypting.
+///
 /// Currently exercised only by tests; the login/verification path (a later
 /// Currently exercised only by tests; the login/verification path (a later
 /// task) is the production consumer.
 /// task) is the production consumer.
 ///
 ///
@@ -127,9 +141,10 @@ pub fn encrypt_totp(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliErro
 /// authentication failure.
 /// authentication failure.
 #[cfg(test)]
 #[cfg(test)]
 pub fn decrypt_totp(config: &Config, blob: &[u8]) -> Result<String, CliError> {
 pub fn decrypt_totp(config: &Config, blob: &[u8]) -> Result<String, CliError> {
-  if blob.len() < 2 + NONCE_LEN || blob[0] != TOTP_BLOB_VERSION {
+  if blob.len() < 2 + NONCE_LEN || (blob[0] != TOTP_BLOB_VERSION && blob[0] != TOTP_BLOB_V1) {
     return Err(CliError::Crypto("malformed TOTP blob".to_owned()));
     return Err(CliError::Crypto("malformed TOTP blob".to_owned()));
   }
   }
+  let version = blob[0];
   let kid_len = blob[1] as usize;
   let kid_len = blob[1] as usize;
   let header = 2 + kid_len;
   let header = 2 + kid_len;
   if blob.len() < header + NONCE_LEN {
   if blob.len() < header + NONCE_LEN {
@@ -141,20 +156,40 @@ pub fn decrypt_totp(config: &Config, blob: &[u8]) -> Result<String, CliError> {
     .keys
     .keys
     .totp_key_by_id(kid)
     .totp_key_by_id(kid)
     .ok_or_else(|| CliError::Crypto(format!("unknown TOTP key id: {kid}")))?;
     .ok_or_else(|| CliError::Crypto(format!("unknown TOTP key id: {kid}")))?;
-  let key = derive_key(secret.expose_secret().as_bytes());
+  let ikm = secret.expose_secret().as_bytes();
 
 
   let mut nonce_bytes = [0u8; NONCE_LEN];
   let mut nonce_bytes = [0u8; NONCE_LEN];
   nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
   nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
-  let mut ciphertext = blob[header + NONCE_LEN..].to_vec();
+  let ciphertext = &blob[header + NONCE_LEN..];
 
 
-  let unbound = UnboundKey::new(&AES_256_GCM, &key)
+  if version == TOTP_BLOB_VERSION {
+    let key =
+      derive_key(ikm).map_err(|_| CliError::Crypto("TOTP key derivation failed".to_owned()))?;
+    return open_with_key(&key, &nonce_bytes, ciphertext);
+  }
+  if let Ok(key) = derive_key(ikm)
+    && let Ok(plaintext) = open_with_key(&key, &nonce_bytes, ciphertext)
+  {
+    return Ok(plaintext);
+  }
+  open_with_key(&derive_key_legacy(ikm), &nonce_bytes, ciphertext)
+}
+
+#[cfg(test)]
+fn open_with_key(
+  key: &[u8; KEY_LEN],
+  nonce_bytes: &[u8; NONCE_LEN],
+  ciphertext: &[u8],
+) -> Result<String, CliError> {
+  let mut in_out = ciphertext.to_vec();
+  let unbound = UnboundKey::new(&AES_256_GCM, key)
     .map_err(|_| CliError::Crypto("TOTP key rejected by AEAD".to_owned()))?;
     .map_err(|_| CliError::Crypto("TOTP key rejected by AEAD".to_owned()))?;
   let opening = LessSafeKey::new(unbound);
   let opening = LessSafeKey::new(unbound);
   let plaintext = opening
   let plaintext = opening
     .open_in_place(
     .open_in_place(
-      Nonce::assume_unique_for_key(nonce_bytes),
+      Nonce::assume_unique_for_key(*nonce_bytes),
       Aad::empty(),
       Aad::empty(),
-      &mut ciphertext,
+      &mut in_out,
     )
     )
     .map_err(|_| CliError::Crypto("TOTP secret decryption failed".to_owned()))?;
     .map_err(|_| CliError::Crypto("TOTP secret decryption failed".to_owned()))?;
   String::from_utf8(plaintext.to_vec())
   String::from_utf8(plaintext.to_vec())
@@ -223,13 +258,30 @@ pub fn generate_es256() -> Result<KeypairPem, CliError> {
   })
   })
 }
 }
 
 
-fn derive_key(totp_key: &[u8]) -> [u8; KEY_LEN] {
+fn derive_key(totp_key: &[u8]) -> Result<[u8; KEY_LEN], ring::error::Unspecified> {
+  let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &[]).extract(totp_key);
+  let okm = prk.expand(&[HKDF_INFO], HkdfAesKey)?;
+  let mut key = [0u8; KEY_LEN];
+  okm.fill(&mut key)?;
+  Ok(key)
+}
+
+#[cfg(test)]
+fn derive_key_legacy(totp_key: &[u8]) -> [u8; KEY_LEN] {
   let digest = Sha256::digest(totp_key);
   let digest = Sha256::digest(totp_key);
   let mut key = [0u8; KEY_LEN];
   let mut key = [0u8; KEY_LEN];
   key.copy_from_slice(&digest);
   key.copy_from_slice(&digest);
   key
   key
 }
 }
 
 
+struct HkdfAesKey;
+
+impl hkdf::KeyType for HkdfAesKey {
+  fn len(&self) -> usize {
+    KEY_LEN
+  }
+}
+
 fn numeric_key_id(key_id: &str) -> u32 {
 fn numeric_key_id(key_id: &str) -> u32 {
   key_id.trim_start_matches('v').parse::<u32>().unwrap_or(1)
   key_id.trim_start_matches('v').parse::<u32>().unwrap_or(1)
 }
 }

+ 71 - 15
src/crypto/totp.rs

@@ -6,10 +6,12 @@
 //! SHA-1, 6 digits, with the validator accepting one step of look-ahead and
 //! SHA-1, 6 digits, with the validator accepting one step of look-ahead and
 //! look-behind.
 //! look-behind.
 //!
 //!
-//! The encrypted-secret blob layout mirrors `cli::secret` (`version(1) ||
+//! The encrypted-secret blob layout mirrors `cli::secret` (`version(2) ||
 //! kid_len(1) || kid || nonce(12) || ciphertext+tag`, AES-256-GCM under
 //! kid_len(1) || kid || nonce(12) || ciphertext+tag`, AES-256-GCM under
-//! `SHA256(totp_key)`), so CLI-provisioned secrets decrypt here and
-//! login-provisioned secrets stay CLI-readable. The two implementations are
+//! HKDF-SHA256(`totp_key`, info `totp-gcm-v2`)), so CLI-provisioned secrets
+//! decrypt here and login-provisioned secrets stay CLI-readable. Version-1
+//! blobs (AES-256-GCM under bare `SHA256(totp_key)`) still decrypt via a
+//! legacy fallback. The two implementations are
 //! intentionally small duplicates: `crypto` cannot depend on `cli`.
 //! intentionally small duplicates: `crypto` cannot depend on `cli`.
 //!
 //!
 //! No error variant carries a secret, code, or key: failures collapse to
 //! No error variant carries a secret, code, or key: failures collapse to
@@ -22,6 +24,7 @@ use qrcode::{QrCode, render::svg};
 use rand::{TryRng as _, rngs::SysRng};
 use rand::{TryRng as _, rngs::SysRng};
 use ring::{
 use ring::{
   aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
   aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
+  hkdf,
   rand::{SecureRandom as _, SystemRandom},
   rand::{SecureRandom as _, SystemRandom},
 };
 };
 use secrecy::ExposeSecret as _;
 use secrecy::ExposeSecret as _;
@@ -40,8 +43,12 @@ pub const TOTP_DIGITS: u8 = 6;
 pub const USED_CODE_TTL: Duration = Duration::from_secs(180);
 pub const USED_CODE_TTL: Duration = Duration::from_secs(180);
 /// Recovery codes minted per enrollment.
 /// Recovery codes minted per enrollment.
 pub const RECOVERY_CODE_COUNT: usize = 10;
 pub const RECOVERY_CODE_COUNT: usize = 10;
-/// Current encrypted-secret blob format version.
-const TOTP_BLOB_VERSION: u8 = 1;
+/// Current encrypted-secret blob format version (HKDF-SHA256 writes).
+const TOTP_BLOB_VERSION: u8 = 2;
+/// Legacy blob version (bare-`SHA256(totp_key)` writes): decrypt-only.
+const TOTP_BLOB_V1: u8 = 1;
+/// HKDF domain-separation label binding the derived key to TOTP AES-GCM use.
+const HKDF_INFO: &[u8] = b"totp-gcm-v2";
 /// AES-GCM nonce length.
 /// AES-GCM nonce length.
 const NONCE_LEN: usize = 12;
 const NONCE_LEN: usize = 12;
 
 
@@ -281,7 +288,7 @@ pub async fn verify_and_claim(
 
 
 /// Encrypt a TOTP secret under the active TOTP-encryption key.
 /// Encrypt a TOTP secret under the active TOTP-encryption key.
 ///
 ///
-/// Blob layout mirrors `cli::secret`: `version(1) || kid_len(1) || kid ||
+/// Blob layout mirrors `cli::secret`: `version(2) || kid_len(1) || kid ||
 /// nonce(12) || ciphertext+tag`.
 /// nonce(12) || ciphertext+tag`.
 ///
 ///
 /// # Errors
 /// # Errors
@@ -294,7 +301,7 @@ pub fn encrypt_secret(keys: &KeyRing, plaintext: &str) -> Result<Vec<u8>, TotpEr
   if kid.len() > usize::from(u8::MAX) {
   if kid.len() > usize::from(u8::MAX) {
     return Err(TotpError::Encrypt);
     return Err(TotpError::Encrypt);
   }
   }
-  let key = derive_key(secret.expose_secret().as_bytes());
+  let key = derive_key(secret.expose_secret().as_bytes()).map_err(|_| TotpError::Encrypt)?;
   let mut nonce_bytes = [0u8; NONCE_LEN];
   let mut nonce_bytes = [0u8; NONCE_LEN];
   SystemRandom::new()
   SystemRandom::new()
     .fill(&mut nonce_bytes)
     .fill(&mut nonce_bytes)
@@ -321,14 +328,19 @@ pub fn encrypt_secret(keys: &KeyRing, plaintext: &str) -> Result<Vec<u8>, TotpEr
 /// Decrypt a secret previously produced by [`encrypt_secret`], trying the key
 /// Decrypt a secret previously produced by [`encrypt_secret`], trying the key
 /// named in the blob (retained keys keep decrypting after rotation).
 /// named in the blob (retained keys keep decrypting after rotation).
 ///
 ///
+/// Version-2 blobs open under the HKDF-SHA256 key; legacy version-1 blobs
+/// first try the HKDF key, then fall back to the bare-`SHA256(totp_key)` key,
+/// so rows written before the HKDF migration keep decrypting.
+///
 /// # Errors
 /// # Errors
 ///
 ///
 /// Returns [`TotpError::Decrypt`] for a malformed blob, an unknown key id, or
 /// Returns [`TotpError::Decrypt`] for a malformed blob, an unknown key id, or
 /// an authentication failure. The reason is never distinguished.
 /// an authentication failure. The reason is never distinguished.
 pub fn decrypt_secret(keys: &KeyRing, blob: &[u8]) -> Result<String, TotpError> {
 pub fn decrypt_secret(keys: &KeyRing, blob: &[u8]) -> Result<String, TotpError> {
-  if blob.len() < 2 + NONCE_LEN || blob[0] != TOTP_BLOB_VERSION {
+  if blob.len() < 2 + NONCE_LEN || (blob[0] != TOTP_BLOB_VERSION && blob[0] != TOTP_BLOB_V1) {
     return Err(TotpError::Decrypt);
     return Err(TotpError::Decrypt);
   }
   }
+  let version = blob[0];
   let kid_len = usize::from(blob[1]);
   let kid_len = usize::from(blob[1]);
   let header = 2 + kid_len;
   let header = 2 + kid_len;
   if blob.len() < header + NONCE_LEN {
   if blob.len() < header + NONCE_LEN {
@@ -337,17 +349,41 @@ pub fn decrypt_secret(keys: &KeyRing, blob: &[u8]) -> Result<String, TotpError>
   let kid = core::str::from_utf8(blob.get(2..header).unwrap_or_default())
   let kid = core::str::from_utf8(blob.get(2..header).unwrap_or_default())
     .map_err(|_| TotpError::Decrypt)?;
     .map_err(|_| TotpError::Decrypt)?;
   let secret = keys.totp_key_by_id(kid).ok_or(TotpError::Decrypt)?;
   let secret = keys.totp_key_by_id(kid).ok_or(TotpError::Decrypt)?;
-  let key = derive_key(secret.expose_secret().as_bytes());
+  let ikm = secret.expose_secret().as_bytes();
   let mut nonce_bytes = [0u8; NONCE_LEN];
   let mut nonce_bytes = [0u8; NONCE_LEN];
   nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
   nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
-  let mut ciphertext = blob[header + NONCE_LEN..].to_vec();
+  let ciphertext = &blob[header + NONCE_LEN..];
+  if version == TOTP_BLOB_VERSION {
+    let key = derive_key(ikm).map_err(|_| TotpError::Decrypt)?;
+    return open_with_key(&key, &nonce_bytes, ciphertext);
+  }
+  if let Ok(key) = derive_key(ikm)
+    && let Ok(plaintext) = open_with_key(&key, &nonce_bytes, ciphertext)
+  {
+    return Ok(plaintext);
+  }
+  open_with_key(&derive_key_legacy(ikm), &nonce_bytes, ciphertext)
+}
+
+/// Open `ciphertext` (with trailing GCM tag) under `key`, returning UTF-8.
+///
+/// # Errors
+///
+/// Returns [`TotpError::Decrypt`] for an authentication failure or
+/// non-UTF-8 plaintext. The reason is never distinguished.
+fn open_with_key(
+  key: &[u8; 32],
+  nonce_bytes: &[u8; NONCE_LEN],
+  ciphertext: &[u8],
+) -> Result<String, TotpError> {
+  let mut in_out = ciphertext.to_vec();
   let opening =
   let opening =
-    LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &key).map_err(|_| TotpError::Decrypt)?);
+    LessSafeKey::new(UnboundKey::new(&AES_256_GCM, key).map_err(|_| TotpError::Decrypt)?);
   let plaintext = opening
   let plaintext = opening
     .open_in_place(
     .open_in_place(
-      Nonce::assume_unique_for_key(nonce_bytes),
+      Nonce::assume_unique_for_key(*nonce_bytes),
       Aad::empty(),
       Aad::empty(),
-      &mut ciphertext,
+      &mut in_out,
     )
     )
     .map_err(|_| TotpError::Decrypt)?;
     .map_err(|_| TotpError::Decrypt)?;
   String::from_utf8(plaintext.to_vec()).map_err(|_| TotpError::Decrypt)
   String::from_utf8(plaintext.to_vec()).map_err(|_| TotpError::Decrypt)
@@ -380,14 +416,34 @@ pub fn qr_svg(uri: &str) -> Result<String, TotpError> {
   })
   })
 }
 }
 
 
-/// Derive the AES-256 key from configured TOTP key material (SHA-256).
-fn derive_key(totp_key: &[u8]) -> [u8; 32] {
+/// Derive the AES-256 key from configured TOTP key material via HKDF-SHA256
+/// (RFC 5869, empty salt, info `totp-gcm-v2` domain-separating TOTP use).
+fn derive_key(totp_key: &[u8]) -> Result<[u8; 32], ring::error::Unspecified> {
+  let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &[]).extract(totp_key);
+  let okm = prk.expand(&[HKDF_INFO], HkdfAesKey)?;
+  let mut key = [0u8; 32];
+  okm.fill(&mut key)?;
+  Ok(key)
+}
+
+/// Legacy derivation (bare `SHA256(totp_key)`): decrypt-only fallback for
+/// version-1 blobs written before the HKDF migration.
+fn derive_key_legacy(totp_key: &[u8]) -> [u8; 32] {
   let digest = Sha256::digest(totp_key);
   let digest = Sha256::digest(totp_key);
   let mut key = [0u8; 32];
   let mut key = [0u8; 32];
   key.copy_from_slice(&digest);
   key.copy_from_slice(&digest);
   key
   key
 }
 }
 
 
+/// `ring::hkdf` output-length marker for a 256-bit AES key.
+struct HkdfAesKey;
+
+impl hkdf::KeyType for HkdfAesKey {
+  fn len(&self) -> usize {
+    32
+  }
+}
+
 /// Lowercase hex encoding of raw bytes.
 /// Lowercase hex encoding of raw bytes.
 fn hex_bytes(bytes: &[u8]) -> String {
 fn hex_bytes(bytes: &[u8]) -> String {
   let mut out = String::with_capacity(bytes.len() * 2);
   let mut out = String::with_capacity(bytes.len() * 2);

+ 81 - 4
src/crypto/totp/tests.rs

@@ -1,12 +1,12 @@
 //! `login_totp` unit suite: RFC 6238 vectors, ±1-step window, AEAD wrap.
 //! `login_totp` unit suite: RFC 6238 vectors, ±1-step window, AEAD wrap.
 
 
 use base32::Alphabet;
 use base32::Alphabet;
-use secrecy::SecretString;
+use secrecy::{ExposeSecret as _, SecretString};
 
 
 use super::{
 use super::{
-  RECOVERY_CODE_COUNT, TOTP_STEP_SECS, TotpError, build_totp, decrypt_secret, encrypt_secret,
-  generate_recovery_codes, generate_secret, normalize_recovery_code, otpauth_uri, qr_svg,
-  replay_fingerprint, verify_window,
+  RECOVERY_CODE_COUNT, TOTP_BLOB_VERSION, TOTP_STEP_SECS, TotpError, build_totp, decrypt_secret,
+  derive_key, derive_key_legacy, encrypt_secret, generate_recovery_codes, generate_secret,
+  normalize_recovery_code, otpauth_uri, qr_svg, replay_fingerprint, verify_window,
 };
 };
 use crate::keys::{KeyId, KeyRing};
 use crate::keys::{KeyId, KeyRing};
 
 
@@ -232,3 +232,80 @@ fn login_totp_qr_svg_rejects_oversized_uri() -> Result<(), TotpError> {
   }
   }
   Ok(())
   Ok(())
 }
 }
+
+#[test]
+fn login_totp_hkdf_differs_from_legacy_sha256() -> Result<(), TotpError> {
+  use sha2::{Digest as _, Sha256};
+  let ikm = b"test-totp-key-0123456789abcdef";
+  let hkdf_key = derive_key(ikm).map_err(|_| TotpError::Encrypt)?;
+  let digest = Sha256::digest(ikm);
+  if hkdf_key.as_slice() == digest.as_slice() {
+    return Err(TotpError::Encrypt);
+  }
+  if derive_key_legacy(ikm).as_slice() != digest.as_slice() {
+    return Err(TotpError::Decrypt);
+  }
+  Ok(())
+}
+
+#[test]
+fn login_totp_new_blobs_use_version_two() -> Result<(), TotpError> {
+  let ring = test_ring()?;
+  let blob = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ")?;
+  if TOTP_BLOB_VERSION != 2 || blob.first().copied() != Some(2) {
+    return Err(TotpError::Encrypt);
+  }
+  if decrypt_secret(&ring, &blob)? != "GEZDGNBVGY3TQOJQ" {
+    return Err(TotpError::Decrypt);
+  }
+  Ok(())
+}
+
+// Build a legacy v1 blob (version 1, AES-256-GCM under SHA256(totp_key))
+// exactly as the pre-HKDF writer did, to prove old rows still decrypt.
+fn encrypt_v1_legacy_blob(ring: &KeyRing, plaintext: &str) -> Result<Vec<u8>, TotpError> {
+  use ring::{
+    aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
+    rand::{SecureRandom as _, SystemRandom},
+  };
+  let (key_id, secret) = ring.active_totp_key().ok_or(TotpError::Encrypt)?;
+  let kid = key_id.as_str().as_bytes();
+  if kid.len() > usize::from(u8::MAX) {
+    return Err(TotpError::Encrypt);
+  }
+  let key = derive_key_legacy(secret.expose_secret().as_bytes());
+  let mut nonce_bytes = [0u8; 12];
+  SystemRandom::new()
+    .fill(&mut nonce_bytes)
+    .map_err(|_| TotpError::UnavailableRandomness)?;
+  let sealing =
+    LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &key).map_err(|_| TotpError::Encrypt)?);
+  let mut in_out = plaintext.as_bytes().to_vec();
+  sealing
+    .seal_in_place_append_tag(
+      Nonce::assume_unique_for_key(nonce_bytes),
+      Aad::empty(),
+      &mut in_out,
+    )
+    .map_err(|_| TotpError::Encrypt)?;
+  let mut blob = Vec::with_capacity(2 + kid.len() + 12 + in_out.len());
+  blob.push(1u8);
+  blob.push(kid.len() as u8);
+  blob.extend_from_slice(kid);
+  blob.extend_from_slice(&nonce_bytes);
+  blob.extend_from_slice(&in_out);
+  Ok(blob)
+}
+
+#[test]
+fn login_totp_decrypts_legacy_v1_sha256_blob() -> Result<(), TotpError> {
+  let ring = test_ring()?;
+  let blob = encrypt_v1_legacy_blob(&ring, "GEZDGNBVGY3TQOJQ")?;
+  if blob.first().copied() != Some(1) {
+    return Err(TotpError::Encrypt);
+  }
+  if decrypt_secret(&ring, &blob)? != "GEZDGNBVGY3TQOJQ" {
+    return Err(TotpError::Decrypt);
+  }
+  Ok(())
+}