Browse Source

Hardened security and updated documentation

Efren Yevale Varela 1 tháng trước cách đây
mục cha
commit
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.
 > 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
   stored (only its Argon2id hash reaches Postgres).
 - 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
 # 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
 
@@ -71,6 +77,24 @@ services:
         chmod 600 /certs/tls.key
         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.
   migrate:
     build:
@@ -82,6 +106,8 @@ services:
     depends_on:
       gencerts:
         condition: service_completed_successfully
+      gensecrets:
+        condition: service_completed_successfully
       db:
         condition: service_healthy
       redis:
@@ -96,10 +122,11 @@ services:
       D_OAUTH_DATABASE__URL: postgres://oauth:oauth-dev-only@db:5432/oauth?sslmode=disable
       D_OAUTH_REDIS__URL: redis://redis:6379
       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:
       - certs:/certs:ro
+      - devsecrets:/run/secrets:ro
 
   app:
     build:
@@ -109,6 +136,8 @@ services:
     depends_on:
       migrate:
         condition: service_completed_successfully
+      gensecrets:
+        condition: service_completed_successfully
       db:
         condition: service_healthy
       redis:
@@ -123,8 +152,8 @@ services:
       D_OAUTH_DATABASE__URL: postgres://oauth:oauth-dev-only@db:5432/oauth?sslmode=disable
       D_OAUTH_REDIS__URL: redis://redis:6379
       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:
       - 8080
       - 8081
@@ -142,8 +171,10 @@ services:
       start_period: 10s
     volumes:
       - certs:/certs:ro
+      - devsecrets:/run/secrets:ro
 
 volumes:
   pgdata:
   redisdata:
   certs:
+  devsecrets:

+ 4 - 1
deploy/k8s/configmap.yaml

@@ -3,7 +3,10 @@
 # Every key maps to a `D_OAUTH_*` environment variable read by
 # `Config::load` (prefix `D_OAUTH`, nested separator `__`). Secret material
 # (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
 kind: ConfigMap
 metadata:

+ 58 - 14
deploy/k8s/deployment.yaml

@@ -10,6 +10,25 @@
 #   default `0.0.0.0:8081`); the public listener stays TLS-only.
 # - `terminationGracePeriodSeconds: 45` covers the server's 1s propagation
 #   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
 kind: Deployment
 metadata:
@@ -37,18 +56,28 @@ spec:
         app.kubernetes.io/part-of: d-oauth
     spec:
       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:
         runAsNonRoot: true
         runAsUser: 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:
           type: RuntimeDefault
       containers:
         - 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
           ports:
             - name: public-tls
@@ -71,16 +100,15 @@ spec:
                 secretKeyRef:
                   name: d-oauth-secrets
                   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`
           # touches no Postgres/Redis), so a dependency outage never
           # triggers a restart storm — readiness absorbs it instead.
@@ -125,11 +153,27 @@ spec:
             - name: certs
               mountPath: /certs
               readOnly: true
+            - name: secrets
+              mountPath: /run/secrets
+              readOnly: true
       volumes:
         - name: certs
           secret:
             secretName: d-oauth-tls
             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:
         - maxSkew: 1
           topologyKey: topology.kubernetes.io/zone

+ 16 - 6
deploy/k8s/kustomization.yaml

@@ -3,10 +3,14 @@
 # Validate: `kustomize build 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
 kind: Kustomization
 namespace: d-oauth
@@ -18,12 +22,18 @@ resources:
   - deployment.yaml
   - migrate-job.yaml
   - pdb.yaml
+  - networkpolicy.yaml
 images:
   - name: 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
 # verification needs L4/TCP passthrough (NLB gateway / TLSRoute); an L7
 # ingress that terminates TLS strips the client certificate. Adding an
 # 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
 # readiness gate (`_sqlx_migrations` version match) keeps new replicas out of
 # 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
 kind: Job
 metadata:
@@ -27,16 +34,20 @@ spec:
         app.kubernetes.io/component: migrate
     spec:
       restartPolicy: Never
+      automountServiceAccountToken: false
       securityContext:
         runAsNonRoot: true
         runAsUser: 65532
         runAsGroup: 65532
+        fsGroup: 65532
+        fsGroupChangePolicy: OnRootMismatch
         seccompProfile:
           type: RuntimeDefault
       containers:
         - 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
           # Overrides the image ENTRYPOINT (["/d-oauth-server","serve"]).
           command: ["/d-oauth-server", "migrate"]
@@ -54,16 +65,12 @@ spec:
                 secretKeyRef:
                   name: d-oauth-secrets
                   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:
             requests:
               cpu: 50m
@@ -81,8 +88,20 @@ spec:
             - name: certs
               mountPath: /certs
               readOnly: true
+            - name: secrets
+              mountPath: /run/secrets
+              readOnly: true
       volumes:
         - name: certs
           secret:
             secretName: d-oauth-tls
             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.
 #
+# 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
 # (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
 #   --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
 kind: Secret
 metadata:

+ 6 - 3
docs/ARCHITECTURE.md

@@ -29,6 +29,7 @@ the library.
 | `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/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/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. |
@@ -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.
 3. CORS: answers preflights from the explicit allowlist. Absent when the
    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.
-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
    `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
   hand-typing. The new `qrcode` dependency is pinned SVG-only (no `image`
   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
 
+- 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
   `Cache-Control: public, max-age=3600` instead of relying on browser
   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
 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
 
 | Key | Default | Rule |
 | --- | --- | --- |
 | `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. |
-| `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. |
 | `cookie_domain` | none (host-only) | Optional cookie domain. |
 | `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. |
 | `allow_open_registration` | `false` | When false, DCR requires a valid initial-access token. |
 | `dcr_redirect_policy` | `catalog` | `catalog`, `catalog_loopback`, or `open`. See below. |
-| `cors_allowed_origins` | empty | An explicit origin allowlist. Empty means no CORS layer. A lone `*` is 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. |
 | `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. |
 
 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
 
@@ -218,12 +228,16 @@ the statement.
 - `trusted_proxy` is unset by default, so forwarded client-cert headers from an
   untrusted hop are stripped before any handler reads them.
 - 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
 
 - `D_OAUTH_CONFIG` pointing at a missing file is not an error; the file is
   optional.
 - 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
   deleting it. See [OPERATIONS.md](OPERATIONS.md).
 - 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. |
 | `active` | `bool` | Soft disable, default true. |
 | `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. |
 
 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
 ```
 
-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
 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/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/state.rs` for `MIGRATOR`.
 - `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
   Postgres and Redis, so no replica holds state another needs.
 - `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.
 - A `startupProbe` and `livenessProbe` on `/health/live`, and a
   `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
   `topologySpreadConstraints` so replicas spread across nodes.
 - 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.
 
 ### 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
 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
 
 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.
 - Non-root with a read-only root filesystem.
 - 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
   not a second API surface.
 - 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.
 - A replica that has no shared state is a bug: token state is in Redis, not in
   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
 
@@ -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
   weekly rebuild.
 - `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,
-  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
   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
   health listener. It never migrates. It drains on `SIGTERM`.
 - `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=...`.
 
 ### 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. |
 | 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. |
-| 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. |
 | 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
 
@@ -274,6 +277,60 @@ security rationale and checklist.
 5. Preserve logs. The `X-Request-Id` header correlates a request to its logs;
    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
 
 - 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/middleware/` for redaction, rate limiting, and security headers.
 - `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. |
 | 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. |
 | 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. |
@@ -51,17 +51,7 @@ list is deliberately short and ordered by value.
 - **Files.** `src/db/user_repo.rs`, `src/db/client_repo.rs`,
   `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
   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.
 - **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
   KMS or HSM. At a minimum, document the hard requirement that the key never
   lives with the database backup.
 - **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
   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
 
-### 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.
 - **Proposed change.** Consider a sandboxed runtime class (gVisor/Kata) and a
   custom seccomp allowlist instead of `RuntimeDefault`.
 - **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
   trusted crate.
@@ -168,7 +111,7 @@ the mitigations to implement.
   `cargo vet`/`cargo crev` review.
 - **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
   every image control.
@@ -179,23 +122,18 @@ the mitigations to implement.
 
 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
 
-- 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 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?

+ 174 - 3
scripts/check_manifests.py

@@ -12,6 +12,15 @@ contract WITHOUT a live cluster:
 - Migration Job: exactly one, runs `migrate`, `restartPolicy: Never`.
 - PDB: `minAvailable: 2`.
 - 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
   only inside `secret.yaml`; no secret-shaped keys in the ConfigMap.
 - 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",
 )
 
+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:
   def __init__(self) -> None:
@@ -118,6 +139,131 @@ def mtls_enabled(docs: list[dict]) -> bool:
   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:
   name = doc.get("metadata", {}).get("name", "?")
   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")
   if (pod_sc.get("seccompProfile") or {}).get("type") != "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"):
     fails.add(f"deployment/{name}: topologySpreadConstraints required")
   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", "")
     if strict_digest and not DIGEST_RE.search(image):
       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:
@@ -176,6 +330,13 @@ def check_job(doc: dict, fails: Failures, strict_digest: bool) -> None:
   spec = doc.get("spec", {}).get("template", {}).get("spec", {})
   if spec.get("restartPolicy") != "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", [])
   if not any(
     "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:
     if strict_digest and not DIGEST_RE.search(container.get("image", "")):
       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:
@@ -229,10 +391,17 @@ def main() -> int:
   fails.items.extend(load_fails.items)
 
   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:
-      fails.add(f"missing required kind: {required} ({kind_name})")
+      fails.add(f"missing required kind: {required}")
 
   check_pem_and_placeholders(docs, fails)
   for doc in by_kind(docs, "ConfigMap"):
@@ -245,6 +414,8 @@ def main() -> int:
     check_pdb(doc, fails)
   for doc in by_kind(docs, "Service"):
     check_service(doc, fails)
+  for doc in by_kind(docs, "NetworkPolicy"):
+    check_networkpolicy(doc, fails)
   check_l7_mtls(docs, fails)
 
   if fails.ok():

+ 1 - 1
src/cli.rs

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

+ 4 - 1
src/cli/ops.rs

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

+ 65 - 13
src/cli/secret.rs

@@ -3,8 +3,10 @@
 //!
 //! 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`]
-//! 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.
 
 use base64::{
@@ -12,17 +14,24 @@ use base64::{
 };
 use ring::{
   aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
+  hkdf,
   rand::{SecureRandom as _, SystemRandom},
   signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair as _},
 };
 use secrecy::ExposeSecret as _;
+#[cfg(test)]
 use sha2::{Digest as _, Sha256};
 
 use super::context::CliError;
 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.
 const NONCE_LEN: usize = 12;
 /// 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.
 ///
-/// 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.
 ///
 /// # Errors
@@ -90,7 +99,8 @@ pub fn encrypt_totp(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliErro
   if kid.len() > u8::MAX as usize {
     return Err(CliError::Crypto("TOTP key id is too long".to_owned()));
   }
-  let key = derive_key(secret.expose_secret().as_bytes());
+  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];
   SystemRandom::new()
     .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`].
 ///
+/// 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
 /// task) is the production consumer.
 ///
@@ -127,9 +141,10 @@ pub fn encrypt_totp(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliErro
 /// authentication failure.
 #[cfg(test)]
 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()));
   }
+  let version = blob[0];
   let kid_len = blob[1] as usize;
   let header = 2 + kid_len;
   if blob.len() < header + NONCE_LEN {
@@ -141,20 +156,40 @@ pub fn decrypt_totp(config: &Config, blob: &[u8]) -> Result<String, CliError> {
     .keys
     .totp_key_by_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];
   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()))?;
   let opening = LessSafeKey::new(unbound);
   let plaintext = opening
     .open_in_place(
-      Nonce::assume_unique_for_key(nonce_bytes),
+      Nonce::assume_unique_for_key(*nonce_bytes),
       Aad::empty(),
-      &mut ciphertext,
+      &mut in_out,
     )
     .map_err(|_| CliError::Crypto("TOTP secret decryption failed".to_owned()))?;
   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 mut key = [0u8; KEY_LEN];
   key.copy_from_slice(&digest);
   key
 }
 
+struct HkdfAesKey;
+
+impl hkdf::KeyType for HkdfAesKey {
+  fn len(&self) -> usize {
+    KEY_LEN
+  }
+}
+
 fn numeric_key_id(key_id: &str) -> u32 {
   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
 //! 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
-//! `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`.
 //!
 //! 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 ring::{
   aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
+  hkdf,
   rand::{SecureRandom as _, SystemRandom},
 };
 use secrecy::ExposeSecret as _;
@@ -40,8 +43,12 @@ pub const TOTP_DIGITS: u8 = 6;
 pub const USED_CODE_TTL: Duration = Duration::from_secs(180);
 /// Recovery codes minted per enrollment.
 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.
 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.
 ///
-/// 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`.
 ///
 /// # Errors
@@ -294,7 +301,7 @@ pub fn encrypt_secret(keys: &KeyRing, plaintext: &str) -> Result<Vec<u8>, TotpEr
   if kid.len() > usize::from(u8::MAX) {
     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];
   SystemRandom::new()
     .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
 /// 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
 ///
 /// Returns [`TotpError::Decrypt`] for a malformed blob, an unknown key id, or
 /// an authentication failure. The reason is never distinguished.
 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);
   }
+  let version = blob[0];
   let kid_len = usize::from(blob[1]);
   let header = 2 + kid_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())
     .map_err(|_| 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];
   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 =
-    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
     .open_in_place(
-      Nonce::assume_unique_for_key(nonce_bytes),
+      Nonce::assume_unique_for_key(*nonce_bytes),
       Aad::empty(),
-      &mut ciphertext,
+      &mut in_out,
     )
     .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 mut key = [0u8; 32];
   key.copy_from_slice(&digest);
   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.
 fn hex_bytes(bytes: &[u8]) -> String {
   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.
 
 use base32::Alphabet;
-use secrecy::SecretString;
+use secrecy::{ExposeSecret as _, SecretString};
 
 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};
 
@@ -232,3 +232,80 @@ fn login_totp_qr_svg_rejects_oversized_uri() -> Result<(), TotpError> {
   }
   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(())
+}