Efren Yevale Varela 4 тижнів тому
батько
коміт
50269aa5bf
100 змінених файлів з 9632 додано та 992 видалено
  1. 21 7
      .sqlx/query-16dda6ce1d97cfc90378c74045a6601a67f1ad83aa2a3b8e25a67d879f74f82f.json
  2. 111 18
      .sqlx/query-1cf0676f9a41ce8b8e07e77673d31d225ec331c2fbfc7d6e6ac9fe9598570a21.json
  3. 101 17
      .sqlx/query-1f4bf0fab2e49823757742bf3d8c0da51893614b667dc5ed39121ecc8f781274.json
  4. 24 0
      .sqlx/query-20c70c8d1ddca080c639f2524965d1fef0e8cd5a6e159c27a0ca1f2d899f85fb.json
  5. 40 0
      .sqlx/query-244eaf934d505ad6d3b1e36eef8f97e90ce4ce4d86361213690a5616be744e88.json
  6. 101 17
      .sqlx/query-310cbe7ee6b575d3ae246b0e401a58e19c0cf92d2eeb58ebdc512b3319e67cd5.json
  7. 24 0
      .sqlx/query-44c435f01020ed8c305e03097375f6f75a4d51336dc72fc40907c4d597332828.json
  8. 28 0
      .sqlx/query-4d8146ec3fc52a8a70a6c213571bad7b14c0ca95a5d55ce1600e6a46e28d5227.json
  9. 101 17
      .sqlx/query-5328a7be851fc8892f4599363cad74cf4bc08ea045e49105977b833af17e0ce1.json
  10. 25 0
      .sqlx/query-57fc3eba28c68f8f58e5d1859514f20843f1482a348981cba08e7793232efddd.json
  11. 16 4
      .sqlx/query-6bbbacf992036c6f696f9d723823dd832dac8603ee6de2d3339fd8a94848d8d2.json
  12. 16 4
      .sqlx/query-79e3601aefbfd67768897e2869f5c6eb0a9bbe8878a3b48c2e4d02c7afe262e8.json
  13. 16 4
      .sqlx/query-836082ac97001615cbd9021f0c6694a5761ed0cd42c38318d2f1755cfadf8e3d.json
  14. 20 7
      .sqlx/query-8ca892dd486bcbaffb23e52023edf7e93e7193993f903bbafdc8c82e71cb310c.json
  15. 16 3
      .sqlx/query-8d3f5427e689a18faa311d2f0ac9f095982c4088868329f4259ebdd876fdf55c.json
  16. 137 0
      .sqlx/query-91af982bb1418227fed7787bba39e3fab97337e7983061e6ffd7535d2674250c.json
  17. 3 2
      .sqlx/query-a3bea2aa8d3d7eaeaa8916665fd083e7118625cbf646a8b71488cebe3c257908.json
  18. 25 0
      .sqlx/query-bfde1d8165ec834894f2125a48c503639787c1ebed0568dd6ced56d150d38db1.json
  19. 24 0
      .sqlx/query-c04af456919cef4b8bd5f911731f8c62b0566a1f3c24fee377cf9be326671715.json
  20. 19 6
      .sqlx/query-c7ca27d77f502c1b9c7f1145b2723487f2f3c6d270803752daba5c821e8b438e.json
  21. 0 28
      .sqlx/query-cd2fda70d5b4529d5d119855fa893a33de822b67b97c186e142c2c2acbb66426.json
  22. 16 4
      .sqlx/query-dd0112c7b1c2b6e3642247bbd6377022f63bc75f253cd7355d6e634f31ee9ee0.json
  23. 110 17
      .sqlx/query-f85e57a560d33626ceb5b28030d25d6f982f8e781ccf7b3e3b3c8fb61e7eed8b.json
  24. 1 0
      AGENTS.md
  25. 7 3
      README.md
  26. 19 0
      RUNBOOK.md
  27. 11 7
      compose.yaml
  28. 6 2
      deploy/k8s/deployment.yaml
  29. 7 3
      deploy/k8s/migrate-job.yaml
  30. 11 0
      deploy/k8s/secret.yaml
  31. 2 1
      docs/ARCHITECTURE.md
  32. 28 0
      docs/CHANGELOG.md
  33. 60 17
      docs/CONFIGURATION.md
  34. 2 1
      docs/CONTRIBUTING.md
  35. 108 16
      docs/DATABASE.md
  36. 102 9
      docs/OPERATIONS.md
  37. 97 36
      docs/SECURITY.md
  38. 0 53
      docs/TODO.md
  39. 68 0
      migrations/1791417600_pii_encryption.sql
  40. 57 9
      scripts/check_manifests.py
  41. 195 0
      scripts/test_check_manifests.py
  42. 71 0
      src/cli.rs
  43. 68 48
      src/cli/admin.rs
  44. 5 0
      src/cli/context.rs
  45. 180 0
      src/cli/pii.rs
  46. 207 0
      src/cli/pii/backfill.rs
  47. 234 0
      src/cli/pii/clients.rs
  48. 183 0
      src/cli/pii/rotate.rs
  49. 211 0
      src/cli/pii/rotate_clients.rs
  50. 673 0
      src/cli/pii/tests.rs
  51. 302 0
      src/cli/pii/verify.rs
  52. 100 116
      src/cli/redirect.rs
  53. 204 20
      src/cli/secret.rs
  54. 4 11
      src/cli/sync.rs
  55. 1 0
      src/cli/test_support.rs
  56. 569 19
      src/cli/tests.rs
  57. 348 0
      src/cli/totp.rs
  58. 178 0
      src/cli/totp/tests.rs
  59. 53 45
      src/cli/user.rs
  60. 8 0
      src/config.rs
  61. 65 6
      src/config/build.rs
  62. 10 0
      src/config/raw.rs
  63. 185 0
      src/config/tests.rs
  64. 470 0
      src/crypto/field.rs
  65. 2 1
      src/crypto/mod.rs
  66. 222 22
      src/crypto/totp.rs
  67. 144 7
      src/crypto/totp/tests.rs
  68. 635 69
      src/db/client_repo.rs
  69. 462 32
      src/db/client_repo/tests.rs
  70. 65 2
      src/db/client_repo/types.rs
  71. 1 1
      src/db/mod.rs
  72. 356 82
      src/db/redirect_repo.rs
  73. 360 51
      src/db/redirect_repo/tests.rs
  74. 132 29
      src/db/user_repo.rs
  75. 96 3
      src/error.rs
  76. 46 0
      src/jose/jar/request_uri.rs
  77. 97 9
      src/keys.rs
  78. 20 0
      src/middleware/audit.rs
  79. 2 0
      src/middleware/ratelimit/test_support.rs
  80. 22 8
      src/protocol/authorize.rs
  81. 192 16
      src/protocol/authorize/tests.rs
  82. 13 8
      src/protocol/client_auth.rs
  83. 31 1
      src/protocol/client_auth/tests_containers.rs
  84. 63 0
      src/protocol/client_auth/tests_containers/assertions.rs
  85. 1 0
      src/protocol/code/tests.rs
  86. 23 9
      src/protocol/dcr.rs
  87. 9 8
      src/protocol/dcr/manage.rs
  88. 53 7
      src/protocol/dcr/manage/support.rs
  89. 50 0
      src/protocol/dcr/manage/tests.rs
  90. 12 5
      src/protocol/dcr/manage/tests_delete.rs
  91. 134 10
      src/protocol/dcr/manage/tests_update.rs
  92. 13 9
      src/protocol/dcr/manage/update.rs
  93. 225 17
      src/protocol/dcr/tests.rs
  94. 3 2
      src/protocol/device.rs
  95. 17 2
      src/protocol/device/tests.rs
  96. 4 0
      src/protocol/introspect.rs
  97. 10 1
      src/protocol/introspect/tests.rs
  98. 2 0
      src/protocol/metadata/tests.rs
  99. 5 1
      src/protocol/mtls.rs
  100. 6 3
      src/protocol/mtls/select.rs

+ 21 - 7
.sqlx/query-714904ff3cb3d812603a8daf505e556a38be72cc0b1388fb6b5929033b8c28f1.json → .sqlx/query-16dda6ce1d97cfc90378c74045a6601a67f1ad83aa2a3b8e25a67d879f74f82f.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "INSERT INTO users\n         (id, email, password_hash, is_admin, must_change_password,\n          password_changed_at, created_at, updated_at)\n       VALUES ($1, $2::text::citext, $3, $4, $5,\n               CASE WHEN $5 THEN NULL ELSE now() END, now(), now())\n       RETURNING id, email::text AS \"email!\", is_admin, must_change_password,\n                 disabled, totp_enabled, password_changed_at",
+  "query": "INSERT INTO users\n         (id, email, email_enc, email_index, password_hash, is_admin,\n          must_change_password, password_changed_at, created_at, updated_at)\n       VALUES ($1, $2::text::citext, $3, $4, $5, $6, $7,\n               CASE WHEN $7 THEN NULL ELSE now() END, now(), now())\n       RETURNING id, email::text AS email, email_enc, is_admin,\n                 must_change_password, disabled, totp_enabled, password_changed_at",
   "describe": {
     "columns": [
       {
@@ -16,12 +16,23 @@
       },
       {
         "ordinal": 1,
-        "name": "email!",
+        "name": "email",
         "type_info": "Text",
         "origin": "Expression"
       },
       {
         "ordinal": 2,
+        "name": "email_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "email_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
         "name": "is_admin",
         "type_info": "Bool",
         "origin": {
@@ -32,7 +43,7 @@
         }
       },
       {
-        "ordinal": 3,
+        "ordinal": 4,
         "name": "must_change_password",
         "type_info": "Bool",
         "origin": {
@@ -43,7 +54,7 @@
         }
       },
       {
-        "ordinal": 4,
+        "ordinal": 5,
         "name": "disabled",
         "type_info": "Bool",
         "origin": {
@@ -54,7 +65,7 @@
         }
       },
       {
-        "ordinal": 5,
+        "ordinal": 6,
         "name": "totp_enabled",
         "type_info": "Bool",
         "origin": {
@@ -65,7 +76,7 @@
         }
       },
       {
-        "ordinal": 6,
+        "ordinal": 7,
         "name": "password_changed_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -80,6 +91,8 @@
       "Left": [
         "Uuid",
         "Text",
+        "Bytea",
+        "Text",
         "Text",
         "Bool",
         "Bool"
@@ -88,6 +101,7 @@
     "nullable": [
       false,
       null,
+      true,
       false,
       false,
       false,
@@ -95,5 +109,5 @@
       true
     ]
   },
-  "hash": "714904ff3cb3d812603a8daf505e556a38be72cc0b1388fb6b5929033b8c28f1"
+  "hash": "16dda6ce1d97cfc90378c74045a6601a67f1ad83aa2a3b8e25a67d879f74f82f"
 }

+ 111 - 18
.sqlx/query-40cfcf7198a65db344c84765f67d8bc6b89088132aa9a0c5b66e0b35487c6244.json → .sqlx/query-1cf0676f9a41ce8b8e07e77673d31d225ec331c2fbfc7d6e6ac9fe9598570a21.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "UPDATE oauth_clients SET client_name = $2, grant_types = $3, response_types = $4, scope = $5, token_endpoint_auth_method = $6, jwks = $7, jwks_uri = $8, tls_client_auth_subject_dn = $9, tls_client_auth_cert_thumbprint = $10, request_uris = $11, contacts = $12, software_id = $13, dpop_bound_access_tokens = $14, updated_at = now() WHERE client_id = $1 AND active = true RETURNING id, client_id, client_secret_expires_at, client_id_issued_at, client_name, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, software_id, dpop_bound_access_tokens, active, created_at, updated_at",
+  "query": "UPDATE oauth_clients SET client_name = $2, client_name_enc = $3, grant_types = $4, response_types = $5, scope = $6, token_endpoint_auth_method = $7, jwks = $8, jwks_uri = $9, jwks_uri_enc = $10, tls_client_auth_subject_dn = $11, tls_client_auth_subject_dn_enc = $12, tls_client_auth_subject_dn_index = $13, tls_client_auth_cert_thumbprint = $14, tls_client_auth_cert_thumbprint_enc = $15, tls_client_auth_cert_thumbprint_index = $16, request_uris = $17, request_uris_enc = $18, contacts = $19, contacts_enc = $20, software_id = $21, software_id_enc = $22, dpop_bound_access_tokens = $23, updated_at = now() WHERE id = $1 RETURNING id, client_id, client_secret_expires_at, client_id_issued_at, client_name, client_name_enc, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, jwks_uri_enc, tls_client_auth_subject_dn, tls_client_auth_subject_dn_enc, tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, dpop_bound_access_tokens, active, created_at, updated_at",
   "describe": {
     "columns": [
       {
@@ -60,6 +60,17 @@
       },
       {
         "ordinal": 5,
+        "name": "client_name_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
         "name": "grant_types",
         "type_info": "TextArray",
         "origin": {
@@ -70,7 +81,7 @@
         }
       },
       {
-        "ordinal": 6,
+        "ordinal": 7,
         "name": "response_types",
         "type_info": "TextArray",
         "origin": {
@@ -81,7 +92,7 @@
         }
       },
       {
-        "ordinal": 7,
+        "ordinal": 8,
         "name": "scope",
         "type_info": "Text",
         "origin": {
@@ -92,7 +103,7 @@
         }
       },
       {
-        "ordinal": 8,
+        "ordinal": 9,
         "name": "token_endpoint_auth_method",
         "type_info": "Text",
         "origin": {
@@ -103,7 +114,7 @@
         }
       },
       {
-        "ordinal": 9,
+        "ordinal": 10,
         "name": "jwks",
         "type_info": "Jsonb",
         "origin": {
@@ -114,7 +125,7 @@
         }
       },
       {
-        "ordinal": 10,
+        "ordinal": 11,
         "name": "jwks_uri",
         "type_info": "Text",
         "origin": {
@@ -125,7 +136,18 @@
         }
       },
       {
-        "ordinal": 11,
+        "ordinal": 12,
+        "name": "jwks_uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks_uri_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 13,
         "name": "tls_client_auth_subject_dn",
         "type_info": "Text",
         "origin": {
@@ -136,7 +158,18 @@
         }
       },
       {
-        "ordinal": 12,
+        "ordinal": 14,
+        "name": "tls_client_auth_subject_dn_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_subject_dn_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 15,
         "name": "tls_client_auth_cert_thumbprint",
         "type_info": "Text",
         "origin": {
@@ -147,7 +180,18 @@
         }
       },
       {
-        "ordinal": 13,
+        "ordinal": 16,
+        "name": "tls_client_auth_cert_thumbprint_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_cert_thumbprint_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 17,
         "name": "request_uris",
         "type_info": "Jsonb",
         "origin": {
@@ -158,7 +202,18 @@
         }
       },
       {
-        "ordinal": 14,
+        "ordinal": 18,
+        "name": "request_uris_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "request_uris_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 19,
         "name": "contacts",
         "type_info": "Jsonb",
         "origin": {
@@ -169,7 +224,18 @@
         }
       },
       {
-        "ordinal": 15,
+        "ordinal": 20,
+        "name": "contacts_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "contacts_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 21,
         "name": "software_id",
         "type_info": "Text",
         "origin": {
@@ -180,7 +246,18 @@
         }
       },
       {
-        "ordinal": 16,
+        "ordinal": 22,
+        "name": "software_id_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "software_id_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 23,
         "name": "dpop_bound_access_tokens",
         "type_info": "Bool",
         "origin": {
@@ -191,7 +268,7 @@
         }
       },
       {
-        "ordinal": 17,
+        "ordinal": 24,
         "name": "active",
         "type_info": "Bool",
         "origin": {
@@ -202,7 +279,7 @@
         }
       },
       {
-        "ordinal": 18,
+        "ordinal": 25,
         "name": "created_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -213,7 +290,7 @@
         }
       },
       {
-        "ordinal": 19,
+        "ordinal": 26,
         "name": "updated_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -226,19 +303,28 @@
     ],
     "parameters": {
       "Left": [
+        "Uuid",
         "Text",
-        "Text",
+        "Bytea",
         "TextArray",
         "TextArray",
         "Text",
         "Text",
         "Jsonb",
         "Text",
+        "Bytea",
         "Text",
+        "Bytea",
+        "Text",
+        "Text",
+        "Bytea",
         "Text",
         "Jsonb",
+        "Bytea",
         "Jsonb",
+        "Bytea",
         "Text",
+        "Bytea",
         "Bool"
       ]
     },
@@ -247,11 +333,18 @@
       false,
       true,
       false,
+      true,
+      true,
       false,
       false,
       false,
       false,
-      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
       true,
       true,
       true,
@@ -265,5 +358,5 @@
       false
     ]
   },
-  "hash": "40cfcf7198a65db344c84765f67d8bc6b89088132aa9a0c5b66e0b35487c6244"
+  "hash": "1cf0676f9a41ce8b8e07e77673d31d225ec331c2fbfc7d6e6ac9fe9598570a21"
 }

+ 101 - 17
.sqlx/query-7375e8cf53fa01c8de3545d3ee0678bf0825c71f2a2938ce1a290ad5a23ef5c9.json → .sqlx/query-1f4bf0fab2e49823757742bf3d8c0da51893614b667dc5ed39121ecc8f781274.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT id, client_id, client_secret_expires_at, client_id_issued_at, client_name, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, software_id, dpop_bound_access_tokens, active, created_at, updated_at FROM oauth_clients WHERE ($1 OR active = true) ORDER BY created_at, client_id",
+  "query": "SELECT id, client_id, client_secret_expires_at, client_id_issued_at, client_name, client_name_enc, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, jwks_uri_enc, tls_client_auth_subject_dn, tls_client_auth_subject_dn_enc, tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, dpop_bound_access_tokens, active, created_at, updated_at FROM oauth_clients WHERE ($1 OR active = true) ORDER BY created_at, client_id",
   "describe": {
     "columns": [
       {
@@ -60,6 +60,17 @@
       },
       {
         "ordinal": 5,
+        "name": "client_name_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
         "name": "grant_types",
         "type_info": "TextArray",
         "origin": {
@@ -70,7 +81,7 @@
         }
       },
       {
-        "ordinal": 6,
+        "ordinal": 7,
         "name": "response_types",
         "type_info": "TextArray",
         "origin": {
@@ -81,7 +92,7 @@
         }
       },
       {
-        "ordinal": 7,
+        "ordinal": 8,
         "name": "scope",
         "type_info": "Text",
         "origin": {
@@ -92,7 +103,7 @@
         }
       },
       {
-        "ordinal": 8,
+        "ordinal": 9,
         "name": "token_endpoint_auth_method",
         "type_info": "Text",
         "origin": {
@@ -103,7 +114,7 @@
         }
       },
       {
-        "ordinal": 9,
+        "ordinal": 10,
         "name": "jwks",
         "type_info": "Jsonb",
         "origin": {
@@ -114,7 +125,7 @@
         }
       },
       {
-        "ordinal": 10,
+        "ordinal": 11,
         "name": "jwks_uri",
         "type_info": "Text",
         "origin": {
@@ -125,7 +136,18 @@
         }
       },
       {
-        "ordinal": 11,
+        "ordinal": 12,
+        "name": "jwks_uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks_uri_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 13,
         "name": "tls_client_auth_subject_dn",
         "type_info": "Text",
         "origin": {
@@ -136,7 +158,18 @@
         }
       },
       {
-        "ordinal": 12,
+        "ordinal": 14,
+        "name": "tls_client_auth_subject_dn_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_subject_dn_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 15,
         "name": "tls_client_auth_cert_thumbprint",
         "type_info": "Text",
         "origin": {
@@ -147,7 +180,18 @@
         }
       },
       {
-        "ordinal": 13,
+        "ordinal": 16,
+        "name": "tls_client_auth_cert_thumbprint_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_cert_thumbprint_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 17,
         "name": "request_uris",
         "type_info": "Jsonb",
         "origin": {
@@ -158,7 +202,18 @@
         }
       },
       {
-        "ordinal": 14,
+        "ordinal": 18,
+        "name": "request_uris_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "request_uris_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 19,
         "name": "contacts",
         "type_info": "Jsonb",
         "origin": {
@@ -169,7 +224,18 @@
         }
       },
       {
-        "ordinal": 15,
+        "ordinal": 20,
+        "name": "contacts_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "contacts_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 21,
         "name": "software_id",
         "type_info": "Text",
         "origin": {
@@ -180,7 +246,18 @@
         }
       },
       {
-        "ordinal": 16,
+        "ordinal": 22,
+        "name": "software_id_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "software_id_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 23,
         "name": "dpop_bound_access_tokens",
         "type_info": "Bool",
         "origin": {
@@ -191,7 +268,7 @@
         }
       },
       {
-        "ordinal": 17,
+        "ordinal": 24,
         "name": "active",
         "type_info": "Bool",
         "origin": {
@@ -202,7 +279,7 @@
         }
       },
       {
-        "ordinal": 18,
+        "ordinal": 25,
         "name": "created_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -213,7 +290,7 @@
         }
       },
       {
-        "ordinal": 19,
+        "ordinal": 26,
         "name": "updated_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -234,11 +311,18 @@
       false,
       true,
       false,
+      true,
+      true,
       false,
       false,
       false,
       false,
-      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
       true,
       true,
       true,
@@ -252,5 +336,5 @@
       false
     ]
   },
-  "hash": "7375e8cf53fa01c8de3545d3ee0678bf0825c71f2a2938ce1a290ad5a23ef5c9"
+  "hash": "1f4bf0fab2e49823757742bf3d8c0da51893614b667dc5ed39121ecc8f781274"
 }

+ 24 - 0
.sqlx/query-20c70c8d1ddca080c639f2524965d1fef0e8cd5a6e159c27a0ca1f2d899f85fb.json

@@ -0,0 +1,24 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT EXISTS(SELECT 1 FROM oauth_clients WHERE tls_client_auth_subject_dn_index = ANY($1) OR (tls_client_auth_subject_dn IS NOT NULL AND tls_client_auth_subject_dn = $2)) AS \"exists!\"",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "exists!",
+        "type_info": "Bool",
+        "origin": "Expression"
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "TextArray",
+        "Text"
+      ]
+    },
+    "nullable": [
+      null
+    ]
+  },
+  "hash": "20c70c8d1ddca080c639f2524965d1fef0e8cd5a6e159c27a0ca1f2d899f85fb"
+}

+ 40 - 0
.sqlx/query-244eaf934d505ad6d3b1e36eef8f97e90ce4ce4d86361213690a5616be744e88.json

@@ -0,0 +1,40 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT ru.uri, ru.uri_enc FROM client_redirect_uris AS cru JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id WHERE cru.client_id = $1 AND ru.enabled = true AND ru.kind = 'post_logout'",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "uri",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri_enc"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": [
+      true,
+      true
+    ]
+  },
+  "hash": "244eaf934d505ad6d3b1e36eef8f97e90ce4ce4d86361213690a5616be744e88"
+}

+ 101 - 17
.sqlx/query-2f7dbb4684015950066d04850a4b0adea06bcf90e85e61b575b0f0efecdc2d06.json → .sqlx/query-310cbe7ee6b575d3ae246b0e401a58e19c0cf92d2eeb58ebdc512b3319e67cd5.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT id, client_id, client_secret_expires_at, client_id_issued_at, client_name, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, software_id, dpop_bound_access_tokens, active, created_at, updated_at FROM oauth_clients WHERE id = $1",
+  "query": "SELECT id, client_id, client_secret_expires_at, client_id_issued_at, client_name, client_name_enc, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, jwks_uri_enc, tls_client_auth_subject_dn, tls_client_auth_subject_dn_enc, tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, dpop_bound_access_tokens, active, created_at, updated_at FROM oauth_clients WHERE id = $1",
   "describe": {
     "columns": [
       {
@@ -60,6 +60,17 @@
       },
       {
         "ordinal": 5,
+        "name": "client_name_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
         "name": "grant_types",
         "type_info": "TextArray",
         "origin": {
@@ -70,7 +81,7 @@
         }
       },
       {
-        "ordinal": 6,
+        "ordinal": 7,
         "name": "response_types",
         "type_info": "TextArray",
         "origin": {
@@ -81,7 +92,7 @@
         }
       },
       {
-        "ordinal": 7,
+        "ordinal": 8,
         "name": "scope",
         "type_info": "Text",
         "origin": {
@@ -92,7 +103,7 @@
         }
       },
       {
-        "ordinal": 8,
+        "ordinal": 9,
         "name": "token_endpoint_auth_method",
         "type_info": "Text",
         "origin": {
@@ -103,7 +114,7 @@
         }
       },
       {
-        "ordinal": 9,
+        "ordinal": 10,
         "name": "jwks",
         "type_info": "Jsonb",
         "origin": {
@@ -114,7 +125,7 @@
         }
       },
       {
-        "ordinal": 10,
+        "ordinal": 11,
         "name": "jwks_uri",
         "type_info": "Text",
         "origin": {
@@ -125,7 +136,18 @@
         }
       },
       {
-        "ordinal": 11,
+        "ordinal": 12,
+        "name": "jwks_uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks_uri_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 13,
         "name": "tls_client_auth_subject_dn",
         "type_info": "Text",
         "origin": {
@@ -136,7 +158,18 @@
         }
       },
       {
-        "ordinal": 12,
+        "ordinal": 14,
+        "name": "tls_client_auth_subject_dn_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_subject_dn_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 15,
         "name": "tls_client_auth_cert_thumbprint",
         "type_info": "Text",
         "origin": {
@@ -147,7 +180,18 @@
         }
       },
       {
-        "ordinal": 13,
+        "ordinal": 16,
+        "name": "tls_client_auth_cert_thumbprint_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_cert_thumbprint_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 17,
         "name": "request_uris",
         "type_info": "Jsonb",
         "origin": {
@@ -158,7 +202,18 @@
         }
       },
       {
-        "ordinal": 14,
+        "ordinal": 18,
+        "name": "request_uris_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "request_uris_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 19,
         "name": "contacts",
         "type_info": "Jsonb",
         "origin": {
@@ -169,7 +224,18 @@
         }
       },
       {
-        "ordinal": 15,
+        "ordinal": 20,
+        "name": "contacts_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "contacts_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 21,
         "name": "software_id",
         "type_info": "Text",
         "origin": {
@@ -180,7 +246,18 @@
         }
       },
       {
-        "ordinal": 16,
+        "ordinal": 22,
+        "name": "software_id_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "software_id_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 23,
         "name": "dpop_bound_access_tokens",
         "type_info": "Bool",
         "origin": {
@@ -191,7 +268,7 @@
         }
       },
       {
-        "ordinal": 17,
+        "ordinal": 24,
         "name": "active",
         "type_info": "Bool",
         "origin": {
@@ -202,7 +279,7 @@
         }
       },
       {
-        "ordinal": 18,
+        "ordinal": 25,
         "name": "created_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -213,7 +290,7 @@
         }
       },
       {
-        "ordinal": 19,
+        "ordinal": 26,
         "name": "updated_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -234,11 +311,18 @@
       false,
       true,
       false,
+      true,
+      true,
       false,
       false,
       false,
       false,
-      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
       true,
       true,
       true,
@@ -252,5 +336,5 @@
       false
     ]
   },
-  "hash": "2f7dbb4684015950066d04850a4b0adea06bcf90e85e61b575b0f0efecdc2d06"
+  "hash": "310cbe7ee6b575d3ae246b0e401a58e19c0cf92d2eeb58ebdc512b3319e67cd5"
 }

+ 24 - 0
.sqlx/query-44c435f01020ed8c305e03097375f6f75a4d51336dc72fc40907c4d597332828.json

@@ -0,0 +1,24 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT EXISTS(SELECT 1 FROM oauth_clients WHERE tls_client_auth_cert_thumbprint_index = ANY($1) OR (tls_client_auth_cert_thumbprint IS NOT NULL AND tls_client_auth_cert_thumbprint = $2)) AS \"exists!\"",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "exists!",
+        "type_info": "Bool",
+        "origin": "Expression"
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "TextArray",
+        "Text"
+      ]
+    },
+    "nullable": [
+      null
+    ]
+  },
+  "hash": "44c435f01020ed8c305e03097375f6f75a4d51336dc72fc40907c4d597332828"
+}

+ 28 - 0
.sqlx/query-4d8146ec3fc52a8a70a6c213571bad7b14c0ca95a5d55ce1600e6a46e28d5227.json

@@ -0,0 +1,28 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT id FROM oauth_clients WHERE client_id = $1 AND active = true",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "id"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": [
+      false
+    ]
+  },
+  "hash": "4d8146ec3fc52a8a70a6c213571bad7b14c0ca95a5d55ce1600e6a46e28d5227"
+}

+ 101 - 17
.sqlx/query-53a221512394ee4da5eaf3b522989ed45e278f4aa1c097c3acf21c8abe413742.json → .sqlx/query-5328a7be851fc8892f4599363cad74cf4bc08ea045e49105977b833af17e0ce1.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT id, client_id, client_secret_expires_at, client_id_issued_at, client_name, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, software_id, dpop_bound_access_tokens, active, created_at, updated_at FROM oauth_clients WHERE client_id = $1 AND active = true",
+  "query": "SELECT id, client_id, client_secret_expires_at, client_id_issued_at, client_name, client_name_enc, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, jwks_uri_enc, tls_client_auth_subject_dn, tls_client_auth_subject_dn_enc, tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, dpop_bound_access_tokens, active, created_at, updated_at FROM oauth_clients WHERE client_id = $1 AND active = true",
   "describe": {
     "columns": [
       {
@@ -60,6 +60,17 @@
       },
       {
         "ordinal": 5,
+        "name": "client_name_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
         "name": "grant_types",
         "type_info": "TextArray",
         "origin": {
@@ -70,7 +81,7 @@
         }
       },
       {
-        "ordinal": 6,
+        "ordinal": 7,
         "name": "response_types",
         "type_info": "TextArray",
         "origin": {
@@ -81,7 +92,7 @@
         }
       },
       {
-        "ordinal": 7,
+        "ordinal": 8,
         "name": "scope",
         "type_info": "Text",
         "origin": {
@@ -92,7 +103,7 @@
         }
       },
       {
-        "ordinal": 8,
+        "ordinal": 9,
         "name": "token_endpoint_auth_method",
         "type_info": "Text",
         "origin": {
@@ -103,7 +114,7 @@
         }
       },
       {
-        "ordinal": 9,
+        "ordinal": 10,
         "name": "jwks",
         "type_info": "Jsonb",
         "origin": {
@@ -114,7 +125,7 @@
         }
       },
       {
-        "ordinal": 10,
+        "ordinal": 11,
         "name": "jwks_uri",
         "type_info": "Text",
         "origin": {
@@ -125,7 +136,18 @@
         }
       },
       {
-        "ordinal": 11,
+        "ordinal": 12,
+        "name": "jwks_uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks_uri_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 13,
         "name": "tls_client_auth_subject_dn",
         "type_info": "Text",
         "origin": {
@@ -136,7 +158,18 @@
         }
       },
       {
-        "ordinal": 12,
+        "ordinal": 14,
+        "name": "tls_client_auth_subject_dn_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_subject_dn_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 15,
         "name": "tls_client_auth_cert_thumbprint",
         "type_info": "Text",
         "origin": {
@@ -147,7 +180,18 @@
         }
       },
       {
-        "ordinal": 13,
+        "ordinal": 16,
+        "name": "tls_client_auth_cert_thumbprint_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_cert_thumbprint_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 17,
         "name": "request_uris",
         "type_info": "Jsonb",
         "origin": {
@@ -158,7 +202,18 @@
         }
       },
       {
-        "ordinal": 14,
+        "ordinal": 18,
+        "name": "request_uris_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "request_uris_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 19,
         "name": "contacts",
         "type_info": "Jsonb",
         "origin": {
@@ -169,7 +224,18 @@
         }
       },
       {
-        "ordinal": 15,
+        "ordinal": 20,
+        "name": "contacts_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "contacts_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 21,
         "name": "software_id",
         "type_info": "Text",
         "origin": {
@@ -180,7 +246,18 @@
         }
       },
       {
-        "ordinal": 16,
+        "ordinal": 22,
+        "name": "software_id_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "software_id_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 23,
         "name": "dpop_bound_access_tokens",
         "type_info": "Bool",
         "origin": {
@@ -191,7 +268,7 @@
         }
       },
       {
-        "ordinal": 17,
+        "ordinal": 24,
         "name": "active",
         "type_info": "Bool",
         "origin": {
@@ -202,7 +279,7 @@
         }
       },
       {
-        "ordinal": 18,
+        "ordinal": 25,
         "name": "created_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -213,7 +290,7 @@
         }
       },
       {
-        "ordinal": 19,
+        "ordinal": 26,
         "name": "updated_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -234,11 +311,18 @@
       false,
       true,
       false,
+      true,
+      true,
       false,
       false,
       false,
       false,
-      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
       true,
       true,
       true,
@@ -252,5 +336,5 @@
       false
     ]
   },
-  "hash": "53a221512394ee4da5eaf3b522989ed45e278f4aa1c097c3acf21c8abe413742"
+  "hash": "5328a7be851fc8892f4599363cad74cf4bc08ea045e49105977b833af17e0ce1"
 }

+ 25 - 0
.sqlx/query-57fc3eba28c68f8f58e5d1859514f20843f1482a348981cba08e7793232efddd.json

@@ -0,0 +1,25 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT EXISTS(SELECT 1 FROM oauth_clients WHERE (tls_client_auth_subject_dn_index = ANY($1) OR (tls_client_auth_subject_dn IS NOT NULL AND tls_client_auth_subject_dn = $2)) AND id <> $3) AS \"exists!\"",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "exists!",
+        "type_info": "Bool",
+        "origin": "Expression"
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "TextArray",
+        "Text",
+        "Uuid"
+      ]
+    },
+    "nullable": [
+      null
+    ]
+  },
+  "hash": "57fc3eba28c68f8f58e5d1859514f20843f1482a348981cba08e7793232efddd"
+}

+ 16 - 4
.sqlx/query-69308568afaa5e9fab1dd59db7b40ca7323832855f4ac20607293134cfb3202e.json → .sqlx/query-6bbbacf992036c6f696f9d723823dd832dac8603ee6de2d3339fd8a94848d8d2.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "UPDATE redirect_uris SET enabled = $2, updated_at = now() WHERE id = $1 RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at",
+  "query": "UPDATE redirect_uris SET enabled = $2, updated_at = now() WHERE id = $1 RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at, uri_enc",
   "describe": {
     "columns": [
       {
@@ -90,6 +90,17 @@
             "name": "updated_at"
           }
         }
+      },
+      {
+        "ordinal": 8,
+        "name": "uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri_enc"
+          }
+        }
       }
     ],
     "parameters": {
@@ -100,14 +111,15 @@
     },
     "nullable": [
       false,
-      false,
+      true,
       false,
       false,
       false,
       true,
       false,
-      false
+      false,
+      true
     ]
   },
-  "hash": "69308568afaa5e9fab1dd59db7b40ca7323832855f4ac20607293134cfb3202e"
+  "hash": "6bbbacf992036c6f696f9d723823dd832dac8603ee6de2d3339fd8a94848d8d2"
 }

+ 16 - 4
.sqlx/query-1371e4448a989ff9f81cfb639d43bb037b1053522fbd49213476b60637e7fa96.json → .sqlx/query-79e3601aefbfd67768897e2869f5c6eb0a9bbe8878a3b48c2e4d02c7afe262e8.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT ru.id, ru.uri, ru.kind, ru.enabled, ru.description, ru.created_by, ru.created_at, ru.updated_at FROM client_redirect_uris AS cru JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id WHERE cru.client_id = $1 AND ru.enabled = true AND ru.kind = 'authorize' ORDER BY ru.uri",
+  "query": "SELECT ru.id, ru.uri, ru.kind, ru.enabled, ru.description, ru.created_by, ru.created_at, ru.updated_at, ru.uri_enc FROM client_redirect_uris AS cru JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id WHERE cru.client_id = $1 AND ru.enabled = true AND ru.kind = 'authorize'",
   "describe": {
     "columns": [
       {
@@ -90,6 +90,17 @@
             "name": "updated_at"
           }
         }
+      },
+      {
+        "ordinal": 8,
+        "name": "uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri_enc"
+          }
+        }
       }
     ],
     "parameters": {
@@ -99,14 +110,15 @@
     },
     "nullable": [
       false,
-      false,
+      true,
       false,
       false,
       false,
       true,
       false,
-      false
+      false,
+      true
     ]
   },
-  "hash": "1371e4448a989ff9f81cfb639d43bb037b1053522fbd49213476b60637e7fa96"
+  "hash": "79e3601aefbfd67768897e2869f5c6eb0a9bbe8878a3b48c2e4d02c7afe262e8"
 }

+ 16 - 4
.sqlx/query-5c4899738b7b454659330a8c2775c8641061a76368ad3b253f08d5d7536c81ab.json → .sqlx/query-836082ac97001615cbd9021f0c6694a5761ed0cd42c38318d2f1755cfadf8e3d.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT id, uri, kind, enabled, description, created_by, created_at, updated_at FROM redirect_uris WHERE ($1 OR enabled = true) ORDER BY uri",
+  "query": "SELECT id, uri, kind, enabled, description, created_by, created_at, updated_at, uri_enc FROM redirect_uris WHERE ($1 OR enabled = true)",
   "describe": {
     "columns": [
       {
@@ -90,6 +90,17 @@
             "name": "updated_at"
           }
         }
+      },
+      {
+        "ordinal": 8,
+        "name": "uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri_enc"
+          }
+        }
       }
     ],
     "parameters": {
@@ -99,14 +110,15 @@
     },
     "nullable": [
       false,
-      false,
+      true,
       false,
       false,
       false,
       true,
       false,
-      false
+      false,
+      true
     ]
   },
-  "hash": "5c4899738b7b454659330a8c2775c8641061a76368ad3b253f08d5d7536c81ab"
+  "hash": "836082ac97001615cbd9021f0c6694a5761ed0cd42c38318d2f1755cfadf8e3d"
 }

+ 20 - 7
.sqlx/query-915a3721e78651ff39ca47db647cc1282ad0ddc8b508257bb17c3e4f049b4e41.json → .sqlx/query-8ca892dd486bcbaffb23e52023edf7e93e7193993f903bbafdc8c82e71cb310c.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT id, email::text AS \"email!\", is_admin, must_change_password,\n              disabled, totp_enabled, password_changed_at\n         FROM users\n        WHERE email = $1::text::citext",
+  "query": "SELECT id, email::text AS email, email_enc, is_admin, must_change_password,\n              disabled, totp_enabled, password_changed_at\n         FROM users\n        WHERE email_index = ANY($1)\n           OR (email_enc IS NULL AND email = $2::text::citext)",
   "describe": {
     "columns": [
       {
@@ -16,12 +16,23 @@
       },
       {
         "ordinal": 1,
-        "name": "email!",
+        "name": "email",
         "type_info": "Text",
         "origin": "Expression"
       },
       {
         "ordinal": 2,
+        "name": "email_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "email_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
         "name": "is_admin",
         "type_info": "Bool",
         "origin": {
@@ -32,7 +43,7 @@
         }
       },
       {
-        "ordinal": 3,
+        "ordinal": 4,
         "name": "must_change_password",
         "type_info": "Bool",
         "origin": {
@@ -43,7 +54,7 @@
         }
       },
       {
-        "ordinal": 4,
+        "ordinal": 5,
         "name": "disabled",
         "type_info": "Bool",
         "origin": {
@@ -54,7 +65,7 @@
         }
       },
       {
-        "ordinal": 5,
+        "ordinal": 6,
         "name": "totp_enabled",
         "type_info": "Bool",
         "origin": {
@@ -65,7 +76,7 @@
         }
       },
       {
-        "ordinal": 6,
+        "ordinal": 7,
         "name": "password_changed_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -78,12 +89,14 @@
     ],
     "parameters": {
       "Left": [
+        "TextArray",
         "Text"
       ]
     },
     "nullable": [
       false,
       null,
+      true,
       false,
       false,
       false,
@@ -91,5 +104,5 @@
       true
     ]
   },
-  "hash": "915a3721e78651ff39ca47db647cc1282ad0ddc8b508257bb17c3e4f049b4e41"
+  "hash": "8ca892dd486bcbaffb23e52023edf7e93e7193993f903bbafdc8c82e71cb310c"
 }

+ 16 - 3
.sqlx/query-0ed2b9b91b8c1630e806e6eaf9d49aa0fc33a8e90932bdc8f0eb5d5d1fc0b44d.json → .sqlx/query-8d3f5427e689a18faa311d2f0ac9f095982c4088868329f4259ebdd876fdf55c.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT password_hash, disabled\n         FROM users\n        WHERE email = $1::text::citext",
+  "query": "SELECT password_hash, disabled, email_enc\n         FROM users\n        WHERE email_index = ANY($1)\n           OR (email_enc IS NULL AND email = $2::text::citext)",
   "describe": {
     "columns": [
       {
@@ -24,17 +24,30 @@
             "name": "disabled"
           }
         }
+      },
+      {
+        "ordinal": 2,
+        "name": "email_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "email_enc"
+          }
+        }
       }
     ],
     "parameters": {
       "Left": [
+        "TextArray",
         "Text"
       ]
     },
     "nullable": [
       false,
-      false
+      false,
+      true
     ]
   },
-  "hash": "0ed2b9b91b8c1630e806e6eaf9d49aa0fc33a8e90932bdc8f0eb5d5d1fc0b44d"
+  "hash": "8d3f5427e689a18faa311d2f0ac9f095982c4088868329f4259ebdd876fdf55c"
 }

+ 137 - 0
.sqlx/query-91af982bb1418227fed7787bba39e3fab97337e7983061e6ffd7535d2674250c.json

@@ -0,0 +1,137 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "INSERT INTO redirect_uris (id, uri, kind, description, created_by, created_at, updated_at, uri_enc, uri_index) VALUES ($1, $2, $3, $4, $5, now(), now(), $6, $7) ON CONFLICT (uri_index, kind) WHERE uri_index IS NOT NULL DO UPDATE SET updated_at = now() RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at, uri_enc, (xmax = 0) AS \"inserted!\"",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "uri",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "kind",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "kind"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "enabled"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "description",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "description"
+          }
+        }
+      },
+      {
+        "ordinal": 5,
+        "name": "created_by",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "created_by"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "created_at"
+          }
+        }
+      },
+      {
+        "ordinal": 7,
+        "name": "updated_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "updated_at"
+          }
+        }
+      },
+      {
+        "ordinal": 8,
+        "name": "uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 9,
+        "name": "inserted!",
+        "type_info": "Bool",
+        "origin": "Expression"
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Text",
+        "Text",
+        "Text",
+        "Uuid",
+        "Bytea",
+        "Text"
+      ]
+    },
+    "nullable": [
+      false,
+      true,
+      false,
+      false,
+      false,
+      true,
+      false,
+      false,
+      true,
+      null
+    ]
+  },
+  "hash": "91af982bb1418227fed7787bba39e3fab97337e7983061e6ffd7535d2674250c"
+}

+ 3 - 2
.sqlx/query-b039c8a8561567cbeaaecb1f301d10e11591ee32111f643867890a36c042cc91.json → .sqlx/query-a3bea2aa8d3d7eaeaa8916665fd083e7118625cbf646a8b71488cebe3c257908.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT EXISTS ( SELECT 1 FROM client_redirect_uris AS cru JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id WHERE cru.client_id = $1 AND ru.enabled = true AND ru.uri = $2) AS \"allowed!\"",
+  "query": "SELECT EXISTS ( SELECT 1 FROM client_redirect_uris AS cru JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id WHERE cru.client_id = $1 AND ru.enabled = true AND (ru.uri_index = ANY($2) OR (ru.uri_enc IS NULL AND ru.uri = $3))) AS \"allowed!\"",
   "describe": {
     "columns": [
       {
@@ -13,6 +13,7 @@
     "parameters": {
       "Left": [
         "Text",
+        "TextArray",
         "Text"
       ]
     },
@@ -20,5 +21,5 @@
       null
     ]
   },
-  "hash": "b039c8a8561567cbeaaecb1f301d10e11591ee32111f643867890a36c042cc91"
+  "hash": "a3bea2aa8d3d7eaeaa8916665fd083e7118625cbf646a8b71488cebe3c257908"
 }

+ 25 - 0
.sqlx/query-bfde1d8165ec834894f2125a48c503639787c1ebed0568dd6ced56d150d38db1.json

@@ -0,0 +1,25 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT EXISTS(SELECT 1 FROM oauth_clients WHERE (tls_client_auth_cert_thumbprint_index = ANY($1) OR (tls_client_auth_cert_thumbprint IS NOT NULL AND tls_client_auth_cert_thumbprint = $2)) AND id <> $3) AS \"exists!\"",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "exists!",
+        "type_info": "Bool",
+        "origin": "Expression"
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "TextArray",
+        "Text",
+        "Uuid"
+      ]
+    },
+    "nullable": [
+      null
+    ]
+  },
+  "hash": "bfde1d8165ec834894f2125a48c503639787c1ebed0568dd6ced56d150d38db1"
+}

+ 24 - 0
.sqlx/query-c04af456919cef4b8bd5f911731f8c62b0566a1f3c24fee377cf9be326671715.json

@@ -0,0 +1,24 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT EXISTS(\n         SELECT 1 FROM users\n          WHERE email_index = ANY($1)\n             OR (email_enc IS NULL AND email = $2::text::citext)\n       ) AS \"exists!\"",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "exists!",
+        "type_info": "Bool",
+        "origin": "Expression"
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "TextArray",
+        "Text"
+      ]
+    },
+    "nullable": [
+      null
+    ]
+  },
+  "hash": "c04af456919cef4b8bd5f911731f8c62b0566a1f3c24fee377cf9be326671715"
+}

+ 19 - 6
.sqlx/query-56a179c3ef725c5ffe95f7c71f2e5f195072bcbc139737eab10b83a50e6c72a1.json → .sqlx/query-c7ca27d77f502c1b9c7f1145b2723487f2f3c6d270803752daba5c821e8b438e.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "INSERT INTO redirect_uris (id, uri, kind, description, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, now(), now()) ON CONFLICT (uri, kind) DO UPDATE SET updated_at = now() RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at",
+  "query": "UPDATE redirect_uris SET uri = $1, uri_enc = $2, uri_index = $3, updated_at = now() WHERE kind = $4 AND (uri_index = ANY($5) OR uri = $6) RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at, uri_enc",
   "describe": {
     "columns": [
       {
@@ -90,27 +90,40 @@
             "name": "updated_at"
           }
         }
+      },
+      {
+        "ordinal": 8,
+        "name": "uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri_enc"
+          }
+        }
       }
     ],
     "parameters": {
       "Left": [
-        "Uuid",
         "Text",
+        "Bytea",
         "Text",
         "Text",
-        "Uuid"
+        "TextArray",
+        "Text"
       ]
     },
     "nullable": [
       false,
-      false,
+      true,
       false,
       false,
       false,
       true,
       false,
-      false
+      false,
+      true
     ]
   },
-  "hash": "56a179c3ef725c5ffe95f7c71f2e5f195072bcbc139737eab10b83a50e6c72a1"
+  "hash": "c7ca27d77f502c1b9c7f1145b2723487f2f3c6d270803752daba5c821e8b438e"
 }

+ 0 - 28
.sqlx/query-cd2fda70d5b4529d5d119855fa893a33de822b67b97c186e142c2c2acbb66426.json

@@ -1,28 +0,0 @@
-{
-  "db_name": "PostgreSQL",
-  "query": "SELECT ru.uri FROM client_redirect_uris AS cru JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id WHERE cru.client_id = $1 AND ru.enabled = true AND ru.kind = 'post_logout' ORDER BY ru.uri",
-  "describe": {
-    "columns": [
-      {
-        "ordinal": 0,
-        "name": "uri",
-        "type_info": "Text",
-        "origin": {
-          "Table": {
-            "table": "redirect_uris",
-            "name": "uri"
-          }
-        }
-      }
-    ],
-    "parameters": {
-      "Left": [
-        "Text"
-      ]
-    },
-    "nullable": [
-      false
-    ]
-  },
-  "hash": "cd2fda70d5b4529d5d119855fa893a33de822b67b97c186e142c2c2acbb66426"
-}

+ 16 - 4
.sqlx/query-4faaddbb8edc3fd5fcc8e3f9e1b3ea741d9f7fb92611f35122046116e0412b3e.json → .sqlx/query-dd0112c7b1c2b6e3642247bbd6377022f63bc75f253cd7355d6e634f31ee9ee0.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT ru.id, ru.uri, ru.kind, ru.enabled, ru.description, ru.created_by, ru.created_at, ru.updated_at FROM client_redirect_uris AS cru JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id WHERE cru.client_id = $1 ORDER BY ru.uri",
+  "query": "SELECT ru.id, ru.uri, ru.kind, ru.enabled, ru.description, ru.created_by, ru.created_at, ru.updated_at, ru.uri_enc FROM client_redirect_uris AS cru JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id WHERE cru.client_id = $1",
   "describe": {
     "columns": [
       {
@@ -90,6 +90,17 @@
             "name": "updated_at"
           }
         }
+      },
+      {
+        "ordinal": 8,
+        "name": "uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri_enc"
+          }
+        }
       }
     ],
     "parameters": {
@@ -99,14 +110,15 @@
     },
     "nullable": [
       false,
-      false,
+      true,
       false,
       false,
       false,
       true,
       false,
-      false
+      false,
+      true
     ]
   },
-  "hash": "4faaddbb8edc3fd5fcc8e3f9e1b3ea741d9f7fb92611f35122046116e0412b3e"
+  "hash": "dd0112c7b1c2b6e3642247bbd6377022f63bc75f253cd7355d6e634f31ee9ee0"
 }

+ 110 - 17
.sqlx/query-6bc0248569d7b6b4d3fd1f7f57fb21cba3b74bfded63c3287b06db8160a07b24.json → .sqlx/query-f85e57a560d33626ceb5b28030d25d6f982f8e781ccf7b3e3b3c8fb61e7eed8b.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "INSERT INTO oauth_clients (id, client_id, client_secret_hash, client_secret_expires_at, client_id_issued_at, client_name, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, software_id, registration_access_token_hash, dpop_bound_access_tokens, created_at, updated_at) VALUES ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, now(), now()) RETURNING id, client_id, client_secret_expires_at, client_id_issued_at, client_name, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, software_id, dpop_bound_access_tokens, active, created_at, updated_at",
+  "query": "INSERT INTO oauth_clients (id, client_id, client_secret_hash, client_secret_expires_at, client_id_issued_at, client_name, client_name_enc, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, jwks_uri_enc, tls_client_auth_subject_dn, tls_client_auth_subject_dn_enc, tls_client_auth_subject_dn_index, tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, tls_client_auth_cert_thumbprint_index, request_uris, request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, registration_access_token_hash, dpop_bound_access_tokens, created_at, updated_at) VALUES ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, now(), now()) RETURNING id, client_id, client_secret_expires_at, client_id_issued_at, client_name, client_name_enc, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, jwks_uri_enc, tls_client_auth_subject_dn, tls_client_auth_subject_dn_enc, tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, dpop_bound_access_tokens, active, created_at, updated_at",
   "describe": {
     "columns": [
       {
@@ -60,6 +60,17 @@
       },
       {
         "ordinal": 5,
+        "name": "client_name_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
         "name": "grant_types",
         "type_info": "TextArray",
         "origin": {
@@ -70,7 +81,7 @@
         }
       },
       {
-        "ordinal": 6,
+        "ordinal": 7,
         "name": "response_types",
         "type_info": "TextArray",
         "origin": {
@@ -81,7 +92,7 @@
         }
       },
       {
-        "ordinal": 7,
+        "ordinal": 8,
         "name": "scope",
         "type_info": "Text",
         "origin": {
@@ -92,7 +103,7 @@
         }
       },
       {
-        "ordinal": 8,
+        "ordinal": 9,
         "name": "token_endpoint_auth_method",
         "type_info": "Text",
         "origin": {
@@ -103,7 +114,7 @@
         }
       },
       {
-        "ordinal": 9,
+        "ordinal": 10,
         "name": "jwks",
         "type_info": "Jsonb",
         "origin": {
@@ -114,7 +125,7 @@
         }
       },
       {
-        "ordinal": 10,
+        "ordinal": 11,
         "name": "jwks_uri",
         "type_info": "Text",
         "origin": {
@@ -125,7 +136,18 @@
         }
       },
       {
-        "ordinal": 11,
+        "ordinal": 12,
+        "name": "jwks_uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks_uri_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 13,
         "name": "tls_client_auth_subject_dn",
         "type_info": "Text",
         "origin": {
@@ -136,7 +158,18 @@
         }
       },
       {
-        "ordinal": 12,
+        "ordinal": 14,
+        "name": "tls_client_auth_subject_dn_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_subject_dn_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 15,
         "name": "tls_client_auth_cert_thumbprint",
         "type_info": "Text",
         "origin": {
@@ -147,7 +180,18 @@
         }
       },
       {
-        "ordinal": 13,
+        "ordinal": 16,
+        "name": "tls_client_auth_cert_thumbprint_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_cert_thumbprint_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 17,
         "name": "request_uris",
         "type_info": "Jsonb",
         "origin": {
@@ -158,7 +202,18 @@
         }
       },
       {
-        "ordinal": 14,
+        "ordinal": 18,
+        "name": "request_uris_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "request_uris_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 19,
         "name": "contacts",
         "type_info": "Jsonb",
         "origin": {
@@ -169,7 +224,18 @@
         }
       },
       {
-        "ordinal": 15,
+        "ordinal": 20,
+        "name": "contacts_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "contacts_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 21,
         "name": "software_id",
         "type_info": "Text",
         "origin": {
@@ -180,7 +246,18 @@
         }
       },
       {
-        "ordinal": 16,
+        "ordinal": 22,
+        "name": "software_id_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "software_id_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 23,
         "name": "dpop_bound_access_tokens",
         "type_info": "Bool",
         "origin": {
@@ -191,7 +268,7 @@
         }
       },
       {
-        "ordinal": 17,
+        "ordinal": 24,
         "name": "active",
         "type_info": "Bool",
         "origin": {
@@ -202,7 +279,7 @@
         }
       },
       {
-        "ordinal": 18,
+        "ordinal": 25,
         "name": "created_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -213,7 +290,7 @@
         }
       },
       {
-        "ordinal": 19,
+        "ordinal": 26,
         "name": "updated_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -231,17 +308,26 @@
         "Text",
         "Timestamptz",
         "Text",
+        "Bytea",
         "TextArray",
         "TextArray",
         "Text",
         "Text",
         "Jsonb",
         "Text",
+        "Bytea",
         "Text",
+        "Bytea",
+        "Text",
+        "Text",
+        "Bytea",
         "Text",
         "Jsonb",
+        "Bytea",
         "Jsonb",
+        "Bytea",
         "Text",
+        "Bytea",
         "Text",
         "Bool"
       ]
@@ -251,11 +337,18 @@
       false,
       true,
       false,
+      true,
+      true,
       false,
       false,
       false,
       false,
-      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
       true,
       true,
       true,
@@ -269,5 +362,5 @@
       false
     ]
   },
-  "hash": "6bc0248569d7b6b4d3fd1f7f57fb21cba3b74bfded63c3287b06db8160a07b24"
+  "hash": "f85e57a560d33626ceb5b28030d25d6f982f8e781ccf7b3e3b3c8fb61e7eed8b"
 }

+ 1 - 0
AGENTS.md

@@ -17,6 +17,7 @@ the admin surface.
 | Configuration key or validation rule | `src/config.rs`, `src/config/` | Add to the raw serde struct, then validate in the matching submodule. Update [docs/CONFIGURATION.md](docs/CONFIGURATION.md). |
 | Key-ring behavior | `src/keys.rs` | Versioned ids only; never print key material. |
 | Crypto primitive (hash, compare, PKCE, TOTP) | `src/crypto/` | Constant-time for secret compares. |
+| PII field codec / blind index | `src/crypto/field.rs` | Versioned AES-256-GCM; never log plaintext or key material. |
 | JOSE verification (DPoP, JAR, `private_key_jwt`) | `src/jose/` | Reject fail-closed with the exact error code. |
 | Postgres query or repository | `src/db/` | `query!`/`query_as!` macros; regenerate `.sqlx`. |
 | Redis record or Lua script | `src/store/` | Keep every key under `{state}`; update [docs/REDIS.md](docs/REDIS.md). |

+ 7 - 3
README.md

@@ -60,6 +60,7 @@ export D_OAUTH_REDIS__URL=redis://127.0.0.1:6379
 export D_OAUTH_REDIS__DURABILITY=waitaof
 export D_OAUTH_TOKEN_PEPPER='replace-with-32-bytes-of-random'
 export D_OAUTH_TOTP_ENCRYPTION_KEY='replace-with-32-bytes-of-random'
+export D_OAUTH_PII_ENCRYPTION_KEY='replace-with-32-bytes-of-random'
 ```
 
 Development relaxes the database TLS requirement. Production requires
@@ -232,8 +233,9 @@ certificate.
   against that draft are listed in [docs/OAUTH.md](docs/OAUTH.md).
 - There is no default credential and no HTTP admin API. Provision through the
   CLI.
-- The token pepper and TOTP encryption key are required; losing the pepper
-  invalidates every live opaque token.
+- The token pepper, TOTP encryption key, and PII field-encryption key are
+  required; losing the pepper invalidates every live opaque token, and losing
+  the PII key makes the sealed directory unreadable.
 - Production requires `issuer` on `https` and a database URL with
   `sslmode=verify-full`.
 - Redis must run with `noeviction` and persistence. The server asserts this at
@@ -255,6 +257,8 @@ certificate.
 - Users and entitlements: `user create|enroll-totp|disable|grant-scope|revoke-scope|scopes`.
 - Admin: `admin bootstrap` (see [RUNBOOK.md](RUNBOOK.md)).
 - Declarative apply: `config sync --file` and `config check`.
+- PII migration and rotation: `pii backfill`, `pii verify`, and `pii rotate`,
+  plus `totp rewrap` for the TOTP key. See [docs/OPERATIONS.md](docs/OPERATIONS.md).
 - DCR initial access: `initial-access-token issue`.
 - Utilities: `keygen` and `healthcheck`.
 
@@ -262,4 +266,4 @@ Full flags are in [docs/OPERATIONS.md](docs/OPERATIONS.md).
 
 ## License
 
-MIT OR Apache-2.0. See [LICENSE.md](LICENSE.md).
+ISC. See [LICENSE.md](LICENSE.md).

+ 19 - 0
RUNBOOK.md

@@ -14,6 +14,25 @@ CLI/Job only.
   `d-oauth-server migrate`
 - Zero enabled admins exist (a migrated database seeds zero users).
 
+## Migrations and the maintenance window
+
+Apply the PII envelope-encryption expansion
+(`migrations/1791417600_pii_encryption.sql`) during a maintenance window with
+the app tier quiesced, from the dedicated migration Job:
+
+```sh
+d-oauth-server migrate
+```
+
+`ALTER TABLE ... DROP NOT NULL` takes `ACCESS EXCLUSIVE` on `users`,
+`redirect_uris`, and `oauth_clients`, blocking reads and writes on each table for
+the duration. Each non-concurrent `CREATE INDEX` in the file takes `SHARE` on its
+table, blocking writes. A non-transactional `CREATE INDEX CONCURRENTLY` is not
+used because sqlx applies every migration inside one transaction, and
+PostgreSQL rejects `CONCURRENTLY` in a transaction. The migration is additive:
+no column is dropped or renamed and the legacy unique constraints are kept, so
+version N-1 replicas keep working through the rollout.
+
 ## 1. Bootstrap the first admin
 
 Interactive (password read from stdin, never argv, never logged):

+ 11 - 7
compose.yaml

@@ -26,8 +26,9 @@
 # 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.
+# D_OAUTH_TOKEN_PEPPER_FILE / D_OAUTH_TOTP_ENCRYPTION_KEY_FILE /
+# D_OAUTH_PII_ENCRYPTION_KEY_FILE, so no key material enters the process
+# environment. Cluster secrets are never baked.
 
 name: d-oauth
 
@@ -77,9 +78,9 @@ 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.
+  # One-shot: DEV-ONLY pepper/TOTP/PII 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:
@@ -91,9 +92,10 @@ services:
         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
+          printf '%s' 'dev-only-pii-key-ROTATE-IN-PROD-0000' > /run/secrets/pii-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
+        chown 65532:65532 /run/secrets/token-pepper /run/secrets/totp-encryption-key /run/secrets/pii-encryption-key
+        chmod 400 /run/secrets/token-pepper /run/secrets/totp-encryption-key /run/secrets/pii-encryption-key
 
   # Exactly-once migration (sqlx advisory lock). Replicas never migrate.
   migrate:
@@ -124,6 +126,7 @@ services:
       D_OAUTH_REDIS__DURABILITY: waitaof
       D_OAUTH_TOKEN_PEPPER_FILE: /run/secrets/token-pepper
       D_OAUTH_TOTP_ENCRYPTION_KEY_FILE: /run/secrets/totp-encryption-key
+      D_OAUTH_PII_ENCRYPTION_KEY_FILE: /run/secrets/pii-encryption-key
     volumes:
       - certs:/certs:ro
       - devsecrets:/run/secrets:ro
@@ -154,6 +157,7 @@ services:
       D_OAUTH_REDIS__DURABILITY: waitaof
       D_OAUTH_TOKEN_PEPPER_FILE: /run/secrets/token-pepper
       D_OAUTH_TOTP_ENCRYPTION_KEY_FILE: /run/secrets/totp-encryption-key
+      D_OAUTH_PII_ENCRYPTION_KEY_FILE: /run/secrets/pii-encryption-key
     expose:
       - 8080
       - 8081

+ 6 - 2
deploy/k8s/deployment.yaml

@@ -16,8 +16,8 @@
 #   Kubernetes API). `networkpolicy.yaml` default-denies pod egress except DNS
 #   + Postgres + Redis.
 # - Secret material is file-mounted, never env-injected: the token pepper,
-#   TOTP key, and the Postgres/Redis passwords are projected from
-#   `d-oauth-secrets` into a tmpfs volume at `/run/secrets` (`defaultMode:
+#   TOTP key, mandatory PII key, and the Postgres/Redis passwords are projected
+#   from `d-oauth-secrets` into a tmpfs volume at `/run/secrets` (`defaultMode:
 #   0400`, group 65532 via `fsGroup`) and read through their `*_FILE`
 #   variables. The passwordless DSNs come from the ConfigMap. The inline
 #   `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` forms remain
@@ -103,6 +103,8 @@ spec:
               value: /run/secrets/token-pepper
             - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
               value: /run/secrets/totp-encryption-key
+            - name: D_OAUTH_PII_ENCRYPTION_KEY_FILE
+              value: /run/secrets/pii-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.
@@ -172,6 +174,8 @@ spec:
                 path: token-pepper
               - key: totp-encryption-key
                 path: totp-encryption-key
+              - key: pii-encryption-key
+                path: pii-encryption-key
       topologySpreadConstraints:
         - maxSkew: 1
           topologyKey: topology.kubernetes.io/zone

+ 7 - 3
deploy/k8s/migrate-job.yaml

@@ -10,9 +10,9 @@
 #
 # Hardened like the Deployment: non-root 65532,
 # read-only rootfs, ALL capabilities dropped, `automountServiceAccountToken:
-# false`, and every secret (token pepper, TOTP key, DB/Redis passwords) mounted
-# as 0400 tmpfs files under `/run/secrets` (read through the `*_FILE`
-# variables) instead of env vars. The image carries the same placeholder
+# false`, and every secret (token pepper, TOTP key, mandatory PII key, DB/Redis
+# passwords) 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
@@ -66,6 +66,8 @@ spec:
               value: /run/secrets/token-pepper
             - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
               value: /run/secrets/totp-encryption-key
+            - name: D_OAUTH_PII_ENCRYPTION_KEY_FILE
+              value: /run/secrets/pii-encryption-key
           resources:
             requests:
               cpu: 50m
@@ -104,3 +106,5 @@ spec:
                 path: token-pepper
               - key: totp-encryption-key
                 path: totp-encryption-key
+              - key: pii-encryption-key
+                path: pii-encryption-key

+ 11 - 0
deploy/k8s/secret.yaml

@@ -8,9 +8,16 @@
 #
 #   token-pepper        -> D_OAUTH_TOKEN_PEPPER_FILE
 #   totp-encryption-key -> D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
+#   pii-encryption-key  -> D_OAUTH_PII_ENCRYPTION_KEY_FILE
 #   database-password   -> D_OAUTH_DATABASE__PASSWORD_FILE
 #   redis-password      -> D_OAUTH_REDIS__PASSWORD_FILE
 #
+# The PII key is MANDATORY: the server refuses to start without it. Unlike the
+# always-required keys above, the PREVIOUS PII/TOTP keys are OPTIONAL and are
+# projected only during a rotation window; `scripts/check_manifests.py` enforces
+# the mandatory projection and, when a workload references a previous-key
+# `*_FILE`, that its basename is actually projected here.
+#
 # The passwordless DSNs (host/user/db/sslmode and the Redis address) are
 # non-secret and live in `configmap.yaml`; only the passwords are here. A
 # `kubectl exec` into a running pod therefore reads no credential from the
@@ -52,3 +59,7 @@ stringData:
   token-pepper: "REPLACE_ME__32-bytes-of-random"
   # 256-bit key encrypting TOTP secrets at rest.
   totp-encryption-key: "REPLACE_ME__32-bytes-of-random"
+  # 256-bit master key encrypting PII fields at rest (mandatory). Losing it
+  # makes every sealed PII value unreadable; rotate per docs/OPERATIONS.md
+  # (retaining the previous key until `pii rotate` completes).
+  pii-encryption-key: "REPLACE_ME__32-bytes-of-random"

+ 2 - 1
docs/ARCHITECTURE.md

@@ -21,8 +21,9 @@ the library.
 | Module | Owns |
 | --- | --- |
 | `src/config.rs`, `src/config/` | Layered configuration load from `D_OAUTH_*` env vars and an optional file, then fail-fast validation. See [CONFIGURATION.md](CONFIGURATION.md). |
-| `src/keys.rs` | The versioned `KeyRing`: token peppers and the TOTP encryption key, each under a key id. |
+| `src/keys.rs` | The versioned `KeyRing`: token peppers, the TOTP encryption key, and the PII field-encryption key, each under a key id with an optional retained previous key. |
 | `src/crypto/` | Constant-time comparison, PKCE, opaque token mint/hash, TOTP, and password/client-secret hashing. |
+| `src/crypto/field.rs` | Versioned AES-256-GCM field codec and keyed-HMAC blind index for the PII columns. |
 | `src/jose/` | DPoP proof verification, JAR request objects, `private_key_jwt` client assertions, and JWK parsing. |
 | `src/db/` | Postgres repositories for the six domain tables. See [DATABASE.md](DATABASE.md). |
 | `src/store/` | Redis ephemeral state and the durability barrier. See [REDIS.md](REDIS.md). |

+ 28 - 0
docs/CHANGELOG.md

@@ -10,6 +10,21 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
 
 ### Added
 
+- Application-level envelope encryption for the PII directory: emails, client
+  names, contacts, redirect URIs, JAR request URIs, JWKS URLs, software ids, and
+  mTLS subject DNs/thumbprints are sealed with versioned AES-256-GCM under a new
+  mandatory `pii_encryption_key`, and the looked-up or unique values carry a
+  keyed-HMAC blind index. TOTP secrets move to a per-row envelope (blob version
+  3) so a DEK can be re-wrapped without re-encrypting the secret. New
+  `pii backfill`, `pii verify`, and `pii rotate` commands run the one-shot
+  migration and PII key rotation, and `totp rewrap` re-wraps the TOTP DEKs. The
+  expansion adds `_enc`/`_index` columns; the backfill blanks the plaintext
+  columns but this release does **not** drop them (the contract drop is a later
+  release).
+- Documentation for the encryption model, the new keys, the
+  expand/dual-write/scale-to-zero-backfill/cutover rollout, and the PII/TOTP
+  rotation runbooks across `docs/DATABASE.md`, `docs/SECURITY.md`,
+  `docs/CONFIGURATION.md`, and `docs/OPERATIONS.md`.
 - `GET /logout` (a server-specific session endpoint, not an OAuth 2.1 or OIDC
   feature) ends the browser session so a different user can sign in. It destroys
   the session and expires the cookie, then renders a neutral page or redirects to
@@ -48,6 +63,11 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
 
 ### Changed
 
+- The key ring now holds three independent chains (token pepper, TOTP
+  encryption key, and PII field-encryption key), each with an optional retained
+  previous key. `pii_dual_write` (default `false`, encrypt-only) turns on
+  dual-write for the rolling-upgrade window; readiness fails closed while the
+  switch is off and any blanked plaintext PII column still holds a value.
 - 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.
@@ -60,6 +80,14 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
   `X-Content-Type-Options: nosniff` on its own probe responses, with a
   regression test pinning both.
 
+### Security
+
+- A PII or TOTP ciphertext that fails to open (tamper, truncation, or a missing
+  key id) is fail-closed: the request returns `503 temporarily_unavailable` and
+  emits a `pii.decrypt_failure` audit event carrying only the request id, never
+  a value. Full-blob substitution by an attacker with database write access
+  remains out of the threat model and is documented in `docs/SECURITY.md`.
+
 ### Fixed
 
 - `GET /.well-known/oauth-authorization-server` reads the enabled scope catalog

+ 60 - 17
docs/CONFIGURATION.md

@@ -36,13 +36,16 @@ 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.
+The key ring follows the same pattern: `token_pepper_file`,
+`totp_encryption_key_file`, and `pii_encryption_key_file` (environment
+spellings `D_OAUTH_TOKEN_PEPPER_FILE`, `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`, and
+`D_OAUTH_PII_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. Each
+purpose also has an optional retained previous key (`*_previous_key` and its
+`*_previous_key_file` form) so a rotation can keep old ciphertext readable.
 
 ## Top-level keys
 
@@ -139,14 +142,47 @@ connection directly, so it may contain any bytes (no URL encoding required).
 | `totp_encryption_key` | none | Inline AES-256-GCM key for TOTP secrets. Required to enroll TOTP. |
 | `totp_encryption_key_file` | none | Mounted TOTP key. |
 | `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. 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).
-
-These two values are the server's master keys and are supplied entirely by the
-deployment. There is a hard custody requirement that they never live in the
+| `totp_previous_key` | none | Optional retained previous TOTP key for rotation. |
+| `totp_previous_key_file` | none | Mounted previous TOTP key. |
+| `totp_previous_key_id` | none | Required when a previous TOTP key is set; must differ from `totp_key_id`. |
+| `pii_encryption_key` | none | Inline AES-256-GCM master key for PII field encryption and blind indexes. Required unless `pii_encryption_key_file` is set. |
+| `pii_encryption_key_file` | none | Mounted PII key. |
+| `pii_key_id` | `v1` | Version id carried in every `_enc` blob and blind index. |
+| `pii_previous_key` | none | Optional retained previous PII key for rotation. |
+| `pii_previous_key_file` | none | Mounted previous PII key. |
+| `pii_previous_key_id` | none | Required when a previous PII key is set; must differ from `pii_key_id`. |
+| `pii_dual_write` | `false` | Rolling-upgrade switch. `true` also writes the plaintext column and emits v2 TOTP blobs so version N-1 replicas keep working; `false` is encrypt-only. |
+
+The exact environment spellings:
+
+| Key | Environment variable |
+| --- | --- |
+| `token_pepper` | `D_OAUTH_TOKEN_PEPPER` |
+| `token_pepper_file` | `D_OAUTH_TOKEN_PEPPER_FILE` |
+| `token_pepper_id` | `D_OAUTH_TOKEN_PEPPER_ID` |
+| `totp_encryption_key` | `D_OAUTH_TOTP_ENCRYPTION_KEY` |
+| `totp_encryption_key_file` | `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE` |
+| `totp_key_id` | `D_OAUTH_TOTP_KEY_ID` |
+| `totp_previous_key` | `D_OAUTH_TOTP_PREVIOUS_KEY` |
+| `totp_previous_key_file` | `D_OAUTH_TOTP_PREVIOUS_KEY_FILE` |
+| `totp_previous_key_id` | `D_OAUTH_TOTP_PREVIOUS_KEY_ID` |
+| `pii_encryption_key` | `D_OAUTH_PII_ENCRYPTION_KEY` |
+| `pii_encryption_key_file` | `D_OAUTH_PII_ENCRYPTION_KEY_FILE` |
+| `pii_key_id` | `D_OAUTH_PII_KEY_ID` |
+| `pii_previous_key` | `D_OAUTH_PII_PREVIOUS_KEY` |
+| `pii_previous_key_file` | `D_OAUTH_PII_PREVIOUS_KEY_FILE` |
+| `pii_previous_key_id` | `D_OAUTH_PII_PREVIOUS_KEY_ID` |
+| `pii_dual_write` | `D_OAUTH_PII_DUAL_WRITE` |
+
+The token pepper and the PII field-encryption key are required: without either
+the key ring cannot be built and the server refuses to start with
+`MissingPiiKey` (or `MissingPepper`). The shipped cluster manifests set
+`D_OAUTH_TOKEN_PEPPER_FILE`, `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`, and
+`D_OAUTH_PII_ENCRYPTION_KEY_FILE` rather than the inline keys; see
+[DEPLOYMENT.md](DEPLOYMENT.md).
+
+These three values are the server's master keys and are supplied entirely by
+the deployment. There is a hard custody requirement that they never live in the
 database or in the same backup as the database; a KMS/HSM or a secret manager
 should hold them and inject them through a `*_FILE` path. The server has no
 built-in KMS integration and never fetches a key itself. The full contract and
@@ -241,14 +277,21 @@ 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.
+- File-backed pepper/TOTP/PII 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.
+- A blank `pii_previous_key` / `totp_previous_key` (including a zero-byte or
+  whitespace-only `*_previous_key_file`) is treated as absent, so an empty mount
+  never becomes a live key.
+- A previous key with no `*_previous_key_id`, a blank id, or an id equal to the
+  active id is a load-time error (`ConfigError::MissingPreviousKeyId`).
+- A previous key set while its active key is absent is `MissingPiiKey` /
+  `MissingTotpKey`, so a retained key can never silently become the active one.
 - 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

+ 2 - 1
docs/CONTRIBUTING.md

@@ -104,8 +104,9 @@ Every module has a single responsibility:
 | Area | Path | Owns |
 | --- | --- | --- |
 | Configuration | `src/config.rs`, `src/config/` | Layered env/file load and fail-fast validation. |
-| Keys | `src/keys.rs` | Versioned `KeyRing` for the token pepper and TOTP encryption key. |
+| Keys | `src/keys.rs` | Versioned `KeyRing` for the token pepper, TOTP encryption key, and PII field-encryption key. |
 | Crypto primitives | `src/crypto/` | Constant-time compare, PKCE, token mint/hash, TOTP, secret hashing. |
+| Field encryption | `src/crypto/field.rs` | AES-256-GCM PII field codec and keyed-HMAC blind index. |
 | JOSE | `src/jose/` | DPoP, JAR, `private_key_jwt` verification, JWK parsing. |
 | Postgres repositories | `src/db/` | The six domain tables. |
 | Redis store | `src/store/` | Ephemeral state: codes, tokens, grants, sessions, PAR, device, DCR, rate limits. |

+ 108 - 16
docs/DATABASE.md

@@ -26,30 +26,44 @@ One row per registered client (RFC 7591 / RFC 7592).
 | `client_secret_hash` | `text` | Argon2id hash, never a raw secret. |
 | `client_secret_expires_at` | `timestamptz` | Optional expiry. |
 | `client_id_issued_at` | `timestamptz` | Issue time. |
-| `client_name` | `text` | Display name. |
+| `client_name` | `text` | Display name. Nullable after the PII expansion migration; the backfill blanks it to NULL. |
+| `client_name_enc` | `bytea` | Sealed display name (versioned AES-256-GCM). |
 | `grant_types` | `text[]` | Registered grant types. |
 | `response_types` | `text[]` | Registered response types. |
 | `scope` | `text` | Registered scope string. |
 | `token_endpoint_auth_method` | `text` | One of the supported methods. |
-| `jwks` | `jsonb` | Optional inline JWKS. |
+| `jwks` | `jsonb` | Optional inline JWKS. Public by design; never sealed. |
 | `jwks_uri` | `text` | Optional JWKS URL (fetched with the SSRF guard). |
-| `tls_client_auth_subject_dn` | `text` | RFC 8705 subject DN binding. |
-| `tls_client_auth_cert_thumbprint` | `text` | RFC 8705 `x5t#S256` binding. |
+| `jwks_uri_enc` | `bytea` | Sealed JWKS URL. |
+| `tls_client_auth_subject_dn` | `text` | RFC 8705 subject DN binding. Nullable after the expansion migration. |
+| `tls_client_auth_subject_dn_enc` | `bytea` | Sealed subject DN. |
+| `tls_client_auth_subject_dn_index` | `text` | Keyed blind index over the subject DN. |
+| `tls_client_auth_cert_thumbprint` | `text` | RFC 8705 `x5t#S256` binding. Nullable after the expansion migration. |
+| `tls_client_auth_cert_thumbprint_enc` | `bytea` | Sealed certificate thumbprint. |
+| `tls_client_auth_cert_thumbprint_index` | `text` | Keyed blind index over the thumbprint. |
 | `request_uris` | `jsonb` | Pre-registered JAR request URIs. |
+| `request_uris_enc` | `bytea` | Sealed request URIs (JSON ciphertext). |
 | `contacts` | `jsonb` | Metadata only. |
+| `contacts_enc` | `bytea` | Sealed contacts (JSON ciphertext). |
 | `software_id` | `text` | Metadata only. |
+| `software_id_enc` | `bytea` | Sealed software id. |
 | `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
-most one client:
+Four partial unique indexes enforce client-identity uniqueness. Two are on the
+legacy plaintext columns, kept inert once the backfill blanks them; two are on
+the new blind-index columns:
 
 - `ux_oauth_clients_tls_subject_dn` on `tls_client_auth_subject_dn` where it is
   not null.
 - `ux_oauth_clients_tls_cert_thumbprint` on `tls_client_auth_cert_thumbprint`
   where it is not null.
+- `ux_oauth_clients_tls_subject_dn_index` on `tls_client_auth_subject_dn_index`
+  where it is not null.
+- `ux_oauth_clients_tls_cert_thumbprint_index` on
+  `tls_client_auth_cert_thumbprint_index` where it is not null.
 
 The `UNIQUE (client_id)` is a table constraint, not merely an index, because
 Postgres accepts only a unique constraint as a foreign-key target.
@@ -61,7 +75,9 @@ One row per resource owner.
 | Column | Type | Notes |
 | --- | --- | --- |
 | `id` | `uuid` | Primary key. |
-| `email` | `citext` | Case-insensitive email-as-username, unique. |
+| `email` | `citext` | Case-insensitive email-as-username, unique. Nullable after the PII expansion migration; the backfill blanks it to NULL. |
+| `email_enc` | `bytea` | Sealed email (versioned AES-256-GCM). |
+| `email_index` | `text` | Keyed blind index over the normalized email. |
 | `password_hash` | `text` | Argon2id PHC string. |
 | `totp_secret_enc` | `bytea` | AES-256-GCM encrypted TOTP secret, never plaintext. |
 | `totp_enabled` | `bool` | Default false. |
@@ -72,6 +88,10 @@ One row per resource owner.
 | `disabled` | `bool` | Default false. |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 
+`ux_users_email_index` is a partial unique index on `email_index` where it is
+not null; the legacy `ux_users_email UNIQUE (email)` constraint is kept and goes
+inert once every `email` is NULL.
+
 There are deliberately no failed-login or lockout columns. Those counters are
 security-ephemeral and live in Redis.
 
@@ -110,13 +130,20 @@ is unique per `(uri, kind)`.
 | Column | Type | Notes |
 | --- | --- | --- |
 | `id` | `uuid` | Primary key. |
-| `uri` | `text` | Unique per `kind`. |
+| `uri` | `text` | Unique per `kind`. Nullable after the PII expansion migration; the backfill blanks it to NULL. |
+| `uri_enc` | `bytea` | Sealed URI (versioned AES-256-GCM). |
+| `uri_index` | `text` | Keyed blind index over the URI, per `kind`. |
 | `kind` | `text` | `authorize` (default) or `post_logout`. |
 | `enabled` | `bool` | Default true. |
 | `description` | `text` | Default empty. |
 | `created_by` | `uuid` | Optional creator. |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 
+`ux_redirect_uris_uri_index_kind` is a partial unique index on
+`(uri_index, kind)` where `uri_index` is not null; the legacy
+`ux_redirect_uris_uri_kind UNIQUE (uri, kind)` constraint is kept and goes inert
+once every `uri` is NULL.
+
 `kind` separates the two browser landings: `authorize` URIs are valid
 authorization-response callbacks (they carry `code`/`state`); `post_logout` URIs
 are bare navigation targets used only by `GET /logout`. Both kinds are matched
@@ -143,6 +170,36 @@ is one account per email regardless of case. Installing `citext` requires
 superuser or trusted-extension privileges, so the migration role must have
 them, or an operator must install the extension out of band before migrating.
 
+## PII encryption at rest
+
+The plaintext identifier columns stay in the schema, but once the migration
+completes they are inert: NULL for every row, kept only so version N-1 replicas
+can still read them during the rollout. The live values move into two parallel
+columns per field:
+
+- **`_enc` (`bytea`)** holds a versioned AES-256-GCM blob. The blob carries the
+  key id (`kid`) of the PII key that wrote it, then a random 96-bit nonce, then
+  the ciphertext and tag. The key is derived from the master PII key with
+  HKDF-SHA256 (info `pii-field-gcm-v1`), so the ciphertext is not key-stable:
+  rotating the master key re-encrypts every blob.
+- **`_index` (`text`)** holds a keyed-HMAC blind index in the form
+  `{kid}:{hex}`. Encrypted bytes cannot be compared or made unique directly, so
+  the columns that are looked up or must stay unique (`email_index`, the two
+  mTLS indexes, `uri_index`) carry this deterministic tag under a separate HMAC
+  key (HKDF info `pii-blind-index-v1`). A read computes candidates across every
+  retained key id, newest first, so lookups work while an old key is retained.
+
+The TOTP secret uses a different envelope. `users.totp_secret_enc` blob version
+3 holds a random per-row data-encryption key (DEK), wrapped by a key-encryption
+key derived from the TOTP master key (HKDF info `totp-kek-v1`). The data AAD
+excludes the `kid`, so `totp rewrap` can re-wrap a DEK under a new TOTP key
+without re-encrypting the secret. Versions 1 and 2 still decrypt.
+
+The blind index is key-dependent, so uniqueness only holds within one key
+generation. Rotating the PII key requires a write freeze (app tier scaled to
+zero) while `pii rotate` re-encrypts and recomputes every index; see
+[OPERATIONS.md](OPERATIONS.md#pii-and-totp-key-rotation).
+
 ## Migration lifecycle
 
 - Migrations are versioned, forward-only sqlx migrations compiled into the
@@ -171,11 +228,39 @@ must be backward compatible:
 A migration that drops or renames a column in one step breaks the old replicas
 during the rollout. Do not do it.
 
+The PII encryption rollout follows this lifecycle exactly:
+
+1. **Expand.** Apply `migrations/1791417600_pii_encryption.sql` with the PII key
+   configured. It adds the `_enc`/`_index` columns and their partial unique
+   indexes and drops `NOT NULL` on the plaintext columns the backfill blanks.
+2. **Dual-write.** Set `pii_dual_write=true` and roll every replica. New writes
+   seal the value and also write the legacy plaintext, so old replicas keep
+   working.
+3. **Scale-to-zero backfill.** Set `pii_dual_write=false` and scale the app tier
+   to zero, so no write can interleave with the one-shot backfill.
+4. **Backfill.** Run `pii backfill` to seal every remaining plaintext value,
+   blank the plaintext columns, and upgrade TOTP blobs to v3.
+5. **Cutover.** Run `pii verify`, then scale the app tier back up. The startup
+   guard fails closed (readiness `503`) if `pii_dual_write=false` and any
+   blanked plaintext PII column still holds a value.
+6. **Contract (later release).** Dropping the now-inert plaintext columns and
+   legacy constraints is a deliberate follow-up release, not part of this
+   change. This release drops and renames nothing.
+
+**Maintenance window.** `ALTER TABLE ... DROP NOT NULL` takes `ACCESS EXCLUSIVE`
+on `users`, `redirect_uris`, and `oauth_clients`, blocking reads and writes for
+its duration; each non-concurrent `CREATE INDEX` takes `SHARE`, blocking writes.
+sqlx runs every migration inside one transaction, so a non-transactional
+`CREATE INDEX CONCURRENTLY` is not used (PostgreSQL rejects it in a
+transaction). Apply the expansion migration from the dedicated migration Job
+during a maintenance window with the app tier quiesced.
+
 ## Security properties
 
 - Only hashed or encrypted secrets are stored: client secrets in
-  `oauth_clients`, passwords in `users`, TOTP secrets encrypted in `users`.
-  Raw tokens are never rows here.
+  `oauth_clients`, passwords in `users`, TOTP secrets encrypted in `users`, and
+  the PII identifiers sealed in the `_enc` columns with their blind indexes in
+  `_index`. Raw tokens are never rows here.
 - The `registration_access_token_hash` column is the one token hash in
   Postgres, so RFC 7592 client management survives a Redis loss.
 - Foreign keys with `ON DELETE CASCADE` keep the join tables consistent, and a
@@ -243,8 +328,11 @@ the Postgres configuration and the operator's log pipeline.
 - Custody the backup key separately from the database, and never co-locate it
   with the master keys ([SECURITY.md](SECURITY.md#master-key-custody)).
 - Store backups off-site and test restores.
-- A backup is a full copy of the plaintext PII columns exactly as a live dump
-  is, so it carries the same disclosure risk and needs the same handling.
+- A backup is a point-in-time copy of the `_enc` ciphertext and the blind
+  indexes, plus the inert NULL plaintext columns. It still needs the same
+  handling as a live dump: after the PII key is rotated, an old backup is
+  readable only with the retained old PII key, so custody of the key store
+  still governs backup disclosure.
 
 ### Postgres-level encryption
 
@@ -257,10 +345,10 @@ Three layers, complementary rather than substitutes:
 | `pgcrypto` | Selected columns | A dump of those columns | An alternative, not the current implementation |
 
 The application already does its own crypto before a row is written: passwords
-and client secrets are Argon2id PHC, and TOTP secrets are AES-256-GCM under
-HKDF-SHA256 (`src/crypto/totp.rs`). `pgcrypto` is an alternative for the
-plaintext PII columns, but it moves key custody into the SQL path and is not
-used today.
+and client secrets are Argon2id PHC, TOTP secrets are AES-256-GCM under
+HKDF-SHA256 (`src/crypto/totp.rs`), and the PII identifiers are sealed with the
+field codec in `src/crypto/field.rs`. `pgcrypto` would move key custody into the
+SQL path and is not used today.
 
 ### See also
 
@@ -302,7 +390,11 @@ SELECT version, success FROM _sqlx_migrations ORDER BY version;
 - `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`.
+- `migrations/1791417600_pii_encryption.sql` for the `_enc`/`_index` PII
+  columns, their partial unique indexes, and the dropped `NOT NULL`s.
 - `src/db/` for the repositories.
+- `src/crypto/field.rs` for the field codec and blind index.
+- `src/cli/pii/` for `pii backfill|verify|rotate`.
 - `src/state.rs` for `MIGRATOR`.
 - `src/cli/ops.rs` for the `migrate` command.
 - `src/state/health.rs` for the readiness schema check.

+ 102 - 9
docs/OPERATIONS.md

@@ -139,6 +139,58 @@ is never an authorization-server signing key; the server publishes no
 `jwks_uri`. `healthcheck` probes the health listener over raw HTTP and exits 0
 or 1; it is the container `HEALTHCHECK`.
 
+### PII and TOTP migration commands
+
+```bash
+d-oauth-server pii backfill
+d-oauth-server pii verify
+d-oauth-server pii rotate
+d-oauth-server totp rewrap
+```
+
+All four take no options and read the key ring from configuration. `pii
+backfill`, `pii verify`, and `pii rotate` require a migration-role DSN
+(`D_OAUTH_DATABASE__URL`); they scan every row and print counts only, never a
+value.
+
+- `pii backfill` seals every remaining plaintext PII value, blanks the plaintext
+  columns, and upgrades every TOTP blob to v3. It refuses while
+  `pii_dual_write=true`, and must run with the app tier scaled to zero.
+- `pii verify` reports the four cutover checks: no plaintext remains, every
+  `_enc` opens under a retained key, every TOTP blob is v3, and every stored
+  blind index matches. It exits non-zero when any check is dirty.
+- `pii rotate` re-encrypts every `_enc` value and recomputes every blind index
+  under the active PII key. Run it under a write freeze with the previous key
+  retained. It is idempotent: a re-run re-encrypts only stale blobs.
+- `totp rewrap` re-wraps every v3 per-row DEK under the active TOTP key without
+  touching the data ciphertext. It is idempotent.
+
+### PII encryption rollout and cutover
+
+The expansion is additive. The backfill blanks the plaintext columns but this
+release does **not** drop them; dropping the now-inert columns is a later
+release. Follow this order exactly:
+
+0. Apply the expansion migration with the PII key configured:
+   `d-oauth-server migrate`. The migration drops `NOT NULL` on the plaintext
+   columns and builds the new indexes, so run it in a maintenance window. See
+   [DATABASE.md](DATABASE.md#expand-and-contract).
+1. Set `pii_dual_write=true` (`D_OAUTH_PII_DUAL_WRITE=true`).
+2. Roll every replica so all of them dual-write and emit v2 TOTP blobs.
+3. Set `pii_dual_write=false` and scale the app tier to zero, so no writer runs
+   during the backfill.
+4. Run `d-oauth-server pii backfill`.
+5. Run `d-oauth-server pii verify`; it must exit 0.
+6. Scale the app tier back up. Startup readiness is fail-closed: if
+   `pii_dual_write=false` and any blanked plaintext column still holds a value,
+   `GET /health/ready` returns `503` and the replica stays out of service.
+
+While the rollout window is open, CLI `d-oauth-server user enroll-totp` and
+login-driven TOTP enrollment MUST run with `D_OAUTH_PII_DUAL_WRITE=true`. With
+it `false` they write v3 TOTP blobs, which a version N-1 replica cannot read, so
+the user's next TOTP check fails on an old replica. Keep dual-write on for the
+whole window; turn it off only at the scale-to-zero step above.
+
 ## config sync file format
 
 The file is JSON. Every section is optional and defaults to empty.
@@ -184,8 +236,9 @@ CLI mutations go to the `audit` target with `event = "cli.mutation"`, an
 `user.revoke_scope`, `scope.create`, `scope.enable`, `scope.disable`,
 `scope.delete`, `redirect_uri.create`, `redirect_uri.enable`,
 `redirect_uri.disable`, `redirect_uri.delete`, `client.attach_redirect_uri`,
-`client.detach_redirect_uri`, `initial_access_token.issue`, and the
-`admin.bootstrap.*` family.
+`client.detach_redirect_uri`, `initial_access_token.issue`,
+`pii.backfill`, `pii.rotate`, `totp.rewrap`, and the `admin.bootstrap.*`
+family. `pii verify` is read-only and emits no mutation event.
 
 HTTP events on the same target include `login.success`, `login.failure`,
 `consent`, `token.issue`, `token.revoke`, `token.refresh_reuse_detected`, and
@@ -206,18 +259,52 @@ live tokens, retain the previous key id for at least the refresh lifetime
 (`ttl.refresh_secs`, default 30 days) before removing it. New tokens are always
 minted under the active pepper.
 
-### TOTP encryption key
-
-The encrypted TOTP blob carries its key id, and the ring can resolve a retained
-TOTP key by id. Rotate by making the new key active and retaining the old key
-until every user has re-enrolled or until existing secrets are re-encrypted.
-
 ### TLS certificate
 
 `serve` loads the certificate chain and key once at startup. Rotate by
 replacing the mounted material and performing a rolling restart; there is no
 hot reload, by design.
 
+## PII and TOTP key rotation
+
+Both the PII field-encryption key and the TOTP encryption key are versioned in
+the ring, and each has an optional retained previous key. Keep the previous key
+id configured until every value has been migrated, then retire it. A previous
+key without a distinct `*_previous_key_id`, or without its active key, is a
+load-time error.
+
+### TOTP encryption key
+
+The TOTP blob carries its key id, and the ring resolves a retained TOTP key by
+id. To rotate:
+
+1. Put the old key in `totp_previous_key` / `totp_previous_key_file` with a
+   distinct `totp_previous_key_id`, and set the new `totp_encryption_key` /
+   `totp_encryption_key_file` as active.
+2. Roll the replicas so the ring loads both keys.
+3. Run `d-oauth-server totp rewrap`. Every v3 blob is re-wrapped under the new
+   key. Version 1 and 2 blobs have no DEK and still decrypt through the legacy
+   path, so re-enroll those users with `d-oauth-server user enroll-totp` before
+   the old key is dropped.
+4. Remove the previous key once no blob names it.
+
+### PII field-encryption key
+
+The blind index is key-dependent, so equality and uniqueness only hold within
+one key generation. Rotate under a write freeze:
+
+1. Put the old key in `pii_previous_key` / `pii_previous_key_file` with a
+   distinct `pii_previous_key_id`, and set the new `pii_encryption_key` /
+   `pii_encryption_key_file` as active.
+2. Roll the replicas, then scale the app tier to zero (the write freeze). No
+   writer may run while the indexes are recomputed.
+3. Run `d-oauth-server pii rotate`. It re-encrypts every `_enc` value and
+   recomputes every blind index under the new key in bounded transactions; a
+   duplicate across key generations aborts that batch instead of overwriting a
+   row. It is idempotent and resumable because the previous key is retained.
+4. Run `d-oauth-server pii verify`; it must exit 0.
+5. Remove the previous key and scale the app tier back up.
+
 ## Backup and restore
 
 Postgres holds all durable state, so a normal backup covers it:
@@ -268,7 +355,10 @@ survives a Redis loss.
   `RUNBOOK.md`.
 - Routine provisioning: `config sync` for scopes, redirect URIs, and
   entitlements; `user create` for users.
-- Key rotation: overlap the pepper for at least the refresh lifetime.
+- Key rotation: overlap the pepper for at least the refresh lifetime. Rotate the
+  TOTP key with `totp rewrap`, and the PII key with a write freeze plus
+  `pii rotate` then `pii verify`; see
+  [PII and TOTP key rotation](#pii-and-totp-key-rotation).
 - Backup: `pg_dump` Postgres; Redis is not backed up.
 - Access: prefer running admin commands as a one-shot Job (or the local CLI
   against the database) over `exec` into a running replica. If `exec` is
@@ -282,5 +372,8 @@ survives a Redis loss.
 - `src/cli/ops.rs`, `src/cli/user.rs`, `src/cli/scope.rs`,
   `src/cli/redirect.rs`, `src/cli/sync.rs`, `src/cli/admin.rs`,
   `src/cli/secret.rs` for implementations.
+- `src/cli/pii/` for `pii backfill|verify|rotate` and `src/cli/totp.rs` for
+  `totp rewrap`.
+- `src/crypto/field.rs` for the PII field codec and blind index.
 - `src/middleware/audit.rs` for the HTTP audit events.
 - `RUNBOOK.md` for the bootstrap and replacement procedure.

+ 97 - 36
docs/SECURITY.md

@@ -19,7 +19,9 @@ https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1).
 - Opaque access and refresh tokens, and the authorization codes and device codes
   that yield them.
 - Client secrets, user passwords, TOTP secrets, and DCR initial-access tokens.
-- The token pepper and TOTP encryption keys.
+- The token pepper, the TOTP encryption key, and the PII field-encryption key.
+- The directory itself: emails, client names, contacts, redirect URIs, JAR
+  request URIs, JWKS URLs, software ids, and mTLS identities.
 - The integrity of the authorization decision: which client may act for which
   user with which scopes.
 
@@ -56,7 +58,7 @@ 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; the Deployment mounts every secret — pepper, TOTP key, and the database/Redis passwords — as `0400` tmpfs files under `/run/secrets` (read through their `*_FILE` variables), and the passwordless DSNs come from the ConfigMap, so no secret enters the environment | An attacker with code execution can still read the mounted secret files under `/run/secrets`; that is the same capability the server has. |
+| Secret exfiltration from the process environment | Secrets are injected at runtime, not baked into image layers; the pepper/TOTP/PII keys live outside the database; the Deployment mounts every secret — pepper, TOTP key, PII key, and the database/Redis passwords — as `0400` tmpfs files under `/run/secrets` (read through their `*_FILE` variables), and the passwordless DSNs come from the ConfigMap, so no secret enters the environment | An attacker with code execution can still read the mounted secret files under `/run/secrets`; that is the same capability the server has. |
 | 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. |
 | Unrestricted pod egress (SSRF to metadata, exfiltration) | `networkpolicy.yaml` default-denies egress except DNS (53 to `kube-system`) and Postgres/Redis (5432/6379) scoped to the namespace hosting them — no internet-wide `0.0.0.0/0` peer; any `ipBlock` peer excludes `169.254.0.0/16`; ingress is limited to pod ports 8080/8081 | Enforcement depends on a NetworkPolicy-capable CNI; a cluster that ignores NetworkPolicy gets no benefit. A managed database outside the cluster needs a specific CIDR or a CNI FQDN policy. The default-deny also blocks the JAR `request_uri` HTTPS fetch — remote request objects are unsupported unless the deployment adds a scoped allowance (see `docs/DEPLOYMENT.md`). |
@@ -66,8 +68,7 @@ class, or runtime setting).
 
 Last reviewed: 2026-10-05. File-mounted DB/Redis passwords, digest pinning,
 NetworkPolicy, `automountServiceAccountToken: false`, and scheduled fuzzing are
-implemented in the shipped manifests and workflows; the remaining mitigations
-are tracked in `docs/TODO.md`.
+implemented in the shipped manifests and workflows.
 
 ## Toplevel rules
 
@@ -162,37 +163,81 @@ Reference: RFC 9126, RFC 9101.
 
 ## Key ring and rotation
 
-- One versioned `KeyRing` holds the token-pepper chain and the TOTP-encryption
-  chain, newest first.
-- Opaque token hashes carry the pepper key id; the TOTP blob carries its key id.
+- One versioned `KeyRing` holds three independent chains, newest first: the
+  token-pepper chain, the TOTP-encryption chain, and the PII field-encryption
+  chain. Each purpose also has an optional retained previous key.
+- Opaque token hashes carry the pepper key id; the TOTP blob and every `_enc`
+  PII blob carry their own key id, and the blind index carries its key id.
 - Rotating the pepper requires retaining the old key for at least the refresh
   lifetime so live tokens still verify.
+- Rotating the PII key re-encrypts every `_enc` value and recomputes every blind
+  index (`pii rotate`); rotating the TOTP key re-wraps the per-row DEKs
+  (`totp rewrap`). See
+  [OPERATIONS.md](OPERATIONS.md#pii-and-totp-key-rotation).
 - `Debug` prints key ids only, never key material.
 - There is no authorization-server JWT-signing key and no `jwks_uri`. The
   `keygen` command emits keys for test fixtures and client-side DPoP only.
 
+## PII field encryption
+
+Reference: AES-256-GCM (`ring`), HKDF-SHA256, HMAC-SHA256.
+
+- The directory columns listed under [Assets](#assets) are sealed in the
+  application with a versioned AES-256-GCM field codec
+  (`src/crypto/field.rs`). The key is derived from the PII master key with
+  HKDF-SHA256 (info `pii-field-gcm-v1`), never from the TOTP key.
+- A keyed-HMAC blind index (`{kid}:{hex}`, info `pii-blind-index-v1`) is stored
+  for the columns that are looked up or must stay unique: `email_index`, the
+  two mTLS indexes, and `uri_index`. Encrypted bytes cannot be compared or made
+  unique directly, so the index is what enforces uniqueness and equality.
+- A read accepts candidates across every retained key id, newest first, so a
+  lookup keeps working while an old key is retained. A create or register path
+  runs an explicit dual-predicate uniqueness pre-check because a partial unique
+  index cannot span a sealed row and a not-yet-backfilled plaintext row.
+- TOTP secrets use a per-row envelope (blob version 3): a random DEK wrapped by
+  a key derived from the TOTP key (info `totp-kek-v1`). The data AAD excludes
+  the key id, so a DEK can be re-wrapped under a new key without re-encrypting
+  the secret. Versions 1 and 2 still decrypt.
+- Decryption of a stored ciphertext is fail-closed: a tampered, truncated,
+  unknown-key, or undecodable blob maps to `AppError::PiiDecrypt`
+  (`temporarily_unavailable`, `503`) and emits the `pii.decrypt_failure` audit
+  event with the request id. No value or plaintext is written to the event.
+- **Out of scope.** An attacker who already has database write access can move
+  an entire encrypted body between rows (full-blob substitution) or replay a
+  sealed value. Field encryption binds ciphertext to a key, not to a row
+  identity, and defending against a write-capable adversary would require row
+  binding or a MAC over the row that this release does not add. This is a
+  documented threat-model boundary, not a defect.
+
 ## Master key custody
 
-- The token pepper and the TOTP-encryption key are the server's master keys.
-  They live in the versioned `KeyRing` (`src/keys.rs`), loaded once at startup
-  from configuration. Nothing re-reads or fetches them later.
+- The token pepper, the TOTP-encryption key, and the PII field-encryption key
+  are the server's master keys. They live in the versioned `KeyRing`
+  (`src/keys.rs`), loaded once at startup from configuration. Nothing re-reads
+  or fetches them later.
 - **Hard requirement.** A master key must never be stored in Postgres, captured
   in the same backup or restore as Postgres, baked into an image layer, placed
   in a ConfigMap, or injected into the process environment by the shipped
-  manifests. A database dump plus its backup must not yield either key.
+  manifests. A database dump plus its backup must not yield any of the three
+  keys.
 - Losing the token pepper invalidates every live opaque access and refresh
   token, because only keyed HMAC hashes are persisted and the pepper is the
-  HMAC key.
-- The real config surface is small: inline `token_pepper` /
-  `totp_encryption_key`, or the mounted-file forms `token_pepper_file` /
-  `totp_encryption_key_file`. When both are set the inline value wins
-  (`resolve_secret` in `src/config/build.rs`). The key ids default to `v1`
-  through `token_pepper_id` / `totp_key_id` (`src/config/raw.rs`).
+  HMAC key. Losing the PII key makes every sealed directory value unreadable.
+- The real config surface is small: inline `token_pepper`, `totp_encryption_key`,
+  and `pii_encryption_key`, or the mounted-file forms `token_pepper_file`,
+  `totp_encryption_key_file`, and `pii_encryption_key_file`. When both are set
+  the inline value wins (`resolve_secret` in `src/config/build.rs`). The key ids
+  default to `v1` through `token_pepper_id` / `totp_key_id` / `pii_key_id`
+  (`src/config/raw.rs`). Each purpose may retain one previous key
+  (`*_previous_key` / `*_previous_key_file`) with an explicit, distinct
+  `*_previous_key_id`; a previous key without its active key is a config error.
 - The shipped cluster manifests never use the inline or environment forms: the
   Secret file-mounts the keys `0400` on a tmpfs volume at `/run/secrets`, and
-  the loader reads them through `D_OAUTH_TOKEN_PEPPER_FILE` /
-  `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE` (`deploy/k8s/secret.yaml`,
-  `deploy/k8s/configmap.yaml`). The ConfigMap carries only non-secret settings.
+  the loader reads them through `D_OAUTH_TOKEN_PEPPER_FILE`,
+  `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`, and `D_OAUTH_PII_ENCRYPTION_KEY_FILE`
+  (`deploy/k8s/secret.yaml`, `deploy/k8s/configmap.yaml`). The ConfigMap carries
+  only non-secret settings; the previous keys stay optional and are never
+  required in a workload.
 - **The server does not talk to a KMS or an HSM.** It has no cloud SDK and no
   key-fetch code path. Sourcing a key from a KMS/HSM or a secret manager is the
   deployment's responsibility; this section is a documented contract, not an
@@ -211,9 +256,10 @@ Custody, in order of preference:
 
 Custody and rotation interact. Retain the old pepper key id for at least the
 refresh lifetime, or live tokens stop verifying; retain the old TOTP key id
-only long enough to re-enroll users, then drop it. The full order is above; a
-rotation that removes an id early is a deliberate invalidation of what that key
-protects.
+only long enough to re-enroll users or re-wrap blobs, then drop it; retain the
+old PII key id until `pii rotate` has re-encrypted every value and recomputed
+every blind index, then drop it. A rotation that removes an id early is a
+deliberate invalidation of what that key protects.
 
 The operator procedure for custody and KMS/HSM sourcing lives in
 [DEPLOYMENT.md](DEPLOYMENT.md#master-key-custody-and-kmshsm).
@@ -311,6 +357,9 @@ security rationale and checklist.
   Treat the grant family as compromised.
 - Repeated `login.failure` for one subject means a credential-stuffing attempt.
 - `admin.bootstrap.force` means an admin was created despite an existing one.
+- `pii.decrypt_failure` means a stored PII or TOTP ciphertext failed to open
+  (tamper, truncation, or a missing key id). Treat it as possible data
+  corruption or key-store drift; it carries only the request id, never a value.
 - A spike in `503` on security endpoints means a dependency is down; the server
   is failing closed, not open.
 
@@ -338,19 +387,19 @@ store can be shown untouched.
 | --- | --- | --- |
 | 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 secrets | Per-row DEK wrapped by AES-256-GCM under HKDF-SHA256 (info `totp-kek-v1`; a re-wrap keeps the data ciphertext unchanged); v1/v2 blobs 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, JAR `request_uris`, `jwks_uri`, scopes, entitlements | Plaintext | Full disclosure. |
+| Emails, client names, contacts, redirect URIs, JAR `request_uris`, `jwks_uri`, software ids, mTLS subject DNs and certificate thumbprints | Sealed AES-256-GCM under HKDF-SHA256 (info `pii-field-gcm-v1`), with keyed-HMAC blind indexes in `_index` | Opaque without the PII key. The blind indexes are deterministic tags, not plaintext, but they do reveal equality within one key generation. |
+| Scopes, entitlements, timestamps, actor ids | Plaintext | Metadata 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`, `totp_enabled`, timestamps | Plaintext | Metadata disclosure. |
 | Client registration metadata (`grant_types`, `response_types`, `token_endpoint_auth_method`, `software_id`, `active`, `dpop_bound_access_tokens`, `client_id_issued_at`, `client_secret_expires_at`) and catalog metadata (scope/redirect `description`, `enabled`, `kind`, actor ids `granted_by`/`created_by`/`attached_by`) | 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.
+If the key store leaked too, the dump is a full compromise: TOTP secrets and
+the sealed directory 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
 
@@ -363,14 +412,25 @@ exposed; do not skip ahead because a later step looks cheaper.
    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
+2. **TOTP encryption key second.** Make a new key active with the old key
+   retained. Existing TOTP blobs stay readable while the old key is retained;
+   run `totp rewrap` to re-wrap every v3 per-row DEK under the new key (the data
+   ciphertext is untouched), or re-enroll with `user enroll-totp` before the old
+   key is dropped. Once the old key is removed, a blob still naming it cannot
+   decrypt.
+3. **PII field-encryption key third.** Make a new PII key active with the old
+   key retained, then run `pii rotate` under a write freeze (the app tier scaled
+   to zero). Rotation re-encrypts every `_enc` value and recomputes every blind
+   index under the new key, in bounded transactions under an advisory lock. The
+   blind index is key-dependent, so uniqueness is only enforced within one key
+   generation during the window; the freeze is what keeps a duplicate from
+   slipping between generations. Run `pii verify` and only then retire the old
+   key. See
+   [OPERATIONS.md](OPERATIONS.md#pii-and-totp-key-rotation).
+4. **Client secrets fourth.** 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
+5. **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.
@@ -396,6 +456,7 @@ service up mid-rotation. The operator checklist with concrete commands is in
 ## File and directory references
 
 - `src/crypto/` for token minting, hashing, TOTP, and constant-time compare.
+- `src/crypto/field.rs` for the PII field codec and keyed blind index.
 - `src/keys.rs` for the key ring.
 - `src/protocol/token/types.rs` for the sender-constraint matrix.
 - `src/jose/dpop.rs`, `src/jose/jar.rs`,

+ 0 - 53
docs/TODO.md

@@ -1,53 +0,0 @@
-# TODO
-
-This is a backlog of deferred hardening work. Each item is a candidate to be
-planned and implemented later; none of them is a current defect that makes the
-server unusable. The items came out of a threat-model review of what an attacker
-can still read or crack from a full dump of the PostgreSQL database; the
-image-trust-boundary items that review surfaced have since been closed (see
-[SECURITY.md](SECURITY.md) and [DEPLOYMENT.md](DEPLOYMENT.md)).
-
-This document only records the gaps. It does not describe anything that is
-already implemented, and it does not propose code changes here.
-
-## Gaps to plan and implement
-
-Each item names the risk, the change to plan, and the files it would touch. The
-list is deliberately short and ordered by value.
-
-### 1. Application-level encryption of PII columns
-
-- **Risk.** Emails, client names, contacts, and redirect URIs are plaintext. A
-  dump leaks the full user and client directory, which enables phishing,
-  credential stuffing against other services, and mapping of who talks to whom.
-- **Proposed change.** Encrypt these columns with AES-256-GCM under a KeyRing
-  key. Add a blind index (a keyed HMAC of the normalized value) for the `email`
-  lookup so case-insensitive uniqueness still works.
-- **Files.** `src/db/user_repo.rs`, `src/db/client_repo.rs`,
-  `src/db/redirect_repo.rs`, `src/keys.rs`, `migrations/`.
-
-### 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
-  entire table in one pass, and a single key compromise exposes all rows.
-- **Proposed change.** Give each row a data encryption key wrapped by the master
-  key, which makes rotation incremental and limits a single leak's blast radius.
-- **Files.** `src/crypto/totp.rs`, `src/keys.rs`.
-
-## Priority
-
-Ranked by value against effort:
-
-- **Medium:** items 1 and 2. Item 1 is medium/high effort because it needs a
-  migration plus a blind index with uniqueness and rotation handling.
-
-Recommendation: implement item 1 next. It closes the largest plaintext
-disclosure; item 2 then reduces the cost of a future key compromise.
-
-## Open questions
-
-- Should PII encryption (item 1) be mandatory or gated behind configuration?
-- 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.

+ 68 - 0
migrations/1791417600_pii_encryption.sql

@@ -0,0 +1,68 @@
+-- PII envelope encryption, expansion step (plan todo 8).
+--
+-- Forward-only: deterministic, additive expansion only. This migration adds the
+-- encrypted (`_enc`) and keyed blind-index (`_index`) columns the PII backfill
+-- fills, and drops NOT NULL on the plaintext columns that backfill blanks to
+-- NULL. Nothing is dropped or renamed: the old plaintext columns, the `citext`
+-- extension, and the legacy unique constraints all remain, so version N-1
+-- replicas keep working through the rolling upgrade (docs/DATABASE.md,
+-- "Expand and contract").
+--
+-- Maintenance window: `ALTER TABLE ... DROP NOT NULL` takes ACCESS EXCLUSIVE on
+-- users / redirect_uris / oauth_clients, blocking both reads and writes for the
+-- duration; each non-concurrent `CREATE INDEX` takes SHARE, blocking writes.
+-- sqlx runs every migration inside ONE transaction, so a non-transactional
+-- `CREATE INDEX CONCURRENTLY` is NOT used (PostgreSQL rejects it in a
+-- transaction). Apply this migration from the dedicated migration Job during a
+-- maintenance window with the app tier quiesced; see RUNBOOK.md.
+--
+-- The legacy `ux_users_email UNIQUE (email)` and
+-- `ux_redirect_uris_uri_kind UNIQUE (uri, kind)` constraints are deliberately
+-- NOT dropped. PostgreSQL treats NULLs as distinct, so once the backfill blanks
+-- the plaintext each constraint goes inert (any number of NULL rows allowed)
+-- while still serving old replicas.
+
+-- (a) Plaintext columns the backfill blanks become nullable.
+ALTER TABLE users         ALTER COLUMN email       DROP NOT NULL;
+ALTER TABLE redirect_uris ALTER COLUMN uri         DROP NOT NULL;
+ALTER TABLE oauth_clients ALTER COLUMN client_name DROP NOT NULL;
+
+-- (b) users: encrypted email ciphertext + keyed blind index.
+ALTER TABLE users
+  ADD COLUMN email_enc   bytea NULL,
+  ADD COLUMN email_index text  NULL;
+
+CREATE UNIQUE INDEX ux_users_email_index
+  ON users (email_index)
+  WHERE email_index IS NOT NULL;
+
+-- (c) oauth_clients: encrypted PII metadata and blind-indexed mTLS identities.
+ALTER TABLE oauth_clients
+  ADD COLUMN client_name_enc                     bytea NULL,
+  ADD COLUMN contacts_enc                        bytea NULL,
+  ADD COLUMN request_uris_enc                    bytea NULL,
+  ADD COLUMN jwks_uri_enc                        bytea NULL,
+  ADD COLUMN software_id_enc                     bytea NULL,
+  ADD COLUMN tls_client_auth_subject_dn_enc      bytea NULL,
+  ADD COLUMN tls_client_auth_cert_thumbprint_enc bytea NULL,
+  ADD COLUMN tls_client_auth_subject_dn_index    text  NULL,
+  ADD COLUMN tls_client_auth_cert_thumbprint_index text NULL;
+
+-- Mirrors ux_oauth_clients_tls_subject_dn / ux_oauth_clients_tls_cert_thumbprint
+-- on the blind-index columns; the legacy plaintext indexes stay in place.
+CREATE UNIQUE INDEX ux_oauth_clients_tls_subject_dn_index
+  ON oauth_clients (tls_client_auth_subject_dn_index)
+  WHERE tls_client_auth_subject_dn_index IS NOT NULL;
+
+CREATE UNIQUE INDEX ux_oauth_clients_tls_cert_thumbprint_index
+  ON oauth_clients (tls_client_auth_cert_thumbprint_index)
+  WHERE tls_client_auth_cert_thumbprint_index IS NOT NULL;
+
+-- (d) redirect_uris: encrypted URI ciphertext + keyed blind index per kind.
+ALTER TABLE redirect_uris
+  ADD COLUMN uri_enc   bytea NULL,
+  ADD COLUMN uri_index text  NULL;
+
+CREATE UNIQUE INDEX ux_redirect_uris_uri_index_kind
+  ON redirect_uris (uri_index, kind)
+  WHERE uri_index IS NOT NULL;

+ 57 - 9
scripts/check_manifests.py

@@ -13,13 +13,18 @@ contract WITHOUT a live cluster:
 - PDB: `minAvailable: 2`.
 - Service: never exposes the health port (8081).
 - Hardening: `automountServiceAccountToken: false` and `fsGroup: 65532`; every
-  secret (token pepper, TOTP key, database and Redis passwords) must be
-  file-mounted from `d-oauth-secrets` at `/run/secrets` (`defaultMode: 0400`)
-  and referenced with its `*_FILE` variable; the inline
-  `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` envs FAIL, and any
-  workload env that injects a secret FAILS (`secretKeyRef`, `envFrom.secretRef`,
-  a literal value embedding a DSN password, or a workload redefinition of
-  `D_OAUTH_DATABASE__URL` / `D_OAUTH_REDIS__URL`).
+  secret (token pepper, TOTP key, mandatory PII key, database and Redis
+  passwords) must be file-mounted from `d-oauth-secrets` at `/run/secrets`
+  (`defaultMode: 0400`) and referenced with its `*_FILE` variable; the inline
+  `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` /
+  `D_OAUTH_PII_ENCRYPTION_KEY` / `D_OAUTH_PII_PREVIOUS_KEY` /
+  `D_OAUTH_TOTP_PREVIOUS_KEY` envs FAIL, and any workload env that injects a
+  secret FAILS (`secretKeyRef`, `envFrom.secretRef`, a literal value embedding a
+  DSN password, or a workload redefinition of `D_OAUTH_DATABASE__URL` /
+  `D_OAUTH_REDIS__URL`). The PII key is mandatory in every workload; the
+  previous PII/TOTP keys are OPTIONAL, but any `*_PREVIOUS_KEY_FILE` reference
+  must point at `/run/secrets/<basename>` with `<basename>` actually projected
+  by the secret volume's `items`.
 - ConfigMap: the ConfigMap referenced by a workload `envFrom` must provide the
   passwordless `D_OAUTH_DATABASE__URL` / `D_OAUTH_REDIS__URL` DSNs, and neither
   may embed a password (it belongs in the Secret, read through the
@@ -59,7 +64,15 @@ import yaml
 HEALTH_PORT = 8081
 PUBLIC_PORT = 8080
 TERMINATION_GRACE_MIN = 45
-SECRET_NAME_RE = re.compile(r"(?i)(pepper|password|secret|private[_-]?key|totp)")
+SECRET_NAME_RE = re.compile(
+  r"(?i)("
+  r"pepper|password|secret|private[_-]?key"
+  r"|pii[_-]?encryption[_-]?key(?!_id)"
+  r"|pii[_-]?previous[_-]?key(?!_id)"
+  r"|totp[_-]?previous[_-]?key(?!_id)"
+  r"|totp[_-]?(encryption[_-]?)?key(?!_id)"
+  r")"
+)
 PEM_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----|-----BEGIN CERTIFICATE-----")
 DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$")
 HIGH_ENTROPY_VALUE_RE = re.compile(r"^[A-Za-z0-9+/=_-]{32,}$")
@@ -80,10 +93,27 @@ SECRET_VOLUME_MODE = 0o400
 FILE_SECRET_ENV_KEYS = {
   "D_OAUTH_TOKEN_PEPPER_FILE": "token-pepper",
   "D_OAUTH_TOTP_ENCRYPTION_KEY_FILE": "totp-encryption-key",
+  "D_OAUTH_PII_ENCRYPTION_KEY_FILE": "pii-encryption-key",
   "D_OAUTH_DATABASE__PASSWORD_FILE": "database-password",
   "D_OAUTH_REDIS__PASSWORD_FILE": "redis-password",
 }
-INLINE_SECRET_ENVS = ("D_OAUTH_TOKEN_PEPPER", "D_OAUTH_TOTP_ENCRYPTION_KEY")
+INLINE_SECRET_ENVS = (
+  "D_OAUTH_TOKEN_PEPPER",
+  "D_OAUTH_TOTP_ENCRYPTION_KEY",
+  "D_OAUTH_PII_ENCRYPTION_KEY",
+  "D_OAUTH_PII_PREVIOUS_KEY",
+  "D_OAUTH_TOTP_PREVIOUS_KEY",
+)
+# The previous keys are OPTIONAL, so their `_FILE` forms are not in
+# FILE_SECRET_ENV_KEYS (that set is mandatory in every workload). They are
+# still secret-shaped, so a workload that references them must file-mount them.
+OPTIONAL_FILE_SECRET_ENVS = (
+  "D_OAUTH_PII_PREVIOUS_KEY_FILE",
+  "D_OAUTH_TOTP_PREVIOUS_KEY_FILE",
+)
+OPTIONAL_FILE_SECRET_RE = re.compile(
+  rf"^{re.escape(SECRETS_MOUNT_PATH)}/(?P<basename>[^/]+)$"
+)
 PASSWORDLESS_DSN_KEYS = ("D_OAUTH_DATABASE__URL", "D_OAUTH_REDIS__URL")
 REQUIRED_EGRESS_PORTS = (53, 5432, 6379)
 METADATA_CIDR = "169.254.0.0/16"
@@ -276,6 +306,24 @@ def check_file_mounted_secrets(
     entry = envs.get(env_name) or {}
     if (entry.get("value") or "").strip() != expected:
       fails.add(f"{workload}/{cname}: {env_name} must point at {expected}")
+  for env_name in OPTIONAL_FILE_SECRET_ENVS:
+    entry = envs.get(env_name)
+    if entry is None:
+      continue
+    value = (entry.get("value") or "").strip()
+    match = OPTIONAL_FILE_SECRET_RE.fullmatch(value)
+    if match is None:
+      fails.add(
+        f"{workload}/{cname}: {env_name} must point at "
+        f"{SECRETS_MOUNT_PATH}/<basename>, not a literal value"
+      )
+      continue
+    basename = match.group("basename")
+    if basename not in set(items.values()):
+      fails.add(
+        f"{workload}/{cname}: {env_name} points at {value} but the "
+        f"{SECRET_NAME} volume does not project {basename!r}"
+      )
 
 
 def policy_ports(rule: dict) -> set[int]:

+ 195 - 0
scripts/test_check_manifests.py

@@ -0,0 +1,195 @@
+#!/usr/bin/env python3
+"""Negative fixture tests for scripts/check_manifests.py (plan task 22).
+
+Runs the real lint over the shipped `deploy/k8s` manifests, mutated in memory
+for each case, and asserts the exact outcome each case must produce. No live
+cluster and no test framework: run with `python3 scripts/test_check_manifests.py`.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import importlib.util
+import io
+import sys
+import tempfile
+from pathlib import Path
+
+import yaml
+
+REPO = Path(__file__).resolve().parent.parent
+MANIFEST_DIR = REPO / "deploy" / "k8s"
+CHECKER_PATH = REPO / "scripts" / "check_manifests.py"
+
+
+def _load_checker():
+  spec = importlib.util.spec_from_file_location("check_manifests", CHECKER_PATH)
+  if spec is None or spec.loader is None:
+    raise RuntimeError(f"cannot load {CHECKER_PATH}")
+  module = importlib.util.module_from_spec(spec)
+  spec.loader.exec_module(module)
+  return module
+
+
+CM = _load_checker()
+
+
+def _load_docs() -> list[dict]:
+  docs: list[dict] = []
+  for path in sorted(MANIFEST_DIR.glob("*.yaml")):
+    if path.name.endswith(".example.yaml"):
+      continue
+    for doc in yaml.safe_load_all(path.read_text()):
+      if doc:
+        doc["_source"] = path.name
+        docs.append(doc)
+  return docs
+
+
+def _run(docs: list[dict]) -> tuple[int, str]:
+  by_source: dict[str, list[dict]] = {}
+  for doc in docs:
+    clean = {k: v for k, v in doc.items() if not k.startswith("_")}
+    by_source.setdefault(doc["_source"], []).append(clean)
+  with tempfile.TemporaryDirectory() as tmp:
+    tmp_path = Path(tmp)
+    for source, source_docs in by_source.items():
+      (tmp_path / source).write_text(yaml.safe_dump_all(source_docs))
+    stdout = io.StringIO()
+    argv = sys.argv
+    sys.argv = ["check_manifests.py", "--manifest-dir", str(tmp_path)]
+    try:
+      with contextlib.redirect_stdout(stdout):
+        code = CM.main()
+    finally:
+      sys.argv = argv
+    return code, stdout.getvalue()
+
+
+def _template(docs: list[dict]) -> dict:
+  deployment = next(d for d in docs if d.get("kind") == "Deployment")
+  return deployment["spec"]["template"]["spec"]
+
+
+def _configmap(docs: list[dict]) -> dict:
+  return next(d for d in docs if d.get("kind") == "ConfigMap")
+
+
+def _secret_volume(tpl: dict) -> dict:
+  return next(
+    v
+    for v in tpl["volumes"]
+    if (v.get("secret") or {}).get("secretName") == CM.SECRET_NAME
+  )
+
+
+def test_baseline_manifests_pass():
+  code, out = _run(_load_docs())
+  assert code == 0, out
+  assert "OK" in out
+
+
+def test_missing_mandatory_pii_projection_fails():
+  docs = _load_docs()
+  volume = _secret_volume(_template(docs))
+  volume["secret"]["items"] = [
+    item for item in volume["secret"]["items"] if item["key"] != "pii-encryption-key"
+  ]
+  code, out = _run(docs)
+  assert code == 1, out
+  assert "must project key 'pii-encryption-key'" in out, out
+
+
+def test_inline_pii_secret_env_fails():
+  for name in (
+    "D_OAUTH_PII_ENCRYPTION_KEY",
+    "D_OAUTH_PII_PREVIOUS_KEY",
+    "D_OAUTH_TOTP_PREVIOUS_KEY",
+  ):
+    docs = _load_docs()
+    _template(docs)["containers"][0]["env"].append(
+      {"name": name, "value": "literal-not-a-file"}
+    )
+    code, out = _run(docs)
+    assert code == 1, (name, out)
+    assert f"{name} must not be injected as an env value" in out, (name, out)
+
+
+def test_configmap_bare_pii_or_previous_key_fails():
+  for key in ("pii_encryption_key", "pii_previous_key", "totp_previous_key"):
+    docs = _load_docs()
+    _configmap(docs)["data"][key] = "bare-secret-value"
+    code, out = _run(docs)
+    assert code == 1, (key, out)
+    assert f"secret-shaped key {key!r}" in out, (key, out)
+
+
+def test_configmap_nonsecret_pii_settings_are_allowed():
+  docs = _load_docs()
+  data = _configmap(docs)["data"]
+  data["D_OAUTH_PII_DUAL_WRITE"] = "false"
+  data["D_OAUTH_PII_KEY_ID"] = "pii-v1"
+  data["D_OAUTH_PII_PREVIOUS_KEY_ID"] = "pii-v0"
+  data["D_OAUTH_TOTP_PREVIOUS_KEY_ID"] = "totp-v0"
+  code, out = _run(docs)
+  assert code == 0, out
+  assert "OK" in out
+
+
+def test_previous_key_file_mounted_passes():
+  docs = _load_docs()
+  tpl = _template(docs)
+  tpl["containers"][0]["env"].append(
+    {"name": "D_OAUTH_PII_PREVIOUS_KEY_FILE", "value": "/run/secrets/pii-previous-key"}
+  )
+  _secret_volume(tpl)["secret"]["items"].append(
+    {"key": "pii-previous-key", "path": "pii-previous-key"}
+  )
+  code, out = _run(docs)
+  assert code == 0, out
+  assert "OK" in out
+
+
+def test_previous_key_file_literal_value_fails():
+  docs = _load_docs()
+  _template(docs)["containers"][0]["env"].append(
+    {"name": "D_OAUTH_TOTP_PREVIOUS_KEY_FILE", "value": "literal-value"}
+  )
+  code, out = _run(docs)
+  assert code == 1, out
+  assert (
+    "D_OAUTH_TOTP_PREVIOUS_KEY_FILE must point at /run/secrets/<basename>" in out
+  ), out
+
+
+def test_previous_key_file_unprojected_basename_fails():
+  docs = _load_docs()
+  _template(docs)["containers"][0]["env"].append(
+    {"name": "D_OAUTH_PII_PREVIOUS_KEY_FILE", "value": "/run/secrets/pii-previous-key"}
+  )
+  code, out = _run(docs)
+  assert code == 1, out
+  assert "does not project 'pii-previous-key'" in out, out
+
+
+def main() -> int:
+  tests = [
+    value
+    for name, value in sorted(globals().items())
+    if name.startswith("test_") and callable(value)
+  ]
+  failed = 0
+  for test in tests:
+    try:
+      test()
+    except AssertionError as exc:
+      failed += 1
+      print(f"FAIL {test.__name__}: {exc}")
+    else:
+      print(f"PASS {test.__name__}")
+  print(f"{len(tests) - failed}/{len(tests)} passed")
+  return 1 if failed else 0
+
+
+if __name__ == "__main__":
+  sys.exit(main())

+ 71 - 0
src/cli.rs

@@ -27,10 +27,12 @@ use clap::{Parser, Subcommand, ValueEnum};
 mod admin;
 mod context;
 mod ops;
+mod pii;
 mod redirect;
 mod scope;
 mod secret;
 mod sync;
+mod totp;
 mod user;
 
 #[cfg(test)]
@@ -104,6 +106,18 @@ pub enum Command {
     #[command(subcommand)]
     command: AdminCommand,
   },
+  /// Re-wrap v3 TOTP secret DEKs under the active TOTP-encryption key.
+  Totp {
+    /// TOTP subcommand.
+    #[command(subcommand)]
+    command: TotpCommand,
+  },
+  /// Backfill and verify sealed PII (migration-role DSN; counts only).
+  Pii {
+    /// PII subcommand.
+    #[command(subcommand)]
+    command: PiiCommand,
+  },
   /// Issue a one-time DCR initial-access token.
   InitialAccessToken {
     /// Initial-access-token subcommand.
@@ -324,6 +338,26 @@ pub enum AdminCommand {
   },
 }
 
+/// `totp` subcommands.
+#[derive(Debug, Subcommand)]
+pub enum TotpCommand {
+  /// Re-wrap every v3 DEK under the active TOTP-encryption key.
+  Rewrap,
+}
+
+/// `pii` subcommands.
+#[derive(Debug, Subcommand)]
+pub enum PiiCommand {
+  /// Seal remaining plaintext PII and upgrade TOTP blobs to v3. Refuses while
+  /// `pii_dual_write=true`; run only with the app tier scaled to zero.
+  Backfill,
+  /// Report the four cutover checks; exits non-zero when any is dirty.
+  Verify,
+  /// Re-encrypt sealed PII under the active key and recompute blind indexes.
+  /// Run under a write freeze with the previous key retained; idempotent.
+  Rotate,
+}
+
 /// `initial-access-token` subcommands.
 #[derive(Debug, Subcommand)]
 pub enum InitialAccessTokenCommand {
@@ -418,6 +452,16 @@ pub async fn run(cli: Cli) -> Result<i32, CliError> {
       run_admin(command).await?;
       Ok(0)
     }
+    Command::Totp { command } => {
+      let context = context::Context::from_env().await?;
+      run_totp(&context, command).await?;
+      Ok(0)
+    }
+    Command::Pii { command } => {
+      let context = context::Context::from_env().await?;
+      run_pii(&context, command).await?;
+      Ok(0)
+    }
     Command::InitialAccessToken { command } => {
       run_initial_access_token(command).await?;
       Ok(0)
@@ -474,6 +518,33 @@ async fn run_admin(command: AdminCommand) -> Result<(), CliError> {
   }
 }
 
+async fn run_totp(context: &context::Context, command: TotpCommand) -> Result<(), CliError> {
+  match command {
+    TotpCommand::Rewrap => {
+      let report = totp::rewrap(context).await?;
+      println!("{report}");
+      Ok(())
+    }
+  }
+}
+
+async fn run_pii(context: &context::Context, command: PiiCommand) -> Result<(), CliError> {
+  match command {
+    PiiCommand::Backfill => {
+      pii::backfill(context).await?;
+      Ok(())
+    }
+    PiiCommand::Verify => {
+      pii::verify(context).await?;
+      Ok(())
+    }
+    PiiCommand::Rotate => {
+      pii::rotate(context).await?;
+      Ok(())
+    }
+  }
+}
+
 async fn run_initial_access_token(command: InitialAccessTokenCommand) -> Result<(), CliError> {
   match command {
     InitialAccessTokenCommand::Issue { uses, ttl, label } => {

+ 68 - 48
src/cli/admin.rs

@@ -27,11 +27,12 @@ use std::{fmt, path::Path};
 
 use base64::Engine as _;
 use ring::rand::{SecureRandom as _, SystemRandom};
-use sqlx::PgPool;
+use sqlx::{PgConnection, PgPool};
 use uuid::Uuid;
 
 use super::context::{CliError, audit, require_nonempty};
 use super::secret;
+use super::user::map_user_repo_error;
 use crate::{config::Config, db::user_repo};
 
 /// Bytes of entropy in a generated bootstrap password (256-bit).
@@ -119,12 +120,13 @@ pub fn generate_password() -> Result<String, CliError> {
   Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
 }
 
-/// Create the first admin: advisory-locked check-and-insert in one transaction.
+/// Create the first admin: advisory-locked existence gate plus a repository
+/// insert.
 ///
-/// The password is hashed before the transaction opens so the lock is held
-/// only for the check and the insert. On success the admin has
-/// `is_admin = true`, `must_change_password = true`, `totp_enabled = false`,
-/// and a `NULL` `password_changed_at`.
+/// The password is hashed before the lock is taken so the lock is held only
+/// for the check and the insert. On success the admin has `is_admin = true`,
+/// `must_change_password = true`, `totp_enabled = false`, and a `NULL`
+/// `password_changed_at`.
 ///
 /// # Errors
 /// Returns [`CliError::Input`] for an empty email/password,
@@ -147,23 +149,57 @@ pub async fn bootstrap(
     }
     BootstrapPassword::Generate => (generate_password()?, true),
   };
-  // Hash before opening the transaction so the advisory lock is held only
-  // for the check and the insert. The plaintext below is never logged and
-  // never stored; only the Argon2id hash reaches the database.
+  // Hash before taking the lock so the lock is held only for the check and the
+  // insert. The plaintext below is never logged and never stored; only the
+  // Argon2id hash reaches the database.
   let password_hash = secret::hash_password(config, &plaintext)?;
 
-  let mut tx = pool.begin().await?;
-  sqlx::query("SELECT pg_advisory_xact_lock(hashtext('d_oauth_admin_bootstrap'))")
-    .execute(&mut *tx)
+  // Serialize within the process so a single process never parks multiple
+  // pooled connections on the database lock while the winner still needs a
+  // second connection for the repository insert.
+  let _serialized = BOOTSTRAP_MUTEX.lock().await;
+  let mut conn = pool.acquire().await?;
+  sqlx::query("SELECT pg_advisory_lock(hashtext('d_oauth_admin_bootstrap'))")
+    .execute(&mut *conn)
     .await?;
+  let outcome = bootstrap_locked(
+    config,
+    pool,
+    &mut conn,
+    &email,
+    &password_hash,
+    force,
+    was_generated.then_some(plaintext),
+  )
+  .await;
+  sqlx::query("SELECT pg_advisory_unlock(hashtext('d_oauth_admin_bootstrap'))")
+    .execute(&mut *conn)
+    .await?;
+  outcome
+}
+
+/// Process-wide serialization for [`bootstrap`]; the session advisory lock
+/// still serializes across processes.
+static BOOTSTRAP_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
+
+/// Run the admin-count gate and the user-repository insert while the advisory
+/// lock is held by `conn`.
+async fn bootstrap_locked(
+  config: &Config,
+  pool: &PgPool,
+  conn: &mut PgConnection,
+  email: &str,
+  password_hash: &str,
+  force: bool,
+  generated_password: Option<String>,
+) -> Result<BootstrapOutcome, CliError> {
   let existing: i64 =
     sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_admin AND NOT disabled")
-      .fetch_one(&mut *tx)
+      .fetch_one(&mut *conn)
       .await?;
 
   if existing > 0 && !force {
-    drop(tx);
-    audit("admin.bootstrap.refused", &email);
+    audit("admin.bootstrap.refused", email);
     return Err(CliError::AlreadyExists(format!(
       "an administrator already exists ({existing}); use `user create --admin` for more admins"
     )));
@@ -174,48 +210,32 @@ pub async fn bootstrap(
        creating one more. This override is audited. \
        Prefer `user create --admin` for routine provisioning."
     );
-    audit("admin.bootstrap.force", &email);
+    audit("admin.bootstrap.force", email);
   }
 
-  let id = Uuid::new_v4();
-  let inserted: Option<Uuid> = sqlx::query_scalar(
-    "INSERT INTO users (
-       id, email, password_hash, is_admin, must_change_password,
-       password_changed_at, created_at, updated_at
-     ) VALUES ($1, $2::citext, $3, true, true, NULL, now(), now())
-     RETURNING id",
+  let record = user_repo::create_user(
+    pool,
+    &config.keys,
+    config.pii_dual_write,
+    &user_repo::NewUser {
+      email: email.to_owned(),
+      password_hash: password_hash.to_owned(),
+      is_admin: true,
+      must_change_password: true,
+    },
   )
-  .bind(id)
-  .bind(&email)
-  .bind(&password_hash)
-  .fetch_optional(&mut *tx)
   .await
-  .map_err(map_insert_error)?;
-  let Some(id) = inserted else {
-    drop(tx);
-    return Err(CliError::AlreadyExists(format!("user {email}")));
-  };
-  tx.commit().await?;
+  .map_err(map_user_repo_error)?;
 
   let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_admin AND NOT disabled")
-    .fetch_one(pool)
+    .fetch_one(&mut *conn)
     .await?;
-  audit("admin.bootstrap.created", &email);
+  audit("admin.bootstrap.created", email);
   audit("admin.count.changed", &total.to_string());
 
   Ok(BootstrapOutcome {
-    id,
-    email,
-    generated_password: if was_generated { Some(plaintext) } else { None },
+    id: record.id,
+    email: email.to_owned(),
+    generated_password,
   })
 }
-
-/// Map an insert failure: unique violations become `AlreadyExists`.
-fn map_insert_error(error: sqlx::Error) -> CliError {
-  match &error {
-    sqlx::Error::Database(db) if db.code().as_deref() == Some("23505") => {
-      CliError::AlreadyExists("user email already exists".to_owned())
-    }
-    _ => CliError::Database(error),
-  }
-}

+ 5 - 0
src/cli/context.rs

@@ -51,6 +51,11 @@ pub enum CliError {
   /// A unique record already exists.
   #[error("already exists: {0}")]
   AlreadyExists(String),
+  /// A repository operation failed (typed `AppError`; payload-free variants
+  /// such as `DuplicateEmail` are matched explicitly by callers, never
+  /// through this catch-all).
+  #[error("application error: {0}")]
+  App(#[from] crate::error::AppError),
   /// A cryptographic operation failed (non-secret detail only).
   #[error("crypto failure: {0}")]
   Crypto(String),

+ 180 - 0
src/cli/pii.rs

@@ -0,0 +1,180 @@
+//! `pii backfill` and `pii verify`: cutover tooling for PII envelope encryption.
+//!
+//! The expansion migration (todo 8) adds every `_enc`/`_index` column and makes
+//! the plaintext columns nullable; writers seal new rows while `pii_dual_write`
+//! holds. The MANDATORY cutover order is exact: (0) apply the expansion
+//! migration with the PII key configured, (1) set `pii_dual_write=true`,
+//! (2) roll all replicas, (3) set `pii_dual_write=false` and SCALE THE APP TIER
+//! TO ZERO, (4) run `pii backfill`, (5) run `pii verify`, (6) scale the app
+//! back up (now encrypt-only and ready). `pii backfill` refuses to run while
+//! `pii_dual_write=true`, and the startup guard (todo 18) is the safety net
+//! that prevents serving the unsafe mix.
+//!
+//! Both commands run against the migration-role DSN (the same
+//! [`Context`] pool `migrate` uses) and print COUNTS only: never a value, a
+//! blob, or key material.
+
+mod backfill;
+mod clients;
+mod rotate;
+mod rotate_clients;
+mod verify;
+
+#[cfg(test)]
+mod tests;
+
+use std::fmt;
+
+use super::context::{CliError, Context};
+
+/// Counts from one `pii backfill` run (counts only, never values or keys).
+#[derive(Debug, Default, Clone, Copy)]
+pub(super) struct BackfillReport {
+  /// `users` rows whose plaintext email was sealed and blanked.
+  pub(super) users_sealed: u64,
+  /// `oauth_clients` rows with at least one sealed-and-blanked PII column.
+  pub(super) clients_sealed: u64,
+  /// `redirect_uris` rows whose plaintext URI was sealed and blanked.
+  pub(super) redirects_sealed: u64,
+  /// TOTP blobs converted from v1/v2 to the v3 envelope format.
+  pub(super) totp_upgraded: u64,
+}
+
+impl fmt::Display for BackfillReport {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    write!(
+      f,
+      "pii backfill: users_sealed={} clients_sealed={} redirects_sealed={} totp_upgraded={}",
+      self.users_sealed, self.clients_sealed, self.redirects_sealed, self.totp_upgraded
+    )
+  }
+}
+
+/// Counts from one `pii verify` run (counts only, never values or keys).
+#[derive(Debug, Default, Clone, Copy)]
+pub(super) struct VerifyReport {
+  /// Rows with any plaintext PII column still non-NULL.
+  pub(super) plaintext: u64,
+  /// Sealed values that fail to open under any retained key.
+  pub(super) undecryptable: u64,
+  /// TOTP blobs still below the v3 envelope format.
+  pub(super) totp_below_v3: u64,
+  /// Sealed indexed rows with a NULL or mismatched `_index`.
+  pub(super) index_mismatch: u64,
+}
+
+impl VerifyReport {
+  /// Whether every check is clean (all four counts are zero).
+  #[must_use]
+  pub(super) fn is_clean(&self) -> bool {
+    self.plaintext == 0
+      && self.undecryptable == 0
+      && self.totp_below_v3 == 0
+      && self.index_mismatch == 0
+  }
+}
+
+impl fmt::Display for VerifyReport {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    write!(
+      f,
+      "pii verify: plaintext={} undecryptable={} totp_below_v3={} index_mismatch={}",
+      self.plaintext, self.undecryptable, self.totp_below_v3, self.index_mismatch
+    )
+  }
+}
+
+/// `pii backfill`: seal every remaining plaintext PII value and upgrade every
+/// pre-v3 TOTP blob, in bounded batches under `pg_advisory_xact_lock`.
+///
+/// # Errors
+/// Returns [`CliError::Input`] while `pii_dual_write` holds (refusal),
+/// [`CliError::Crypto`] when no active PII key is configured, a stored
+/// ciphertext cannot be opened (the batch rolls back, plaintext intact), or a
+/// seal fails, and [`CliError::Database`] for PostgreSQL failures.
+pub(super) async fn backfill(context: &Context) -> Result<BackfillReport, CliError> {
+  let report = backfill::backfill_with(
+    context.pool(),
+    &context.config().keys,
+    context.config().pii_dual_write,
+  )
+  .await?;
+  println!("{report}");
+  Ok(report)
+}
+
+/// Counts from one `pii rotate` run (counts only, never values or keys).
+#[derive(Debug, Default, Clone, Copy)]
+pub(super) struct RotateReport {
+  /// `users` rows re-encrypted under the active PII key.
+  pub(super) users_rotated: u64,
+  /// `oauth_clients` rows re-encrypted under the active PII key.
+  pub(super) clients_rotated: u64,
+  /// `redirect_uris` rows re-encrypted under the active PII key.
+  pub(super) redirects_rotated: u64,
+}
+
+impl fmt::Display for RotateReport {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    write!(
+      f,
+      "pii rotate: users_rotated={} clients_rotated={} redirects_rotated={}",
+      self.users_rotated, self.clients_rotated, self.redirects_rotated
+    )
+  }
+}
+
+/// `pii rotate`: re-encrypt every stale PII blob under the active key.
+///
+/// # Errors
+/// Returns [`CliError::Crypto`] when no active PII key is configured or a
+/// stored blob cannot be opened, and [`CliError::Database`] for PostgreSQL
+/// failures (including unique-index collisions, which roll the batch back).
+pub(super) async fn rotate(context: &Context) -> Result<RotateReport, CliError> {
+  let report = rotate::rotate_with(context.pool(), &context.config().keys).await?;
+  println!("{report}");
+  Ok(report)
+}
+
+/// `pii verify`: report the four cutover checks and fail when any is dirty.
+///
+/// The four checks are (1) any plaintext PII column still non-NULL,
+/// (2) any `_enc` that fails to open under a retained key, (3) any TOTP blob
+/// still below v3, and (4) any sealed indexed row with a NULL or mismatched
+/// `_index`.
+///
+/// # Errors
+/// Returns [`CliError::Input`] when any check reports a non-zero count and
+/// [`CliError::Database`] for PostgreSQL failures.
+pub(super) async fn verify(context: &Context) -> Result<VerifyReport, CliError> {
+  let report = verify::verify_with(context.pool(), &context.config().keys).await?;
+  println!("{report}");
+  ensure_clean(&report)?;
+  Ok(report)
+}
+
+/// Map a dirty [`VerifyReport`] to a non-zero exit (the counts are the message).
+///
+/// # Errors
+/// Returns [`CliError::Input`] carrying the four counts when any is non-zero.
+pub(super) fn ensure_clean(report: &VerifyReport) -> Result<(), CliError> {
+  if report.is_clean() {
+    Ok(())
+  } else {
+    Err(CliError::Input(format!("pii verify failed: {report}")))
+  }
+}
+
+/// Build a payload-free abort error: a stored ciphertext that no longer opens
+/// rolls the batch back, leaving the plaintext intact for retry. The column
+/// name is schema, never a value or key material.
+pub(super) fn malformed(column: &str) -> CliError {
+  CliError::Crypto(format!(
+    "pii backfill aborted: stored ciphertext for {column} cannot be opened; batch rolled back"
+  ))
+}
+
+/// Build a payload-free sealing error (never carries key or value material).
+pub(super) fn seal_failed() -> CliError {
+  CliError::Crypto("PII field sealing failed".to_owned())
+}

+ 207 - 0
src/cli/pii/backfill.rs

@@ -0,0 +1,207 @@
+//! `pii backfill`: seal legacy plaintext PII and upgrade TOTP blobs to v3.
+//!
+//! Every table is scanned in bounded batches. Each batch runs in its own
+//! transaction holding `pg_advisory_xact_lock` on the connection that owns
+//! that transaction (mirroring `totp rewrap`), so the lock and the batch
+//! commit/rollback are atomic. For each row with a non-null plaintext PII
+//! value the sealed `_enc` (plus `_index` where the column exists) is written
+//! and the plaintext column is nulled; any TOTP blob below v3 is decrypted
+//! with the old path and re-encrypted with `envelope = true`. A stored
+//! `_enc` that no longer opens aborts the batch (rollback, plaintext intact)
+//! instead of being overwritten. The command is idempotent: a re-run finds no
+//! plaintext and rewrites nothing.
+
+use sqlx::{PgPool, Row as _};
+use uuid::Uuid;
+
+use super::super::context::{CliError, audit};
+use super::clients::backfill_clients;
+use super::{BackfillReport, malformed, seal_failed};
+use crate::crypto::field::{blind_index_active, open_text, seal_text};
+use crate::crypto::totp::{decrypt_secret, encrypt_secret};
+use crate::db::user_repo::normalize_email;
+use crate::keys::KeyRing;
+
+/// Rows scanned per batch transaction.
+const BATCH_SIZE: usize = 100;
+/// Envelope-encrypted TOTP blob version (per-row DEK); anything else is legacy.
+const TOTP_BLOB_V3: u8 = 3;
+/// Advisory-lock key for the `users` batches, stable across processes.
+const USERS_LOCK: &str = "d_oauth_pii_backfill_users";
+/// Advisory-lock key for the `redirect_uris` batches, stable across processes.
+const REDIRECTS_LOCK: &str = "d_oauth_pii_backfill_redirects";
+
+/// Seal remaining plaintext PII and upgrade TOTP blobs, refusing while
+/// dual-write holds.
+///
+/// # Errors
+/// Returns [`CliError::Input`] while `dual_write` holds (refusal),
+/// [`CliError::Crypto`] when no active PII key is configured, a stored
+/// ciphertext cannot be opened (the batch rolls back, plaintext intact), or a
+/// seal fails, and [`CliError::Database`] for PostgreSQL failures.
+pub(super) async fn backfill_with(
+  pool: &PgPool,
+  keys: &KeyRing,
+  dual_write: bool,
+) -> Result<BackfillReport, CliError> {
+  if dual_write {
+    return Err(CliError::Input(
+      "pii backfill refused while pii_dual_write=true: cutover order is \
+       dual-write off, scale-to-zero, backfill, verify"
+        .to_owned(),
+    ));
+  }
+  if keys.active_pii_key().is_none() {
+    return Err(CliError::Crypto(
+      "no active PII encryption key configured".to_owned(),
+    ));
+  }
+  let mut report = BackfillReport::default();
+  backfill_users(pool, keys, &mut report).await?;
+  backfill_clients(pool, keys, &mut report).await?;
+  backfill_redirects(pool, keys, &mut report).await?;
+  audit("pii.backfill", "pii");
+  Ok(report)
+}
+
+/// Seal `users.email` rows and upgrade pre-v3 TOTP blobs, one locked batch at
+/// a time (keyset pagination on `id`, never `OFFSET`).
+async fn backfill_users(
+  pool: &PgPool,
+  keys: &KeyRing,
+  report: &mut BackfillReport,
+) -> Result<(), CliError> {
+  let mut after = Uuid::nil();
+  loop {
+    let mut tx = pool.begin().await?;
+    sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))")
+      .bind(USERS_LOCK)
+      .execute(&mut *tx)
+      .await?;
+    let rows = sqlx::query(
+      "SELECT id, email, email_enc, totp_secret_enc FROM users \
+       WHERE (email IS NOT NULL OR totp_secret_enc IS NOT NULL) AND id > $1 \
+       ORDER BY id LIMIT $2",
+    )
+    .bind(after)
+    .bind(BATCH_SIZE as i64)
+    .fetch_all(&mut *tx)
+    .await?;
+    if rows.is_empty() {
+      tx.commit().await?;
+      break;
+    }
+    let fetched = rows.len();
+    for row in &rows {
+      let id: Uuid = row.try_get("id")?;
+      let email: Option<String> = row.try_get("email")?;
+      let email_enc: Option<Vec<u8>> = row.try_get("email_enc")?;
+      let totp: Option<Vec<u8>> = row.try_get("totp_secret_enc")?;
+
+      let mut sealed_email: Option<Vec<u8>> = None;
+      let mut sealed_index: Option<String> = None;
+      if let Some(plain) = email {
+        if let Some(existing) = email_enc {
+          open_text(keys, &existing).map_err(|_| malformed("users.email_enc"))?;
+        }
+        let canonical = normalize_email(&plain);
+        sealed_email = Some(seal_text(keys, &canonical).map_err(|_| seal_failed())?);
+        sealed_index = Some(blind_index_active(keys, &canonical));
+      }
+      let mut upgraded_totp: Option<Vec<u8>> = None;
+      if let Some(blob) = totp
+        && blob.first().copied() != Some(TOTP_BLOB_V3)
+      {
+        let plaintext =
+          decrypt_secret(keys, &blob).map_err(|_| malformed("users.totp_secret_enc"))?;
+        upgraded_totp = Some(encrypt_secret(keys, &plaintext, true).map_err(|_| seal_failed())?);
+      }
+      if sealed_email.is_some() {
+        sqlx::query(
+          "UPDATE users SET email_enc = $2, email_index = $3, email = NULL, \
+             totp_secret_enc = COALESCE($4, totp_secret_enc) WHERE id = $1",
+        )
+        .bind(id)
+        .bind(sealed_email.as_deref())
+        .bind(sealed_index.as_deref())
+        .bind(upgraded_totp.as_deref())
+        .execute(&mut *tx)
+        .await?;
+        report.users_sealed = report.users_sealed.saturating_add(1);
+      } else if upgraded_totp.is_some() {
+        sqlx::query("UPDATE users SET totp_secret_enc = $2 WHERE id = $1")
+          .bind(id)
+          .bind(upgraded_totp.as_deref())
+          .execute(&mut *tx)
+          .await?;
+      }
+      if upgraded_totp.is_some() {
+        report.totp_upgraded = report.totp_upgraded.saturating_add(1);
+      }
+      after = id;
+    }
+    tx.commit().await?;
+    if fetched < BATCH_SIZE {
+      break;
+    }
+  }
+  Ok(())
+}
+
+/// Seal `redirect_uris.uri` rows, one locked batch at a time.
+async fn backfill_redirects(
+  pool: &PgPool,
+  keys: &KeyRing,
+  report: &mut BackfillReport,
+) -> Result<(), CliError> {
+  let mut after = Uuid::nil();
+  loop {
+    let mut tx = pool.begin().await?;
+    sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))")
+      .bind(REDIRECTS_LOCK)
+      .execute(&mut *tx)
+      .await?;
+    let rows = sqlx::query(
+      "SELECT id, uri, uri_enc FROM redirect_uris \
+       WHERE uri IS NOT NULL AND id > $1 ORDER BY id LIMIT $2",
+    )
+    .bind(after)
+    .bind(BATCH_SIZE as i64)
+    .fetch_all(&mut *tx)
+    .await?;
+    if rows.is_empty() {
+      tx.commit().await?;
+      break;
+    }
+    let fetched = rows.len();
+    for row in &rows {
+      let id: Uuid = row.try_get("id")?;
+      let uri: Option<String> = row.try_get("uri")?;
+      let uri_enc: Option<Vec<u8>> = row.try_get("uri_enc")?;
+      let Some(plain) = uri else {
+        after = id;
+        continue;
+      };
+      if let Some(existing) = uri_enc {
+        open_text(keys, &existing).map_err(|_| malformed("redirect_uris.uri_enc"))?;
+      }
+      let sealed = seal_text(keys, &plain).map_err(|_| seal_failed())?;
+      let index = blind_index_active(keys, &plain);
+      sqlx::query(
+        "UPDATE redirect_uris SET uri_enc = $2, uri_index = $3, uri = NULL WHERE id = $1",
+      )
+      .bind(id)
+      .bind(&sealed)
+      .bind(&index)
+      .execute(&mut *tx)
+      .await?;
+      report.redirects_sealed = report.redirects_sealed.saturating_add(1);
+      after = id;
+    }
+    tx.commit().await?;
+    if fetched < BATCH_SIZE {
+      break;
+    }
+  }
+  Ok(())
+}

+ 234 - 0
src/cli/pii/clients.rs

@@ -0,0 +1,234 @@
+//! `pii backfill` over `oauth_clients`: seal the seven PII metadata columns.
+//!
+//! The seven plaintext columns are blanked exactly as listed by the plan:
+//! `client_name`, `contacts`, `request_uris`, `jwks_uri`, `software_id`,
+//! `tls_client_auth_subject_dn`, and `tls_client_auth_cert_thumbprint`. Only
+//! the two mTLS identities carry a blind index, computed over the exact
+//! stored string.
+
+use sqlx::{PgPool, Row as _};
+use uuid::Uuid;
+
+use super::super::context::CliError;
+use super::{BackfillReport, malformed, seal_failed};
+use crate::crypto::field::{blind_index_active, open_json, open_text, seal_json, seal_text};
+use crate::keys::KeyRing;
+
+/// Rows scanned per batch transaction.
+const BATCH_SIZE: usize = 100;
+/// Advisory-lock key for the `oauth_clients` batches, stable across processes.
+const CLIENTS_LOCK: &str = "d_oauth_pii_backfill_clients";
+
+/// Seal every non-null `oauth_clients` PII column, one locked batch at a time
+/// (keyset pagination on `id`, never `OFFSET`).
+pub(super) async fn backfill_clients(
+  pool: &PgPool,
+  keys: &KeyRing,
+  report: &mut BackfillReport,
+) -> Result<(), CliError> {
+  let mut after = Uuid::nil();
+  loop {
+    let mut tx = pool.begin().await?;
+    sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))")
+      .bind(CLIENTS_LOCK)
+      .execute(&mut *tx)
+      .await?;
+    let rows = sqlx::query(
+      "SELECT id, client_name, contacts, request_uris, jwks_uri, software_id, \
+         tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, \
+         client_name_enc, contacts_enc, request_uris_enc, jwks_uri_enc, software_id_enc, \
+         tls_client_auth_subject_dn_enc, tls_client_auth_cert_thumbprint_enc, \
+         tls_client_auth_subject_dn_index, tls_client_auth_cert_thumbprint_index \
+       FROM oauth_clients \
+       WHERE (client_name IS NOT NULL OR contacts IS NOT NULL OR request_uris IS NOT NULL \
+         OR jwks_uri IS NOT NULL OR software_id IS NOT NULL \
+         OR tls_client_auth_subject_dn IS NOT NULL OR tls_client_auth_cert_thumbprint IS NOT NULL) \
+         AND id > $1 ORDER BY id LIMIT $2",
+    )
+    .bind(after)
+    .bind(BATCH_SIZE as i64)
+    .fetch_all(&mut *tx)
+    .await?;
+    if rows.is_empty() {
+      tx.commit().await?;
+      break;
+    }
+    let fetched = rows.len();
+    for row in &rows {
+      let id: Uuid = row.try_get("id")?;
+      let sealed = seal_client_row(keys, row)?;
+      let Some(sealed) = sealed else {
+        after = id;
+        continue;
+      };
+      sqlx::query(
+        "UPDATE oauth_clients SET client_name_enc = $2, contacts_enc = $3, request_uris_enc = $4, \
+           jwks_uri_enc = $5, software_id_enc = $6, tls_client_auth_subject_dn_enc = $7, \
+           tls_client_auth_cert_thumbprint_enc = $8, tls_client_auth_subject_dn_index = $9, \
+           tls_client_auth_cert_thumbprint_index = $10, client_name = NULL, contacts = NULL, \
+           request_uris = NULL, jwks_uri = NULL, software_id = NULL, \
+           tls_client_auth_subject_dn = NULL, tls_client_auth_cert_thumbprint = NULL \
+         WHERE id = $1",
+      )
+      .bind(id)
+      .bind(sealed.name_enc.as_deref())
+      .bind(sealed.contacts_enc.as_deref())
+      .bind(sealed.request_uris_enc.as_deref())
+      .bind(sealed.jwks_uri_enc.as_deref())
+      .bind(sealed.software_enc.as_deref())
+      .bind(sealed.dn_enc.as_deref())
+      .bind(sealed.thumb_enc.as_deref())
+      .bind(sealed.dn_index.as_deref())
+      .bind(sealed.thumb_index.as_deref())
+      .execute(&mut *tx)
+      .await?;
+      report.clients_sealed = report.clients_sealed.saturating_add(1);
+      after = id;
+    }
+    tx.commit().await?;
+    if fetched < BATCH_SIZE {
+      break;
+    }
+  }
+  Ok(())
+}
+
+/// The sealed form of one `oauth_clients` row: fresh ciphertext for every
+/// column the row sets (untouched columns keep their stored bytes), plus the
+/// two mTLS blind indexes under the exact stored string.
+struct SealedClient {
+  name_enc: Option<Vec<u8>>,
+  contacts_enc: Option<Vec<u8>>,
+  request_uris_enc: Option<Vec<u8>>,
+  jwks_uri_enc: Option<Vec<u8>>,
+  software_enc: Option<Vec<u8>>,
+  dn_enc: Option<Vec<u8>>,
+  thumb_enc: Option<Vec<u8>>,
+  dn_index: Option<String>,
+  thumb_index: Option<String>,
+}
+
+/// Seal the non-null plaintext columns of one client row, validating any
+/// already-stored ciphertext first (`None` when the row sets no plaintext).
+fn seal_client_row(
+  keys: &KeyRing,
+  row: &sqlx::postgres::PgRow,
+) -> Result<Option<SealedClient>, CliError> {
+  let name: Option<String> = row.try_get("client_name")?;
+  let contacts: Option<serde_json::Value> = row.try_get("contacts")?;
+  let request_uris: Option<serde_json::Value> = row.try_get("request_uris")?;
+  let jwks_uri: Option<String> = row.try_get("jwks_uri")?;
+  let software_id: Option<String> = row.try_get("software_id")?;
+  let subject_dn: Option<String> = row.try_get("tls_client_auth_subject_dn")?;
+  let thumbprint: Option<String> = row.try_get("tls_client_auth_cert_thumbprint")?;
+  if name.is_none()
+    && contacts.is_none()
+    && request_uris.is_none()
+    && jwks_uri.is_none()
+    && software_id.is_none()
+    && subject_dn.is_none()
+    && thumbprint.is_none()
+  {
+    return Ok(None);
+  }
+  check_text(keys, row, "client_name_enc", name.is_some())?;
+  check_json(keys, row, "contacts_enc", contacts.is_some())?;
+  check_json(keys, row, "request_uris_enc", request_uris.is_some())?;
+  check_text(keys, row, "jwks_uri_enc", jwks_uri.is_some())?;
+  check_text(keys, row, "software_id_enc", software_id.is_some())?;
+  check_text(
+    keys,
+    row,
+    "tls_client_auth_subject_dn_enc",
+    subject_dn.is_some(),
+  )?;
+  check_text(
+    keys,
+    row,
+    "tls_client_auth_cert_thumbprint_enc",
+    thumbprint.is_some(),
+  )?;
+  let seal_text_value = |value: Option<String>| -> Result<Option<Vec<u8>>, CliError> {
+    value
+      .map(|plain| seal_text(keys, &plain).map_err(|_| seal_failed()))
+      .transpose()
+  };
+  let seal_json_value = |value: Option<serde_json::Value>| -> Result<Option<Vec<u8>>, CliError> {
+    value
+      .map(|plain| seal_json(keys, &plain).map_err(|_| seal_failed()))
+      .transpose()
+  };
+  let existing_bytes =
+    |column: &str| -> Result<Option<Vec<u8>>, CliError> { Ok(row.try_get(column)?) };
+  let existing_index =
+    |column: &str| -> Result<Option<String>, CliError> { Ok(row.try_get(column)?) };
+  Ok(Some(SealedClient {
+    name_enc: or_existing(seal_text_value(name)?, existing_bytes("client_name_enc")?),
+    contacts_enc: or_existing(seal_json_value(contacts)?, existing_bytes("contacts_enc")?),
+    request_uris_enc: or_existing(
+      seal_json_value(request_uris)?,
+      existing_bytes("request_uris_enc")?,
+    ),
+    jwks_uri_enc: or_existing(seal_text_value(jwks_uri)?, existing_bytes("jwks_uri_enc")?),
+    software_enc: or_existing(
+      seal_text_value(software_id)?,
+      existing_bytes("software_id_enc")?,
+    ),
+    dn_enc: or_existing(
+      seal_text_value(subject_dn.clone())?,
+      existing_bytes("tls_client_auth_subject_dn_enc")?,
+    ),
+    thumb_enc: or_existing(
+      seal_text_value(thumbprint.clone())?,
+      existing_bytes("tls_client_auth_cert_thumbprint_enc")?,
+    ),
+    dn_index: or_existing(
+      subject_dn.map(|value| blind_index_active(keys, &value)),
+      existing_index("tls_client_auth_subject_dn_index")?,
+    ),
+    thumb_index: or_existing(
+      thumbprint.map(|value| blind_index_active(keys, &value)),
+      existing_index("tls_client_auth_cert_thumbprint_index")?,
+    ),
+  }))
+}
+
+/// When `seal` holds, validate the already-stored text ciphertext still opens.
+fn check_text(
+  keys: &KeyRing,
+  row: &sqlx::postgres::PgRow,
+  column: &str,
+  seal: bool,
+) -> Result<(), CliError> {
+  if !seal {
+    return Ok(());
+  }
+  let existing: Option<Vec<u8>> = row.try_get(column)?;
+  if let Some(blob) = existing {
+    open_text(keys, &blob).map_err(|_| malformed(column))?;
+  }
+  Ok(())
+}
+
+/// When `seal` holds, validate the already-stored JSON ciphertext still opens.
+fn check_json(
+  keys: &KeyRing,
+  row: &sqlx::postgres::PgRow,
+  column: &str,
+  seal: bool,
+) -> Result<(), CliError> {
+  if !seal {
+    return Ok(());
+  }
+  let existing: Option<Vec<u8>> = row.try_get(column)?;
+  if let Some(blob) = existing {
+    open_json::<serde_json::Value>(keys, &blob).map_err(|_| malformed(column))?;
+  }
+  Ok(())
+}
+
+/// Prefer the freshly sealed value, keeping the stored bytes for columns the
+/// row leaves untouched.
+fn or_existing<T>(sealed: Option<T>, existing: Option<T>) -> Option<T> {
+  sealed.or(existing)
+}

+ 183 - 0
src/cli/pii/rotate.rs

@@ -0,0 +1,183 @@
+//! `pii rotate`: re-encrypt sealed PII under the active PII key.
+//!
+//! The field AEAD key derives from the master PII key, so ciphertext is NOT
+//! key-stable: retiring the old key without re-encryption would make reads
+//! fail. The blind index is key-dependent too, so every `_index` is recomputed
+//! under the active key. Run under a write freeze with the previous key
+//! retained; retire it only after this command succeeds everywhere.
+//!
+//! Each table is scanned in bounded batches. Every batch runs in its own
+//! transaction holding `pg_advisory_xact_lock` on the connection that owns
+//! that transaction, so a unique-index collision rolls the whole batch back
+//! instead of partially overwriting. Rows whose blob already names the active
+//! key are skipped, making a re-run a no-op. Plaintext columns are never
+//! touched and only counts leave this module.
+
+use sqlx::{PgPool, Row as _};
+use uuid::Uuid;
+
+use super::super::context::{CliError, audit};
+use super::rotate_clients::rotate_clients;
+use super::{RotateReport, malformed, seal_failed};
+use crate::crypto::field::{blind_index_active, open_text, seal_text};
+use crate::db::user_repo::normalize_email;
+use crate::keys::KeyRing;
+
+/// Rows scanned per batch transaction.
+pub(super) const BATCH_SIZE: usize = 100;
+/// Field-blob format version (mirrors the codec; unparseable blobs abort).
+const FIELD_BLOB_VERSION: u8 = 1;
+/// Advisory-lock key for the `users` batches, stable across processes.
+const USERS_LOCK: &str = "d_oauth_pii_rotate_users";
+/// Advisory-lock key for the `redirect_uris` batches, stable across processes.
+const REDIRECTS_LOCK: &str = "d_oauth_pii_rotate_redirects";
+
+/// Re-encrypt every stale PII blob under the active key, re-indexing as it goes.
+///
+/// A blob that no longer opens (previous key already retired) aborts the batch
+/// fail-closed instead of writing a wrong value; a unique-index collision on
+/// the recomputed index rolls the batch back the same way.
+///
+/// # Errors
+/// Returns [`CliError::Crypto`] when no active PII key is configured or a
+/// stored blob cannot be opened, and [`CliError::Database`] for PostgreSQL
+/// failures (including unique-index collisions).
+pub(super) async fn rotate_with(pool: &PgPool, keys: &KeyRing) -> Result<RotateReport, CliError> {
+  let mut report = RotateReport::default();
+  rotate_users(pool, keys, &mut report).await?;
+  rotate_clients(pool, keys, &mut report).await?;
+  rotate_redirects(pool, keys, &mut report).await?;
+  audit("pii.rotate", "pii");
+  Ok(report)
+}
+
+/// The active PII key id, fail-closed when unconfigured.
+pub(super) fn active_kid(keys: &KeyRing) -> Result<String, CliError> {
+  keys
+    .active_pii_key()
+    .map(|(id, _)| id.as_str().to_owned())
+    .ok_or_else(|| CliError::Crypto("no active PII encryption key configured".to_owned()))
+}
+
+/// The key id named inside a field blob (`None` when the header is corrupt).
+pub(super) fn blob_kid(blob: &[u8]) -> Option<&str> {
+  if blob.len() < 2 || blob[0] != FIELD_BLOB_VERSION {
+    return None;
+  }
+  let end = 2 + usize::from(blob[1]);
+  if blob.len() < end {
+    return None;
+  }
+  core::str::from_utf8(blob.get(2..end)?).ok()
+}
+
+/// Re-encrypt stale `users.email_enc` rows, one locked batch at a time.
+async fn rotate_users(
+  pool: &PgPool,
+  keys: &KeyRing,
+  report: &mut RotateReport,
+) -> Result<(), CliError> {
+  let active_kid = active_kid(keys)?;
+  let mut after = Uuid::nil();
+  loop {
+    let mut tx = pool.begin().await?;
+    sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))")
+      .bind(USERS_LOCK)
+      .execute(&mut *tx)
+      .await?;
+    let rows = sqlx::query(
+      "SELECT id, email_enc FROM users WHERE email_enc IS NOT NULL AND id > $1 \
+       ORDER BY id LIMIT $2",
+    )
+    .bind(after)
+    .bind(BATCH_SIZE as i64)
+    .fetch_all(&mut *tx)
+    .await?;
+    if rows.is_empty() {
+      tx.commit().await?;
+      break;
+    }
+    let fetched = rows.len();
+    for row in &rows {
+      let id: Uuid = row.try_get("id")?;
+      let blob: Vec<u8> = row.try_get("email_enc")?;
+      let kid = blob_kid(&blob).ok_or_else(|| malformed("users.email_enc"))?;
+      if kid == active_kid {
+        after = id;
+        continue;
+      }
+      let opened = open_text(keys, &blob).map_err(|_| malformed("users.email_enc"))?;
+      let canonical = normalize_email(&opened);
+      let resealed = seal_text(keys, &canonical).map_err(|_| seal_failed())?;
+      let index = blind_index_active(keys, &canonical);
+      sqlx::query("UPDATE users SET email_enc = $2, email_index = $3 WHERE id = $1")
+        .bind(id)
+        .bind(&resealed)
+        .bind(&index)
+        .execute(&mut *tx)
+        .await?;
+      report.users_rotated = report.users_rotated.saturating_add(1);
+      after = id;
+    }
+    tx.commit().await?;
+    if fetched < BATCH_SIZE {
+      break;
+    }
+  }
+  Ok(())
+}
+
+/// Re-encrypt stale `redirect_uris.uri_enc` rows, one locked batch at a time.
+async fn rotate_redirects(
+  pool: &PgPool,
+  keys: &KeyRing,
+  report: &mut RotateReport,
+) -> Result<(), CliError> {
+  let active_kid = active_kid(keys)?;
+  let mut after = Uuid::nil();
+  loop {
+    let mut tx = pool.begin().await?;
+    sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))")
+      .bind(REDIRECTS_LOCK)
+      .execute(&mut *tx)
+      .await?;
+    let rows = sqlx::query(
+      "SELECT id, uri_enc FROM redirect_uris WHERE uri_enc IS NOT NULL AND id > $1 \
+       ORDER BY id LIMIT $2",
+    )
+    .bind(after)
+    .bind(BATCH_SIZE as i64)
+    .fetch_all(&mut *tx)
+    .await?;
+    if rows.is_empty() {
+      tx.commit().await?;
+      break;
+    }
+    let fetched = rows.len();
+    for row in &rows {
+      let id: Uuid = row.try_get("id")?;
+      let blob: Vec<u8> = row.try_get("uri_enc")?;
+      let kid = blob_kid(&blob).ok_or_else(|| malformed("redirect_uris.uri_enc"))?;
+      if kid == active_kid {
+        after = id;
+        continue;
+      }
+      let opened = open_text(keys, &blob).map_err(|_| malformed("redirect_uris.uri_enc"))?;
+      let resealed = seal_text(keys, &opened).map_err(|_| seal_failed())?;
+      let index = blind_index_active(keys, &opened);
+      sqlx::query("UPDATE redirect_uris SET uri_enc = $2, uri_index = $3 WHERE id = $1")
+        .bind(id)
+        .bind(&resealed)
+        .bind(&index)
+        .execute(&mut *tx)
+        .await?;
+      report.redirects_rotated = report.redirects_rotated.saturating_add(1);
+      after = id;
+    }
+    tx.commit().await?;
+    if fetched < BATCH_SIZE {
+      break;
+    }
+  }
+  Ok(())
+}

+ 211 - 0
src/cli/pii/rotate_clients.rs

@@ -0,0 +1,211 @@
+//! `pii rotate` over `oauth_clients`: re-encrypt the seven PII columns.
+//!
+//! A row is rewritten only when at least one blob names a retired key; both
+//! mTLS blind indexes are then recomputed from the opened exact strings under
+//! the active key. Plaintext columns are never touched.
+
+use sqlx::{PgPool, Row as _};
+use uuid::Uuid;
+
+use super::super::context::CliError;
+use super::rotate::{BATCH_SIZE, active_kid, blob_kid};
+use super::{RotateReport, malformed, seal_failed};
+use crate::crypto::field::{blind_index_active, open_json, open_text, seal_json, seal_text};
+use crate::keys::KeyRing;
+
+/// Advisory-lock key for the `oauth_clients` batches, stable across processes.
+const CLIENTS_LOCK: &str = "d_oauth_pii_rotate_clients";
+
+/// Re-encrypt stale `oauth_clients` PII columns, one locked batch at a time.
+pub(super) async fn rotate_clients(
+  pool: &PgPool,
+  keys: &KeyRing,
+  report: &mut RotateReport,
+) -> Result<(), CliError> {
+  let active_kid = active_kid(keys)?;
+  let mut after = Uuid::nil();
+  loop {
+    let mut tx = pool.begin().await?;
+    sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))")
+      .bind(CLIENTS_LOCK)
+      .execute(&mut *tx)
+      .await?;
+    let rows = sqlx::query(
+      "SELECT id, client_name_enc, contacts_enc, request_uris_enc, jwks_uri_enc, software_id_enc, \
+         tls_client_auth_subject_dn_enc, tls_client_auth_cert_thumbprint_enc \
+       FROM oauth_clients \
+       WHERE (client_name_enc IS NOT NULL OR contacts_enc IS NOT NULL \
+         OR request_uris_enc IS NOT NULL OR jwks_uri_enc IS NOT NULL OR software_id_enc IS NOT NULL \
+         OR tls_client_auth_subject_dn_enc IS NOT NULL \
+         OR tls_client_auth_cert_thumbprint_enc IS NOT NULL) \
+         AND id > $1 ORDER BY id LIMIT $2",
+    )
+    .bind(after)
+    .bind(BATCH_SIZE as i64)
+    .fetch_all(&mut *tx)
+    .await?;
+    if rows.is_empty() {
+      tx.commit().await?;
+      break;
+    }
+    let fetched = rows.len();
+    for row in &rows {
+      let id: Uuid = row.try_get("id")?;
+      let name = open_text_col(
+        keys,
+        row.try_get("client_name_enc")?,
+        "client_name_enc",
+        &active_kid,
+      )?;
+      let contacts = open_json_col(
+        keys,
+        row.try_get("contacts_enc")?,
+        "contacts_enc",
+        &active_kid,
+      )?;
+      let request_uris = open_json_col(
+        keys,
+        row.try_get("request_uris_enc")?,
+        "request_uris_enc",
+        &active_kid,
+      )?;
+      let jwks = open_text_col(
+        keys,
+        row.try_get("jwks_uri_enc")?,
+        "jwks_uri_enc",
+        &active_kid,
+      )?;
+      let software = open_text_col(
+        keys,
+        row.try_get("software_id_enc")?,
+        "software_id_enc",
+        &active_kid,
+      )?;
+      let subject = open_text_col(
+        keys,
+        row.try_get("tls_client_auth_subject_dn_enc")?,
+        "tls_client_auth_subject_dn_enc",
+        &active_kid,
+      )?;
+      let thumb = open_text_col(
+        keys,
+        row.try_get("tls_client_auth_cert_thumbprint_enc")?,
+        "tls_client_auth_cert_thumbprint_enc",
+        &active_kid,
+      )?;
+      let stale = [name.as_ref(), jwks.as_ref(), software.as_ref()]
+        .iter()
+        .any(|cell| matches!(cell, Some((_, _, false))))
+        || [contacts.as_ref(), request_uris.as_ref()]
+          .iter()
+          .any(|cell| matches!(cell, Some((_, _, false))))
+        || [subject.as_ref(), thumb.as_ref()]
+          .iter()
+          .any(|cell| matches!(cell, Some((_, _, false))));
+      if !stale {
+        after = id;
+        continue;
+      }
+      let (name_enc, _) = reseal_text(keys, name)?;
+      let contacts_enc = reseal_json(keys, contacts)?;
+      let request_uris_enc = reseal_json(keys, request_uris)?;
+      let (jwks_enc, _) = reseal_text(keys, jwks)?;
+      let (software_enc, _) = reseal_text(keys, software)?;
+      let (dn_enc, dn_plain) = reseal_text(keys, subject)?;
+      let (thumb_enc, thumb_plain) = reseal_text(keys, thumb)?;
+      let dn_index = dn_plain.map(|value| blind_index_active(keys, &value));
+      let thumb_index = thumb_plain.map(|value| blind_index_active(keys, &value));
+      sqlx::query(
+        "UPDATE oauth_clients SET client_name_enc = $2, contacts_enc = $3, request_uris_enc = $4, \
+           jwks_uri_enc = $5, software_id_enc = $6, tls_client_auth_subject_dn_enc = $7, \
+           tls_client_auth_cert_thumbprint_enc = $8, tls_client_auth_subject_dn_index = $9, \
+           tls_client_auth_cert_thumbprint_index = $10 WHERE id = $1",
+      )
+      .bind(id)
+      .bind(name_enc.as_deref())
+      .bind(contacts_enc.as_deref())
+      .bind(request_uris_enc.as_deref())
+      .bind(jwks_enc.as_deref())
+      .bind(software_enc.as_deref())
+      .bind(dn_enc.as_deref())
+      .bind(thumb_enc.as_deref())
+      .bind(dn_index.as_deref())
+      .bind(thumb_index.as_deref())
+      .execute(&mut *tx)
+      .await?;
+      report.clients_rotated = report.clients_rotated.saturating_add(1);
+      after = id;
+    }
+    tx.commit().await?;
+    if fetched < BATCH_SIZE {
+      break;
+    }
+  }
+  Ok(())
+}
+
+/// One opened text column: the plaintext plus whether it already names `active`.
+fn open_text_col(
+  keys: &KeyRing,
+  blob: Option<Vec<u8>>,
+  column: &'static str,
+  active_kid: &str,
+) -> Result<Option<(String, Vec<u8>, bool)>, CliError> {
+  let Some(blob) = blob else {
+    return Ok(None);
+  };
+  let is_current = blob_kid(&blob).ok_or_else(|| malformed(column))? == active_kid;
+  let plain = open_text(keys, &blob).map_err(|_| malformed(column))?;
+  Ok(Some((plain, blob, is_current)))
+}
+
+/// One opened JSON column: the value plus whether it already names `active`.
+fn open_json_col(
+  keys: &KeyRing,
+  blob: Option<Vec<u8>>,
+  column: &'static str,
+  active_kid: &str,
+) -> Result<Option<(serde_json::Value, Vec<u8>, bool)>, CliError> {
+  let Some(blob) = blob else {
+    return Ok(None);
+  };
+  let is_current = blob_kid(&blob).ok_or_else(|| malformed(column))? == active_kid;
+  let plain = open_json::<serde_json::Value>(keys, &blob).map_err(|_| malformed(column))?;
+  Ok(Some((plain, blob, is_current)))
+}
+
+/// Re-seal a text value only when stale, otherwise keep the stored bytes.
+fn reseal_text(
+  keys: &KeyRing,
+  opened: Option<(String, Vec<u8>, bool)>,
+) -> Result<(Option<Vec<u8>>, Option<String>), CliError> {
+  opened
+    .map(|(plain, blob, current)| {
+      if current {
+        Ok((Some(blob), Some(plain)))
+      } else {
+        seal_text(keys, &plain)
+          .map_err(|_| seal_failed())
+          .map(|sealed| (Some(sealed), Some(plain)))
+      }
+    })
+    .transpose()
+    .map(|option| option.unwrap_or((None, None)))
+}
+
+/// Re-seal a JSON value only when stale, otherwise keep the stored bytes.
+fn reseal_json(
+  keys: &KeyRing,
+  opened: Option<(serde_json::Value, Vec<u8>, bool)>,
+) -> Result<Option<Vec<u8>>, CliError> {
+  opened
+    .map(|(value, blob, current)| {
+      if current {
+        Ok(Some(blob))
+      } else {
+        seal_json(keys, &value).map_err(|_| seal_failed()).map(Some)
+      }
+    })
+    .transpose()
+    .map(Option::flatten)
+}

+ 673 - 0
src/cli/pii/tests.rs

@@ -0,0 +1,673 @@
+//! `test(pii_backfill_)` / `test(pii_verify_)` suite: cutover backfill + checks.
+//!
+//! Every test gets a fresh migrated database from
+//! [`crate::db::test_support`]: `pii backfill` and `pii verify` scan whole
+//! tables, so the shared CLI harness would race other suites. Only counts are
+//! asserted, never values or key material.
+
+use secrecy::SecretString;
+use serde_json::json;
+use sqlx::{PgPool, Row as _};
+use uuid::Uuid;
+
+use super::backfill::backfill_with;
+use super::ensure_clean;
+use super::rotate::rotate_with;
+use super::verify::verify_with;
+use crate::cli::test_support::TestError;
+use crate::crypto::field::{blind_index_active, open_text, seal_text};
+use crate::crypto::totp as crypto_totp;
+use crate::db::test_support::migrated_pool;
+use crate::db::user_repo::normalize_email;
+use crate::keys::{KeyId, KeyRing};
+
+/// Fixed TOTP secret plaintext (base32, deterministic across runs).
+const TOTP_SECRET: &str = "JBSWY3DPEHPK3PXP";
+
+fn key(id: &str, material: &str) -> (KeyId, SecretString) {
+  (KeyId::new(id), SecretString::from(material.to_owned()))
+}
+
+/// A ring with throwaway pepper, TOTP, and PII chains (newest first).
+fn test_keys() -> Result<KeyRing, TestError> {
+  KeyRing::from_parts(
+    vec![key("p1", "pii-test-pepper-0123456789abcdef")],
+    vec![key("t1", "pii-test-totp-key-0123456789abcdef")],
+    vec![key("k1", "pii-test-pii-key-0123456789abcdef")],
+  )
+  .map_err(|error| TestError::Harness(error.to_string()))
+}
+
+/// Bind a fresh migrated database: whole-table scans need isolation, so never
+/// the shared CLI harness. Keeps the shared container alive via `_db`.
+macro_rules! fresh_pool {
+  () => {
+    migrated_pool()
+      .await
+      .map_err(|error| TestError::Harness(error.to_string()))
+  };
+}
+
+/// Insert a legacy `users` row: plaintext email plus an optional TOTP blob.
+async fn insert_user(
+  pool: &PgPool,
+  id: Uuid,
+  email: Option<&str>,
+  totp: Option<&[u8]>,
+) -> Result<(), TestError> {
+  sqlx::query(
+    "INSERT INTO users (id, email, password_hash, totp_secret_enc, totp_enabled, created_at, updated_at) \
+     VALUES ($1, $2, 'x', $3, false, now(), now())",
+  )
+  .bind(id)
+  .bind(email)
+  .bind(totp)
+  .execute(pool)
+  .await?;
+  Ok(())
+}
+
+/// Insert a legacy `oauth_clients` row with every PII column in plaintext.
+async fn insert_client(pool: &PgPool, client_id: &str) -> Result<Uuid, TestError> {
+  let id = Uuid::new_v4();
+  sqlx::query(
+    "INSERT INTO oauth_clients (id, client_id, client_id_issued_at, client_name, grant_types, \
+       response_types, scope, token_endpoint_auth_method, jwks_uri, tls_client_auth_subject_dn, \
+       tls_client_auth_cert_thumbprint, software_id, contacts, request_uris, created_at, updated_at) \
+     VALUES ($1, $2, now(), 'Legacy Client', ARRAY['authorization_code'], ARRAY['code'], \
+       'api:read', 'none', 'https://legacy.example.test/jwks', 'CN=legacy', 'THUMB-LEGACY', \
+       'sw-legacy', $3, $4, now(), now())",
+  )
+  .bind(id)
+  .bind(client_id)
+  .bind(json!({"email": "ops@legacy.example.test"}))
+  .bind(json!(["https://legacy.example.test/request.jwk"]))
+  .execute(pool)
+  .await?;
+  Ok(id)
+}
+
+/// Insert a legacy `redirect_uris` row with the URI in plaintext.
+async fn insert_redirect(pool: &PgPool, uri: &str) -> Result<Uuid, TestError> {
+  let id = Uuid::new_v4();
+  sqlx::query(
+    "INSERT INTO redirect_uris (id, uri, kind, enabled, description, created_at, updated_at) \
+     VALUES ($1, $2, 'authorize', true, '', now(), now())",
+  )
+  .bind(id)
+  .bind(uri)
+  .execute(pool)
+  .await?;
+  Ok(id)
+}
+
+/// The stored plaintext of one column, if still present.
+async fn plaintext(
+  pool: &PgPool,
+  sql: &'static str,
+  id: Uuid,
+) -> Result<Option<String>, TestError> {
+  let row = sqlx::query(sql).bind(id).fetch_one(pool).await?;
+  Ok(row.try_get::<Option<String>, _>(0)?)
+}
+
+/// Every `oauth_clients` plaintext PII column of one row must be blanked.
+async fn assert_client_plaintext_blank(pool: &PgPool, id: Uuid) -> Result<(), TestError> {
+  let row = sqlx::query(
+    "SELECT client_name, contacts, request_uris, jwks_uri, software_id, \
+       tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint \
+     FROM oauth_clients WHERE id = $1",
+  )
+  .bind(id)
+  .fetch_one(pool)
+  .await?;
+  assert_eq!(row.try_get::<Option<String>, _>("client_name")?, None);
+  assert_eq!(
+    row.try_get::<Option<serde_json::Value>, _>("contacts")?,
+    None
+  );
+  assert_eq!(
+    row.try_get::<Option<serde_json::Value>, _>("request_uris")?,
+    None
+  );
+  assert_eq!(row.try_get::<Option<String>, _>("jwks_uri")?, None);
+  assert_eq!(row.try_get::<Option<String>, _>("software_id")?, None);
+  assert_eq!(
+    row.try_get::<Option<String>, _>("tls_client_auth_subject_dn")?,
+    None
+  );
+  assert_eq!(
+    row.try_get::<Option<String>, _>("tls_client_auth_cert_thumbprint")?,
+    None
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn pii_backfill_encrypts_and_blanks_plaintext() -> Result<(), TestError> {
+  let (_db, pool) = fresh_pool!()?;
+  let keys = test_keys()?;
+
+  let mixed_email = "Backfill-Me@Example.Test";
+  let canonical = normalize_email(mixed_email);
+  let v2 = crypto_totp::encrypt_secret(&keys, TOTP_SECRET, false)
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  let user_id = Uuid::new_v4();
+  insert_user(&pool, user_id, Some(mixed_email), Some(&v2)).await?;
+  let client_id = insert_client(&pool, "backfill-client").await?;
+  let redirect_id = insert_redirect(&pool, "https://app.example.test/callback").await?;
+
+  let report = backfill_with(&pool, &keys, false)
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  assert_eq!(report.users_sealed, 1);
+  assert_eq!(report.clients_sealed, 1);
+  assert_eq!(report.redirects_sealed, 1);
+  assert_eq!(report.totp_upgraded, 1);
+
+  // Users: plaintext blanked, ciphertext opens to the canonical email, the
+  // blind index matches the active key, and the TOTP blob is now v3.
+  assert_eq!(
+    plaintext(&pool, "SELECT email FROM users WHERE id = $1", user_id).await?,
+    None
+  );
+  let row = sqlx::query("SELECT email_enc, email_index, totp_secret_enc FROM users WHERE id = $1")
+    .bind(user_id)
+    .fetch_one(&pool)
+    .await?;
+  let email_enc = row.try_get::<Option<Vec<u8>>, _>("email_enc")?;
+  let email_index = row.try_get::<Option<String>, _>("email_index")?;
+  let totp = row.try_get::<Option<Vec<u8>>, _>("totp_secret_enc")?;
+  let blob = email_enc.ok_or(TestError::Missing("email_enc"))?;
+  assert_eq!(
+    open_text(&keys, &blob).map_err(|error| TestError::Harness(error.to_string()))?,
+    canonical
+  );
+  assert_eq!(email_index, Some(blind_index_active(&keys, &canonical)));
+  let totp_blob = totp.ok_or(TestError::Missing("totp_secret_enc"))?;
+  assert_eq!(totp_blob.first().copied(), Some(3));
+  assert_eq!(
+    crypto_totp::decrypt_secret(&keys, &totp_blob)
+      .map_err(|error| TestError::Harness(error.to_string()))?,
+    TOTP_SECRET
+  );
+
+  // Clients: every plaintext column blanked, every `_enc` opens, and both
+  // mTLS blind indexes match the active key under the exact string.
+  assert_client_plaintext_blank(&pool, client_id).await?;
+  let row = sqlx::query(
+    "SELECT client_name_enc, contacts_enc, request_uris_enc, jwks_uri_enc, software_id_enc, \
+       tls_client_auth_subject_dn_enc, tls_client_auth_cert_thumbprint_enc, \
+       tls_client_auth_subject_dn_index, tls_client_auth_cert_thumbprint_index \
+     FROM oauth_clients WHERE id = $1",
+  )
+  .bind(client_id)
+  .fetch_one(&pool)
+  .await?;
+  let name: Vec<u8> = row.try_get("client_name_enc")?;
+  assert_eq!(
+    open_text(&keys, &name).map_err(|error| TestError::Harness(error.to_string()))?,
+    "Legacy Client"
+  );
+  let dn: Vec<u8> = row.try_get("tls_client_auth_subject_dn_enc")?;
+  assert_eq!(
+    open_text(&keys, &dn).map_err(|error| TestError::Harness(error.to_string()))?,
+    "CN=legacy"
+  );
+  let thumb: Vec<u8> = row.try_get("tls_client_auth_cert_thumbprint_enc")?;
+  assert_eq!(
+    open_text(&keys, &thumb).map_err(|error| TestError::Harness(error.to_string()))?,
+    "THUMB-LEGACY"
+  );
+  assert_eq!(
+    row.try_get::<Option<String>, _>("tls_client_auth_subject_dn_index")?,
+    Some(blind_index_active(&keys, "CN=legacy"))
+  );
+  assert_eq!(
+    row.try_get::<Option<String>, _>("tls_client_auth_cert_thumbprint_index")?,
+    Some(blind_index_active(&keys, "THUMB-LEGACY"))
+  );
+
+  // Redirects: plaintext blanked, ciphertext opens, index matches exactly.
+  assert_eq!(
+    plaintext(
+      &pool,
+      "SELECT uri FROM redirect_uris WHERE id = $1",
+      redirect_id
+    )
+    .await?,
+    None
+  );
+  let row = sqlx::query("SELECT uri_enc, uri_index FROM redirect_uris WHERE id = $1")
+    .bind(redirect_id)
+    .fetch_one(&pool)
+    .await?;
+  let uri_enc: Vec<u8> = row.try_get("uri_enc")?;
+  assert_eq!(
+    open_text(&keys, &uri_enc).map_err(|error| TestError::Harness(error.to_string()))?,
+    "https://app.example.test/callback"
+  );
+  assert_eq!(
+    row.try_get::<Option<String>, _>("uri_index")?,
+    Some(blind_index_active(
+      &keys,
+      "https://app.example.test/callback"
+    ))
+  );
+
+  // A fully backfilled database verifies clean (the exit-0 path).
+  let report = verify_with(&pool, &keys)
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  assert!(report.is_clean());
+  ensure_clean(&report).map_err(|error| TestError::Harness(error.to_string()))?;
+  Ok(())
+}
+
+#[tokio::test]
+async fn pii_backfill_is_idempotent() -> Result<(), TestError> {
+  let (_db, pool) = fresh_pool!()?;
+  let keys = test_keys()?;
+
+  let user_id = Uuid::new_v4();
+  insert_user(&pool, user_id, Some("idem@example.test"), None).await?;
+  let first = backfill_with(&pool, &keys, false)
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  assert_eq!(first.users_sealed, 1);
+  let sealed: Vec<u8> = sqlx::query_scalar("SELECT email_enc FROM users WHERE id = $1")
+    .bind(user_id)
+    .fetch_one(&pool)
+    .await?;
+
+  let second = backfill_with(&pool, &keys, false)
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  assert_eq!(second.users_sealed, 0, "a re-run must seal zero users");
+  assert_eq!(second.clients_sealed, 0, "a re-run must seal zero clients");
+  assert_eq!(
+    second.redirects_sealed, 0,
+    "a re-run must seal zero redirects"
+  );
+  assert_eq!(
+    second.totp_upgraded, 0,
+    "a re-run must upgrade zero TOTP blobs"
+  );
+  let again: Vec<u8> = sqlx::query_scalar("SELECT email_enc FROM users WHERE id = $1")
+    .bind(user_id)
+    .fetch_one(&pool)
+    .await?;
+  assert_eq!(sealed, again, "a re-run must not touch sealed bytes");
+  Ok(())
+}
+
+#[tokio::test]
+async fn pii_verify_detects_plaintext() -> Result<(), TestError> {
+  let (_db, pool) = fresh_pool!()?;
+  let keys = test_keys()?;
+
+  insert_user(&pool, Uuid::new_v4(), Some("plain@example.test"), None).await?;
+  let report = verify_with(&pool, &keys)
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  assert!(report.plaintext > 0, "legacy plaintext must be counted");
+  assert!(
+    ensure_clean(&report).is_err(),
+    "plaintext must exit non-zero"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn pii_verify_detects_corrupt_ciphertext() -> Result<(), TestError> {
+  let (_db, pool) = fresh_pool!()?;
+  let keys = test_keys()?;
+
+  let id = Uuid::new_v4();
+  let sealed = seal_text(&keys, "sealed@example.test")
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  sqlx::query(
+    "INSERT INTO users (id, email, password_hash, email_enc, email_index, totp_enabled, created_at, updated_at) \
+     VALUES ($1, NULL, 'x', $2, 'k1:00', false, now(), now())",
+  )
+  .bind(id)
+  .bind(&sealed)
+  .execute(&pool)
+  .await?;
+  let corrupt: &[u8] = b"corrupt-blob";
+  sqlx::query("UPDATE users SET email_enc = $2 WHERE id = $1")
+    .bind(id)
+    .bind(corrupt)
+    .execute(&pool)
+    .await?;
+
+  let report = verify_with(&pool, &keys)
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  assert!(
+    report.undecryptable > 0,
+    "a corrupt ciphertext must be counted"
+  );
+  assert!(
+    ensure_clean(&report).is_err(),
+    "corruption must exit non-zero"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn pii_verify_rejects_non_v3_totp() -> Result<(), TestError> {
+  let (_db, pool) = fresh_pool!()?;
+  let keys = test_keys()?;
+
+  let v2 = crypto_totp::encrypt_secret(&keys, TOTP_SECRET, false)
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  insert_user(&pool, Uuid::new_v4(), None, Some(&v2)).await?;
+  let report = verify_with(&pool, &keys)
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  assert_eq!(report.totp_below_v3, 1);
+  assert_eq!(
+    report.undecryptable, 0,
+    "a v2 blob still opens under a retained key"
+  );
+  assert!(
+    ensure_clean(&report).is_err(),
+    "a pre-v3 blob must exit non-zero"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn pii_verify_detects_inconsistent_index() -> Result<(), TestError> {
+  let (_db, pool) = fresh_pool!()?;
+  let keys = test_keys()?;
+
+  let sealed = seal_text(&keys, "indexed@example.test")
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  sqlx::query(
+    "INSERT INTO users (id, email, password_hash, email_enc, email_index, totp_enabled, created_at, updated_at) \
+     VALUES ($1, NULL, 'x', $2, NULL, false, now(), now())",
+  )
+  .bind(Uuid::new_v4())
+  .bind(&sealed)
+  .execute(&pool)
+  .await?;
+  let report = verify_with(&pool, &keys)
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  assert!(
+    report.index_mismatch > 0,
+    "a NULL blind index must be counted"
+  );
+  assert_eq!(report.undecryptable, 0, "the ciphertext itself still opens");
+  assert!(
+    ensure_clean(&report).is_err(),
+    "an index mismatch must exit non-zero"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn pii_backfill_refuses_while_dual_write() -> Result<(), TestError> {
+  let (_db, pool) = fresh_pool!()?;
+  let keys = test_keys()?;
+
+  let refused = backfill_with(&pool, &keys, true).await;
+  assert!(
+    refused.is_err(),
+    "backfill must refuse while pii_dual_write=true"
+  );
+  Ok(())
+}
+
+/// A ring whose active PII key is `k2` with `k1` retained (post-rotation shape).
+fn rotated_keys() -> Result<KeyRing, TestError> {
+  KeyRing::from_parts(
+    vec![key("p1", "pii-test-pepper-0123456789abcdef")],
+    vec![key("t1", "pii-test-totp-key-0123456789abcdef")],
+    vec![
+      key("k2", "pii-test-pii-next-0123456789abcdef"),
+      key("k1", "pii-test-pii-key-0123456789abcdef"),
+    ],
+  )
+  .map_err(|error| TestError::Harness(error.to_string()))
+}
+
+/// A ring holding ONLY the new PII key (the retired-previous shape).
+fn new_key_only() -> Result<KeyRing, TestError> {
+  KeyRing::from_parts(
+    vec![key("p1", "pii-test-pepper-0123456789abcdef")],
+    vec![key("t1", "pii-test-totp-key-0123456789abcdef")],
+    vec![key("k2", "pii-test-pii-next-0123456789abcdef")],
+  )
+  .map_err(|error| TestError::Harness(error.to_string()))
+}
+
+/// Seal one legacy `users` row directly under the old key (post-backfill shape:
+/// plaintext NULL, `_enc` + `_index` present).
+async fn insert_sealed_user(pool: &PgPool, keys: &KeyRing, email: &str) -> Result<Uuid, TestError> {
+  let id = Uuid::new_v4();
+  let canonical = normalize_email(email);
+  let sealed =
+    seal_text(keys, &canonical).map_err(|error| TestError::Harness(error.to_string()))?;
+  let index = blind_index_active(keys, &canonical);
+  sqlx::query(
+    "INSERT INTO users (id, email, password_hash, email_enc, email_index, totp_enabled, created_at, updated_at) \
+     VALUES ($1, NULL, 'x', $2, $3, false, now(), now())",
+  )
+  .bind(id)
+  .bind(&sealed)
+  .bind(&index)
+  .execute(pool)
+  .await?;
+  Ok(id)
+}
+
+#[tokio::test]
+async fn pii_rotate_reencrypts_and_reindexes() -> Result<(), TestError> {
+  let (_db, pool) = fresh_pool!()?;
+  let old = test_keys()?;
+  let rotated = rotated_keys()?;
+
+  // Seed every table under the old key via the proven backfill path.
+  let user_id = Uuid::new_v4();
+  insert_user(&pool, user_id, Some("Rotate-Me@Example.Test"), None).await?;
+  let client_id = insert_client(&pool, "rotate-client").await?;
+  let redirect_id = insert_redirect(&pool, "https://app.example.test/rotated").await?;
+  backfill_with(&pool, &old, false)
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+
+  let report = rotate_with(&pool, &rotated)
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  assert_eq!(report.users_rotated, 1);
+  assert_eq!(report.clients_rotated, 1);
+  assert_eq!(report.redirects_rotated, 1);
+
+  // After the old key is retired, every `_enc` opens under the new key alone
+  // and every blind index names the active key.
+  let fresh = new_key_only()?;
+  let row = sqlx::query("SELECT email_enc, email_index FROM users WHERE id = $1")
+    .bind(user_id)
+    .fetch_one(&pool)
+    .await?;
+  let email_enc = row.try_get::<Option<Vec<u8>>, _>("email_enc")?;
+  let blob = email_enc.ok_or(TestError::Missing("email_enc"))?;
+  assert_eq!(
+    open_text(&fresh, &blob).map_err(|error| TestError::Harness(error.to_string()))?,
+    "rotate-me@example.test"
+  );
+  assert_eq!(
+    row.try_get::<Option<String>, _>("email_index")?,
+    Some(blind_index_active(&fresh, "rotate-me@example.test"))
+  );
+
+  let row = sqlx::query(
+    "SELECT tls_client_auth_subject_dn_enc, tls_client_auth_subject_dn_index, \
+       tls_client_auth_cert_thumbprint_enc, tls_client_auth_cert_thumbprint_index \
+     FROM oauth_clients WHERE id = $1",
+  )
+  .bind(client_id)
+  .fetch_one(&pool)
+  .await?;
+  let dn = row.try_get::<Option<Vec<u8>>, _>("tls_client_auth_subject_dn_enc")?;
+  let dn_blob = dn.ok_or(TestError::Missing("tls_client_auth_subject_dn_enc"))?;
+  assert_eq!(
+    open_text(&fresh, &dn_blob).map_err(|error| TestError::Harness(error.to_string()))?,
+    "CN=legacy"
+  );
+  assert_eq!(
+    row.try_get::<Option<String>, _>("tls_client_auth_subject_dn_index")?,
+    Some(blind_index_active(&fresh, "CN=legacy"))
+  );
+  let thumb = row.try_get::<Option<Vec<u8>>, _>("tls_client_auth_cert_thumbprint_enc")?;
+  let thumb_blob = thumb.ok_or(TestError::Missing("tls_client_auth_cert_thumbprint_enc"))?;
+  assert_eq!(
+    open_text(&fresh, &thumb_blob).map_err(|error| TestError::Harness(error.to_string()))?,
+    "THUMB-LEGACY"
+  );
+  assert_eq!(
+    row.try_get::<Option<String>, _>("tls_client_auth_cert_thumbprint_index")?,
+    Some(blind_index_active(&fresh, "THUMB-LEGACY"))
+  );
+
+  let row = sqlx::query("SELECT uri_enc, uri_index FROM redirect_uris WHERE id = $1")
+    .bind(redirect_id)
+    .fetch_one(&pool)
+    .await?;
+  let uri_enc = row.try_get::<Option<Vec<u8>>, _>("uri_enc")?;
+  let uri_blob = uri_enc.ok_or(TestError::Missing("uri_enc"))?;
+  assert_eq!(
+    open_text(&fresh, &uri_blob).map_err(|error| TestError::Harness(error.to_string()))?,
+    "https://app.example.test/rotated"
+  );
+  assert_eq!(
+    row.try_get::<Option<String>, _>("uri_index")?,
+    Some(blind_index_active(
+      &fresh,
+      "https://app.example.test/rotated"
+    ))
+  );
+
+  // Plaintext columns stay blank and the new-key lookup resolves the user.
+  assert_eq!(
+    plaintext(&pool, "SELECT email FROM users WHERE id = $1", user_id).await?,
+    None
+  );
+  let found = crate::db::user_repo::find_by_email(&pool, &fresh, "rotate-me@example.test").await?;
+  assert_eq!(found.map(|record| record.id), Some(user_id));
+  Ok(())
+}
+
+#[tokio::test]
+async fn pii_rotate_is_idempotent() -> Result<(), TestError> {
+  let (_db, pool) = fresh_pool!()?;
+  let old = test_keys()?;
+  let rotated = rotated_keys()?;
+
+  let user_id = Uuid::new_v4();
+  insert_user(&pool, user_id, Some("idem-rotate@example.test"), None).await?;
+  backfill_with(&pool, &old, false)
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  let first = rotate_with(&pool, &rotated)
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  assert_eq!(first.users_rotated, 1);
+  let sealed: Vec<u8> = sqlx::query_scalar("SELECT email_enc FROM users WHERE id = $1")
+    .bind(user_id)
+    .fetch_one(&pool)
+    .await?;
+
+  let second = rotate_with(&pool, &rotated)
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+  assert_eq!(second.users_rotated, 0, "a re-run must rotate zero users");
+  assert_eq!(
+    second.clients_rotated, 0,
+    "a re-run must rotate zero clients"
+  );
+  assert_eq!(
+    second.redirects_rotated, 0,
+    "a re-run must rotate zero redirects"
+  );
+  let again: Vec<u8> = sqlx::query_scalar("SELECT email_enc FROM users WHERE id = $1")
+    .bind(user_id)
+    .fetch_one(&pool)
+    .await?;
+  assert_eq!(sealed, again, "a re-run must not touch rotated bytes");
+  Ok(())
+}
+
+#[tokio::test]
+async fn pii_rotate_aborts_batch_on_collision() -> Result<(), TestError> {
+  let (_db, pool) = fresh_pool!()?;
+  let old = test_keys()?;
+  let rotated = rotated_keys()?;
+
+  // Two rows for the same canonical email, both sealed under the old key. The
+  // second carries a NULL index (partial unique indexes skip NULLs), so both
+  // inserts succeed; rotating them in one batch must collide on the new index.
+  let first_id = insert_sealed_user(&pool, &old, "dupe@example.test").await?;
+  let second_id = Uuid::new_v4();
+  let sealed =
+    seal_text(&old, "dupe@example.test").map_err(|error| TestError::Harness(error.to_string()))?;
+  sqlx::query(
+    "INSERT INTO users (id, email, password_hash, email_enc, email_index, totp_enabled, created_at, updated_at) \
+     VALUES ($1, NULL, 'x', $2, NULL, false, now(), now())",
+  )
+  .bind(second_id)
+  .bind(&sealed)
+  .execute(&pool)
+  .await?;
+
+  let failed = rotate_with(&pool, &rotated).await;
+  assert!(
+    failed.is_err(),
+    "a cross-generation duplicate must abort the batch"
+  );
+
+  // The batch rolled back: neither row was re-encrypted under the new key.
+  let fresh = new_key_only()?;
+  for id in [first_id, second_id] {
+    let blob: Option<Vec<u8>> = sqlx::query_scalar("SELECT email_enc FROM users WHERE id = $1")
+      .bind(id)
+      .fetch_one(&pool)
+      .await?;
+    let blob = blob.ok_or(TestError::Missing("email_enc"))?;
+    assert!(
+      open_text(&fresh, &blob).is_err(),
+      "a rolled-back row must not open under the new key alone"
+    );
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn pii_backfill_aborts_on_malformed_ciphertext() -> Result<(), TestError> {
+  let (_db, pool) = fresh_pool!()?;
+  let keys = test_keys()?;
+
+  let id = Uuid::new_v4();
+  insert_user(&pool, id, Some("retry@example.test"), None).await?;
+  let corrupt: &[u8] = b"corrupt-blob";
+  sqlx::query("UPDATE users SET email_enc = $2 WHERE id = $1")
+    .bind(id)
+    .bind(corrupt)
+    .execute(&pool)
+    .await?;
+
+  let failed = backfill_with(&pool, &keys, false).await;
+  assert!(
+    failed.is_err(),
+    "a malformed ciphertext must abort the batch"
+  );
+  assert_eq!(
+    plaintext(&pool, "SELECT email FROM users WHERE id = $1", id).await?,
+    Some("retry@example.test".to_owned()),
+    "the aborted batch must leave the plaintext intact for retry"
+  );
+  Ok(())
+}

+ 302 - 0
src/cli/pii/verify.rs

@@ -0,0 +1,302 @@
+//! `pii verify`: the four cutover checks over sealed PII.
+//!
+//! The checks are (1) any plaintext PII column still non-NULL, (2) any `_enc`
+//! (including `users.totp_secret_enc`) that fails to open under a retained key,
+//! (3) any TOTP blob still below v3, and (4) any sealed indexed row with a NULL
+//! or mismatched `_index`. Index comparison recomputes the canonical value per
+//! column (`normalize_email` for `email_enc`, the exact string for `uri_enc`
+//! and the two mTLS identities) and accepts a match against
+//! `blind_index_candidates` under any retained key, since during a rotation
+//! window a row may legitimately carry an index under the previous key. Only
+//! counts are reported, never values or key material.
+
+use sqlx::{PgPool, Row as _};
+use uuid::Uuid;
+
+use super::super::context::CliError;
+use super::VerifyReport;
+use crate::crypto::field::{blind_index_candidates, open_json, open_text};
+use crate::crypto::totp::decrypt_secret;
+use crate::db::user_repo::normalize_email;
+use crate::keys::KeyRing;
+
+/// Rows scanned per verification batch.
+const BATCH_SIZE: usize = 500;
+
+/// Run the four cutover checks, reporting counts for each.
+///
+/// A dirty report is still `Ok` here; the caller maps it to a non-zero exit
+/// via `ensure_clean`.
+///
+/// # Errors
+/// Returns [`CliError::Database`] for PostgreSQL failures.
+pub(super) async fn verify_with(pool: &PgPool, keys: &KeyRing) -> Result<VerifyReport, CliError> {
+  let mut report = VerifyReport::default();
+  count_plaintext(pool, &mut report).await?;
+  count_totp_below_v3(pool, &mut report).await?;
+  scan_users(pool, keys, &mut report).await?;
+  scan_clients(pool, keys, &mut report).await?;
+  scan_redirects(pool, keys, &mut report).await?;
+  Ok(report)
+}
+
+/// Check (1): rows with any blanked plaintext PII column still non-NULL.
+async fn count_plaintext(pool: &PgPool, report: &mut VerifyReport) -> Result<(), CliError> {
+  let users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email IS NOT NULL")
+    .fetch_one(pool)
+    .await?;
+  let clients: i64 = sqlx::query_scalar(
+    "SELECT COUNT(*) FROM oauth_clients \
+     WHERE client_name IS NOT NULL OR contacts IS NOT NULL OR request_uris IS NOT NULL \
+       OR jwks_uri IS NOT NULL OR software_id IS NOT NULL \
+       OR tls_client_auth_subject_dn IS NOT NULL OR tls_client_auth_cert_thumbprint IS NOT NULL",
+  )
+  .fetch_one(pool)
+  .await?;
+  let redirects: i64 =
+    sqlx::query_scalar("SELECT COUNT(*) FROM redirect_uris WHERE uri IS NOT NULL")
+      .fetch_one(pool)
+      .await?;
+  report.plaintext = report
+    .plaintext
+    .saturating_add(count_to_u64(users)?)
+    .saturating_add(count_to_u64(clients)?)
+    .saturating_add(count_to_u64(redirects)?);
+  Ok(())
+}
+
+/// Check (3): TOTP blobs still below the v3 envelope format (pure SQL on the
+/// version byte; short blobs count as below-v3, fail-closed).
+async fn count_totp_below_v3(pool: &PgPool, report: &mut VerifyReport) -> Result<(), CliError> {
+  let below: i64 = sqlx::query_scalar(
+    "SELECT COUNT(*) FROM users WHERE totp_secret_enc IS NOT NULL \
+     AND (length(totp_secret_enc) < 2 OR get_byte(totp_secret_enc, 0) <> 3)",
+  )
+  .fetch_one(pool)
+  .await?;
+  report.totp_below_v3 = report.totp_below_v3.saturating_add(count_to_u64(below)?);
+  Ok(())
+}
+
+/// Checks (2) and (4) over `users`: `email_enc` opens and its `email_index`
+/// matches a retained-key candidate for the canonical email; every TOTP blob
+/// opens under a retained TOTP key.
+async fn scan_users(
+  pool: &PgPool,
+  keys: &KeyRing,
+  report: &mut VerifyReport,
+) -> Result<(), CliError> {
+  let mut after = Uuid::nil();
+  loop {
+    let rows = sqlx::query(
+      "SELECT id, email_enc, email_index, totp_secret_enc FROM users \
+       WHERE (email_enc IS NOT NULL OR totp_secret_enc IS NOT NULL) AND id > $1 \
+       ORDER BY id LIMIT $2",
+    )
+    .bind(after)
+    .bind(BATCH_SIZE as i64)
+    .fetch_all(pool)
+    .await?;
+    if rows.is_empty() {
+      break;
+    }
+    let fetched = rows.len();
+    for row in &rows {
+      let id: Uuid = row.try_get("id")?;
+      let email_enc: Option<Vec<u8>> = row.try_get("email_enc")?;
+      let email_index: Option<String> = row.try_get("email_index")?;
+      let totp: Option<Vec<u8>> = row.try_get("totp_secret_enc")?;
+      if let Some(blob) = email_enc {
+        match open_text(keys, &blob) {
+          Ok(plain) => {
+            if !index_matches(keys, &normalize_email(&plain), email_index) {
+              report.index_mismatch = report.index_mismatch.saturating_add(1);
+            }
+          }
+          Err(_) => {
+            report.undecryptable = report.undecryptable.saturating_add(1);
+          }
+        }
+      }
+      if let Some(blob) = totp
+        && decrypt_secret(keys, &blob).is_err()
+      {
+        report.undecryptable = report.undecryptable.saturating_add(1);
+      }
+      after = id;
+    }
+    if fetched < BATCH_SIZE {
+      break;
+    }
+  }
+  Ok(())
+}
+
+/// Checks (2) and (4) over `oauth_clients`: every `_enc` opens (JSON columns
+/// via the JSON codec), and both mTLS indexes match a retained-key candidate
+/// for the exact stored string.
+async fn scan_clients(
+  pool: &PgPool,
+  keys: &KeyRing,
+  report: &mut VerifyReport,
+) -> Result<(), CliError> {
+  let mut after = Uuid::nil();
+  loop {
+    let rows = sqlx::query(
+      "SELECT id, client_name_enc, contacts_enc, request_uris_enc, jwks_uri_enc, software_id_enc, \
+         tls_client_auth_subject_dn_enc, tls_client_auth_cert_thumbprint_enc, \
+         tls_client_auth_subject_dn_index, tls_client_auth_cert_thumbprint_index \
+       FROM oauth_clients \
+       WHERE (client_name_enc IS NOT NULL OR contacts_enc IS NOT NULL \
+         OR request_uris_enc IS NOT NULL OR jwks_uri_enc IS NOT NULL OR software_id_enc IS NOT NULL \
+         OR tls_client_auth_subject_dn_enc IS NOT NULL \
+         OR tls_client_auth_cert_thumbprint_enc IS NOT NULL) \
+         AND id > $1 ORDER BY id LIMIT $2",
+    )
+    .bind(after)
+    .bind(BATCH_SIZE as i64)
+    .fetch_all(pool)
+    .await?;
+    if rows.is_empty() {
+      break;
+    }
+    let fetched = rows.len();
+    for row in &rows {
+      let id: Uuid = row.try_get("id")?;
+      check_optional_text(keys, row, "client_name_enc", None, report)?;
+      check_optional_json(keys, row, "contacts_enc", report)?;
+      check_optional_json(keys, row, "request_uris_enc", report)?;
+      check_optional_text(keys, row, "jwks_uri_enc", None, report)?;
+      check_optional_text(keys, row, "software_id_enc", None, report)?;
+      for (enc_col, index_col) in [
+        (
+          "tls_client_auth_subject_dn_enc",
+          "tls_client_auth_subject_dn_index",
+        ),
+        (
+          "tls_client_auth_cert_thumbprint_enc",
+          "tls_client_auth_cert_thumbprint_index",
+        ),
+      ] {
+        let stored: Option<String> = row.try_get(index_col)?;
+        check_optional_text(keys, row, enc_col, Some(stored), report)?;
+      }
+      after = id;
+    }
+    if fetched < BATCH_SIZE {
+      break;
+    }
+  }
+  Ok(())
+}
+
+/// Check one optional text `_enc`: an unopenable blob counts as
+/// undecryptable; an opened value counts a NULL or mismatched `_index` when
+/// `index` holds the stored index (`None` for columns without one). The caller
+/// passes the canonical form implicitly: the opened plaintext is already
+/// canonical for these columns (email rows canonicalize via
+/// [`normalize_email`] before calling [`index_matches`]).
+fn check_optional_text(
+  keys: &KeyRing,
+  row: &sqlx::postgres::PgRow,
+  column: &str,
+  index: Option<Option<String>>,
+  report: &mut VerifyReport,
+) -> Result<(), CliError> {
+  let blob: Option<Vec<u8>> = row.try_get(column)?;
+  let Some(blob) = blob else {
+    return Ok(());
+  };
+  match open_text(keys, &blob) {
+    Ok(plain) => {
+      if let Some(stored) = index
+        && !index_matches(keys, &plain, stored)
+      {
+        report.index_mismatch = report.index_mismatch.saturating_add(1);
+      }
+    }
+    Err(_) => {
+      report.undecryptable = report.undecryptable.saturating_add(1);
+    }
+  }
+  Ok(())
+}
+
+/// Check one optional JSON `_enc`: an unopenable blob counts as undecryptable.
+fn check_optional_json(
+  keys: &KeyRing,
+  row: &sqlx::postgres::PgRow,
+  column: &str,
+  report: &mut VerifyReport,
+) -> Result<(), CliError> {
+  let blob: Option<Vec<u8>> = row.try_get(column)?;
+  if let Some(blob) = blob
+    && open_json::<serde_json::Value>(keys, &blob).is_err()
+  {
+    report.undecryptable = report.undecryptable.saturating_add(1);
+  }
+  Ok(())
+}
+
+/// Checks (2) and (4) over `redirect_uris`: `uri_enc` opens and `uri_index`
+/// matches a retained-key candidate for the exact stored string.
+async fn scan_redirects(
+  pool: &PgPool,
+  keys: &KeyRing,
+  report: &mut VerifyReport,
+) -> Result<(), CliError> {
+  let mut after = Uuid::nil();
+  loop {
+    let rows = sqlx::query(
+      "SELECT id, uri_enc, uri_index FROM redirect_uris \
+       WHERE uri_enc IS NOT NULL AND id > $1 ORDER BY id LIMIT $2",
+    )
+    .bind(after)
+    .bind(BATCH_SIZE as i64)
+    .fetch_all(pool)
+    .await?;
+    if rows.is_empty() {
+      break;
+    }
+    let fetched = rows.len();
+    for row in &rows {
+      let id: Uuid = row.try_get("id")?;
+      let blob: Option<Vec<u8>> = row.try_get("uri_enc")?;
+      let index: Option<String> = row.try_get("uri_index")?;
+      if let Some(blob) = blob {
+        match open_text(keys, &blob) {
+          Ok(plain) => {
+            if !index_matches(keys, &plain, index) {
+              report.index_mismatch = report.index_mismatch.saturating_add(1);
+            }
+          }
+          Err(_) => {
+            report.undecryptable = report.undecryptable.saturating_add(1);
+          }
+        }
+      }
+      after = id;
+    }
+    if fetched < BATCH_SIZE {
+      break;
+    }
+  }
+  Ok(())
+}
+
+/// Whether `stored` names the canonical value under any retained PII key
+/// (rotation-window tolerant). A NULL stored index never matches.
+fn index_matches(keys: &KeyRing, canonical: &str, stored: Option<String>) -> bool {
+  match stored {
+    Some(index) => blind_index_candidates(keys, canonical)
+      .iter()
+      .any(|candidate| candidate == &index),
+    None => false,
+  }
+}
+
+/// Convert a `COUNT(*)` row to `u64`, failing closed on the impossible
+/// negative rather than wrapping or truncating.
+fn count_to_u64(count: i64) -> Result<u64, CliError> {
+  u64::try_from(count).map_err(|_| CliError::Crypto("row count out of range".to_owned()))
+}

+ 100 - 116
src/cli/redirect.rs

@@ -1,6 +1,6 @@
 //! `redirect-uri` and `client` subcommands plus the shared URI upsert.
 
-use sqlx::{PgPool, Row as _};
+use sqlx::Row as _;
 use uuid::Uuid;
 
 use super::{
@@ -8,6 +8,10 @@ use super::{
   context::{CliError, Context, audit},
   scope::Upsert,
 };
+use crate::{
+  crypto::field::blind_index_candidates,
+  db::{NewRedirectUri, RedirectKind, UpsertOutcome, redirect_repo},
+};
 
 /// Run a `redirect-uri` subcommand.
 ///
@@ -20,7 +24,7 @@ pub async fn run_uri(context: &Context, command: RedirectUriCommand) -> Result<(
       kind,
       description,
     } => {
-      match upsert(context.pool(), &uri, &kind, description.as_deref(), true).await? {
+      match upsert(context, &uri, &kind, description.as_deref(), true).await? {
         Upsert::Created => println!("created redirect uri {uri}"),
         Upsert::Updated => println!("updated redirect uri {uri}"),
         Upsert::Unchanged => println!("redirect uri {uri} unchanged"),
@@ -56,21 +60,17 @@ pub async fn run_client(context: &Context, command: ClientCommand) -> Result<(),
 }
 
 async fn list(context: &Context) -> Result<(), CliError> {
-  let rows =
-    sqlx::query("SELECT uri, kind, description, enabled FROM redirect_uris ORDER BY uri, kind")
-      .fetch_all(context.pool())
-      .await?;
-  if rows.is_empty() {
+  let uris = redirect_repo::list(context.pool(), &context.config().keys, true).await?;
+  if uris.is_empty() {
     println!("redirect uri catalog is empty");
     return Ok(());
   }
-  for row in rows {
-    let uri: String = row.try_get("uri")?;
-    let kind: String = row.try_get("kind")?;
-    let description: String = row.try_get("description")?;
-    let enabled: bool = row.try_get("enabled")?;
-    let state = if enabled { "enabled" } else { "disabled" };
-    println!("{uri}\t{kind}\t{state}\t{description}");
+  for entry in uris {
+    let state = if entry.enabled { "enabled" } else { "disabled" };
+    println!(
+      "{}\t{}\t{}\t{}",
+      entry.uri, entry.kind, state, entry.description
+    );
   }
   Ok(())
 }
@@ -81,16 +81,11 @@ async fn set_enabled(
   kind: &str,
   enabled: bool,
 ) -> Result<(), CliError> {
-  let affected = sqlx::query(
-    "UPDATE redirect_uris SET enabled = $3, updated_at = now() WHERE uri = $1 AND kind = $2",
-  )
-  .bind(uri)
-  .bind(kind)
-  .bind(enabled)
-  .execute(context.pool())
-  .await?
-  .rows_affected();
-  if affected == 0 {
+  let id = find_redirect_id(context, uri, kind).await?;
+  if redirect_repo::set_enabled(context.pool(), &context.config().keys, id, enabled)
+    .await?
+    .is_none()
+  {
     return Err(CliError::NotFound(format!("redirect uri {uri} ({kind})")));
   }
   let operation = if enabled {
@@ -107,59 +102,27 @@ async fn set_enabled(
 }
 
 async fn delete(context: &Context, uri: &str, kind: &str) -> Result<(), CliError> {
-  let referenced: bool = sqlx::query_scalar(
-    "SELECT EXISTS(
-       SELECT 1 FROM client_redirect_uris c
-       JOIN redirect_uris r ON r.id = c.redirect_uri_id
-       WHERE r.uri = $1 AND r.kind = $2)",
-  )
-  .bind(uri)
-  .bind(kind)
-  .fetch_one(context.pool())
-  .await?;
-  if referenced {
-    let affected = sqlx::query(
-      "UPDATE redirect_uris SET enabled = false, updated_at = now() \
-       WHERE uri = $1 AND kind = $2",
-    )
-    .bind(uri)
-    .bind(kind)
-    .execute(context.pool())
-    .await?
-    .rows_affected();
-    if affected == 0 {
-      return Err(CliError::NotFound(format!("redirect uri {uri} ({kind})")));
-    }
-    audit("redirect_uri.disable", uri);
-    println!("redirect uri {uri} ({kind}) is attached; disabled instead of deleted");
+  let id = find_redirect_id(context, uri, kind).await?;
+  if redirect_repo::delete_if_unattached(context.pool(), id).await? {
+    audit("redirect_uri.delete", uri);
+    println!("deleted redirect uri {uri} ({kind})");
     return Ok(());
   }
-  let affected = sqlx::query("DELETE FROM redirect_uris WHERE uri = $1 AND kind = $2")
-    .bind(uri)
-    .bind(kind)
-    .execute(context.pool())
+  if redirect_repo::set_enabled(context.pool(), &context.config().keys, id, false)
     .await?
-    .rows_affected();
-  if affected == 0 {
+    .is_none()
+  {
     return Err(CliError::NotFound(format!("redirect uri {uri} ({kind})")));
   }
-  audit("redirect_uri.delete", uri);
-  println!("deleted redirect uri {uri} ({kind})");
+  audit("redirect_uri.disable", uri);
+  println!("redirect uri {uri} ({kind}) is attached; disabled instead of deleted");
   Ok(())
 }
 
 async fn attach(context: &Context, client_id: &str, uri: &str, kind: &str) -> Result<(), CliError> {
   ensure_client_exists(context, client_id).await?;
   let redirect_id = find_redirect_id(context, uri, kind).await?;
-  sqlx::query(
-    "INSERT INTO client_redirect_uris (client_id, redirect_uri_id, attached_at)
-     VALUES ($1, $2, now())
-     ON CONFLICT (client_id, redirect_uri_id) DO NOTHING",
-  )
-  .bind(client_id)
-  .bind(redirect_id)
-  .execute(context.pool())
-  .await?;
+  redirect_repo::attach(context.pool(), client_id, redirect_id, None).await?;
   audit("client.attach_redirect_uri", &format!("{client_id}:{uri}"));
   println!("attached {uri} ({kind}) to {client_id}");
   Ok(())
@@ -167,11 +130,7 @@ async fn attach(context: &Context, client_id: &str, uri: &str, kind: &str) -> Re
 
 async fn detach(context: &Context, client_id: &str, uri: &str, kind: &str) -> Result<(), CliError> {
   let redirect_id = find_redirect_id(context, uri, kind).await?;
-  sqlx::query("DELETE FROM client_redirect_uris WHERE client_id = $1 AND redirect_uri_id = $2")
-    .bind(client_id)
-    .bind(redirect_id)
-    .execute(context.pool())
-    .await?;
+  redirect_repo::detach(context.pool(), client_id, redirect_id).await?;
   audit("client.detach_redirect_uri", &format!("{client_id}:{uri}"));
   println!("detached {uri} ({kind}) from {client_id}");
   Ok(())
@@ -179,75 +138,100 @@ async fn detach(context: &Context, client_id: &str, uri: &str, kind: &str) -> Re
 
 async fn list_for_client(context: &Context, client_id: &str) -> Result<(), CliError> {
   ensure_client_exists(context, client_id).await?;
-  let uris: Vec<(String, String)> = sqlx::query_as(
-    "SELECT r.uri, r.kind FROM client_redirect_uris c
-       JOIN redirect_uris r ON r.id = c.redirect_uri_id
-      WHERE c.client_id = $1
-      ORDER BY r.uri, r.kind",
-  )
-  .bind(client_id)
-  .fetch_all(context.pool())
-  .await?;
+  let uris =
+    redirect_repo::list_for_client(context.pool(), &context.config().keys, client_id).await?;
   if uris.is_empty() {
     println!("{client_id} has no attached redirect URIs");
   } else {
-    for (uri, kind) in uris {
-      println!("{uri}\t{kind}");
+    for entry in uris {
+      println!("{}\t{}", entry.uri, entry.kind);
     }
   }
   Ok(())
 }
 
-/// Idempotently create or update a redirect URI.
+/// Idempotently create a catalog redirect URI, or adopt a legacy plaintext row.
+///
+/// The write routes through [`redirect_repo::upsert`] with the PII key ring and
+/// the dual-write flag, so the URI is sealed into `uri_enc` and blind-indexed
+/// into `uri_index`. An already-sealed row is left untouched (`Unchanged`),
+/// which preserves its `description`/`enabled` and keeps `config sync`
+/// idempotent; a declared `enabled` applies only to a freshly created row.
 ///
 /// # Errors
-/// Returns [`CliError::Database`] on failure.
+/// Returns [`CliError`] on a repository or database failure.
 pub async fn upsert(
-  pool: &PgPool,
+  context: &Context,
   uri: &str,
   kind: &str,
   description: Option<&str>,
   enabled: bool,
 ) -> Result<Upsert, CliError> {
-  let description = description.unwrap_or_default();
-  let id = Uuid::new_v4();
-  let row = sqlx::query(
-    "INSERT INTO redirect_uris (id, uri, kind, description, enabled, created_at, updated_at)
-     VALUES ($1, $2, $3, $4, $5, now(), now())
-     ON CONFLICT (uri, kind) DO UPDATE
-       SET description = EXCLUDED.description,
-           enabled = EXCLUDED.enabled,
-           updated_at = now()
-       WHERE redirect_uris.description IS DISTINCT FROM EXCLUDED.description
-          OR redirect_uris.enabled IS DISTINCT FROM EXCLUDED.enabled
-     RETURNING (xmax = 0) AS inserted",
-  )
-  .bind(id)
-  .bind(uri)
-  .bind(kind)
-  .bind(description)
-  .bind(enabled)
-  .fetch_optional(pool)
-  .await?;
-  Ok(match row {
-    Some(row) if row.try_get::<bool, _>("inserted").unwrap_or(false) => Upsert::Created,
-    Some(_) => Upsert::Updated,
-    None => Upsert::Unchanged,
+  let keys = &context.config().keys;
+  if find_existing(context, uri, kind)
+    .await?
+    .is_some_and(|(_, encrypted)| encrypted)
+  {
+    return Ok(Upsert::Unchanged);
+  }
+  let new = NewRedirectUri {
+    id: Uuid::new_v4(),
+    uri,
+    kind: RedirectKind::parse(kind),
+    description: description.unwrap_or_default(),
+    created_by: None,
+  };
+  let (row, outcome) =
+    redirect_repo::upsert(context.pool(), keys, context.config().pii_dual_write, &new).await?;
+  if outcome == UpsertOutcome::Created && !enabled {
+    redirect_repo::set_enabled(context.pool(), keys, row.id, false).await?;
+  }
+  Ok(match outcome {
+    UpsertOutcome::Created => Upsert::Created,
+    UpsertOutcome::Updated => Upsert::Updated,
   })
 }
 
 /// Resolve a catalog redirect-URI id by its exact URI and kind.
 ///
+/// The lookup matches through the keyed blind index (ciphertext rows) with a
+/// plaintext fallback for legacy rows whose `uri_index` is still NULL, so a
+/// sealed catalog stays resolvable.
+///
 /// # Errors
 /// Returns [`CliError::NotFound`] when the URI is not in the catalog.
 pub async fn find_redirect_id(context: &Context, uri: &str, kind: &str) -> Result<Uuid, CliError> {
-  let id: Option<Uuid> =
-    sqlx::query_scalar("SELECT id FROM redirect_uris WHERE uri = $1 AND kind = $2")
-      .bind(uri)
-      .bind(kind)
-      .fetch_optional(context.pool())
-      .await?;
-  id.ok_or_else(|| CliError::NotFound(format!("redirect uri {uri} ({kind})")))
+  find_existing(context, uri, kind)
+    .await?
+    .map(|(id, _)| id)
+    .ok_or_else(|| CliError::NotFound(format!("redirect uri {uri} ({kind})")))
+}
+
+/// Resolve a catalog row's id and whether it already carries ciphertext,
+/// through the blind-index candidates plus the legacy plaintext fallback.
+async fn find_existing(
+  context: &Context,
+  uri: &str,
+  kind: &str,
+) -> Result<Option<(Uuid, bool)>, CliError> {
+  let candidates = blind_index_candidates(&context.config().keys, uri);
+  let row = sqlx::query(
+    "SELECT id, (uri_enc IS NOT NULL) AS encrypted FROM redirect_uris \
+     WHERE kind = $1 AND (uri_index = ANY($2) OR (uri_enc IS NULL AND uri = $3))",
+  )
+  .bind(kind)
+  .bind(&candidates)
+  .bind(uri)
+  .fetch_optional(context.pool())
+  .await?;
+  match row {
+    Some(row) => {
+      let id: Uuid = row.try_get("id")?;
+      let encrypted: bool = row.try_get("encrypted")?;
+      Ok(Some((id, encrypted)))
+    }
+    None => Ok(None),
+  }
 }
 
 /// Ensure an OAuth client exists.

+ 204 - 20
src/cli/secret.rs

@@ -1,13 +1,17 @@
 //! Secret handling for the CLI: password hashing, token hashing, TOTP-secret
 //! encryption, and ES256 keygen.
 //!
-//! 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 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.
+//! Nothing here ever logs or prints a secret. TOTP secrets use the same blob
+//! format as [`crate::crypto::totp`]: the writer is version-gated, emitting the
+//! v3 per-row-DEK envelope when `envelope = true` and the legacy v2
+//! HKDF-SHA256 blob (info `totp-gcm-v2`) otherwise. Version-3 blobs wrap a
+//! random DEK under HKDF-SHA256 (`totp-kek-v1`) and seal the data with a fixed
+//! AAD that never names the kid, so `totp rewrap` can move a DEK to a new key
+//! without touching the ciphertext. Version-2 and legacy version-1
+//! (bare `SHA256(totp_key)`) blobs still decrypt. The two implementations are
+//! intentionally small duplicates: `crypto` cannot depend on `cli`. `keygen`
+//! emits an ES256 keypair for test fixtures and client-side DPoP keys only —
+//! never an AS signing key.
 
 use base64::{
   Engine as _, engine::general_purpose::STANDARD, engine::general_purpose::URL_SAFE_NO_PAD,
@@ -21,21 +25,32 @@ use ring::{
 use secrecy::ExposeSecret as _;
 #[cfg(test)]
 use sha2::{Digest as _, Sha256};
+use zeroize::Zeroizing;
 
 use super::context::CliError;
 use crate::config::Config;
 
-/// Current TOTP-blob format version (HKDF-SHA256 writes).
+/// Legacy v2 blob version (HKDF-SHA256 writes while `pii_dual_write` is on).
 const TOTP_BLOB_VERSION: u8 = 2;
+/// Envelope blob version (per-row DEK): written when `envelope = true`.
+const TOTP_BLOB_V3: u8 = 3;
 /// 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";
+/// HKDF domain-separation label for the key-encryption key that wraps v3 DEKs.
+const KEK_INFO: &[u8] = b"totp-kek-v1";
+/// AAD sealing the v3 data ciphertext: format tag plus version only. It must
+/// NOT name the wrapping key, so a DEK re-wrap changes the wrap without
+/// touching this ciphertext.
+const DATA_AAD: &[u8] = b"totp-v3\x03";
 /// AES-GCM nonce length.
 const NONCE_LEN: usize = 12;
 /// AES-256 key length.
 const KEY_LEN: usize = 32;
+/// Wrapped-DEK length: a 32-byte DEK sealed under AES-256-GCM (16-byte tag).
+const WRAPPED_DEK_LEN: usize = 32 + 16;
 
 /// A generated ES256 keypair, serialized for test fixtures / DPoP clients.
 pub struct KeypairPem {
@@ -84,13 +99,25 @@ pub fn token_hash(config: &Config, token: &str) -> Result<String, CliError> {
 
 /// Encrypt a TOTP secret with the active TOTP-encryption key.
 ///
-/// 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 writer is version-gated: `envelope = false` (while `pii_dual_write` is
+/// on) writes the legacy v2 blob so old replicas keep reading; `envelope =
+/// true` writes the v3 per-row-DEK envelope. Callers pass
+/// `!config.pii_dual_write`.
 ///
 /// # Errors
 /// Returns [`CliError::Crypto`] when no TOTP key is configured or the AEAD
 /// fails.
-pub fn encrypt_totp(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliError> {
+pub fn encrypt_totp(config: &Config, plaintext: &str, envelope: bool) -> Result<Vec<u8>, CliError> {
+  if envelope {
+    encrypt_v3(config, plaintext)
+  } else {
+    encrypt_v2(config, plaintext)
+  }
+}
+
+/// Legacy v2 write: `version(2) || kid_len(1) || kid || nonce(12) ||
+/// ciphertext+tag`, AES-256-GCM under HKDF-SHA256 with empty AAD.
+fn encrypt_v2(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliError> {
   let (key_id, secret) = config
     .keys
     .active_totp_key()
@@ -127,11 +154,99 @@ pub fn encrypt_totp(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliErro
   Ok(blob)
 }
 
+/// Envelope write: `version(3) || kid_len(1) || kid || wrap_nonce(12) ||
+/// wrapped_dek(32+16) || data_nonce(12) || secret_ct+tag`.
+///
+/// A random 32-byte DEK is generated per write, wrapped under a KEK derived
+/// via HKDF-SHA256 (info `totp-kek-v1`) from the active TOTP key, and the
+/// secret is sealed under the DEK. The data nonce is generated FIRST because
+/// the wrap AAD binds it: a wrapped DEK lifted from another row names the
+/// wrong nonce and fails to unwrap.
+fn encrypt_v3(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliError> {
+  let (key_id, secret) = config
+    .keys
+    .active_totp_key()
+    .ok_or_else(|| CliError::Crypto("no TOTP encryption key configured".to_owned()))?;
+  let kid = key_id.as_str().as_bytes();
+  if kid.len() > u8::MAX as usize {
+    return Err(CliError::Crypto("TOTP key id is too long".to_owned()));
+  }
+  let kek = Zeroizing::new(
+    derive_labeled(secret.expose_secret().as_bytes(), KEK_INFO)
+      .map_err(|_| CliError::Crypto("TOTP key derivation failed".to_owned()))?,
+  );
+  let rng = SystemRandom::new();
+  let mut data_nonce = [0u8; NONCE_LEN];
+  rng
+    .fill(&mut data_nonce)
+    .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
+  let mut wrap_nonce = [0u8; NONCE_LEN];
+  rng
+    .fill(&mut wrap_nonce)
+    .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
+  let mut dek = Zeroizing::new([0u8; KEY_LEN]);
+  rng
+    .fill(&mut dek[..])
+    .map_err(|_| CliError::Crypto("operating-system randomness unavailable".to_owned()))?;
+  let mut wrapped: Vec<u8> = dek.to_vec();
+  seal_with_aad(
+    &kek[..],
+    wrap_nonce,
+    &wrap_aad(kid, &data_nonce),
+    &mut wrapped,
+  )?;
+  let mut data_ct = plaintext.as_bytes().to_vec();
+  seal_with_aad(&dek[..], data_nonce, DATA_AAD, &mut data_ct)?;
+  let mut blob =
+    Vec::with_capacity(2 + kid.len() + NONCE_LEN + WRAPPED_DEK_LEN + NONCE_LEN + data_ct.len());
+  blob.push(TOTP_BLOB_V3);
+  blob.push(kid.len() as u8);
+  blob.extend_from_slice(kid);
+  blob.extend_from_slice(&wrap_nonce);
+  blob.extend_from_slice(&wrapped);
+  blob.extend_from_slice(&data_nonce);
+  blob.extend_from_slice(&data_ct);
+  Ok(blob)
+}
+
+/// Wrap AAD binding a wrapped DEK to its row: format tag, version, wrapping
+/// kid, and the data nonce the DEK was sealed for.
+fn wrap_aad(kid: &[u8], data_nonce: &[u8; NONCE_LEN]) -> Vec<u8> {
+  let mut aad = Vec::with_capacity(DATA_AAD.len() + 1 + kid.len() + NONCE_LEN);
+  aad.extend_from_slice(DATA_AAD);
+  aad.push(kid.len() as u8);
+  aad.extend_from_slice(kid);
+  aad.extend_from_slice(data_nonce);
+  aad
+}
+
+/// Seal `in_out` in place under `key` and `aad`, appending the GCM tag.
+fn seal_with_aad(
+  key: &[u8],
+  nonce_bytes: [u8; NONCE_LEN],
+  aad: &[u8],
+  in_out: &mut Vec<u8>,
+) -> Result<(), CliError> {
+  let sealing = LessSafeKey::new(
+    UnboundKey::new(&AES_256_GCM, key)
+      .map_err(|_| CliError::Crypto("TOTP key rejected by AEAD".to_owned()))?,
+  );
+  sealing
+    .seal_in_place_append_tag(
+      Nonce::assume_unique_for_key(nonce_bytes),
+      Aad::from(aad),
+      in_out,
+    )
+    .map_err(|_| CliError::Crypto("TOTP secret encryption failed".to_owned()))
+}
+
 /// 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.
+/// Version-3 envelopes unwrap the per-row DEK under the HKDF-SHA256 KEK, then
+/// open the data ciphertext; 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.
@@ -141,7 +256,9 @@ 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 && blob[0] != TOTP_BLOB_V1) {
+  if blob.len() < 2 + NONCE_LEN
+    || (blob[0] != TOTP_BLOB_V3 && blob[0] != TOTP_BLOB_VERSION && blob[0] != TOTP_BLOB_V1)
+  {
     return Err(CliError::Crypto("malformed TOTP blob".to_owned()));
   }
   let version = blob[0];
@@ -150,13 +267,19 @@ pub fn decrypt_totp(config: &Config, blob: &[u8]) -> Result<String, CliError> {
   if blob.len() < header + NONCE_LEN {
     return Err(CliError::Crypto("malformed TOTP blob".to_owned()));
   }
-  let kid = std::str::from_utf8(&blob[2..header])
+  let kid_bytes = blob
+    .get(2..header)
+    .ok_or_else(|| CliError::Crypto("malformed TOTP blob".to_owned()))?;
+  let kid = std::str::from_utf8(kid_bytes)
     .map_err(|_| CliError::Crypto("malformed TOTP key id".to_owned()))?;
   let secret = config
     .keys
     .totp_key_by_id(kid)
     .ok_or_else(|| CliError::Crypto(format!("unknown TOTP key id: {kid}")))?;
   let ikm = secret.expose_secret().as_bytes();
+  if version == TOTP_BLOB_V3 {
+    return decrypt_v3(ikm, kid_bytes, &blob[header..]);
+  }
 
   let mut nonce_bytes = [0u8; NONCE_LEN];
   nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
@@ -175,12 +298,67 @@ pub fn decrypt_totp(config: &Config, blob: &[u8]) -> Result<String, CliError> {
   open_with_key(&derive_key_legacy(ikm), &nonce_bytes, ciphertext)
 }
 
+/// Open a v3 envelope body: `wrap_nonce(12) || wrapped_dek(32+16) ||
+/// data_nonce(12) || secret_ct+tag`.
+///
+/// The KEK is re-derived from the key named in the blob; the wrap AAD rebinds
+/// that kid plus the stored data nonce, so a wrapped DEK copied from another
+/// row fails closed.
+#[cfg(test)]
+fn decrypt_v3(ikm: &[u8], kid: &[u8], body: &[u8]) -> Result<String, CliError> {
+  if body.len() < NONCE_LEN + WRAPPED_DEK_LEN + NONCE_LEN + 16 {
+    return Err(CliError::Crypto("malformed TOTP blob".to_owned()));
+  }
+  let (wrap_nonce, rest) = body.split_at(NONCE_LEN);
+  let (wrapped_dek, rest) = rest.split_at(WRAPPED_DEK_LEN);
+  let (data_nonce, ciphertext) = rest.split_at(NONCE_LEN);
+  let mut wrap_nonce_bytes = [0u8; NONCE_LEN];
+  wrap_nonce_bytes.copy_from_slice(wrap_nonce);
+  let mut data_nonce_bytes = [0u8; NONCE_LEN];
+  data_nonce_bytes.copy_from_slice(data_nonce);
+  let kek = Zeroizing::new(
+    derive_labeled(ikm, KEK_INFO)
+      .map_err(|_| CliError::Crypto("TOTP key derivation failed".to_owned()))?,
+  );
+  let dek_bytes = open_with_aad(
+    &kek[..],
+    &wrap_nonce_bytes,
+    &wrap_aad(kid, &data_nonce_bytes),
+    wrapped_dek,
+  )?;
+  if dek_bytes.len() != KEY_LEN {
+    return Err(CliError::Crypto("TOTP secret decryption failed".to_owned()));
+  }
+  let mut dek_key = [0u8; KEY_LEN];
+  dek_key.copy_from_slice(&dek_bytes);
+  let dek = Zeroizing::new(dek_key);
+  let plaintext = open_with_aad(&dek[..], &data_nonce_bytes, DATA_AAD, ciphertext)?;
+  String::from_utf8(plaintext)
+    .map_err(|_| CliError::Crypto("decrypted TOTP secret is not UTF-8".to_owned()))
+}
+
+/// Open `ciphertext` (with trailing GCM tag) under `key` with empty AAD,
+/// returning UTF-8.
 #[cfg(test)]
 fn open_with_key(
   key: &[u8; KEY_LEN],
   nonce_bytes: &[u8; NONCE_LEN],
   ciphertext: &[u8],
 ) -> Result<String, CliError> {
+  let plaintext = open_with_aad(key, nonce_bytes, &[], ciphertext)?;
+  String::from_utf8(plaintext)
+    .map_err(|_| CliError::Crypto("decrypted TOTP secret is not UTF-8".to_owned()))
+}
+
+/// Open `ciphertext` (with trailing GCM tag) under `key` and `aad`, returning
+/// the raw plaintext.
+#[cfg(test)]
+fn open_with_aad(
+  key: &[u8],
+  nonce_bytes: &[u8; NONCE_LEN],
+  aad: &[u8],
+  ciphertext: &[u8],
+) -> Result<Vec<u8>, 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()))?;
@@ -188,12 +366,11 @@ fn open_with_key(
   let plaintext = opening
     .open_in_place(
       Nonce::assume_unique_for_key(*nonce_bytes),
-      Aad::empty(),
+      Aad::from(aad),
       &mut in_out,
     )
     .map_err(|_| CliError::Crypto("TOTP secret decryption failed".to_owned()))?;
-  String::from_utf8(plaintext.to_vec())
-    .map_err(|_| CliError::Crypto("decrypted TOTP secret is not UTF-8".to_owned()))
+  Ok(plaintext.to_vec())
 }
 
 /// Generate a fresh 160-bit TOTP secret, base32 (RFC 4648, no padding).
@@ -259,8 +436,15 @@ pub fn generate_es256() -> Result<KeypairPem, CliError> {
 }
 
 fn derive_key(totp_key: &[u8]) -> Result<[u8; KEY_LEN], ring::error::Unspecified> {
+  derive_labeled(totp_key, HKDF_INFO)
+}
+
+/// HKDF-SHA256 (RFC 5869, empty salt) under an explicit domain-separation
+/// label: `totp-gcm-v2` seals data directly, `totp-kek-v1` wraps v3 DEKs.
+fn derive_labeled(totp_key: &[u8], info: &[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 info_labels = [info];
+  let okm = prk.expand(&info_labels, HkdfAesKey)?;
   let mut key = [0u8; KEY_LEN];
   okm.fill(&mut key)?;
   Ok(key)

+ 4 - 11
src/cli/sync.rs

@@ -12,6 +12,7 @@ use super::{
   context::{CliError, Context, audit},
   redirect, scope,
 };
+use crate::db::redirect_repo;
 
 /// Top-level declarative file.
 #[derive(Debug, Default, Deserialize)]
@@ -165,7 +166,7 @@ pub async fn apply(context: &Context, file: &SyncFile) -> Result<SyncReport, Cli
 
   for entry in &file.redirect_uris {
     match redirect::upsert(
-      context.pool(),
+      context,
       &entry.uri,
       &entry.kind,
       entry.description.as_deref(),
@@ -196,16 +197,8 @@ pub async fn apply(context: &Context, file: &SyncFile) -> Result<SyncReport, Cli
   for entry in &file.client_redirect_uris {
     redirect::ensure_client_exists(context, &entry.client_id).await?;
     let redirect_id = redirect::find_redirect_id(context, &entry.uri, &entry.kind).await?;
-    report.attachments_added += sqlx::query(
-      "INSERT INTO client_redirect_uris (client_id, redirect_uri_id, attached_at)
-       VALUES ($1, $2, now())
-       ON CONFLICT (client_id, redirect_uri_id) DO NOTHING",
-    )
-    .bind(&entry.client_id)
-    .bind(redirect_id)
-    .execute(context.pool())
-    .await?
-    .rows_affected();
+    report.attachments_added +=
+      u64::from(redirect_repo::attach(context.pool(), &entry.client_id, redirect_id, None).await?);
   }
 
   audit("config.sync", &report.to_string());

+ 1 - 0
src/cli/test_support.rs

@@ -207,6 +207,7 @@ fn test_config(pg_url: &str, redis_url: &str) -> Result<Config, TestError> {
     "redis": {"url": redis_url, "durability": "waitaof"},
     "token_pepper": "cli-test-pepper-0123456789abcdef",
     "totp_encryption_key": "cli-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "cli-test-pii-key-0123456789abcdef",
   });
   let source = ::config::Config::try_from(&fixture)?;
   Ok(Config::from_source(source)?)

+ 569 - 19
src/cli/tests.rs

@@ -5,6 +5,13 @@ use std::sync::Arc;
 
 use base64::Engine as _;
 use clap::Parser as _;
+use ring::{
+  aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
+  hkdf,
+  rand::{SecureRandom as _, SystemRandom},
+};
+use secrecy::{ExposeSecret as _, SecretString};
+use sqlx::Row as _;
 use testcontainers::{
   GenericImage, ImageExt,
   core::{IntoContainerPort, WaitFor},
@@ -13,7 +20,7 @@ use testcontainers::{
 use time::OffsetDateTime;
 
 use super::{
-  Cli, ScopeCommand, admin, ops, scope, secret,
+  Cli, ClientCommand, RedirectUriCommand, ScopeCommand, admin, ops, redirect, scope, secret,
   sync::{RedirectEntry, ScopeEntry, SyncFile},
   test_support::{self, TestError},
   user,
@@ -21,7 +28,8 @@ use super::{
 use crate::{
   config::Config,
   crypto::totp,
-  db::user_repo,
+  db::{client_repo, redirect_repo, user_repo},
+  keys::{KeyId, KeyRing},
   state::{AppState, RedisConn},
   ui::login::{
     CHANGE_PASSWORD_PATH, ConfirmOutcome, LoginAttempt, LoginDeps, LoginOutcome, PasswordOutcome,
@@ -57,6 +65,7 @@ async fn bootstrap_setup(
     "redis": {"url": redis_url, "durability": "waitaof"},
     "token_pepper": "bootstrap-test-pepper-0123456789abcdef",
     "totp_encryption_key": "bootstrap-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "bootstrap-test-pii-key-0123456789abcdef",
     "argon2": {"memory_kib": 8192, "iterations": 1, "parallelism": 1},
   });
   let source = ::config::Config::try_from(&fixture)?;
@@ -78,16 +87,15 @@ async fn cli_user_create_inserts_user() -> Result<(), TestError> {
   let email = test_support::unique_email("create");
   user::create(&context, &email, true, PASSWORD).await?;
 
-  let (admin, must_change, hash): (bool, bool, String) = sqlx::query_as(
-    "SELECT is_admin, must_change_password, password_hash
-       FROM users WHERE email = $1::citext",
-  )
-  .bind(&email)
-  .fetch_one(context.pool())
-  .await?;
-
-  assert!(admin);
-  assert!(must_change);
+  let found = user_repo::find_by_email(context.pool(), &context.config().keys, &email)
+    .await?
+    .ok_or(TestError::Missing("created user"))?;
+  assert!(found.is_admin);
+  assert!(found.must_change_password);
+  let hash: String = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1")
+    .bind(found.id)
+    .fetch_one(context.pool())
+    .await?;
   assert!(hash.starts_with("$argon2id$"));
   assert!(!hash.contains(PASSWORD));
   Ok(())
@@ -107,6 +115,53 @@ async fn cli_user_create_duplicate_email_fails() -> Result<(), TestError> {
   Ok(())
 }
 
+#[tokio::test]
+async fn cli_user_create_and_lookup() -> Result<(), TestError> {
+  let (_harness, context) = test_support::context().await?;
+  let email = test_support::unique_email("create-lookup");
+  user::create(&context, &email, false, PASSWORD).await?;
+
+  // The lookup resolves through the encrypted repository (blind index); the
+  // plaintext `email` column may be NULL, so only the sealed row proves it.
+  let id = user::find_user_id(&context, &email).await?;
+  let record = user_repo::find_by_email(context.pool(), &context.config().keys, &email)
+    .await?
+    .ok_or(TestError::Missing("created user"))?;
+  assert_eq!(record.id, id);
+  assert_eq!(record.email, email.to_lowercase());
+
+  // Case-insensitive lookup through the same encrypted path.
+  let upper_id = user::find_user_id(&context, &email.to_uppercase()).await?;
+  assert_eq!(upper_id, id);
+
+  // A duplicate create maps to `AlreadyExists` with a payload-free message:
+  // the CLI error must never carry the email address.
+  match user::create(&context, &email, false, PASSWORD).await {
+    Err(super::context::CliError::AlreadyExists(message)) => {
+      assert!(
+        !message.contains(&email) && !message.contains(&email.to_lowercase()),
+        "duplicate error leaked the email: {message}"
+      );
+    }
+    Err(other) => return Err(TestError::Harness(format!("unexpected error: {other:?}"))),
+    Ok(()) => return Err(TestError::Missing("duplicate create must fail")),
+  }
+
+  // A missing user is a clean, payload-free not-found.
+  let missing = test_support::unique_email("create-lookup-missing");
+  match user::find_user_id(&context, &missing).await {
+    Err(super::context::CliError::NotFound(message)) => {
+      assert!(
+        !message.contains('@') && !message.contains(&missing),
+        "not-found error leaked the email: {message}"
+      );
+    }
+    Err(other) => return Err(TestError::Harness(format!("unexpected error: {other:?}"))),
+    Ok(_) => return Err(TestError::Missing("missing user must be NotFound")),
+  }
+  Ok(())
+}
+
 #[tokio::test]
 async fn cli_initial_access_token_stores_only_hash() -> Result<(), TestError> {
   let harness = test_support::harness().await?;
@@ -149,9 +204,12 @@ async fn cli_user_enroll_totp_stores_encrypted_secret() -> Result<(), TestError>
   user::create(&context, &email, false, PASSWORD).await?;
 
   let enrollment = user::enroll_totp(&context, &email).await?;
+  let user = user_repo::find_by_email(context.pool(), &context.config().keys, &email)
+    .await?
+    .ok_or(TestError::Missing("created user"))?;
   let (blob, enabled): (Option<Vec<u8>>, bool) =
-    sqlx::query_as("SELECT totp_secret_enc, totp_enabled FROM users WHERE email = $1::citext")
-      .bind(&email)
+    sqlx::query_as("SELECT totp_secret_enc, totp_enabled FROM users WHERE id = $1")
+      .bind(user.id)
       .fetch_one(context.pool())
       .await?;
 
@@ -170,6 +228,205 @@ async fn cli_user_enroll_totp_stores_encrypted_secret() -> Result<(), TestError>
   Ok(())
 }
 
+#[tokio::test]
+async fn cli_totp_v3_cross_decrypts() -> Result<(), TestError> {
+  let (_harness, context) = test_support::context().await?;
+  let config = context.config();
+  let plaintext = secret::generate_totp_secret()?;
+
+  // CLI -> crypto: a v3 envelope the CLI wrote opens under crypto::totp.
+  let cli_blob = secret::encrypt_totp(config, &plaintext, true)?;
+  assert_eq!(
+    cli_blob.first().copied(),
+    Some(3),
+    "CLI envelope write must be v3"
+  );
+  assert_eq!(totp::decrypt_secret(&config.keys, &cli_blob)?, plaintext);
+
+  // CLI round-trips through its own reader.
+  assert_eq!(secret::decrypt_totp(config, &cli_blob)?, plaintext);
+
+  // crypto -> CLI: a v3 envelope crypto wrote opens under the CLI.
+  let crypto_blob = totp::encrypt_secret(&config.keys, &plaintext, true)?;
+  assert_eq!(crypto_blob.first().copied(), Some(3));
+  assert_eq!(secret::decrypt_totp(config, &crypto_blob)?, plaintext);
+
+  // The version gate keeps the legacy v2 write cross-compatible too.
+  let legacy = secret::encrypt_totp(config, &plaintext, false)?;
+  assert_eq!(
+    legacy.first().copied(),
+    Some(2),
+    "gated CLI write must stay v2"
+  );
+  assert_eq!(totp::decrypt_secret(&config.keys, &legacy)?, plaintext);
+  assert_eq!(secret::decrypt_totp(config, &legacy)?, plaintext);
+
+  // Data AAD excludes the kid: re-wrap the DEK under a different kid (same key
+  // material) leaving the data nonce and ciphertext byte-identical, and the
+  // crypto reader still opens it — which holds only if the data seal never
+  // bound the wrapping kid. This is what `totp rewrap` relies on.
+  let (kid, material) = config
+    .keys
+    .active_totp_key()
+    .ok_or(TestError::Missing("active TOTP key"))?;
+  let target = KeyId::new("v99");
+  let rewrapped = rewrap_v3(&cli_blob, kid.as_str(), material, target.as_str())?;
+  let ring = key_ring_with_totp(&config.keys, target, material)?;
+  assert_eq!(totp::decrypt_secret(&ring, &rewrapped)?, plaintext);
+
+  // A truncated blob fails closed without leaking plaintext.
+  let truncated = &cli_blob[..12];
+  assert!(secret::decrypt_totp(config, truncated).is_err());
+  assert!(
+    !truncated
+      .windows(plaintext.len())
+      .any(|window| window == plaintext.as_bytes())
+  );
+  Ok(())
+}
+
+/// Re-wrap a v3 envelope's DEK under `target_kid`, keeping the data nonce and
+/// ciphertext untouched. Mirrors `crypto::totp::wrap_aad` / `derive_labeled`.
+fn rewrap_v3(
+  blob: &[u8],
+  source_kid: &str,
+  material: &SecretString,
+  target_kid: &str,
+) -> Result<Vec<u8>, TestError> {
+  const NONCE_LEN: usize = 12;
+  const WRAPPED_DEK_LEN: usize = 48;
+
+  let header = 2 + usize::from(blob[1]);
+  let wrap_nonce = &blob[header..header + NONCE_LEN];
+  let wrapped_dek = &blob[header + NONCE_LEN..header + NONCE_LEN + WRAPPED_DEK_LEN];
+  let data_start = header + NONCE_LEN + WRAPPED_DEK_LEN;
+  let data_nonce = &blob[data_start..data_start + NONCE_LEN];
+  let data_ct = &blob[data_start + NONCE_LEN..];
+
+  let kek = totp_derive_kek(material.expose_secret().as_bytes())?;
+  let dek = totp_open(
+    &kek,
+    wrap_nonce,
+    &totp_wrap_aad(source_kid.as_bytes(), data_nonce),
+    wrapped_dek,
+  )?;
+
+  let mut new_wrap_nonce = [0u8; NONCE_LEN];
+  SystemRandom::new()
+    .fill(&mut new_wrap_nonce)
+    .map_err(|_| TestError::Harness("operating-system randomness unavailable".to_owned()))?;
+  let mut new_wrapped = dek;
+  totp_seal(
+    &kek,
+    &new_wrap_nonce,
+    &totp_wrap_aad(target_kid.as_bytes(), data_nonce),
+    &mut new_wrapped,
+  )?;
+
+  if target_kid.len() > usize::from(u8::MAX) {
+    return Err(TestError::Harness("target kid too long".to_owned()));
+  }
+  let mut out = Vec::new();
+  out.push(3u8);
+  out.push(target_kid.len() as u8);
+  out.extend_from_slice(target_kid.as_bytes());
+  out.extend_from_slice(&new_wrap_nonce);
+  out.extend_from_slice(&new_wrapped);
+  out.extend_from_slice(data_nonce);
+  out.extend_from_slice(data_ct);
+  Ok(out)
+}
+
+/// Build a ring whose only TOTP key is `id` -> `material`, reusing the source
+/// ring's pepper and PII chains.
+fn key_ring_with_totp(
+  source: &KeyRing,
+  id: KeyId,
+  material: &SecretString,
+) -> Result<KeyRing, TestError> {
+  let peppers = source
+    .peppers_newest_first()
+    .map(|(id, secret)| (id.clone(), secret.clone()))
+    .collect();
+  let pii_keys = source
+    .pii_keys_newest_first()
+    .map(|(id, secret)| (id.clone(), secret.clone()))
+    .collect();
+  KeyRing::from_parts(peppers, vec![(id, material.clone())], pii_keys)
+    .map_err(|error| TestError::Harness(error.to_string()))
+}
+
+/// `wrap_aad = b"totp-v3\x03" || [kid_len] || kid || data_nonce`.
+fn totp_wrap_aad(kid: &[u8], data_nonce: &[u8]) -> Vec<u8> {
+  let mut aad = Vec::with_capacity(9 + kid.len() + data_nonce.len());
+  aad.extend_from_slice(b"totp-v3\x03");
+  aad.push(kid.len() as u8);
+  aad.extend_from_slice(kid);
+  aad.extend_from_slice(data_nonce);
+  aad
+}
+
+/// HKDF-SHA256(ikm, info `totp-kek-v1`), matching `crypto::totp`.
+fn totp_derive_kek(ikm: &[u8]) -> Result<[u8; 32], TestError> {
+  let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &[]).extract(ikm);
+  let info_labels = [b"totp-kek-v1".as_slice()];
+  let okm = prk
+    .expand(&info_labels, TotpHkdfKey)
+    .map_err(|_| TestError::Harness("TOTP KEK derivation failed".to_owned()))?;
+  let mut key = [0u8; 32];
+  okm
+    .fill(&mut key)
+    .map_err(|_| TestError::Harness("TOTP KEK derivation failed".to_owned()))?;
+  Ok(key)
+}
+
+fn totp_open(
+  key: &[u8; 32],
+  nonce: &[u8],
+  aad: &[u8],
+  ciphertext: &[u8],
+) -> Result<Vec<u8>, TestError> {
+  let mut nonce_bytes = [0u8; 12];
+  nonce_bytes.copy_from_slice(nonce);
+  let mut in_out = ciphertext.to_vec();
+  let opening = LessSafeKey::new(
+    UnboundKey::new(&AES_256_GCM, key)
+      .map_err(|_| TestError::Harness("TOTP key rejected by AEAD".to_owned()))?,
+  );
+  let plaintext = opening
+    .open_in_place(
+      Nonce::assume_unique_for_key(nonce_bytes),
+      Aad::from(aad),
+      &mut in_out,
+    )
+    .map_err(|_| TestError::Harness("TOTP DEK unwrap failed".to_owned()))?;
+  Ok(plaintext.to_vec())
+}
+
+fn totp_seal(
+  key: &[u8; 32],
+  nonce: &[u8; 12],
+  aad: &[u8],
+  in_out: &mut Vec<u8>,
+) -> Result<(), TestError> {
+  let sealing = LessSafeKey::new(
+    UnboundKey::new(&AES_256_GCM, key)
+      .map_err(|_| TestError::Harness("TOTP key rejected by AEAD".to_owned()))?,
+  );
+  sealing
+    .seal_in_place_append_tag(Nonce::assume_unique_for_key(*nonce), Aad::from(aad), in_out)
+    .map_err(|_| TestError::Harness("TOTP DEK wrap failed".to_owned()))
+}
+
+/// `ring::hkdf` output-length marker for a 256-bit AES key.
+struct TotpHkdfKey;
+
+impl hkdf::KeyType for TotpHkdfKey {
+  fn len(&self) -> usize {
+    32
+  }
+}
+
 #[tokio::test]
 async fn cli_scope_create_and_grant_take_effect() -> Result<(), TestError> {
   let (_harness, context) = test_support::context().await?;
@@ -236,6 +493,298 @@ async fn cli_config_sync_is_idempotent() -> Result<(), TestError> {
   Ok(())
 }
 
+/// Insert a minimal client so redirect attachments satisfy their FKs.
+async fn insert_redirect_client(
+  pool: &sqlx::PgPool,
+  keys: &KeyRing,
+  client_id: &str,
+) -> Result<(), TestError> {
+  client_repo::insert(
+    pool,
+    keys,
+    false,
+    &client_repo::NewClient {
+      id: uuid::Uuid::new_v4(),
+      client_id: client_id.to_owned(),
+      client_secret_hash: None,
+      client_secret_expires_at: None,
+      client_name: "CLI Redirect Client".to_owned(),
+      grant_types: vec!["authorization_code".to_owned()],
+      response_types: vec!["code".to_owned()],
+      scope: String::new(),
+      token_endpoint_auth_method: "none".to_owned(),
+      jwks: None,
+      jwks_uri: None,
+      tls_client_auth_subject_dn: None,
+      tls_client_auth_cert_thumbprint: None,
+      request_uris: None,
+      contacts: None,
+      software_id: None,
+      dpop_bound_access_tokens: false,
+      registration_access_token_hash: None,
+    },
+  )
+  .await?;
+  Ok(())
+}
+
+/// `config sync` writes redirect URIs through the encrypted repository:
+/// created-vs-updated is preserved, a second identical apply is a true no-op,
+/// and an existing row's description/enabled are never overwritten.
+#[tokio::test]
+async fn config_sync_redirects() -> Result<(), TestError> {
+  let (_harness, context) = test_support::context().await?;
+  let keys = &context.config().keys;
+  let uri = format!("https://{}.example.test/synced", uuid::Uuid::new_v4());
+  let file = SyncFile {
+    scopes: Vec::new(),
+    redirect_uris: vec![RedirectEntry {
+      uri: uri.clone(),
+      kind: "authorize".to_owned(),
+      description: Some("synced".to_owned()),
+      enabled: true,
+    }],
+    user_scopes: Vec::new(),
+    client_redirect_uris: Vec::new(),
+  };
+
+  let first = super::sync::apply(&context, &file).await?;
+  assert_eq!(first.redirects_created, 1);
+  assert_eq!(first.redirects_updated, 0);
+
+  // Encrypt-only at rest: plaintext blank, ciphertext + blind index present.
+  let id = redirect::find_redirect_id(&context, &uri, "authorize").await?;
+  let stored = sqlx::query(
+    "SELECT uri, uri_enc, uri_index, description, enabled FROM redirect_uris WHERE id = $1",
+  )
+  .bind(id)
+  .fetch_one(context.pool())
+  .await?;
+  assert!(stored.try_get::<Option<String>, _>("uri")?.is_none());
+  assert!(stored.try_get::<Option<Vec<u8>>, _>("uri_enc")?.is_some());
+  assert!(stored.try_get::<Option<String>, _>("uri_index")?.is_some());
+  assert_eq!(stored.try_get::<String, _>("description")?, "synced");
+  assert!(stored.try_get::<bool, _>("enabled")?);
+
+  // A second identical apply changes nothing.
+  let second = super::sync::apply(&context, &file).await?;
+  assert!(second.is_noop(), "second sync must be a no-op: {second}");
+
+  // A legacy NULL-index row is adopted as Updated (not Created) and keeps its
+  // original description and disabled state.
+  let legacy_uri = format!("https://legacy-{}.example.test/cb", uuid::Uuid::new_v4());
+  let legacy_id = uuid::Uuid::new_v4();
+  sqlx::query(
+    "INSERT INTO redirect_uris (id, uri, kind, description, enabled, created_at, updated_at) \
+     VALUES ($1, $2, 'authorize', 'legacy', false, now(), now())",
+  )
+  .bind(legacy_id)
+  .bind(&legacy_uri)
+  .execute(context.pool())
+  .await?;
+  let adopt = SyncFile {
+    redirect_uris: vec![RedirectEntry {
+      uri: legacy_uri.clone(),
+      kind: "authorize".to_owned(),
+      description: Some("declared".to_owned()),
+      enabled: true,
+    }],
+    ..SyncFile::default()
+  };
+  let third = super::sync::apply(&context, &adopt).await?;
+  assert_eq!(third.redirects_created, 0);
+  assert_eq!(third.redirects_updated, 1);
+
+  let adopted = redirect_repo::list(context.pool(), keys, true)
+    .await?
+    .into_iter()
+    .find(|entry| entry.id == legacy_id)
+    .ok_or(TestError::Missing("adopted legacy redirect"))?;
+  assert_eq!(adopted.uri, legacy_uri);
+  assert_eq!(
+    adopted.description, "legacy",
+    "sync must not overwrite description"
+  );
+  assert!(!adopted.enabled, "sync must not re-enable an existing row");
+  let sealed: Option<Vec<u8>> =
+    sqlx::query_scalar("SELECT uri_enc FROM redirect_uris WHERE id = $1")
+      .bind(legacy_id)
+      .fetch_one(context.pool())
+      .await?;
+  assert!(sealed.is_some(), "legacy row must be sealed after adoption");
+  Ok(())
+}
+
+/// The CLI upsert/list/enable/disable/attach/detach round-trip through
+/// ciphertext: a sealed row is idempotent, a legacy NULL-index row is adopted,
+/// deleting an attached URI disables instead of deleting, and an unknown URI is
+/// a clean not-found.
+#[tokio::test]
+async fn cli_redirect_upsert_and_attach() -> Result<(), TestError> {
+  let (_harness, context) = test_support::context().await?;
+  let keys = &context.config().keys;
+  let uri = format!("https://{}.example.test/cb", uuid::Uuid::new_v4());
+
+  redirect::run_uri(
+    &context,
+    RedirectUriCommand::Create {
+      uri: uri.clone(),
+      kind: "authorize".to_owned(),
+      description: Some("initial".to_owned()),
+    },
+  )
+  .await?;
+  let id = redirect::find_redirect_id(&context, &uri, "authorize").await?;
+  let stored = sqlx::query(
+    "SELECT uri, uri_enc, uri_index, description, enabled FROM redirect_uris WHERE id = $1",
+  )
+  .bind(id)
+  .fetch_one(context.pool())
+  .await?;
+  assert!(stored.try_get::<Option<String>, _>("uri")?.is_none());
+  assert!(stored.try_get::<Option<Vec<u8>>, _>("uri_enc")?.is_some());
+  assert!(stored.try_get::<Option<String>, _>("uri_index")?.is_some());
+
+  // Disable the row, then re-create the same URI with a different description:
+  // it must stay disabled and keep its original description.
+  redirect::run_uri(
+    &context,
+    RedirectUriCommand::Disable {
+      uri: uri.clone(),
+      kind: "authorize".to_owned(),
+    },
+  )
+  .await?;
+  redirect::run_uri(
+    &context,
+    RedirectUriCommand::Create {
+      uri: uri.clone(),
+      kind: "authorize".to_owned(),
+      description: Some("changed".to_owned()),
+    },
+  )
+  .await?;
+  let preserved = sqlx::query("SELECT description, enabled FROM redirect_uris WHERE id = $1")
+    .bind(id)
+    .fetch_one(context.pool())
+    .await?;
+  assert_eq!(preserved.try_get::<String, _>("description")?, "initial");
+  assert!(!preserved.try_get::<bool, _>("enabled")?);
+
+  // Re-enable so the attachment is effective.
+  redirect::run_uri(
+    &context,
+    RedirectUriCommand::Enable {
+      uri: uri.clone(),
+      kind: "authorize".to_owned(),
+    },
+  )
+  .await?;
+
+  // A legacy NULL-index row resolves through the plaintext fallback and is
+  // adopted in place on the next create.
+  let legacy_uri = format!("https://legacy-{}.example.test/cb", uuid::Uuid::new_v4());
+  let legacy_id = uuid::Uuid::new_v4();
+  sqlx::query(
+    "INSERT INTO redirect_uris (id, uri, kind, description, created_at, updated_at) \
+     VALUES ($1, $2, 'authorize', 'legacy', now(), now())",
+  )
+  .bind(legacy_id)
+  .bind(&legacy_uri)
+  .execute(context.pool())
+  .await?;
+  assert_eq!(
+    redirect::find_redirect_id(&context, &legacy_uri, "authorize").await?,
+    legacy_id
+  );
+  redirect::run_uri(
+    &context,
+    RedirectUriCommand::Create {
+      uri: legacy_uri.clone(),
+      kind: "authorize".to_owned(),
+      description: Some("adopted".to_owned()),
+    },
+  )
+  .await?;
+  let legacy_sealed: Option<Vec<u8>> =
+    sqlx::query_scalar("SELECT uri_enc FROM redirect_uris WHERE id = $1")
+      .bind(legacy_id)
+      .fetch_one(context.pool())
+      .await?;
+  assert!(legacy_sealed.is_some(), "legacy row must gain ciphertext");
+
+  // Attach through the CLI; then delete disables instead of deleting.
+  let client_id = format!("client-{}", uuid::Uuid::new_v4().simple());
+  insert_redirect_client(context.pool(), keys, &client_id).await?;
+  redirect::run_client(
+    &context,
+    ClientCommand::AttachRedirectUri {
+      client_id: client_id.clone(),
+      uri: uri.clone(),
+      kind: "authorize".to_owned(),
+    },
+  )
+  .await?;
+  let attached = redirect_repo::list_for_client(context.pool(), keys, &client_id).await?;
+  assert_eq!(attached.len(), 1);
+  assert_eq!(attached[0].uri, uri);
+
+  redirect::run_uri(
+    &context,
+    RedirectUriCommand::Delete {
+      uri: uri.clone(),
+      kind: "authorize".to_owned(),
+    },
+  )
+  .await?;
+  let disabled = sqlx::query("SELECT enabled FROM redirect_uris WHERE id = $1")
+    .bind(id)
+    .fetch_one(context.pool())
+    .await?;
+  assert!(
+    !disabled.try_get::<bool, _>("enabled")?,
+    "an attached URI must be disabled, not deleted"
+  );
+  assert!(
+    redirect::find_redirect_id(&context, &uri, "authorize")
+      .await
+      .is_ok()
+  );
+
+  // Detach, then delete removes the row.
+  redirect::run_client(
+    &context,
+    ClientCommand::DetachRedirectUri {
+      client_id: client_id.clone(),
+      uri: uri.clone(),
+      kind: "authorize".to_owned(),
+    },
+  )
+  .await?;
+  redirect::run_uri(
+    &context,
+    RedirectUriCommand::Delete {
+      uri: uri.clone(),
+      kind: "authorize".to_owned(),
+    },
+  )
+  .await?;
+  assert!(
+    redirect::find_redirect_id(&context, &uri, "authorize")
+      .await
+      .is_err()
+  );
+
+  // An unknown URI is a clean not-found, not a database error.
+  let missing =
+    redirect::find_redirect_id(&context, "https://unknown.example.test/x", "authorize").await;
+  assert!(matches!(
+    missing,
+    Err(super::context::CliError::NotFound(_))
+  ));
+  Ok(())
+}
+
 #[tokio::test]
 async fn cli_mutations_emit_audit_events() -> Result<(), TestError> {
   let (_harness, context) = test_support::context().await?;
@@ -304,9 +853,9 @@ async fn admin_bootstrap_creates_first_admin_must_change() -> Result<(), TestErr
 
   let row: (bool, bool, bool, Option<OffsetDateTime>) = sqlx::query_as(
     "SELECT is_admin, must_change_password, totp_enabled, password_changed_at
-       FROM users WHERE email = $1::citext",
+       FROM users WHERE id = $1",
   )
-  .bind(&email)
+  .bind(outcome.id)
   .fetch_one(&pool)
   .await?;
   assert!(row.0, "bootstrap must create an admin");
@@ -317,7 +866,7 @@ async fn admin_bootstrap_creates_first_admin_must_change() -> Result<(), TestErr
     "password_changed_at stays NULL until rotation"
   );
 
-  let verdict = user_repo::verify_password(&pool, &email, BOOTSTRAP_PASSWORD).await?;
+  let verdict = user_repo::verify_password(&pool, &config.keys, &email, BOOTSTRAP_PASSWORD).await?;
   assert_eq!(verdict, user_repo::PasswordVerdict::Matched);
   assert!(user_repo::has_any_admin(&pool).await?);
 
@@ -465,7 +1014,7 @@ async fn admin_bootstrap_generated_password_is_256_bit_and_unlogged() -> Result<
     .map_err(|_| TestError::Missing("generated password must be base64"))?;
   assert_eq!(raw.len(), 32, "generated passwords are 256-bit");
 
-  let verdict = user_repo::verify_password(&pool, &email, &generated).await?;
+  let verdict = user_repo::verify_password(&pool, &config.keys, &email, &generated).await?;
   assert_eq!(verdict, user_repo::PasswordVerdict::Matched);
 
   let log = test_support::captured(&capture.buffer);
@@ -589,7 +1138,7 @@ async fn admin_bootstrap_first_login_restricted_until_rotation() -> Result<(), T
       return Err(TestError::Missing("promotion needs TOTP enrollment first"));
     }
   }
-  let user = user_repo::find_by_email(&pool, &email)
+  let user = user_repo::find_by_email(&pool, &config.keys, &email)
     .await?
     .ok_or(TestError::Missing("bootstrap admin"))?;
   assert!(!user.must_change_password);
@@ -704,6 +1253,7 @@ async fn open_pool_uses_mounted_database_password() -> Result<(), TestError> {
     "redis": {"url": "redis://127.0.0.1:6379", "durability": "waitaof"},
     "token_pepper": "cli-test-pepper-0123456789abcdef",
     "totp_encryption_key": "cli-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "cli-test-pii-key-0123456789abcdef",
   });
   let source = ::config::Config::try_from(&fixture)?;
   let config = Config::from_source(source)?;

+ 348 - 0
src/cli/totp.rs

@@ -0,0 +1,348 @@
+//! `totp rewrap`: re-wrap v3 TOTP DEKs under the active TOTP-encryption key.
+//!
+//! A v3 blob is `version(3) || kid_len(1) || kid || wrap_nonce(12) ||
+//! wrapped_dek(32+16) || data_nonce(12) || secret_ct+tag`. Because the data
+//! AAD binds only the format tag and version (never the wrapping kid), moving a
+//! row to a new key means unwrapping its per-row DEK with the key named in the
+//! blob and re-wrapping that SAME DEK under the active key: the data nonce and
+//! the secret ciphertext+tag stay byte-identical, and only `kid`, `wrap_nonce`,
+//! and `wrapped_dek` change. The secret plaintext is never decrypted here.
+//!
+//! Rows already wrapped by the active key are skipped, leaving v1/v2 rows to
+//! `pii backfill` (they have no DEK to re-wrap). A scan processes bounded
+//! batches; each batch runs in its own transaction holding
+//! `pg_advisory_xact_lock` on the connection that owns that transaction, so the
+//! lock and the batch commit/rollback are atomic. The command prints counts
+//! only and never emits blob or key material.
+
+use std::fmt;
+
+use ring::{
+  aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
+  hkdf,
+  rand::{SecureRandom as _, SystemRandom},
+};
+use secrecy::{ExposeSecret as _, SecretString};
+use sqlx::PgPool;
+use uuid::Uuid;
+use zeroize::Zeroizing;
+
+use super::context::{CliError, Context, audit};
+use crate::keys::KeyRing;
+
+/// Envelope-encrypted blob version (per-row DEK).
+const TOTP_BLOB_V3: u8 = 3;
+/// HKDF domain-separation label for the key-encryption key wrapping v3 DEKs.
+const KEK_INFO: &[u8] = b"totp-kek-v1";
+/// AAD sealing the v3 data ciphertext: format tag plus version only.
+const DATA_AAD: &[u8] = b"totp-v3\x03";
+/// AES-GCM nonce length.
+const NONCE_LEN: usize = 12;
+/// AES-GCM tag length.
+const TAG_LEN: usize = 16;
+/// AES-256 key length.
+const KEY_LEN: usize = 32;
+/// Wrapped-DEK length: a 32-byte DEK sealed under AES-256-GCM.
+const WRAPPED_DEK_LEN: usize = KEY_LEN + TAG_LEN;
+/// Rows scanned/rewrapped per batch transaction.
+const BATCH_SIZE: usize = 100;
+/// Advisory-lock key, stable across processes.
+const REWRAP_LOCK: &str = "d_oauth_totp_rewrap";
+
+/// What a single row's blob implies for the re-wrap pass.
+enum RewrapDecision {
+  /// Already wrapped by the active key: leave untouched.
+  AlreadyActive,
+  /// v1/v2 (or an unknown version): no DEK to re-wrap; `pii backfill` owns it.
+  Legacy,
+  /// A v3 blob that cannot be parsed or opened: skipped, never overwritten.
+  Malformed,
+  /// The blob re-wrapped under the active key (this exact byte string is stored).
+  Rewritten(Vec<u8>),
+}
+
+/// Counts from one `totp rewrap` run (plaintext counts, never blob material).
+#[derive(Debug, Default, Clone, Copy)]
+pub(super) struct RewrapReport {
+  /// Non-null `totp_secret_enc` rows examined.
+  pub(super) scanned: u64,
+  /// v3 rows re-wrapped under the active key.
+  pub(super) rewrapped: u64,
+  /// v3 rows already wrapped by the active key.
+  pub(super) already_active: u64,
+  /// v1/v2 rows left for `pii backfill`.
+  pub(super) legacy: u64,
+  /// v3 rows that could not be parsed or opened (skipped).
+  pub(super) malformed: u64,
+}
+
+impl fmt::Display for RewrapReport {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    write!(
+      f,
+      "totp rewrap: scanned={} rewrapped={} already_active={} skipped_legacy={} skipped_malformed={}",
+      self.scanned, self.rewrapped, self.already_active, self.legacy, self.malformed
+    )
+  }
+}
+
+/// `totp rewrap`: re-wrap every v3 DEK under the active TOTP key.
+///
+/// # Errors
+/// Returns [`CliError::Crypto`] when no active TOTP key is configured or a
+/// cryptographic operation fails, and [`CliError::Database`] for PostgreSQL
+/// failures.
+pub(super) async fn rewrap(context: &Context) -> Result<RewrapReport, CliError> {
+  rewrap_with(context.pool(), &context.config().keys).await
+}
+
+/// Re-wrap `users.totp_secret_enc` v3 blobs under the active key in bounded
+/// batches.
+///
+/// Each batch is one transaction holding `pg_advisory_xact_lock` on its own
+/// connection, so the lock and the batch commit/rollback are atomic. Keyset
+/// pagination on `id` bounds each batch and keeps a re-run idempotent.
+///
+/// # Errors
+/// Returns [`CliError::Crypto`] when no active TOTP key exists or a required
+/// cryptographic operation fails, and [`CliError::Database`] for PostgreSQL
+/// failures.
+async fn rewrap_with(pool: &PgPool, keys: &KeyRing) -> Result<RewrapReport, CliError> {
+  let (active_key_id, active_secret) = keys
+    .active_totp_key()
+    .ok_or_else(|| CliError::Crypto("no active TOTP encryption key configured".to_owned()))?;
+  let active_kid = active_key_id.as_str();
+  if active_kid.len() > usize::from(u8::MAX) {
+    return Err(CliError::Crypto("TOTP key id is too long".to_owned()));
+  }
+
+  let mut report = RewrapReport::default();
+  let mut after = Uuid::nil();
+  loop {
+    let mut tx = pool.begin().await?;
+    sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))")
+      .bind(REWRAP_LOCK)
+      .execute(&mut *tx)
+      .await?;
+    let rows: Vec<(Uuid, Vec<u8>)> = sqlx::query_as(
+      "SELECT id, totp_secret_enc FROM users
+        WHERE totp_secret_enc IS NOT NULL AND id > $1
+        ORDER BY id
+        LIMIT $2",
+    )
+    .bind(after)
+    .bind(BATCH_SIZE as i64)
+    .fetch_all(&mut *tx)
+    .await?;
+    if rows.is_empty() {
+      tx.commit().await?;
+      break;
+    }
+    let fetched = rows.len();
+    for (id, blob) in &rows {
+      report.scanned = report.scanned.saturating_add(1);
+      match rewrap_v3_blob(keys, blob, active_kid, active_secret)? {
+        RewrapDecision::Rewritten(new_blob) => {
+          sqlx::query("UPDATE users SET totp_secret_enc = $2 WHERE id = $1")
+            .bind(id)
+            .bind(&new_blob)
+            .execute(&mut *tx)
+            .await?;
+          report.rewrapped = report.rewrapped.saturating_add(1);
+        }
+        RewrapDecision::AlreadyActive => {
+          report.already_active = report.already_active.saturating_add(1);
+        }
+        RewrapDecision::Legacy => {
+          report.legacy = report.legacy.saturating_add(1);
+        }
+        RewrapDecision::Malformed => {
+          report.malformed = report.malformed.saturating_add(1);
+        }
+      }
+      after = *id;
+    }
+    tx.commit().await?;
+    if fetched < BATCH_SIZE {
+      break;
+    }
+  }
+  audit("totp.rewrap", "users.totp_secret_enc");
+  Ok(report)
+}
+
+/// Decide what to do with one stored blob: skip a non-v3 or an active-key row,
+/// or re-wrap the DEK onto `active_kid` while preserving the data nonce and
+/// ciphertext+tag.
+///
+/// A v3 blob that cannot be parsed, names an unknown key, or fails to unwrap is
+/// reported as [`RewrapDecision::Malformed`] and left untouched — never
+/// overwritten with garbage.
+///
+/// # Errors
+/// Returns [`CliError::Crypto`] for an active-key derivation, RNG, or sealing
+/// failure (a system fault that must abort the batch).
+fn rewrap_v3_blob(
+  keys: &KeyRing,
+  blob: &[u8],
+  active_kid: &str,
+  active_secret: &SecretString,
+) -> Result<RewrapDecision, CliError> {
+  if blob.first().copied() != Some(TOTP_BLOB_V3) {
+    return Ok(RewrapDecision::Legacy);
+  }
+  let Some(&kid_len_raw) = blob.get(1) else {
+    return Ok(RewrapDecision::Malformed);
+  };
+  let header = 2 + usize::from(kid_len_raw);
+  let Some(kid_bytes) = blob.get(2..header) else {
+    return Ok(RewrapDecision::Malformed);
+  };
+  let Ok(kid) = std::str::from_utf8(kid_bytes) else {
+    return Ok(RewrapDecision::Malformed);
+  };
+  if kid == active_kid {
+    return Ok(RewrapDecision::AlreadyActive);
+  }
+  let Some(body) = blob.get(header..) else {
+    return Ok(RewrapDecision::Malformed);
+  };
+  if body.len() < NONCE_LEN + WRAPPED_DEK_LEN + NONCE_LEN + TAG_LEN {
+    return Ok(RewrapDecision::Malformed);
+  }
+  let Some(source_secret) = keys.totp_key_by_id(kid) else {
+    return Ok(RewrapDecision::Malformed);
+  };
+
+  let (wrap_nonce, rest) = body.split_at(NONCE_LEN);
+  let (wrapped_dek, rest) = rest.split_at(WRAPPED_DEK_LEN);
+  let (data_nonce, data_ct) = rest.split_at(NONCE_LEN);
+  let data_nonce = to_nonce(data_nonce)?;
+
+  let source_kek = Zeroizing::new(
+    derive_labeled(source_secret.expose_secret().as_bytes(), KEK_INFO)
+      .map_err(|_| crypto_error("TOTP key derivation failed"))?,
+  );
+  let Some(dek_bytes) = unwrap_dek(
+    &source_kek[..],
+    wrap_nonce,
+    &wrap_aad(kid_bytes, &data_nonce),
+    wrapped_dek,
+  ) else {
+    return Ok(RewrapDecision::Malformed);
+  };
+  if dek_bytes.len() != KEY_LEN {
+    return Ok(RewrapDecision::Malformed);
+  }
+
+  let active_kek = Zeroizing::new(
+    derive_labeled(active_secret.expose_secret().as_bytes(), KEK_INFO)
+      .map_err(|_| crypto_error("TOTP key derivation failed"))?,
+  );
+  let mut new_wrap_nonce = [0u8; NONCE_LEN];
+  SystemRandom::new()
+    .fill(&mut new_wrap_nonce)
+    .map_err(|_| crypto_error("operating-system randomness unavailable"))?;
+  let mut dek = Zeroizing::new(dek_bytes);
+  seal_with_aad(
+    &active_kek[..],
+    new_wrap_nonce,
+    &wrap_aad(active_kid.as_bytes(), &data_nonce),
+    &mut dek,
+  )?;
+
+  let mut out = Vec::with_capacity(
+    2 + active_kid.len() + NONCE_LEN + WRAPPED_DEK_LEN + NONCE_LEN + data_ct.len(),
+  );
+  out.push(TOTP_BLOB_V3);
+  out.push(active_kid.len() as u8);
+  out.extend_from_slice(active_kid.as_bytes());
+  out.extend_from_slice(&new_wrap_nonce);
+  out.extend_from_slice(&dek[..]);
+  out.extend_from_slice(data_nonce.as_slice());
+  out.extend_from_slice(data_ct);
+  Ok(RewrapDecision::Rewritten(out))
+}
+
+/// Wrap AAD binding a wrapped DEK to its row: format tag, version, wrapping
+/// kid, and the data nonce the DEK was sealed for.
+fn wrap_aad(kid: &[u8], data_nonce: &[u8; NONCE_LEN]) -> Vec<u8> {
+  let mut aad = Vec::with_capacity(DATA_AAD.len() + 1 + kid.len() + NONCE_LEN);
+  aad.extend_from_slice(DATA_AAD);
+  aad.push(kid.len() as u8);
+  aad.extend_from_slice(kid);
+  aad.extend_from_slice(data_nonce);
+  aad
+}
+
+/// Unwrap a v3 DEK under `kek`, returning `None` on any parse or AEAD failure
+/// (the caller treats that as a malformed row).
+fn unwrap_dek(kek: &[u8], nonce: &[u8], aad: &[u8], wrapped: &[u8]) -> Option<Vec<u8>> {
+  let nonce = to_nonce(nonce).ok()?;
+  let mut in_out = wrapped.to_vec();
+  let opening = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, kek).ok()?);
+  let plaintext = opening
+    .open_in_place(
+      Nonce::assume_unique_for_key(nonce),
+      Aad::from(aad),
+      &mut in_out,
+    )
+    .ok()?;
+  Some(plaintext.to_vec())
+}
+
+/// Seal `in_out` in place under `key`, appending the GCM tag.
+///
+/// # Errors
+/// Returns [`CliError::Crypto`] when the AEAD rejects the key or the seal.
+fn seal_with_aad(
+  key: &[u8],
+  nonce: [u8; NONCE_LEN],
+  aad: &[u8],
+  in_out: &mut Vec<u8>,
+) -> Result<(), CliError> {
+  let sealing = LessSafeKey::new(
+    UnboundKey::new(&AES_256_GCM, key).map_err(|_| crypto_error("TOTP key rejected by AEAD"))?,
+  );
+  sealing
+    .seal_in_place_append_tag(Nonce::assume_unique_for_key(nonce), Aad::from(aad), in_out)
+    .map_err(|_| crypto_error("TOTP DEK wrap failed"))
+}
+
+/// HKDF-SHA256 (RFC 5869, empty salt) under an explicit domain-separation
+/// label: `totp-kek-v1` wraps v3 DEKs.
+fn derive_labeled(totp_key: &[u8], info: &[u8]) -> Result<[u8; KEY_LEN], ring::error::Unspecified> {
+  let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &[]).extract(totp_key);
+  let info_labels = [info];
+  let okm = prk.expand(&info_labels, HkdfAesKey)?;
+  let mut key = [0u8; KEY_LEN];
+  okm.fill(&mut key)?;
+  Ok(key)
+}
+
+/// `ring::hkdf` output-length marker for a 256-bit AES key.
+struct HkdfAesKey;
+
+impl hkdf::KeyType for HkdfAesKey {
+  fn len(&self) -> usize {
+    KEY_LEN
+  }
+}
+
+/// Convert a byte slice to a fixed 12-byte nonce.
+///
+/// # Errors
+/// Returns [`CliError::Crypto`] when the slice is not exactly [`NONCE_LEN`].
+fn to_nonce(bytes: &[u8]) -> Result<[u8; NONCE_LEN], CliError> {
+  bytes
+    .try_into()
+    .map_err(|_| crypto_error("malformed TOTP blob"))
+}
+
+/// Build a payload-free CLI crypto error (never carries key or blob material).
+fn crypto_error(message: &str) -> CliError {
+  CliError::Crypto(message.to_owned())
+}
+
+#[cfg(test)]
+mod tests;

+ 178 - 0
src/cli/totp/tests.rs

@@ -0,0 +1,178 @@
+//! `test(totp_rewrap_)` suite: DEK re-wrap onto the active TOTP key.
+
+use secrecy::SecretString;
+use sqlx::PgPool;
+use uuid::Uuid;
+
+use super::*;
+use crate::cli::test_support::TestError;
+use crate::crypto::totp as crypto_totp;
+use crate::keys::{KeyId, KeyRing};
+
+const OLD_ID: &str = "old";
+const NEW_ID: &str = "new";
+const OLD_MATERIAL: &str = "old-totp-material-0123456789abcdef";
+const NEW_MATERIAL: &str = "new-totp-material-0123456789abcdef";
+
+/// A parsed v3 body, borrowing the blob.
+struct Parsed<'a> {
+  kid: &'a str,
+  wrap_nonce: &'a [u8],
+  wrapped_dek: &'a [u8],
+  data_nonce: &'a [u8],
+  data_ct: &'a [u8],
+}
+
+fn key(id: &str, material: &str) -> (KeyId, SecretString) {
+  (KeyId::new(id), SecretString::from(material.to_owned()))
+}
+
+/// A ring whose TOTP chain is `totp` (newest first), with throwaway pepper/PII
+/// chains (untouched by re-wrap).
+fn ring_with_totp(totp: &[(&str, &str)]) -> Result<KeyRing, TestError> {
+  let totp_keys = totp
+    .iter()
+    .map(|(id, material)| key(id, material))
+    .collect();
+  KeyRing::from_parts(
+    vec![key("p1", "pepper-material-0123456789abcdef")],
+    totp_keys,
+    vec![key("k1", "pii-material-0123456789abcdef")],
+  )
+  .map_err(|error| TestError::Harness(error.to_string()))
+}
+
+async fn insert_user(pool: &PgPool, id: Uuid, blob: &[u8]) -> Result<(), TestError> {
+  sqlx::query(
+    "INSERT INTO users (id, email, password_hash, totp_secret_enc, totp_enabled, created_at, updated_at)
+     VALUES ($1, NULL, 'x', $2, true, now(), now())",
+  )
+  .bind(id)
+  .bind(blob)
+  .execute(pool)
+  .await?;
+  Ok(())
+}
+
+async fn stored_blob(pool: &PgPool, id: Uuid) -> Result<Vec<u8>, TestError> {
+  let blob: Option<Vec<u8>> = sqlx::query_scalar("SELECT totp_secret_enc FROM users WHERE id = $1")
+    .bind(id)
+    .fetch_one(pool)
+    .await?;
+  blob.ok_or(TestError::Missing("totp_secret_enc"))
+}
+
+fn parse(blob: &[u8]) -> Result<Parsed<'_>, TestError> {
+  let kid_len = usize::from(*blob.get(1).ok_or(TestError::Missing("kid_len"))?);
+  let header = 2 + kid_len;
+  let kid = std::str::from_utf8(blob.get(2..header).ok_or(TestError::Missing("kid"))?)
+    .map_err(|_| TestError::Missing("kid utf8"))?;
+  let body = blob.get(header..).ok_or(TestError::Missing("body"))?;
+  let wrap_nonce = body
+    .get(..NONCE_LEN)
+    .ok_or(TestError::Missing("wrap_nonce"))?;
+  let wrapped_dek = body
+    .get(NONCE_LEN..NONCE_LEN + WRAPPED_DEK_LEN)
+    .ok_or(TestError::Missing("wrapped_dek"))?;
+  let data_start = NONCE_LEN + WRAPPED_DEK_LEN;
+  let data_nonce = body
+    .get(data_start..data_start + NONCE_LEN)
+    .ok_or(TestError::Missing("data_nonce"))?;
+  let data_ct = body
+    .get(data_start + NONCE_LEN..)
+    .ok_or(TestError::Missing("data_ct"))?;
+  Ok(Parsed {
+    kid,
+    wrap_nonce,
+    wrapped_dek,
+    data_nonce,
+    data_ct,
+  })
+}
+
+#[tokio::test]
+async fn totp_rewrap_moves_v3_rows_to_active_key() -> Result<(), TestError> {
+  let (_db, pool) = crate::db::test_support::migrated_pool()
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+
+  let old = ring_with_totp(&[(OLD_ID, OLD_MATERIAL)])?;
+  let full = ring_with_totp(&[(NEW_ID, NEW_MATERIAL), (OLD_ID, OLD_MATERIAL)])?;
+  let new_only = ring_with_totp(&[(NEW_ID, NEW_MATERIAL)])?;
+
+  let plaintext = crate::cli::secret::generate_totp_secret()?;
+  let v3 = crypto_totp::encrypt_secret(&old, &plaintext, true)?;
+  let v2 = crypto_totp::encrypt_secret(&old, &plaintext, false)?;
+  // A v3 header that names a key but carries no body: malformed, must be skipped.
+  let malformed = vec![3u8, 3, b'o', b'l', b'd'];
+
+  let v3_id = Uuid::new_v4();
+  let v2_id = Uuid::new_v4();
+  let bad_id = Uuid::new_v4();
+  insert_user(&pool, v3_id, &v3).await?;
+  insert_user(&pool, v2_id, &v2).await?;
+  insert_user(&pool, bad_id, &malformed).await?;
+
+  let report = rewrap_with(&pool, &full).await?;
+  assert_eq!(report.scanned, 3);
+  assert_eq!(report.rewrapped, 1);
+  assert_eq!(report.legacy, 1);
+  assert_eq!(report.malformed, 1);
+  assert_eq!(report.already_active, 0);
+
+  let after = stored_blob(&pool, v3_id).await?;
+  let before = parse(&v3)?;
+  let now = parse(&after)?;
+  assert_eq!(now.kid, NEW_ID, "row must now name the active key");
+  assert_eq!(
+    now.data_nonce, before.data_nonce,
+    "data nonce must be preserved byte-for-byte"
+  );
+  assert_eq!(
+    now.data_ct, before.data_ct,
+    "secret ciphertext+tag must be preserved byte-for-byte"
+  );
+  assert_ne!(
+    now.wrap_nonce, before.wrap_nonce,
+    "wrap nonce must be fresh"
+  );
+  assert_ne!(
+    now.wrapped_dek, before.wrapped_dek,
+    "wrapped DEK must be re-sealed under the active key"
+  );
+  assert_eq!(crypto_totp::decrypt_secret(&new_only, &after)?, plaintext);
+
+  // v1/v2 and malformed blobs are skipped and never overwritten.
+  assert_eq!(stored_blob(&pool, v2_id).await?, v2);
+  assert_eq!(stored_blob(&pool, bad_id).await?, malformed);
+  Ok(())
+}
+
+#[tokio::test]
+async fn totp_rewrap_is_idempotent() -> Result<(), TestError> {
+  let (_db, pool) = crate::db::test_support::migrated_pool()
+    .await
+    .map_err(|error| TestError::Harness(error.to_string()))?;
+
+  let old = ring_with_totp(&[(OLD_ID, OLD_MATERIAL)])?;
+  let full = ring_with_totp(&[(NEW_ID, NEW_MATERIAL), (OLD_ID, OLD_MATERIAL)])?;
+
+  let plaintext = crate::cli::secret::generate_totp_secret()?;
+  let v3 = crypto_totp::encrypt_secret(&old, &plaintext, true)?;
+  let id = Uuid::new_v4();
+  insert_user(&pool, id, &v3).await?;
+
+  let first = rewrap_with(&pool, &full).await?;
+  assert_eq!(first.rewrapped, 1);
+  let after_first = stored_blob(&pool, id).await?;
+
+  let second = rewrap_with(&pool, &full).await?;
+  assert_eq!(second.rewrapped, 0, "a re-run must rewrite nothing");
+  assert_eq!(second.already_active, 1);
+  assert_eq!(
+    stored_blob(&pool, id).await?,
+    after_first,
+    "a re-run must not touch an already-active row"
+  );
+  Ok(())
+}

+ 53 - 45
src/cli/user.rs

@@ -6,6 +6,7 @@ use super::{
   context::{CliError, Context, audit, require_nonempty},
   secret,
 };
+use crate::{db::user_repo, error::AppError};
 
 /// The one-time TOTP enrollment material printed to the operator.
 pub struct Enrollment {
@@ -32,31 +33,24 @@ pub async fn create(
   let email = normalized_email(email)?;
   require_nonempty(password, "password")?;
   let password_hash = secret::hash_password(context.config(), password)?;
-  let id = Uuid::new_v4();
 
-  let result = sqlx::query(
-    "INSERT INTO users (
-       id, email, password_hash, is_admin, must_change_password, created_at, updated_at
-     ) VALUES ($1, $2::citext, $3, $4, true, now(), now())",
+  let record = user_repo::create_user(
+    context.pool(),
+    &context.config().keys,
+    context.config().pii_dual_write,
+    &user_repo::NewUser {
+      email: email.clone(),
+      password_hash,
+      is_admin: admin,
+      must_change_password: true,
+    },
   )
-  .bind(id)
-  .bind(&email)
-  .bind(&password_hash)
-  .bind(admin)
-  .execute(context.pool())
-  .await;
-
-  match result {
-    Ok(_) => {
-      audit("user.create", &email);
-      println!("created user {email} (id {id})");
-      Ok(())
-    }
-    Err(sqlx::Error::Database(db)) if db.code().as_deref() == Some("23505") => {
-      Err(CliError::AlreadyExists(format!("user {email}")))
-    }
-    Err(error) => Err(error.into()),
-  }
+  .await
+  .map_err(map_user_repo_error)?;
+
+  audit("user.create", &email);
+  println!("created user {email} (id {})", record.id);
+  Ok(())
 }
 
 /// Enroll TOTP for a user, returning the one-time secret to print.
@@ -67,17 +61,18 @@ pub async fn enroll_totp(context: &Context, email: &str) -> Result<Enrollment, C
   let email = normalized_email(email)?;
   let id = find_user_id(context, &email).await?;
   let totp_secret = secret::generate_totp_secret()?;
-  let blob = secret::encrypt_totp(context.config(), &totp_secret)?;
-
-  sqlx::query(
-    "UPDATE users
-        SET totp_secret_enc = $2, totp_enabled = true, updated_at = now()
-      WHERE id = $1",
-  )
-  .bind(id)
-  .bind(&blob)
-  .execute(context.pool())
-  .await?;
+  let blob = secret::encrypt_totp(
+    context.config(),
+    &totp_secret,
+    !context.config().pii_dual_write,
+  )?;
+
+  let updated = user_repo::set_totp_secret(context.pool(), id, &blob)
+    .await
+    .map_err(map_user_repo_error)?;
+  if !updated {
+    return Err(CliError::NotFound("user not found".to_owned()));
+  }
 
   audit("user.enroll_totp", &email);
   Ok(Enrollment {
@@ -93,10 +88,12 @@ pub async fn enroll_totp(context: &Context, email: &str) -> Result<Enrollment, C
 pub async fn disable(context: &Context, email: &str) -> Result<(), CliError> {
   let email = normalized_email(email)?;
   let id = find_user_id(context, &email).await?;
-  sqlx::query("UPDATE users SET disabled = true, updated_at = now() WHERE id = $1")
-    .bind(id)
-    .execute(context.pool())
-    .await?;
+  let updated = user_repo::disable_user(context.pool(), id)
+    .await
+    .map_err(map_user_repo_error)?;
+  if !updated {
+    return Err(CliError::NotFound("user not found".to_owned()));
+  }
   audit("user.disable", &email);
   println!("disabled user {email}");
   Ok(())
@@ -161,16 +158,27 @@ pub async fn list_scopes(context: &Context, email: &str) -> Result<Vec<String>,
   Ok(scopes)
 }
 
-/// Resolve a user id by case-insensitive email.
+/// Resolve a user id by email through the repository's encrypted lookup
+/// (blind index with a legacy plaintext fallback for rows with no ciphertext).
 ///
 /// # Errors
-/// Returns [`CliError::NotFound`] when no such user exists.
+/// Returns [`CliError::NotFound`] when no such user exists. The message is
+/// payload-free: it never echoes the submitted email.
 pub async fn find_user_id(context: &Context, email: &str) -> Result<Uuid, CliError> {
-  let id: Option<Uuid> = sqlx::query_scalar("SELECT id FROM users WHERE email = $1::citext")
-    .bind(email)
-    .fetch_optional(context.pool())
-    .await?;
-  id.ok_or_else(|| CliError::NotFound(format!("user {email}")))
+  user_repo::find_by_email(context.pool(), &context.config().keys, email)
+    .await
+    .map_err(map_user_repo_error)?
+    .map(|record| record.id)
+    .ok_or_else(|| CliError::NotFound("user not found".to_owned()))
+}
+
+/// Map a repository failure: the typed payload-free duplicate becomes
+/// `AlreadyExists`; anything else converts without carrying PII.
+pub(super) fn map_user_repo_error(error: AppError) -> CliError {
+  match error {
+    AppError::DuplicateEmail => CliError::AlreadyExists("user email already exists".to_owned()),
+    other => CliError::App(other),
+  }
 }
 
 async fn ensure_scope_exists(context: &Context, scope: &str) -> Result<(), CliError> {

+ 8 - 0
src/config.rs

@@ -78,6 +78,9 @@ pub struct Config {
   pub redis: RedisConfig,
   /// Versioned token-pepper and TOTP-encryption keys.
   pub keys: KeyRing,
+  /// Write PII plaintext alongside ciphertext during the rotation window
+  /// (default false: encrypt-only).
+  pub pii_dual_write: bool,
   /// Per-endpoint failure policy.
   pub failure_policy: FailurePolicy,
   /// Token/request lifetimes.
@@ -118,6 +121,7 @@ impl fmt::Debug for Config {
       .field("database", &self.database)
       .field("redis", &self.redis)
       .field("keys", &self.keys)
+      .field("pii_dual_write", &self.pii_dual_write)
       .field("failure_policy", &self.failure_policy)
       .field("ttl", &self.ttl)
       .field("argon2", &self.argon2)
@@ -315,6 +319,10 @@ pub enum ConfigError {
   /// The key ring could not be constructed.
   #[error("key ring error: {0}")]
   KeyRing(#[from] crate::keys::KeyRingError),
+  /// A retained (`*_previous_key`) key was provided without an explicit
+  /// distinct previous key id.
+  #[error("previous key requires an explicit distinct previous key id")]
+  MissingPreviousKeyId,
 }
 
 /// Read an optional secret from a mounted file, trimming trailing newlines.

+ 65 - 6
src/config/build.rs

@@ -9,7 +9,7 @@ use std::{
   str::FromStr,
 };
 
-use secrecy::SecretString;
+use secrecy::{ExposeSecret, SecretString};
 
 use super::{
   Config, ConfigError, TlsConfig,
@@ -21,7 +21,7 @@ use super::{
   scope::{ScopeDefinition, TokenExchangeRule},
   types::{Argon2Params, RateLimitConfig, TtlConfig},
 };
-use crate::keys::{KeyId, KeyRing};
+use crate::keys::{KeyId, KeyRing, KeyRingError};
 
 const DEFAULT_BIND_ADDR: &str = "0.0.0.0:8080";
 const DEFAULT_HEALTH_ADDR: &str = "0.0.0.0:8081";
@@ -76,6 +76,7 @@ impl Config {
       cookie_secure: raw.cookie_secure.unwrap_or(true),
       trusted_proxy,
       allow_open_registration: raw.allow_open_registration,
+      pii_dual_write: raw.pii_dual_write,
       dcr_redirect_policy: raw.dcr_redirect_policy,
       cors_allowed_origins: raw.cors_allowed_origins,
       scope_registry,
@@ -151,15 +152,73 @@ fn build_key_ring(raw: &RawConfig) -> Result<KeyRing, ConfigError> {
     raw.totp_encryption_key.as_deref(),
     raw.totp_encryption_key_file.as_deref(),
   )?;
+  let pii = non_blank(resolve_secret(
+    raw.pii_encryption_key.as_deref(),
+    raw.pii_encryption_key_file.as_deref(),
+  )?);
+  let totp_previous = non_blank(resolve_secret(
+    raw.totp_previous_key.as_deref(),
+    raw.totp_previous_key_file.as_deref(),
+  )?);
+  let pii_previous = non_blank(resolve_secret(
+    raw.pii_previous_key.as_deref(),
+    raw.pii_previous_key_file.as_deref(),
+  )?);
   let pepper_id = key_id_or_default(raw.token_pepper_id.as_deref());
   let totp_id = key_id_or_default(raw.totp_key_id.as_deref());
+  let pii_id = key_id_or_default(raw.pii_key_id.as_deref());
   let peppers = pepper
     .map(|secret| vec![(pepper_id, secret)])
     .unwrap_or_default();
-  let totp_keys = totp
-    .map(|secret| vec![(totp_id, secret)])
-    .unwrap_or_default();
-  KeyRing::from_parts(peppers, totp_keys).map_err(ConfigError::from)
+  let totp_keys = active_first_chain(
+    totp.map(|secret| (totp_id, secret)),
+    raw.totp_previous_key_id.as_deref(),
+    totp_previous,
+    KeyRingError::MissingTotpKey,
+  )?;
+  let pii_keys = active_first_chain(
+    pii.map(|secret| (pii_id, secret)),
+    raw.pii_previous_key_id.as_deref(),
+    pii_previous,
+    KeyRingError::MissingPiiKey,
+  )?;
+  KeyRing::from_parts(peppers, totp_keys, pii_keys).map_err(ConfigError::from)
+}
+
+/// Build an active-first chain from the active key plus its retained previous
+/// key. A missing active key with no previous key yields an empty chain (the
+/// caller maps it to `missing_active`); a previous key without its active key
+/// (R2) or without an explicit distinct id (R1) is rejected here so a retained
+/// key can never silently become active.
+fn active_first_chain(
+  active: Option<(KeyId, SecretString)>,
+  previous_id_raw: Option<&str>,
+  previous: Option<SecretString>,
+  missing_active: KeyRingError,
+) -> Result<Vec<(KeyId, SecretString)>, ConfigError> {
+  match (active, previous) {
+    (None, None) => Ok(Vec::new()),
+    (Some(pair), None) => Ok(vec![pair]),
+    (None, Some(_)) => Err(ConfigError::KeyRing(missing_active)),
+    (Some(active_pair), Some(previous_secret)) => {
+      let id_raw = previous_id_raw
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+        .ok_or(ConfigError::MissingPreviousKeyId)?;
+      let id = KeyId::new(id_raw);
+      if id == active_pair.0 {
+        return Err(ConfigError::MissingPreviousKeyId);
+      }
+      Ok(vec![active_pair, (id, previous_secret)])
+    }
+  }
+}
+
+/// Treat empty/whitespace-only resolved material as absent. The inline path in
+/// `resolve_secret` already drops blanks, but a mounted file can be zero-byte
+/// or whitespace-only; such a value must not become a chain element.
+fn non_blank(secret: Option<SecretString>) -> Option<SecretString> {
+  secret.filter(|value| !value.expose_secret().trim().is_empty())
 }
 
 fn resolve_secret(

+ 10 - 0
src/config/raw.rs

@@ -41,6 +41,16 @@ pub(crate) struct RawConfig {
   pub totp_encryption_key: Option<String>,
   pub totp_encryption_key_file: Option<PathBuf>,
   pub totp_key_id: Option<String>,
+  pub totp_previous_key: Option<String>,
+  pub totp_previous_key_file: Option<PathBuf>,
+  pub totp_previous_key_id: Option<String>,
+  pub pii_encryption_key: Option<String>,
+  pub pii_encryption_key_file: Option<PathBuf>,
+  pub pii_key_id: Option<String>,
+  pub pii_previous_key: Option<String>,
+  pub pii_previous_key_file: Option<PathBuf>,
+  pub pii_previous_key_id: Option<String>,
+  pub pii_dual_write: bool,
   pub failure_policy: RawFailurePolicy,
   pub ttl: RawTtlConfig,
   pub argon2: RawArgon2,

+ 185 - 0
src/config/tests.rs

@@ -32,6 +32,8 @@ fn base() -> Map<String, Value> {
     "token_pepper_id": "v1",
     "totp_encryption_key": "SUPER_SECRET_TOTP_KEY",
     "totp_key_id": "v1",
+    "pii_encryption_key": "SUPER_SECRET_PII_KEY",
+    "pii_key_id": "v1",
     "cookie_secure": true,
     "allow_open_registration": false,
     "dcr_redirect_policy": "catalog",
@@ -249,6 +251,7 @@ fn config_debug_redacts_secret_values() -> Result<(), Box<dyn Error>> {
   let rendered = format!("{cfg:?}");
   assert!(!rendered.contains("SUPER_SECRET_PEPPER_VALUE"));
   assert!(!rendered.contains("SUPER_SECRET_TOTP_KEY"));
+  assert!(!rendered.contains("SUPER_SECRET_PII_KEY"));
   assert!(!rendered.contains("DB_PASSWORD_XYZ"));
   assert!(rendered.contains("REDACTED"));
   Ok(())
@@ -288,6 +291,188 @@ fn config_env_double_underscore_maps_to_nested_snake_keys() -> Result<(), Box<dy
   Ok(())
 }
 
+#[test]
+fn config_rejects_missing_pii_key() {
+  let result = try_load(&without("pii_encryption_key"));
+  assert!(matches!(
+    result,
+    Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
+  ));
+}
+
+#[test]
+fn config_rejects_empty_active_pii_key() {
+  let mut map = base();
+  map.insert(
+    "pii_encryption_key".to_owned(),
+    Value::String("   ".to_owned()),
+  );
+  let result = try_load(&map);
+  assert!(matches!(
+    result,
+    Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
+  ));
+
+  let path = std::env::temp_dir().join(format!("d-oauth-empty-pii-key-{}.txt", std::process::id()));
+  if std::fs::write(&path, "").is_ok() {
+    let mut file_map = base();
+    file_map.remove("pii_encryption_key");
+    file_map.insert(
+      "pii_encryption_key_file".to_owned(),
+      Value::String(path.to_string_lossy().into_owned()),
+    );
+    let result = try_load(&file_map);
+    let _ = std::fs::remove_file(&path);
+    assert!(matches!(
+      result,
+      Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
+    ));
+  }
+}
+
+#[test]
+fn config_builds_pii_chain() -> Result<(), Box<dyn Error>> {
+  let mut map = base();
+  map.insert(
+    "pii_previous_key".to_owned(),
+    Value::String("SUPER_SECRET_PII_PREVIOUS".to_owned()),
+  );
+  map.insert(
+    "pii_previous_key_id".to_owned(),
+    Value::String("v0".to_owned()),
+  );
+  map.insert("pii_dual_write".to_owned(), Value::Bool(true));
+  let cfg = try_load(&map)?;
+  let active = cfg.keys.active_pii_key().ok_or("missing active pii key")?;
+  assert_eq!(active.0.as_str(), "v1");
+  let retained = cfg
+    .keys
+    .pii_key_by_id("v0")
+    .ok_or("missing retained pii key")?;
+  assert!(!cfg.keys.pii_key_by_id("v9").is_some());
+  let _ = retained;
+  let ids: Vec<&str> = cfg
+    .keys
+    .pii_keys_newest_first()
+    .map(|(id, _)| id.as_str())
+    .collect();
+  assert_eq!(ids, vec!["v1", "v0"]);
+  assert!(cfg.pii_dual_write);
+
+  let dual_default = try_load(&base())?;
+  assert!(!dual_default.pii_dual_write);
+  Ok(())
+}
+
+#[test]
+fn config_rejects_previous_key_without_id() {
+  let mut map = base();
+  map.insert(
+    "pii_previous_key".to_owned(),
+    Value::String("SUPER_SECRET_PII_PREVIOUS".to_owned()),
+  );
+  let result = try_load(&map);
+  assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
+
+  let mut same_id = base();
+  same_id.insert(
+    "pii_previous_key".to_owned(),
+    Value::String("SUPER_SECRET_PII_PREVIOUS".to_owned()),
+  );
+  same_id.insert(
+    "pii_previous_key_id".to_owned(),
+    Value::String("v1".to_owned()),
+  );
+  let result = try_load(&same_id);
+  assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
+
+  let mut totp = base();
+  totp.insert(
+    "totp_previous_key".to_owned(),
+    Value::String("SUPER_SECRET_TOTP_PREVIOUS".to_owned()),
+  );
+  let result = try_load(&totp);
+  assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
+}
+
+#[test]
+fn config_rejects_previous_key_without_active() {
+  let mut pii = without("pii_encryption_key");
+  pii.insert(
+    "pii_previous_key".to_owned(),
+    Value::String("SUPER_SECRET_PII_PREVIOUS".to_owned()),
+  );
+  pii.insert(
+    "pii_previous_key_id".to_owned(),
+    Value::String("v0".to_owned()),
+  );
+  let result = try_load(&pii);
+  assert!(matches!(
+    result,
+    Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
+  ));
+
+  let mut totp = without("totp_encryption_key");
+  totp.insert(
+    "totp_previous_key".to_owned(),
+    Value::String("SUPER_SECRET_TOTP_PREVIOUS".to_owned()),
+  );
+  totp.insert(
+    "totp_previous_key_id".to_owned(),
+    Value::String("v0".to_owned()),
+  );
+  let result = try_load(&totp);
+  assert!(matches!(
+    result,
+    Err(ConfigError::KeyRing(KeyRingError::MissingTotpKey))
+  ));
+}
+
+#[test]
+fn config_treats_empty_previous_key_as_absent() -> Result<(), Box<dyn Error>> {
+  let mut map = base();
+  map.insert(
+    "pii_previous_key".to_owned(),
+    Value::String("   ".to_owned()),
+  );
+  map.insert(
+    "pii_previous_key_id".to_owned(),
+    Value::String("v0".to_owned()),
+  );
+  let cfg = try_load(&map)?;
+  let ids: Vec<&str> = cfg
+    .keys
+    .pii_keys_newest_first()
+    .map(|(id, _)| id.as_str())
+    .collect();
+  assert_eq!(ids, vec!["v1"]);
+
+  let path = std::env::temp_dir().join(format!(
+    "d-oauth-empty-pii-previous-{}.txt",
+    std::process::id()
+  ));
+  if std::fs::write(&path, "").is_ok() {
+    let mut file_map = base();
+    file_map.insert(
+      "pii_previous_key_file".to_owned(),
+      Value::String(path.to_string_lossy().into_owned()),
+    );
+    file_map.insert(
+      "pii_previous_key_id".to_owned(),
+      Value::String("v0".to_owned()),
+    );
+    let cfg = try_load(&file_map)?;
+    let _ = std::fs::remove_file(&path);
+    let ids: Vec<&str> = cfg
+      .keys
+      .pii_keys_newest_first()
+      .map(|(id, _)| id.as_str())
+      .collect();
+    assert_eq!(ids, vec!["v1"]);
+  }
+  Ok(())
+}
+
 #[test]
 fn read_secret_file_preserves_surrounding_spaces() -> Result<(), Box<dyn Error>> {
   use secrecy::ExposeSecret as _;

+ 470 - 0
src/crypto/field.rs

@@ -0,0 +1,470 @@
+//! Versioned AES-256-GCM codec for PII field values at rest.
+//!
+//! Fields (emails, client names, redirect URIs, mTLS identities) are sealed
+//! into an opaque blob and only the ciphertext is stored. The AEAD key is
+//! derived per key-ring generation from the PII-encryption key via HKDF-SHA256
+//! (RFC 5869, empty salt, info `pii-field-gcm-v1`), so the field codec is
+//! domain-separated from the TOTP-encryption use of its own key chain.
+//!
+//! Blob layout:
+//!
+//! ```text
+//! version(1)=1 || kid_len(1) || kid || nonce(12) || AES-256-GCM ct+tag
+//! ```
+//!
+//! The reader resolves the named `kid` through [`KeyRing::pii_key_by_id`], so a
+//! retained key keeps opening values written before a rotation. Every failure
+//! (unknown kid, malformed blob, tampered ciphertext, bad tag) collapses to the
+//! same opaque [`FieldError::Decrypt`]: the reason is never distinguished and no
+//! plaintext or key material ever reaches a log or an error message.
+//!
+//! Transient derived key bytes are wrapped in [`zeroize::Zeroizing`] and wiped
+//! when they leave scope.
+//!
+//! The same PII key chain feeds a keyed **blind index** for equality lookups.
+//! [`blind_index_active`] and [`blind_index_candidates`] derive an HMAC-SHA256
+//! key under a distinct HKDF info label (`pii-blind-index-v1`) and format each
+//! entry as `"{kid}:{lowercase_hex_digest}"`. The index never exposes the
+//! plaintext, and its HMAC key is cryptographically independent of the
+//! field-encryption key.
+
+use ring::{
+  aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey},
+  hkdf, hmac,
+  rand::{SecureRandom as _, SystemRandom},
+};
+use secrecy::ExposeSecret as _;
+use zeroize::Zeroizing;
+
+use crate::keys::KeyRing;
+
+/// Current field-blob format version.
+const FIELD_BLOB_VERSION: u8 = 1;
+/// HKDF domain-separation label for the PII field-encryption key.
+const HKDF_INFO: &[u8] = b"pii-field-gcm-v1";
+/// HKDF domain-separation label for the PII blind-index HMAC key.
+const BLIND_INDEX_INFO: &[u8] = b"pii-blind-index-v1";
+/// AES-GCM nonce length in bytes.
+const NONCE_LEN: usize = 12;
+/// Maximum key-id length encodable by the one-byte length prefix.
+const MAX_KID_LEN: usize = u8::MAX as usize;
+
+/// Failures sealing or opening a PII field.
+///
+/// Every variant carries a static, payload-free message: no plaintext, token,
+/// or key material can appear in a log, a metric, or a `Debug` rendering.
+#[derive(Debug, thiserror::Error)]
+pub enum FieldError {
+  /// No PII key is configured, or the AEAD rejected the input.
+  #[error("PII field encryption failed")]
+  Encrypt,
+  /// The blob is malformed, names an unknown key, or fails authentication.
+  #[error("PII field cannot be decrypted")]
+  Decrypt,
+  /// The operating-system random number generator failed.
+  #[error("operating-system randomness unavailable")]
+  UnavailableRandomness,
+}
+
+/// Seal a UTF-8 string under the active PII key.
+///
+/// # Errors
+///
+/// Returns [`FieldError::Encrypt`] when no PII key is configured or the AEAD
+/// rejects the input, or [`FieldError::UnavailableRandomness`] when the
+/// operating-system RNG fails.
+pub fn seal_text(keys: &KeyRing, plaintext: &str) -> Result<Vec<u8>, FieldError> {
+  seal_bytes(keys, plaintext.as_bytes())
+}
+
+/// Seal a JSON-serializable value under the active PII key.
+///
+/// # Errors
+///
+/// Returns [`FieldError::Encrypt`] when the value cannot be serialized, no PII
+/// key is configured, or the AEAD rejects the input; see also
+/// [`FieldError::UnavailableRandomness`].
+pub fn seal_json<T: serde::Serialize>(keys: &KeyRing, value: &T) -> Result<Vec<u8>, FieldError> {
+  let encoded = serde_json::to_vec(value).map_err(|_| FieldError::Encrypt)?;
+  seal_bytes(keys, &encoded)
+}
+
+/// Seal raw bytes under the active PII key.
+///
+/// Blob layout: `version(1)=1 || kid_len(1) || kid || nonce(12) || ct+tag`.
+///
+/// # Errors
+///
+/// Returns [`FieldError::Encrypt`] when no PII key is configured or the AEAD
+/// rejects the input, or [`FieldError::UnavailableRandomness`] when the
+/// operating-system RNG fails.
+pub fn seal_bytes(keys: &KeyRing, plaintext: &[u8]) -> Result<Vec<u8>, FieldError> {
+  let (key_id, secret) = keys.active_pii_key().ok_or(FieldError::Encrypt)?;
+  let kid = key_id.as_str().as_bytes();
+  if kid.is_empty() || kid.len() > MAX_KID_LEN {
+    return Err(FieldError::Encrypt);
+  }
+  let key =
+    Zeroizing::new(derive_key(secret.expose_secret().as_bytes()).map_err(|_| FieldError::Encrypt)?);
+  let mut nonce_bytes = [0u8; NONCE_LEN];
+  SystemRandom::new()
+    .fill(&mut nonce_bytes)
+    .map_err(|_| FieldError::UnavailableRandomness)?;
+  let sealing =
+    LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &key[..]).map_err(|_| FieldError::Encrypt)?);
+  let mut in_out = plaintext.to_vec();
+  sealing
+    .seal_in_place_append_tag(
+      Nonce::assume_unique_for_key(nonce_bytes),
+      Aad::empty(),
+      &mut in_out,
+    )
+    .map_err(|_| FieldError::Encrypt)?;
+  let mut blob = Vec::with_capacity(2 + kid.len() + NONCE_LEN + in_out.len());
+  blob.push(FIELD_BLOB_VERSION);
+  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)
+}
+
+/// Open a sealed UTF-8 string, resolving the key named in the blob.
+///
+/// # Errors
+///
+/// Returns [`FieldError::Decrypt`] for a malformed blob, an unknown key id, a
+/// non-UTF-8 plaintext, or an authentication failure. The reason is never
+/// distinguished.
+pub fn open_text(keys: &KeyRing, blob: &[u8]) -> Result<String, FieldError> {
+  String::from_utf8(open_bytes(keys, blob)?).map_err(|_| FieldError::Decrypt)
+}
+
+/// Open a sealed JSON value, resolving the key named in the blob.
+///
+/// # Errors
+///
+/// Returns [`FieldError::Decrypt`] for a malformed blob, an unknown key id, an
+/// authentication failure, or a value that is not valid JSON for `T`. The
+/// reason is never distinguished.
+pub fn open_json<T: serde::de::DeserializeOwned>(
+  keys: &KeyRing,
+  blob: &[u8],
+) -> Result<T, FieldError> {
+  serde_json::from_slice(&open_bytes(keys, blob)?).map_err(|_| FieldError::Decrypt)
+}
+
+/// Open sealed raw bytes, resolving the key named in the blob.
+///
+/// # Errors
+///
+/// Returns [`FieldError::Decrypt`] for a malformed blob, an unknown key id, or
+/// an authentication failure. The reason is never distinguished.
+pub fn open_bytes(keys: &KeyRing, blob: &[u8]) -> Result<Vec<u8>, FieldError> {
+  if blob.len() < 2 + NONCE_LEN || blob[0] != FIELD_BLOB_VERSION {
+    return Err(FieldError::Decrypt);
+  }
+  let kid_len = usize::from(blob[1]);
+  let header = 2 + kid_len;
+  if blob.len() < header + NONCE_LEN {
+    return Err(FieldError::Decrypt);
+  }
+  let kid = core::str::from_utf8(blob.get(2..header).unwrap_or_default())
+    .map_err(|_| FieldError::Decrypt)?;
+  let secret = keys.pii_key_by_id(kid).ok_or(FieldError::Decrypt)?;
+  let key =
+    Zeroizing::new(derive_key(secret.expose_secret().as_bytes()).map_err(|_| FieldError::Decrypt)?);
+  let mut nonce_bytes = [0u8; NONCE_LEN];
+  nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
+  let ciphertext = &blob[header + NONCE_LEN..];
+  let mut in_out = ciphertext.to_vec();
+  let opening =
+    LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &key[..]).map_err(|_| FieldError::Decrypt)?);
+  let plaintext = opening
+    .open_in_place(
+      Nonce::assume_unique_for_key(nonce_bytes),
+      Aad::empty(),
+      &mut in_out,
+    )
+    .map_err(|_| FieldError::Decrypt)?;
+  Ok(plaintext.to_vec())
+}
+
+/// Derive a 256-bit AES key from PII key material via HKDF-SHA256 (RFC 5869,
+/// empty salt, info `pii-field-gcm-v1`).
+fn derive_key(pii_key: &[u8]) -> Result<[u8; 32], ring::error::Unspecified> {
+  let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &[]).extract(pii_key);
+  let okm = prk.expand(&[HKDF_INFO], HkdfAesKey)?;
+  let mut key = [0u8; 32];
+  okm.fill(&mut key)?;
+  Ok(key)
+}
+
+/// `ring::hkdf` output-length marker for a 256-bit AES key.
+struct HkdfAesKey;
+
+impl hkdf::KeyType for HkdfAesKey {
+  fn len(&self) -> usize {
+    32
+  }
+}
+
+/// `ring::hkdf` output-length marker for the 256-bit blind-index HMAC key.
+struct HkdfIndexKey;
+
+impl hkdf::KeyType for HkdfIndexKey {
+  fn len(&self) -> usize {
+    32
+  }
+}
+
+/// Compute the active blind index for a canonicalized value.
+///
+/// The caller MUST pass the canonical form (for example
+/// [`crate::db::user_repo::normalize_email`] for emails, or the exact stored
+/// string for redirect URIs and mTLS identities); this function never
+/// normalizes. The result is `"{kid}:{lowercase_hex_hmac_sha256}"`, where `kid`
+/// is the active PII key id.
+///
+/// # Key generation
+///
+/// The HMAC key is derived from one PII key generation, so a value's index is
+/// stable only within that generation: two logically equal values indexed under
+/// different keys have different stored indexes. Reads must therefore query
+/// [`blind_index_candidates`], which spans every retained generation. The
+/// `pii rotate` command re-indexes every row and runs under a write freeze.
+#[must_use]
+pub fn blind_index_active(keys: &KeyRing, normalized: &str) -> String {
+  // A `KeyRing` is only built with a non-empty PII chain (`from_parts` rejects
+  // an empty one), so the active key always exists.
+  keys
+    .active_pii_key()
+    .map(|(key_id, secret)| {
+      index_value(
+        key_id.as_str(),
+        secret.expose_secret().as_bytes(),
+        normalized,
+      )
+    })
+    .unwrap_or_default()
+}
+
+/// Compute the blind index under every retained PII key, newest-first.
+///
+/// The first entry equals [`blind_index_active`]. Reads match a stored index
+/// against this whole list so a value written before a rotation is still found
+/// while the old key is retained. The list length equals the PII key chain
+/// length.
+#[must_use]
+pub fn blind_index_candidates(keys: &KeyRing, normalized: &str) -> Vec<String> {
+  keys
+    .pii_keys_newest_first()
+    .map(|(key_id, secret)| {
+      index_value(
+        key_id.as_str(),
+        secret.expose_secret().as_bytes(),
+        normalized,
+      )
+    })
+    .collect()
+}
+
+/// Format `"{kid}:{lowercase_hex_hmac_sha256}"` for one PII key generation.
+fn index_value(kid: &str, pii_key: &[u8], normalized: &str) -> String {
+  let key = derive_index_key(pii_key);
+  let mac_key = hmac::Key::new(hmac::HMAC_SHA256, &key[..]);
+  let tag = hmac::sign(&mac_key, normalized.as_bytes());
+  format!("{kid}:{}", hex_lower(tag.as_ref()))
+}
+
+/// Derive the 256-bit blind-index HMAC key via HKDF-SHA256 (RFC 5869, empty
+/// salt, info `pii-blind-index-v1`).
+///
+/// A 32-byte expand/fill for SHA-256 cannot fail; a failure leaves the key
+/// zeroed rather than panicking in a request path. The returned bytes are wiped
+/// on drop.
+fn derive_index_key(pii_key: &[u8]) -> Zeroizing<[u8; 32]> {
+  let mut key = Zeroizing::new([0u8; 32]);
+  let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, &[]).extract(pii_key);
+  if let Ok(okm) = prk.expand(&[BLIND_INDEX_INFO], HkdfIndexKey) {
+    let _ = okm.fill(&mut key[..]);
+  }
+  key
+}
+
+/// Lowercase hexadecimal encoding.
+fn hex_lower(bytes: &[u8]) -> String {
+  const HEX: &[u8; 16] = b"0123456789abcdef";
+  let mut out = String::with_capacity(bytes.len() * 2);
+  for byte in bytes {
+    out.push(char::from(HEX[usize::from(byte >> 4)]));
+    out.push(char::from(HEX[usize::from(byte & 0x0f)]));
+  }
+  out
+}
+
+#[cfg(test)]
+mod tests {
+  use secrecy::SecretString;
+
+  use super::{
+    FieldError, blind_index_active, blind_index_candidates, open_bytes, open_json, open_text,
+    seal_bytes, seal_json, seal_text,
+  };
+  use crate::keys::{KeyId, KeyRing};
+
+  /// Test key ring with one pepper, one TOTP key, and one PII key generation.
+  fn test_ring() -> Result<KeyRing, FieldError> {
+    KeyRing::from_parts(
+      vec![(
+        KeyId::new("v1"),
+        SecretString::from("test-pepper-0123456789abcdef"),
+      )],
+      vec![(
+        KeyId::new("v1"),
+        SecretString::from("test-totp-key-0123456789abcdef"),
+      )],
+      vec![(
+        KeyId::new("v1"),
+        SecretString::from("test-pii-key-0123456789abcdef"),
+      )],
+    )
+    .map_err(|_| FieldError::Encrypt)
+  }
+
+  fn ring_with_pii(pii: Vec<(&str, &str)>) -> Result<KeyRing, FieldError> {
+    let pii_keys = pii
+      .into_iter()
+      .map(|(id, value)| (KeyId::new(id), SecretString::from(value)))
+      .collect();
+    KeyRing::from_parts(
+      vec![(
+        KeyId::new("v1"),
+        SecretString::from("test-pepper-0123456789abcdef"),
+      )],
+      vec![(
+        KeyId::new("v1"),
+        SecretString::from("test-totp-key-0123456789abcdef"),
+      )],
+      pii_keys,
+    )
+    .map_err(|_| FieldError::Encrypt)
+  }
+
+  #[test]
+  fn blind_index_is_deterministic() -> Result<(), FieldError> {
+    let keys = test_ring()?;
+    let first = blind_index_active(&keys, "alice@example.com");
+    let second = blind_index_active(&keys, "alice@example.com");
+    assert_eq!(first, second);
+
+    // Format is `"{kid}:{lowercase_hex_hmac_sha256}"`.
+    let (kid, digest) = first.split_once(':').ok_or(FieldError::Encrypt)?;
+    assert_eq!(kid, "v1");
+    assert_eq!(digest.len(), 64);
+    assert!(
+      digest
+        .bytes()
+        .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')),
+      "digest must be lowercase hex: {digest}"
+    );
+
+    assert_ne!(
+      blind_index_active(&keys, "alice@example.com"),
+      blind_index_active(&keys, "bob@example.com")
+    );
+
+    // Two different PII keys produce different indexes for the same input,
+    // even under the same key id: the HMAC key, not just the kid, is bound.
+    let other = ring_with_pii(vec![("v1", "test-pii-key-fedcba9876543210")])?;
+    assert_ne!(
+      blind_index_active(&keys, "alice@example.com"),
+      blind_index_active(&other, "alice@example.com")
+    );
+    Ok(())
+  }
+
+  #[test]
+  fn blind_index_candidates_order_newest_first() -> Result<(), FieldError> {
+    let keys = ring_with_pii(vec![
+      ("v2", "test-pii-key-new-0123456789abcdef"),
+      ("v1", "test-pii-key-old-0123456789abcdef"),
+    ])?;
+    let candidates = blind_index_candidates(&keys, "alice@example.com");
+    assert_eq!(candidates.len(), 2);
+    assert!(candidates[0].starts_with("v2:"));
+    assert!(candidates[1].starts_with("v1:"));
+    assert_ne!(candidates[0], candidates[1]);
+    assert_eq!(
+      Some(blind_index_active(&keys, "alice@example.com")),
+      candidates.first().cloned()
+    );
+    Ok(())
+  }
+
+  #[test]
+  fn field_seal_open_round_trip() -> Result<(), FieldError> {
+    let keys = test_ring()?;
+    let plaintext = "alice@example.com";
+    let sealed = seal_text(&keys, plaintext)?;
+    // Blob layout: version=1, kid_len=2, kid="v1", nonce(12), ct+tag.
+    assert_eq!(sealed[0], 1);
+    assert_eq!(usize::from(sealed[1]), 2);
+    assert_eq!(&sealed[2..4], b"v1");
+    assert!(sealed.len() > 2 + 2 + 12 + 16);
+    // A sealed UTF-8 value must not contain the plaintext substring.
+    assert!(
+      !sealed
+        .windows(plaintext.len())
+        .any(|window| window == plaintext.as_bytes())
+    );
+    assert_eq!(open_text(&keys, &sealed)?, plaintext);
+
+    // A JSON value round-trips through the same blob codec.
+    let value = serde_json::json!({ "email": "alice@example.com", "age": 30 });
+    let sealed_json = seal_json(&keys, &value)?;
+    let restored: serde_json::Value = open_json(&keys, &sealed_json)?;
+    assert_eq!(restored, value);
+
+    // Raw bytes round-trip unchanged.
+    let bytes = [0u8, 255, 1, 2, 3];
+    let sealed_bytes = seal_bytes(&keys, &bytes)?;
+    assert_eq!(open_bytes(&keys, &sealed_bytes)?, bytes);
+    Ok(())
+  }
+
+  #[test]
+  fn field_rejects_tampered_blob() -> Result<(), FieldError> {
+    let keys = test_ring()?;
+    let sealed = seal_text(&keys, "secret@example.com")?;
+    // Flipping one ciphertext byte must fail authentication.
+    let mut tampered = sealed.clone();
+    let last = tampered.len() - 1;
+    tampered[last] ^= 0x01;
+    assert!(matches!(
+      open_text(&keys, &tampered),
+      Err(FieldError::Decrypt)
+    ));
+    // A truncated blob fails closed too, without panicking.
+    assert!(matches!(
+      open_text(&keys, &sealed[..5]),
+      Err(FieldError::Decrypt)
+    ));
+    Ok(())
+  }
+
+  #[test]
+  fn field_rejects_unknown_kid() -> Result<(), FieldError> {
+    let keys = test_ring()?;
+    let mut sealed = seal_text(&keys, "secret@example.com")?;
+    // Overwrite the kid with a same-length id that is not in the ring.
+    assert_eq!(usize::from(sealed[1]), 2);
+    sealed[2] = b'v';
+    sealed[3] = b'9';
+    assert!(matches!(
+      open_text(&keys, &sealed),
+      Err(FieldError::Decrypt)
+    ));
+    Ok(())
+  }
+}

+ 2 - 1
src/crypto/mod.rs

@@ -1,9 +1,10 @@
 //! Crypto core: token minting, keyed hash-at-rest, constant-time helpers.
 //!
-//! Re-exports `tokens`, `pkce`, `secret`, `totp`, and `ct`. All secret
+//! Re-exports `tokens`, `pkce`, `secret`, `totp`, `field`, and `ct`. All secret
 //! comparison goes through `ct`; intermediates are `zeroize`d.
 
 pub mod ct;
+pub mod field;
 pub mod pkce;
 pub mod secret;
 pub mod tokens;

+ 222 - 22
src/crypto/totp.rs

@@ -6,13 +6,18 @@
 //! 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(2) ||
-//! kid_len(1) || kid || nonce(12) || ciphertext+tag`, AES-256-GCM under
-//! 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`.
+//! The encrypted-secret blob layout mirrors `cli::secret`. Fresh writes use
+//! the v3 envelope (`version(3) || kid_len(1) || kid || wrap_nonce(12) ||
+//! wrapped_dek(32+16) || data_nonce(12) || ciphertext+tag`): a random 32-byte
+//! DEK per row, wrapped under HKDF-SHA256(`totp_key`, info `totp-kek-v1`).
+//! The wrap AAD binds the data nonce; the data AAD carries only the format
+//! tag plus version, never the kid, so `totp rewrap` can move a DEK to a new
+//! key without touching the data ciphertext. The writer is version-gated
+//! (`envelope = !pii_dual_write`) so old replicas keep reading v2 during the
+//! rolling upgrade. Version-2 blobs (HKDF `totp-gcm-v2`, empty AAD) and
+//! version-1 blobs (bare `SHA256(totp_key)`) still decrypt. The two
+//! implementations are intentionally small duplicates: `crypto` cannot depend
+//! on `cli`.
 //!
 //! No error variant carries a secret, code, or key: failures collapse to
 //! static messages.
@@ -30,6 +35,7 @@ use ring::{
 use secrecy::ExposeSecret as _;
 use sha2::{Digest as _, Sha256};
 use totp_rs::{Algorithm, Builder, Secret};
+use zeroize::Zeroizing;
 
 use crate::{crypto::ct::ct_eq, keys::KeyRing};
 
@@ -45,12 +51,23 @@ pub const USED_CODE_TTL: Duration = Duration::from_secs(180);
 pub const RECOVERY_CODE_COUNT: usize = 10;
 /// Current encrypted-secret blob format version (HKDF-SHA256 writes).
 const TOTP_BLOB_VERSION: u8 = 2;
+/// Envelope-encrypted blob version (per-row DEK): written when the caller
+/// passes `envelope = true`.
+const TOTP_BLOB_V3: u8 = 3;
 /// 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";
+/// HKDF domain-separation label for the key-encryption key that wraps v3 DEKs.
+const KEK_INFO: &[u8] = b"totp-kek-v1";
+/// AAD sealing the v3 data ciphertext: format tag plus version only. It must
+/// NOT name the wrapping key, so a DEK re-wrap changes the wrap without
+/// touching this ciphertext.
+const DATA_AAD: &[u8] = b"totp-v3\x03";
 /// AES-GCM nonce length.
 const NONCE_LEN: usize = 12;
+/// Wrapped-DEK length: a 32-byte DEK sealed under AES-256-GCM (16-byte tag).
+const WRAPPED_DEK_LEN: usize = 32 + 16;
 
 /// Failures from TOTP enrollment and verification.
 ///
@@ -82,10 +99,23 @@ pub enum TotpError {
 }
 
 impl From<TotpError> for crate::error::AppError {
-  /// Every TOTP failure is an internal, masked failure: the client sees
-  /// `server_error`, never secret material or store detail.
+  /// Fail-closed mapping: a stored ciphertext that fails to open is
+  /// [`AppError::PiiDecrypt`] (503 + `pii.decrypt_failure` at the route
+  /// layer); store and sealing failures are `temporarily_unavailable` (503),
+  /// never a 500 and never fail-open. Only QR rendering — a server-side
+  /// rendering defect, not a decrypt or dependency failure — stays
+  /// `server_error`. Every target is payload-free: no secret, code, key, or
+  /// store detail reaches the client.
   fn from(error: TotpError) -> Self {
-    Self::internal(error)
+    match error {
+      TotpError::Decrypt => Self::PiiDecrypt,
+      TotpError::Store(_)
+      | TotpError::UnexpectedReply
+      | TotpError::Encrypt
+      | TotpError::UnavailableRandomness
+      | TotpError::InvalidSecret => Self::temporarily_unavailable(),
+      TotpError::Qr => Self::server_error(),
+    }
   }
 }
 
@@ -288,14 +318,30 @@ pub async fn verify_and_claim(
 
 /// Encrypt a TOTP secret under the active TOTP-encryption key.
 ///
-/// Blob layout mirrors `cli::secret`: `version(2) || kid_len(1) || kid ||
-/// nonce(12) || ciphertext+tag`.
+/// The writer is version-gated for the rolling upgrade: `envelope = false`
+/// (while `pii_dual_write` is on) writes the legacy v2 blob so old replicas
+/// keep reading; `envelope = true` writes the v3 per-row-DEK envelope.
+/// Callers pass `!config.pii_dual_write`.
 ///
 /// # Errors
 ///
 /// Returns [`TotpError::Encrypt`] when no TOTP key is configured or the AEAD
 /// fails.
-pub fn encrypt_secret(keys: &KeyRing, plaintext: &str) -> Result<Vec<u8>, TotpError> {
+pub fn encrypt_secret(
+  keys: &KeyRing,
+  plaintext: &str,
+  envelope: bool,
+) -> Result<Vec<u8>, TotpError> {
+  if envelope {
+    encrypt_v3(keys, plaintext)
+  } else {
+    encrypt_v2(keys, plaintext)
+  }
+}
+
+/// Legacy v2 write: `version(2) || kid_len(1) || kid || nonce(12) ||
+/// ciphertext+tag`, AES-256-GCM under HKDF-SHA256 with empty AAD.
+fn encrypt_v2(keys: &KeyRing, plaintext: &str) -> Result<Vec<u8>, TotpError> {
   let (key_id, secret) = keys.active_totp_key().ok_or(TotpError::Encrypt)?;
   let kid = key_id.as_str().as_bytes();
   if kid.len() > usize::from(u8::MAX) {
@@ -325,19 +371,107 @@ pub fn encrypt_secret(keys: &KeyRing, plaintext: &str) -> Result<Vec<u8>, TotpEr
   Ok(blob)
 }
 
+/// Envelope write: `version(3) || kid_len(1) || kid || wrap_nonce(12) ||
+/// wrapped_dek(32+16) || data_nonce(12) || secret_ct+tag`.
+///
+/// A random 32-byte DEK is generated per write, wrapped under a KEK derived
+/// via HKDF-SHA256 (info `totp-kek-v1`) from the active TOTP key, and the
+/// secret is sealed under the DEK. The data nonce is generated FIRST because
+/// the wrap AAD binds it: a wrapped DEK lifted from another row names the
+/// wrong nonce and fails to unwrap.
+fn encrypt_v3(keys: &KeyRing, plaintext: &str) -> Result<Vec<u8>, TotpError> {
+  let (key_id, secret) = keys.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 kek = Zeroizing::new(
+    derive_labeled(secret.expose_secret().as_bytes(), KEK_INFO).map_err(|_| TotpError::Encrypt)?,
+  );
+  let rng = SystemRandom::new();
+  let mut data_nonce = [0u8; NONCE_LEN];
+  rng
+    .fill(&mut data_nonce)
+    .map_err(|_| TotpError::UnavailableRandomness)?;
+  let mut wrap_nonce = [0u8; NONCE_LEN];
+  rng
+    .fill(&mut wrap_nonce)
+    .map_err(|_| TotpError::UnavailableRandomness)?;
+  let mut dek = Zeroizing::new([0u8; 32]);
+  rng
+    .fill(&mut dek[..])
+    .map_err(|_| TotpError::UnavailableRandomness)?;
+  let mut wrapped: Vec<u8> = dek.to_vec();
+  seal_with_aad(
+    &kek[..],
+    wrap_nonce,
+    &wrap_aad(kid, &data_nonce),
+    &mut wrapped,
+  )?;
+  let mut data_ct = plaintext.as_bytes().to_vec();
+  seal_with_aad(&dek[..], data_nonce, DATA_AAD, &mut data_ct)?;
+  let mut blob =
+    Vec::with_capacity(2 + kid.len() + NONCE_LEN + WRAPPED_DEK_LEN + NONCE_LEN + data_ct.len());
+  blob.push(TOTP_BLOB_V3);
+  blob.push(kid.len() as u8);
+  blob.extend_from_slice(kid);
+  blob.extend_from_slice(&wrap_nonce);
+  blob.extend_from_slice(&wrapped);
+  blob.extend_from_slice(&data_nonce);
+  blob.extend_from_slice(&data_ct);
+  Ok(blob)
+}
+
+/// Wrap AAD binding a wrapped DEK to its row: format tag, version, wrapping
+/// kid, and the data nonce the DEK was sealed for.
+fn wrap_aad(kid: &[u8], data_nonce: &[u8; NONCE_LEN]) -> Vec<u8> {
+  let mut aad = Vec::with_capacity(DATA_AAD.len() + 1 + kid.len() + NONCE_LEN);
+  aad.extend_from_slice(DATA_AAD);
+  aad.push(kid.len() as u8);
+  aad.extend_from_slice(kid);
+  aad.extend_from_slice(data_nonce);
+  aad
+}
+
+/// Seal `in_out` in place under `key`, appending the GCM tag.
+///
+/// # Errors
+///
+/// Returns [`TotpError::Encrypt`] when the AEAD rejects the key.
+fn seal_with_aad(
+  key: &[u8],
+  nonce_bytes: [u8; NONCE_LEN],
+  aad: &[u8],
+  in_out: &mut Vec<u8>,
+) -> Result<(), TotpError> {
+  let sealing =
+    LessSafeKey::new(UnboundKey::new(&AES_256_GCM, key).map_err(|_| TotpError::Encrypt)?);
+  sealing
+    .seal_in_place_append_tag(
+      Nonce::assume_unique_for_key(nonce_bytes),
+      Aad::from(aad),
+      in_out,
+    )
+    .map_err(|_| TotpError::Encrypt)
+}
+
 /// 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.
+/// Version-3 envelopes unwrap the per-row DEK under the HKDF-SHA256 KEK, then
+/// open the data ciphertext; 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 && blob[0] != TOTP_BLOB_V1) {
+  if blob.len() < 2 + NONCE_LEN
+    || (blob[0] != TOTP_BLOB_V3 && blob[0] != TOTP_BLOB_VERSION && blob[0] != TOTP_BLOB_V1)
+  {
     return Err(TotpError::Decrypt);
   }
   let version = blob[0];
@@ -346,10 +480,13 @@ pub fn decrypt_secret(keys: &KeyRing, blob: &[u8]) -> Result<String, TotpError>
   if blob.len() < header + NONCE_LEN {
     return Err(TotpError::Decrypt);
   }
-  let kid = core::str::from_utf8(blob.get(2..header).unwrap_or_default())
-    .map_err(|_| TotpError::Decrypt)?;
+  let kid_bytes = blob.get(2..header).ok_or(TotpError::Decrypt)?;
+  let kid = core::str::from_utf8(kid_bytes).map_err(|_| TotpError::Decrypt)?;
   let secret = keys.totp_key_by_id(kid).ok_or(TotpError::Decrypt)?;
   let ikm = secret.expose_secret().as_bytes();
+  if version == TOTP_BLOB_V3 {
+    return decrypt_v3(ikm, kid_bytes, &blob[header..]);
+  }
   let mut nonce_bytes = [0u8; NONCE_LEN];
   nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
   let ciphertext = &blob[header + NONCE_LEN..];
@@ -365,6 +502,45 @@ pub fn decrypt_secret(keys: &KeyRing, blob: &[u8]) -> Result<String, TotpError>
   open_with_key(&derive_key_legacy(ikm), &nonce_bytes, ciphertext)
 }
 
+/// Open a v3 envelope body: `wrap_nonce(12) || wrapped_dek(32+16) ||
+/// data_nonce(12) || secret_ct+tag`.
+///
+/// The KEK is re-derived from the key named in the blob; the wrap AAD
+/// rebinds that kid plus the stored data nonce, so a wrapped DEK copied from
+/// another row fails closed.
+///
+/// # Errors
+///
+/// Returns [`TotpError::Decrypt`] for a malformed body, an unknown key, or
+/// an authentication failure. The reason is never distinguished.
+fn decrypt_v3(ikm: &[u8], kid: &[u8], body: &[u8]) -> Result<String, TotpError> {
+  if body.len() < NONCE_LEN + WRAPPED_DEK_LEN + NONCE_LEN + 16 {
+    return Err(TotpError::Decrypt);
+  }
+  let (wrap_nonce, rest) = body.split_at(NONCE_LEN);
+  let (wrapped_dek, rest) = rest.split_at(WRAPPED_DEK_LEN);
+  let (data_nonce, ciphertext) = rest.split_at(NONCE_LEN);
+  let mut wrap_nonce_bytes = [0u8; NONCE_LEN];
+  wrap_nonce_bytes.copy_from_slice(wrap_nonce);
+  let mut data_nonce_bytes = [0u8; NONCE_LEN];
+  data_nonce_bytes.copy_from_slice(data_nonce);
+  let kek = Zeroizing::new(derive_labeled(ikm, KEK_INFO).map_err(|_| TotpError::Decrypt)?);
+  let dek_bytes = open_with_aad(
+    &kek[..],
+    &wrap_nonce_bytes,
+    &wrap_aad(kid, &data_nonce_bytes),
+    wrapped_dek,
+  )?;
+  if dek_bytes.len() != 32 {
+    return Err(TotpError::Decrypt);
+  }
+  let mut dek_key = [0u8; 32];
+  dek_key.copy_from_slice(&dek_bytes);
+  let dek = Zeroizing::new(dek_key);
+  let plaintext = open_with_aad(&dek[..], &data_nonce_bytes, DATA_AAD, ciphertext)?;
+  String::from_utf8(plaintext).map_err(|_| TotpError::Decrypt)
+}
+
 /// Open `ciphertext` (with trailing GCM tag) under `key`, returning UTF-8.
 ///
 /// # Errors
@@ -376,17 +552,34 @@ fn open_with_key(
   nonce_bytes: &[u8; NONCE_LEN],
   ciphertext: &[u8],
 ) -> Result<String, TotpError> {
+  let plaintext = open_with_aad(key, nonce_bytes, &[], ciphertext)?;
+  String::from_utf8(plaintext).map_err(|_| TotpError::Decrypt)
+}
+
+/// Open `ciphertext` (with trailing GCM tag) under `key` and `aad`,
+/// returning the raw plaintext.
+///
+/// # Errors
+///
+/// Returns [`TotpError::Decrypt`] for an authentication failure. The reason
+/// is never distinguished.
+fn open_with_aad(
+  key: &[u8],
+  nonce_bytes: &[u8; NONCE_LEN],
+  aad: &[u8],
+  ciphertext: &[u8],
+) -> Result<Vec<u8>, TotpError> {
   let mut in_out = ciphertext.to_vec();
   let opening =
     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),
-      Aad::empty(),
+      Aad::from(aad),
       &mut in_out,
     )
     .map_err(|_| TotpError::Decrypt)?;
-  String::from_utf8(plaintext.to_vec()).map_err(|_| TotpError::Decrypt)
+  Ok(plaintext.to_vec())
 }
 
 /// Build an `otpauth://` enrollment URI (same shape as `cli::secret`).
@@ -419,8 +612,15 @@ pub fn qr_svg(uri: &str) -> Result<String, TotpError> {
 /// 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> {
+  derive_labeled(totp_key, HKDF_INFO)
+}
+
+/// HKDF-SHA256 (RFC 5869, empty salt) under an explicit domain-separation
+/// label: `totp-gcm-v2` seals data directly, `totp-kek-v1` wraps v3 DEKs.
+fn derive_labeled(totp_key: &[u8], info: &[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 info_labels = [info];
+  let okm = prk.expand(&info_labels, HkdfAesKey)?;
   let mut key = [0u8; 32];
   okm.fill(&mut key)?;
   Ok(key)

+ 144 - 7
src/crypto/totp/tests.rs

@@ -8,7 +8,10 @@ use super::{
   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::{
+  error::{AppError, OAuthErrorCode},
+  keys::{KeyId, KeyRing},
+};
 
 /// Test KeyRing with one pepper generation and one TOTP-encryption key.
 fn test_ring() -> Result<KeyRing, TotpError> {
@@ -21,6 +24,10 @@ fn test_ring() -> Result<KeyRing, TotpError> {
       KeyId::new("v1"),
       SecretString::from("test-totp-key-0123456789abcdef"),
     )],
+    vec![(
+      KeyId::new("v1"),
+      SecretString::from("test-pii-key-0123456789abcdef"),
+    )],
   )
   .map_err(|_| TotpError::Encrypt)
 }
@@ -100,7 +107,7 @@ fn login_totp_secret_round_trip() -> Result<(), TotpError> {
   if secret.len() != 32 {
     return Err(TotpError::InvalidSecret);
   }
-  let blob = encrypt_secret(&ring, &secret)?;
+  let blob = encrypt_secret(&ring, &secret, true)?;
   let back = decrypt_secret(&ring, &blob)?;
   if back != secret {
     return Err(TotpError::Decrypt);
@@ -115,7 +122,7 @@ fn login_totp_secret_round_trip() -> Result<(), TotpError> {
 #[test]
 fn login_totp_decrypt_rejects_tampered_and_foreign_blobs() -> Result<(), TotpError> {
   let ring = test_ring()?;
-  let mut blob = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ")?;
+  let mut blob = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ", true)?;
   let last = blob.len() - 1;
   blob[last] ^= 0x01;
   if decrypt_secret(&ring, &blob).is_ok() {
@@ -127,9 +134,10 @@ fn login_totp_decrypt_rejects_tampered_and_foreign_blobs() -> Result<(), TotpErr
   let foreign = KeyRing::from_parts(
     vec![(KeyId::new("v1"), SecretString::from("other-pepper"))],
     vec![(KeyId::new("v1"), SecretString::from("other-totp-key"))],
+    vec![(KeyId::new("v1"), SecretString::from("other-pii-key"))],
   )
   .map_err(|_| TotpError::Encrypt)?;
-  let blob = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ")?;
+  let blob = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ", true)?;
   if decrypt_secret(&foreign, &blob).is_ok() {
     return Err(TotpError::Decrypt);
   }
@@ -248,14 +256,109 @@ fn login_totp_hkdf_differs_from_legacy_sha256() -> Result<(), TotpError> {
   Ok(())
 }
 
+/// The writer is version-gated for the rolling upgrade: `envelope = false`
+/// (while `pii_dual_write` is on and old replicas still read) writes the
+/// legacy v2 blob; `envelope = true` writes the v3 per-row-DEK envelope.
+/// `decrypt_secret` opens both.
 #[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) {
+  let legacy = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ", false)?;
+  if TOTP_BLOB_VERSION != 2 || legacy.first().copied() != Some(2) {
     return Err(TotpError::Encrypt);
   }
-  if decrypt_secret(&ring, &blob)? != "GEZDGNBVGY3TQOJQ" {
+  if decrypt_secret(&ring, &legacy)? != "GEZDGNBVGY3TQOJQ" {
+    return Err(TotpError::Decrypt);
+  }
+  let enveloped = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ", true)?;
+  if enveloped.first().copied() != Some(3) {
+    return Err(TotpError::Encrypt);
+  }
+  if decrypt_secret(&ring, &enveloped)? != "GEZDGNBVGY3TQOJQ" {
+    return Err(TotpError::Decrypt);
+  }
+  Ok(())
+}
+
+#[test]
+fn totp_v3_envelope_round_trip() -> Result<(), TotpError> {
+  let ring = test_ring()?;
+  let secret = "GEZDGNBVGY3TQOJQ";
+  let blob = encrypt_secret(&ring, secret, true)?;
+  if blob.first().copied() != Some(3) {
+    return Err(TotpError::Encrypt);
+  }
+  if decrypt_secret(&ring, &blob)? != secret {
+    return Err(TotpError::Decrypt);
+  }
+  // The envelope carries no plaintext.
+  if String::from_utf8_lossy(&blob).contains(secret) {
+    return Err(TotpError::Encrypt);
+  }
+  Ok(())
+}
+
+#[test]
+fn totp_v3_two_rows_use_distinct_deks() -> Result<(), TotpError> {
+  let ring = test_ring()?;
+  let first = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ", true)?;
+  let second = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ", true)?;
+  // Fresh DEK + nonces per write: identical plaintext never repeats a blob.
+  if first == second {
+    return Err(TotpError::Encrypt);
+  }
+  if decrypt_secret(&ring, &first)? != "GEZDGNBVGY3TQOJQ" {
+    return Err(TotpError::Decrypt);
+  }
+  if decrypt_secret(&ring, &second)? != "GEZDGNBVGY3TQOJQ" {
+    return Err(TotpError::Decrypt);
+  }
+  Ok(())
+}
+
+/// V3 layout: `version(1)=3 || kid_len(1) || kid || wrap_nonce(12) ||
+/// wrapped_dek(32+16) || data_nonce(12) || secret_ct+tag`.
+fn v3_wrap_range(blob: &[u8]) -> Result<(usize, usize), TotpError> {
+  if blob.first().copied() != Some(3) || blob.len() < 2 {
+    return Err(TotpError::Decrypt);
+  }
+  let header = 2 + usize::from(blob[1]);
+  let start = header + 12;
+  let end = start + 48;
+  if blob.len() < end + 12 + 16 {
+    return Err(TotpError::Decrypt);
+  }
+  Ok((start, end))
+}
+
+#[test]
+fn totp_v3_rejects_swapped_wrapped_dek() -> Result<(), TotpError> {
+  let ring = test_ring()?;
+  let first = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ", true)?;
+  let second = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ", true)?;
+  let (from_start, from_end) = v3_wrap_range(&second)?;
+  let (to_start, to_end) = v3_wrap_range(&first)?;
+  // A wrapped DEK moved from another row binds that row's data nonce in its
+  // wrap AAD, so it must fail to unwrap here.
+  let mut swapped = first.clone();
+  swapped[to_start..to_end].copy_from_slice(&second[from_start..from_end]);
+  if decrypt_secret(&ring, &swapped).is_ok() {
+    return Err(TotpError::Decrypt);
+  }
+  // A tampered wrapped DEK fails closed.
+  let mut tampered = first.clone();
+  tampered[to_start] ^= 0x01;
+  if decrypt_secret(&ring, &tampered).is_ok() {
+    return Err(TotpError::Decrypt);
+  }
+  // A tampered data nonce breaks the wrap AAD binding too.
+  let mut nonce_tampered = first.clone();
+  nonce_tampered[to_end] ^= 0x01;
+  if decrypt_secret(&ring, &nonce_tampered).is_ok() {
+    return Err(TotpError::Decrypt);
+  }
+  // The untouched blob still opens.
+  if decrypt_secret(&ring, &first)? != "GEZDGNBVGY3TQOJQ" {
     return Err(TotpError::Decrypt);
   }
   Ok(())
@@ -309,3 +412,37 @@ fn login_totp_decrypts_legacy_v1_sha256_blob() -> Result<(), TotpError> {
   }
   Ok(())
 }
+
+#[test]
+fn totp_error_maps_fail_closed() {
+  use axum::http::StatusCode;
+  // A stored ciphertext that fails to open is PII-decrypt (503), so the
+  // route layer emits `pii.decrypt_failure` with the request id.
+  let decrypt = AppError::from(TotpError::Decrypt);
+  assert!(matches!(decrypt, AppError::PiiDecrypt));
+  assert_eq!(decrypt.code(), OAuthErrorCode::TemporarilyUnavailable);
+  assert_eq!(decrypt.status(), StatusCode::SERVICE_UNAVAILABLE);
+  // Stored-value and dependency failures are 503, never fail-open 500, and
+  // carry no secret, key, or store detail.
+  let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "store down");
+  let failures = [
+    AppError::from(TotpError::Encrypt),
+    AppError::from(TotpError::UnavailableRandomness),
+    AppError::from(TotpError::InvalidSecret),
+    AppError::from(TotpError::UnexpectedReply),
+    AppError::from(TotpError::from(redis::RedisError::from(io))),
+  ];
+  for error in failures {
+    assert_eq!(error.code(), OAuthErrorCode::TemporarilyUnavailable);
+    assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE);
+    assert!(
+      !format!("{error:?}").contains("store down"),
+      "a mapped error must stay payload-free"
+    );
+  }
+  // QR rendering is a server-side rendering defect, not a decrypt or
+  // dependency failure, so it stays `server_error`.
+  let qr = AppError::from(TotpError::Qr);
+  assert_eq!(qr.code(), OAuthErrorCode::ServerError);
+  assert_eq!(qr.status(), StatusCode::INTERNAL_SERVER_ERROR);
+}

+ 635 - 69
src/db/client_repo.rs

@@ -5,6 +5,14 @@
 //! `sqlx` macro call, so the schema is checked at compile time and no SQL is
 //! ever interpolated.
 //!
+//! PII metadata (`client_name`, `contacts`, `request_uris`, `jwks_uri`,
+//! `software_id`, and the two mTLS identities) is sealed into `_enc` bytea
+//! columns (AES-256-GCM under the PII key chain) and read back through typed
+//! decrypt helpers that fall back to the legacy plaintext only when `_enc`
+//! is NULL. Plaintext is written only while `dual_write` holds; the two mTLS
+//! identities are always blind-indexed (`_index`, `"{kid}:{hex}"`) for
+//! equality checks. Reads never match on decrypted values in SQL.
+//!
 //! The metadata projection ([`Client`]) deliberately omits
 //! `client_secret_hash` and `registration_access_token_hash`: the secret hash
 //! is selected only by [`verify_secret`], and the registration hash only by
@@ -21,154 +29,520 @@ use sqlx::PgPool;
 use time::OffsetDateTime;
 use uuid::Uuid;
 
+use crate::crypto::field::{
+  blind_index_active, blind_index_candidates, open_json, open_text, seal_json, seal_text,
+};
 use crate::crypto::secret::verify_secret as verify_argon2;
+use crate::error::AppError;
+use crate::keys::KeyRing;
 
 /// Insert a new client and return its safe projection.
 ///
+/// The seven sealed PII columns are written as ciphertext; their plaintext is
+/// written only while `dual_write` holds. Both mTLS identities are checked
+/// against the dual predicate before writing because a legacy row with a NULL
+/// `_index` would otherwise let a duplicate slip past the partial unique
+/// index.
+///
 /// # Errors
 ///
-/// Returns a database error on a duplicate `client_id`, a duplicate mTLS
-/// identity, or any other constraint violation.
-pub async fn insert(pool: &PgPool, new: &NewClient) -> Result<Client, sqlx::Error> {
-  sqlx::query_as!(
-    Client,
+/// Returns [`AppError::DuplicateClientIdentity`] on a duplicate subject DN or
+/// thumbprint (including a lost INSERT race via `23505`),
+/// [`AppError::PiiDecrypt`] when a stored ciphertext fails to open, or a
+/// masked error on any other database failure.
+pub async fn insert(
+  pool: &PgPool,
+  keys: &KeyRing,
+  dual_write: bool,
+  new: &NewClient,
+) -> Result<Client, AppError> {
+  check_mtls_duplicates(
+    pool,
+    keys,
+    new.tls_client_auth_subject_dn.as_deref(),
+    new.tls_client_auth_cert_thumbprint.as_deref(),
+    None,
+  )
+  .await?;
+  let sealed = seal_pii(
+    keys,
+    &SealInput {
+      client_name: &new.client_name,
+      contacts: new.contacts.as_ref(),
+      request_uris: new.request_uris.as_ref(),
+      jwks_uri: new.jwks_uri.as_deref(),
+      software_id: new.software_id.as_deref(),
+      subject_dn: new.tls_client_auth_subject_dn.as_deref(),
+      thumbprint: new.tls_client_auth_cert_thumbprint.as_deref(),
+    },
+  )?;
+  let dn_index = new
+    .tls_client_auth_subject_dn
+    .as_deref()
+    .map(|v| blind_index_active(keys, v));
+  let thumb_index = new
+    .tls_client_auth_cert_thumbprint
+    .as_deref()
+    .map(|v| blind_index_active(keys, v));
+  let name_plain: Option<&str> = if dual_write {
+    Some(&new.client_name)
+  } else {
+    None
+  };
+  let contacts_plain: Option<serde_json::Value> = if dual_write {
+    new.contacts.clone()
+  } else {
+    None
+  };
+  let request_uris_plain: Option<serde_json::Value> = if dual_write {
+    new.request_uris.clone()
+  } else {
+    None
+  };
+  let jwks_uri_plain: Option<&str> = if dual_write {
+    new.jwks_uri.as_deref()
+  } else {
+    None
+  };
+  let software_plain: Option<&str> = if dual_write {
+    new.software_id.as_deref()
+  } else {
+    None
+  };
+  let dn_plain: Option<&str> = if dual_write {
+    new.tls_client_auth_subject_dn.as_deref()
+  } else {
+    None
+  };
+  let thumb_plain: Option<&str> = if dual_write {
+    new.tls_client_auth_cert_thumbprint.as_deref()
+  } else {
+    None
+  };
+  let row = sqlx::query!(
     "INSERT INTO oauth_clients (id, client_id, client_secret_hash, client_secret_expires_at, \
-       client_id_issued_at, client_name, grant_types, response_types, scope, \
-       token_endpoint_auth_method, jwks, jwks_uri, tls_client_auth_subject_dn, \
-       tls_client_auth_cert_thumbprint, request_uris, contacts, software_id, \
-       registration_access_token_hash, dpop_bound_access_tokens, created_at, updated_at) \
+       client_id_issued_at, client_name, client_name_enc, grant_types, response_types, scope, \
+       token_endpoint_auth_method, jwks, jwks_uri, jwks_uri_enc, tls_client_auth_subject_dn, \
+       tls_client_auth_subject_dn_enc, tls_client_auth_subject_dn_index, \
+       tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, \
+       tls_client_auth_cert_thumbprint_index, request_uris, request_uris_enc, contacts, \
+       contacts_enc, software_id, software_id_enc, registration_access_token_hash, \
+       dpop_bound_access_tokens, created_at, updated_at) \
      VALUES ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, \
-       $18, now(), now()) \
+       $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, now(), now()) \
      RETURNING id, client_id, client_secret_expires_at, client_id_issued_at, client_name, \
-       grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, \
-       tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, \
-       software_id, dpop_bound_access_tokens, active, created_at, updated_at",
+       client_name_enc, grant_types, response_types, scope, token_endpoint_auth_method, jwks, \
+       jwks_uri, jwks_uri_enc, tls_client_auth_subject_dn, tls_client_auth_subject_dn_enc, \
+       tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, \
+       request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, \
+       dpop_bound_access_tokens, active, created_at, updated_at",
     new.id,
     &new.client_id,
     new.client_secret_hash.as_deref(),
     new.client_secret_expires_at,
-    &new.client_name,
+    name_plain,
+    sealed.name_enc,
     &new.grant_types,
     &new.response_types,
     &new.scope,
     &new.token_endpoint_auth_method,
     new.jwks.clone(),
-    new.jwks_uri.as_deref(),
-    new.tls_client_auth_subject_dn.as_deref(),
-    new.tls_client_auth_cert_thumbprint.as_deref(),
-    new.request_uris.clone(),
-    new.contacts.clone(),
-    new.software_id.as_deref(),
+    jwks_uri_plain,
+    sealed.jwks_uri_enc,
+    dn_plain,
+    sealed.dn_enc,
+    dn_index,
+    thumb_plain,
+    sealed.thumb_enc,
+    thumb_index,
+    request_uris_plain,
+    sealed.request_uris_enc,
+    contacts_plain,
+    sealed.contacts_enc,
+    software_plain,
+    sealed.software_enc,
     new.registration_access_token_hash.as_deref(),
     new.dpop_bound_access_tokens,
   )
   .fetch_one(pool)
-  .await
+  .await?;
+  Ok(Client {
+    id: row.id,
+    client_id: row.client_id,
+    client_secret_expires_at: row.client_secret_expires_at,
+    client_id_issued_at: row.client_id_issued_at,
+    client_name: decrypt_required_text(keys, row.client_name_enc, row.client_name)?,
+    grant_types: row.grant_types,
+    response_types: row.response_types,
+    scope: row.scope,
+    token_endpoint_auth_method: row.token_endpoint_auth_method,
+    jwks: row.jwks,
+    jwks_uri: decrypt_optional_text(keys, row.jwks_uri_enc, row.jwks_uri)?,
+    tls_client_auth_subject_dn: decrypt_optional_text(
+      keys,
+      row.tls_client_auth_subject_dn_enc,
+      row.tls_client_auth_subject_dn,
+    )?,
+    tls_client_auth_cert_thumbprint: decrypt_optional_text(
+      keys,
+      row.tls_client_auth_cert_thumbprint_enc,
+      row.tls_client_auth_cert_thumbprint,
+    )?,
+    request_uris: decrypt_optional_json(keys, row.request_uris_enc, row.request_uris)?,
+    contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
+    software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
+    dpop_bound_access_tokens: row.dpop_bound_access_tokens,
+    active: row.active,
+    created_at: row.created_at,
+    updated_at: row.updated_at,
+  })
 }
 
 /// Find an active client by its public `client_id`.
 ///
 /// # Errors
 ///
-/// Returns a database error if the query fails.
+/// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open,
+/// or a masked error on any database failure.
 pub async fn find_active_by_client_id(
   pool: &PgPool,
+  keys: &KeyRing,
   client_id: &str,
-) -> Result<Option<Client>, sqlx::Error> {
-  sqlx::query_as!(
-    Client,
+) -> Result<Option<Client>, AppError> {
+  let row = sqlx::query!(
     "SELECT id, client_id, client_secret_expires_at, client_id_issued_at, client_name, \
-       grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, \
-       tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, \
-       software_id, dpop_bound_access_tokens, active, created_at, updated_at \
+       client_name_enc, grant_types, response_types, scope, token_endpoint_auth_method, jwks, \
+       jwks_uri, jwks_uri_enc, tls_client_auth_subject_dn, tls_client_auth_subject_dn_enc, \
+       tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, \
+       request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, \
+       dpop_bound_access_tokens, active, created_at, updated_at \
      FROM oauth_clients WHERE client_id = $1 AND active = true",
     client_id,
   )
   .fetch_optional(pool)
-  .await
+  .await?;
+  row
+    .map(|row| {
+      Ok(Client {
+        id: row.id,
+        client_id: row.client_id,
+        client_secret_expires_at: row.client_secret_expires_at,
+        client_id_issued_at: row.client_id_issued_at,
+        client_name: decrypt_required_text(keys, row.client_name_enc, row.client_name)?,
+        grant_types: row.grant_types,
+        response_types: row.response_types,
+        scope: row.scope,
+        token_endpoint_auth_method: row.token_endpoint_auth_method,
+        jwks: row.jwks,
+        jwks_uri: decrypt_optional_text(keys, row.jwks_uri_enc, row.jwks_uri)?,
+        tls_client_auth_subject_dn: decrypt_optional_text(
+          keys,
+          row.tls_client_auth_subject_dn_enc,
+          row.tls_client_auth_subject_dn,
+        )?,
+        tls_client_auth_cert_thumbprint: decrypt_optional_text(
+          keys,
+          row.tls_client_auth_cert_thumbprint_enc,
+          row.tls_client_auth_cert_thumbprint,
+        )?,
+        request_uris: decrypt_optional_json(keys, row.request_uris_enc, row.request_uris)?,
+        contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
+        software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
+        dpop_bound_access_tokens: row.dpop_bound_access_tokens,
+        active: row.active,
+        created_at: row.created_at,
+        updated_at: row.updated_at,
+      })
+    })
+    .transpose()
 }
 
 /// Find a client by its surrogate id, active or not.
 ///
 /// # Errors
 ///
-/// Returns a database error if the query fails.
-pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Client>, sqlx::Error> {
-  sqlx::query_as!(
-    Client,
+/// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open,
+/// or a masked error on any database failure.
+pub async fn find_by_id(
+  pool: &PgPool,
+  keys: &KeyRing,
+  id: Uuid,
+) -> Result<Option<Client>, AppError> {
+  let row = sqlx::query!(
     "SELECT id, client_id, client_secret_expires_at, client_id_issued_at, client_name, \
-       grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, \
-       tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, \
-       software_id, dpop_bound_access_tokens, active, created_at, updated_at \
+       client_name_enc, grant_types, response_types, scope, token_endpoint_auth_method, jwks, \
+       jwks_uri, jwks_uri_enc, tls_client_auth_subject_dn, tls_client_auth_subject_dn_enc, \
+       tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, \
+       request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, \
+       dpop_bound_access_tokens, active, created_at, updated_at \
      FROM oauth_clients WHERE id = $1",
     id,
   )
   .fetch_optional(pool)
-  .await
+  .await?;
+  row
+    .map(|row| {
+      Ok(Client {
+        id: row.id,
+        client_id: row.client_id,
+        client_secret_expires_at: row.client_secret_expires_at,
+        client_id_issued_at: row.client_id_issued_at,
+        client_name: decrypt_required_text(keys, row.client_name_enc, row.client_name)?,
+        grant_types: row.grant_types,
+        response_types: row.response_types,
+        scope: row.scope,
+        token_endpoint_auth_method: row.token_endpoint_auth_method,
+        jwks: row.jwks,
+        jwks_uri: decrypt_optional_text(keys, row.jwks_uri_enc, row.jwks_uri)?,
+        tls_client_auth_subject_dn: decrypt_optional_text(
+          keys,
+          row.tls_client_auth_subject_dn_enc,
+          row.tls_client_auth_subject_dn,
+        )?,
+        tls_client_auth_cert_thumbprint: decrypt_optional_text(
+          keys,
+          row.tls_client_auth_cert_thumbprint_enc,
+          row.tls_client_auth_cert_thumbprint,
+        )?,
+        request_uris: decrypt_optional_json(keys, row.request_uris_enc, row.request_uris)?,
+        contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
+        software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
+        dpop_bound_access_tokens: row.dpop_bound_access_tokens,
+        active: row.active,
+        created_at: row.created_at,
+        updated_at: row.updated_at,
+      })
+    })
+    .transpose()
 }
 
 /// List clients ordered by issue time, optionally including deactivated ones.
 ///
 /// # Errors
 ///
-/// Returns a database error if the query fails.
-pub async fn list(pool: &PgPool, include_inactive: bool) -> Result<Vec<Client>, sqlx::Error> {
-  sqlx::query_as!(
-    Client,
+/// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open,
+/// or a masked error on any database failure.
+pub async fn list(
+  pool: &PgPool,
+  keys: &KeyRing,
+  include_inactive: bool,
+) -> Result<Vec<Client>, AppError> {
+  let rows = sqlx::query!(
     "SELECT id, client_id, client_secret_expires_at, client_id_issued_at, client_name, \
-       grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, \
-       tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, \
-       software_id, dpop_bound_access_tokens, active, created_at, updated_at \
+       client_name_enc, grant_types, response_types, scope, token_endpoint_auth_method, jwks, \
+       jwks_uri, jwks_uri_enc, tls_client_auth_subject_dn, tls_client_auth_subject_dn_enc, \
+       tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, \
+       request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, \
+       dpop_bound_access_tokens, active, created_at, updated_at \
      FROM oauth_clients WHERE ($1 OR active = true) ORDER BY created_at, client_id",
     include_inactive,
   )
   .fetch_all(pool)
-  .await
+  .await?;
+  rows
+    .into_iter()
+    .map(|row| {
+      Ok(Client {
+        id: row.id,
+        client_id: row.client_id,
+        client_secret_expires_at: row.client_secret_expires_at,
+        client_id_issued_at: row.client_id_issued_at,
+        client_name: decrypt_required_text(keys, row.client_name_enc, row.client_name)?,
+        grant_types: row.grant_types,
+        response_types: row.response_types,
+        scope: row.scope,
+        token_endpoint_auth_method: row.token_endpoint_auth_method,
+        jwks: row.jwks,
+        jwks_uri: decrypt_optional_text(keys, row.jwks_uri_enc, row.jwks_uri)?,
+        tls_client_auth_subject_dn: decrypt_optional_text(
+          keys,
+          row.tls_client_auth_subject_dn_enc,
+          row.tls_client_auth_subject_dn,
+        )?,
+        tls_client_auth_cert_thumbprint: decrypt_optional_text(
+          keys,
+          row.tls_client_auth_cert_thumbprint_enc,
+          row.tls_client_auth_cert_thumbprint,
+        )?,
+        request_uris: decrypt_optional_json(keys, row.request_uris_enc, row.request_uris)?,
+        contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
+        software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
+        dpop_bound_access_tokens: row.dpop_bound_access_tokens,
+        active: row.active,
+        created_at: row.created_at,
+        updated_at: row.updated_at,
+      })
+    })
+    .collect()
 }
 
 /// Full-replace the allowlisted metadata of an active client.
 ///
 /// Never touches identity, secret, issue time, active flag, or registration
 /// token. Returns the updated projection, or `None` if no active client has
-/// that `client_id`.
+/// that `client_id`. Both mTLS identity checks exclude the row being updated
+/// so a client can rewrite its own unchanged DN/thumbprint.
 ///
 /// # Errors
 ///
-/// Returns a database error if the update fails.
+/// Returns [`AppError::DuplicateClientIdentity`] on a duplicate subject DN or
+/// thumbprint, [`AppError::PiiDecrypt`] when a stored ciphertext fails to
+/// open, or a masked error on any other database failure.
 pub async fn update_metadata(
   pool: &PgPool,
+  keys: &KeyRing,
+  dual_write: bool,
   client_id: &str,
   meta: &ClientMetadata,
-) -> Result<Option<Client>, sqlx::Error> {
-  sqlx::query_as!(
-    Client,
-    "UPDATE oauth_clients SET client_name = $2, grant_types = $3, response_types = $4, \
-       scope = $5, token_endpoint_auth_method = $6, jwks = $7, jwks_uri = $8, \
-       tls_client_auth_subject_dn = $9, tls_client_auth_cert_thumbprint = $10, \
-       request_uris = $11, contacts = $12, software_id = $13, dpop_bound_access_tokens = $14, \
-       updated_at = now() \
-     WHERE client_id = $1 AND active = true \
-     RETURNING id, client_id, client_secret_expires_at, client_id_issued_at, client_name, \
-       grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, \
-       tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, \
-       software_id, dpop_bound_access_tokens, active, created_at, updated_at",
+) -> Result<Option<Client>, AppError> {
+  let current = sqlx::query!(
+    "SELECT id FROM oauth_clients WHERE client_id = $1 AND active = true",
     client_id,
-    &meta.client_name,
+  )
+  .fetch_optional(pool)
+  .await?;
+  let Some(current) = current else {
+    return Ok(None);
+  };
+  check_mtls_duplicates(
+    pool,
+    keys,
+    meta.tls_client_auth_subject_dn.as_deref(),
+    meta.tls_client_auth_cert_thumbprint.as_deref(),
+    Some(current.id),
+  )
+  .await?;
+  let sealed = seal_pii(
+    keys,
+    &SealInput {
+      client_name: &meta.client_name,
+      contacts: meta.contacts.as_ref(),
+      request_uris: meta.request_uris.as_ref(),
+      jwks_uri: meta.jwks_uri.as_deref(),
+      software_id: meta.software_id.as_deref(),
+      subject_dn: meta.tls_client_auth_subject_dn.as_deref(),
+      thumbprint: meta.tls_client_auth_cert_thumbprint.as_deref(),
+    },
+  )?;
+  let dn_index = meta
+    .tls_client_auth_subject_dn
+    .as_deref()
+    .map(|v| blind_index_active(keys, v));
+  let thumb_index = meta
+    .tls_client_auth_cert_thumbprint
+    .as_deref()
+    .map(|v| blind_index_active(keys, v));
+  let name_plain: Option<&str> = if dual_write {
+    Some(&meta.client_name)
+  } else {
+    None
+  };
+  let contacts_plain: Option<serde_json::Value> = if dual_write {
+    meta.contacts.clone()
+  } else {
+    None
+  };
+  let request_uris_plain: Option<serde_json::Value> = if dual_write {
+    meta.request_uris.clone()
+  } else {
+    None
+  };
+  let jwks_uri_plain: Option<&str> = if dual_write {
+    meta.jwks_uri.as_deref()
+  } else {
+    None
+  };
+  let software_plain: Option<&str> = if dual_write {
+    meta.software_id.as_deref()
+  } else {
+    None
+  };
+  let dn_plain: Option<&str> = if dual_write {
+    meta.tls_client_auth_subject_dn.as_deref()
+  } else {
+    None
+  };
+  let thumb_plain: Option<&str> = if dual_write {
+    meta.tls_client_auth_cert_thumbprint.as_deref()
+  } else {
+    None
+  };
+  let row = sqlx::query!(
+    "UPDATE oauth_clients SET client_name = $2, client_name_enc = $3, grant_types = $4, \
+       response_types = $5, scope = $6, token_endpoint_auth_method = $7, jwks = $8, \
+       jwks_uri = $9, jwks_uri_enc = $10, tls_client_auth_subject_dn = $11, \
+       tls_client_auth_subject_dn_enc = $12, tls_client_auth_subject_dn_index = $13, \
+       tls_client_auth_cert_thumbprint = $14, tls_client_auth_cert_thumbprint_enc = $15, \
+       tls_client_auth_cert_thumbprint_index = $16, request_uris = $17, request_uris_enc = $18, \
+       contacts = $19, contacts_enc = $20, software_id = $21, software_id_enc = $22, \
+       dpop_bound_access_tokens = $23, updated_at = now() \
+     WHERE id = $1 \
+     RETURNING id, client_id, client_secret_expires_at, client_id_issued_at, client_name, \
+       client_name_enc, grant_types, response_types, scope, token_endpoint_auth_method, jwks, \
+       jwks_uri, jwks_uri_enc, tls_client_auth_subject_dn, tls_client_auth_subject_dn_enc, \
+       tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, \
+       request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, \
+       dpop_bound_access_tokens, active, created_at, updated_at",
+    current.id,
+    name_plain,
+    sealed.name_enc,
     &meta.grant_types,
     &meta.response_types,
     &meta.scope,
     &meta.token_endpoint_auth_method,
     meta.jwks.clone(),
-    meta.jwks_uri.as_deref(),
-    meta.tls_client_auth_subject_dn.as_deref(),
-    meta.tls_client_auth_cert_thumbprint.as_deref(),
-    meta.request_uris.clone(),
-    meta.contacts.clone(),
-    meta.software_id.as_deref(),
+    jwks_uri_plain,
+    sealed.jwks_uri_enc,
+    dn_plain,
+    sealed.dn_enc,
+    dn_index,
+    thumb_plain,
+    sealed.thumb_enc,
+    thumb_index,
+    request_uris_plain,
+    sealed.request_uris_enc,
+    contacts_plain,
+    sealed.contacts_enc,
+    software_plain,
+    sealed.software_enc,
     meta.dpop_bound_access_tokens,
   )
   .fetch_optional(pool)
-  .await
+  .await?;
+  row
+    .map(|row| {
+      Ok(Client {
+        id: row.id,
+        client_id: row.client_id,
+        client_secret_expires_at: row.client_secret_expires_at,
+        client_id_issued_at: row.client_id_issued_at,
+        client_name: decrypt_required_text(keys, row.client_name_enc, row.client_name)?,
+        grant_types: row.grant_types,
+        response_types: row.response_types,
+        scope: row.scope,
+        token_endpoint_auth_method: row.token_endpoint_auth_method,
+        jwks: row.jwks,
+        jwks_uri: decrypt_optional_text(keys, row.jwks_uri_enc, row.jwks_uri)?,
+        tls_client_auth_subject_dn: decrypt_optional_text(
+          keys,
+          row.tls_client_auth_subject_dn_enc,
+          row.tls_client_auth_subject_dn,
+        )?,
+        tls_client_auth_cert_thumbprint: decrypt_optional_text(
+          keys,
+          row.tls_client_auth_cert_thumbprint_enc,
+          row.tls_client_auth_cert_thumbprint,
+        )?,
+        request_uris: decrypt_optional_json(keys, row.request_uris_enc, row.request_uris)?,
+        contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
+        software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
+        dpop_bound_access_tokens: row.dpop_bound_access_tokens,
+        active: row.active,
+        created_at: row.created_at,
+        updated_at: row.updated_at,
+      })
+    })
+    .transpose()
 }
 
 /// Deactivate a client (soft delete). Returns `true` if a live row changed.
@@ -247,20 +621,29 @@ pub async fn verify_secret(
 
 /// Whether `uri` is attached to the client and enabled in the catalog.
 ///
+/// The presented URI is matched through its blind-index candidates (one per
+/// retained PII key) with a fallback for legacy rows that predate the index:
+/// their plaintext `uri` is compared only while `uri_enc` is NULL. Decrypted
+/// URIs are never compared in SQL.
+///
 /// # Errors
 ///
 /// Returns a database error if the query fails.
 pub async fn redirect_uri_allowed(
   pool: &PgPool,
+  keys: &KeyRing,
   client_id: &str,
   uri: &str,
 ) -> Result<bool, sqlx::Error> {
+  let candidates = blind_index_candidates(keys, uri);
   sqlx::query_scalar!(
     "SELECT EXISTS ( \
        SELECT 1 FROM client_redirect_uris AS cru \
        JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id \
-       WHERE cru.client_id = $1 AND ru.enabled = true AND ru.uri = $2) AS \"allowed!\"",
+       WHERE cru.client_id = $1 AND ru.enabled = true \
+         AND (ru.uri_index = ANY($2) OR (ru.uri_enc IS NULL AND ru.uri = $3))) AS \"allowed!\"",
     client_id,
+    &candidates,
     uri,
   )
   .fetch_one(pool)
@@ -336,3 +719,186 @@ pub async fn set_registration_token_hash(
   .await?;
   Ok(result.rows_affected() > 0)
 }
+
+/// Sealed PII ciphertext for one write.
+struct SealedPii {
+  name_enc: Vec<u8>,
+  contacts_enc: Option<Vec<u8>>,
+  request_uris_enc: Option<Vec<u8>>,
+  jwks_uri_enc: Option<Vec<u8>>,
+  software_enc: Option<Vec<u8>>,
+  dn_enc: Option<Vec<u8>>,
+  thumb_enc: Option<Vec<u8>>,
+}
+
+/// The seven PII plaintext inputs to one seal.
+struct SealInput<'a> {
+  client_name: &'a str,
+  contacts: Option<&'a serde_json::Value>,
+  request_uris: Option<&'a serde_json::Value>,
+  jwks_uri: Option<&'a str>,
+  software_id: Option<&'a str>,
+  subject_dn: Option<&'a str>,
+  thumbprint: Option<&'a str>,
+}
+
+/// Seal the seven PII metadata columns under the active PII key.
+fn seal_pii(keys: &KeyRing, input: &SealInput<'_>) -> Result<SealedPii, AppError> {
+  Ok(SealedPii {
+    name_enc: seal_text(keys, input.client_name).map_err(AppError::from)?,
+    contacts_enc: input
+      .contacts
+      .map(|v| seal_json(keys, v))
+      .transpose()
+      .map_err(AppError::from)?,
+    request_uris_enc: input
+      .request_uris
+      .map(|v| seal_json(keys, v))
+      .transpose()
+      .map_err(AppError::from)?,
+    jwks_uri_enc: input
+      .jwks_uri
+      .map(|v| seal_text(keys, v))
+      .transpose()
+      .map_err(AppError::from)?,
+    software_enc: input
+      .software_id
+      .map(|v| seal_text(keys, v))
+      .transpose()
+      .map_err(AppError::from)?,
+    dn_enc: input
+      .subject_dn
+      .map(|v| seal_text(keys, v))
+      .transpose()
+      .map_err(AppError::from)?,
+    thumb_enc: input
+      .thumbprint
+      .map(|v| seal_text(keys, v))
+      .transpose()
+      .map_err(AppError::from)?,
+  })
+}
+
+/// Explicit dual-predicate duplicate check for both mTLS identities.
+///
+/// The partial unique indexes alone cannot catch a duplicate across the
+/// encrypt-only pre-backfill window (a legacy row carries a NULL `_index`
+/// while a new row carries a non-NULL index), so both predicates run
+/// explicitly before every write. `exclude_id` skips the row being updated.
+async fn check_mtls_duplicates(
+  pool: &PgPool,
+  keys: &KeyRing,
+  subject_dn: Option<&str>,
+  thumbprint: Option<&str>,
+  exclude_id: Option<Uuid>,
+) -> Result<(), AppError> {
+  if let Some(dn) = subject_dn {
+    let candidates = blind_index_candidates(keys, dn);
+    let exists = match exclude_id {
+      Some(id) => {
+        sqlx::query_scalar!(
+          "SELECT EXISTS(SELECT 1 FROM oauth_clients \
+         WHERE (tls_client_auth_subject_dn_index = ANY($1) \
+           OR (tls_client_auth_subject_dn IS NOT NULL AND tls_client_auth_subject_dn = $2)) \
+           AND id <> $3) AS \"exists!\"",
+          &candidates,
+          dn,
+          id,
+        )
+        .fetch_one(pool)
+        .await?
+      }
+      None => {
+        sqlx::query_scalar!(
+          "SELECT EXISTS(SELECT 1 FROM oauth_clients \
+         WHERE tls_client_auth_subject_dn_index = ANY($1) \
+            OR (tls_client_auth_subject_dn IS NOT NULL AND tls_client_auth_subject_dn = $2)) \
+         AS \"exists!\"",
+          &candidates,
+          dn,
+        )
+        .fetch_one(pool)
+        .await?
+      }
+    };
+    if exists {
+      return Err(AppError::DuplicateClientIdentity);
+    }
+  }
+  if let Some(thumb) = thumbprint {
+    let candidates = blind_index_candidates(keys, thumb);
+    let exists = match exclude_id {
+      Some(id) => {
+        sqlx::query_scalar!(
+          "SELECT EXISTS(SELECT 1 FROM oauth_clients \
+         WHERE (tls_client_auth_cert_thumbprint_index = ANY($1) \
+           OR (tls_client_auth_cert_thumbprint IS NOT NULL \
+             AND tls_client_auth_cert_thumbprint = $2)) \
+           AND id <> $3) AS \"exists!\"",
+          &candidates,
+          thumb,
+          id,
+        )
+        .fetch_one(pool)
+        .await?
+      }
+      None => {
+        sqlx::query_scalar!(
+          "SELECT EXISTS(SELECT 1 FROM oauth_clients \
+         WHERE tls_client_auth_cert_thumbprint_index = ANY($1) \
+            OR (tls_client_auth_cert_thumbprint IS NOT NULL \
+              AND tls_client_auth_cert_thumbprint = $2)) AS \"exists!\"",
+          &candidates,
+          thumb,
+        )
+        .fetch_one(pool)
+        .await?
+      }
+    };
+    if exists {
+      return Err(AppError::DuplicateClientIdentity);
+    }
+  }
+  Ok(())
+}
+
+/// Open a required text field: ciphertext wins, legacy plaintext is the
+/// fallback only when `_enc` is NULL, and a row with neither fails closed.
+fn decrypt_required_text(
+  keys: &KeyRing,
+  enc: Option<Vec<u8>>,
+  plain: Option<String>,
+) -> Result<String, AppError> {
+  if let Some(blob) = enc {
+    return open_text(keys, &blob).map_err(|_| AppError::PiiDecrypt);
+  }
+  plain.ok_or(AppError::PiiDecrypt)
+}
+
+/// Open an optional text field with the same ciphertext-wins rule.
+fn decrypt_optional_text(
+  keys: &KeyRing,
+  enc: Option<Vec<u8>>,
+  plain: Option<String>,
+) -> Result<Option<String>, AppError> {
+  if let Some(blob) = enc {
+    return open_text(keys, &blob)
+      .map(Some)
+      .map_err(|_| AppError::PiiDecrypt);
+  }
+  Ok(plain)
+}
+
+/// Open an optional JSON field with the same ciphertext-wins rule.
+fn decrypt_optional_json(
+  keys: &KeyRing,
+  enc: Option<Vec<u8>>,
+  plain: Option<serde_json::Value>,
+) -> Result<Option<serde_json::Value>, AppError> {
+  if let Some(blob) = enc {
+    return open_json(keys, &blob)
+      .map(Some)
+      .map_err(|_| AppError::PiiDecrypt);
+  }
+  Ok(plain)
+}

+ 462 - 32
src/db/client_repo/tests.rs

@@ -1,10 +1,11 @@
 //! `test(client_repo)` — real-Postgres tests for `db::client_repo`.
 //!
-//! Covers CRUD round-trips, secret verification, the active/deactivated
+//! Covers encrypted CRUD round-trips, secret verification, the active/deactivated
 //! projection rules, the metadata allowlist, the redirect/scope rules, and
 //! registration-token hash storage. Every test gets a fresh migrated database
 //! from [`crate::db::test_support`].
 
+use secrecy::SecretString;
 use serde_json::json;
 use sqlx::PgPool;
 use uuid::Uuid;
@@ -16,6 +17,23 @@ use super::{set_registration_token_hash, update_metadata, verify_secret};
 use crate::crypto::secret::{Argon2Params, hash_secret};
 use crate::db::redirect_repo;
 use crate::db::test_support::{TestError, migrated_pool};
+use crate::error::{AppError, OAuthErrorCode};
+use crate::keys::{KeyId, KeyRing};
+
+/// Test PII key ring for the encrypted client projection.
+fn test_keys() -> Result<KeyRing, TestError> {
+  Ok(KeyRing::from_parts(
+    vec![(
+      KeyId::new("v1"),
+      SecretString::from("client-repo-test-pepper"),
+    )],
+    vec![(
+      KeyId::new("v1"),
+      SecretString::from("client-repo-test-totp"),
+    )],
+    vec![(KeyId::new("v1"), SecretString::from("client-repo-test-pii"))],
+  )?)
+}
 
 /// Low-cost Argon2id parameters keep the tests fast.
 fn params() -> Result<Argon2Params, TestError> {
@@ -64,36 +82,126 @@ async fn insert_scope(pool: &PgPool, name: &str, enabled: bool) -> Result<(), sq
   .map(|_| ())
 }
 
+/// Insert a legacy plaintext row: all `_enc`/`_index` NULL, PII in clear.
+async fn insert_legacy(
+  pool: &PgPool,
+  client_id: &str,
+  dn: Option<&str>,
+  thumbprint: Option<&str>,
+) -> Result<Uuid, TestError> {
+  let id = Uuid::new_v4();
+  sqlx::query(
+    "INSERT INTO oauth_clients (id, client_id, client_id_issued_at, client_name, grant_types, \
+       response_types, scope, token_endpoint_auth_method, jwks_uri, tls_client_auth_subject_dn, \
+       tls_client_auth_cert_thumbprint, software_id, created_at, updated_at) \
+     VALUES ($1, $2, now(), 'Legacy Client', ARRAY['authorization_code'], ARRAY['code'], \
+       'api:read', 'tls_client_auth', 'https://legacy.example.test/jwks', $3, $4, 'sw-legacy', \
+       now(), now())",
+  )
+  .bind(id)
+  .bind(client_id)
+  .bind(dn)
+  .bind(thumbprint)
+  .execute(pool)
+  .await?;
+  Ok(id)
+}
+
 /// Insert + find by both keys round-trips the full metadata projection.
 #[tokio::test]
 async fn client_repo_insert_and_find_round_trip() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
   let mut new = confidential("client-round-trip");
   new.client_secret_hash = Some(hashed("s3cret")?);
   new.jwks = Some(json!({"keys": []}));
+  new.jwks_uri = Some("https://app.example.test/jwks".to_owned());
   new.contacts = Some(json!(["ops@example.test"]));
   new.software_id = Some("sw-1".to_owned());
   new.request_uris = Some(json!(["https://app.example.test/request"]));
+  new.tls_client_auth_subject_dn = Some("CN=round-trip, O=Example".to_owned());
+  new.tls_client_auth_cert_thumbprint = Some("round-trip-thumb".to_owned());
 
-  let created = insert(&pool, &new).await?;
+  let created = insert(&pool, &keys, false, &new).await?;
   assert_eq!(created.client_id, "client-round-trip");
   assert_eq!(created.client_name, "Test Client");
   assert!(created.active);
   assert_eq!(created.grant_types, vec!["authorization_code".to_owned()]);
   assert_eq!(created.scope, "api:read api:write");
   assert_eq!(created.jwks, Some(json!({"keys": []})));
+  assert_eq!(
+    created.jwks_uri.as_deref(),
+    Some("https://app.example.test/jwks")
+  );
+  assert_eq!(created.contacts, Some(json!(["ops@example.test"])));
+  assert_eq!(
+    created.request_uris,
+    Some(json!(["https://app.example.test/request"]))
+  );
+  assert_eq!(created.software_id.as_deref(), Some("sw-1"));
+  assert_eq!(
+    created.tls_client_auth_subject_dn.as_deref(),
+    Some("CN=round-trip, O=Example")
+  );
+  assert_eq!(
+    created.tls_client_auth_cert_thumbprint.as_deref(),
+    Some("round-trip-thumb")
+  );
 
-  let by_client_id = find_active_by_client_id(&pool, "client-round-trip").await?;
+  // Encrypt-only: no PII plaintext at rest, ciphertext + blind indexes set.
+  let at_rest = sqlx::query(
+    "SELECT client_name, client_name_enc, contacts, contacts_enc, jwks_uri, jwks_uri_enc, \
+       tls_client_auth_subject_dn, tls_client_auth_subject_dn_enc, \
+       tls_client_auth_subject_dn_index, tls_client_auth_cert_thumbprint, \
+       tls_client_auth_cert_thumbprint_enc, tls_client_auth_cert_thumbprint_index \
+     FROM oauth_clients WHERE client_id = 'client-round-trip'",
+  )
+  .fetch_one(&pool)
+  .await?;
+  use sqlx::Row as _;
+  let name_plain: Option<String> = at_rest.try_get("client_name")?;
+  let name_enc: Option<Vec<u8>> = at_rest.try_get("client_name_enc")?;
+  let contacts_plain: Option<serde_json::Value> = at_rest.try_get("contacts")?;
+  let contacts_enc: Option<Vec<u8>> = at_rest.try_get("contacts_enc")?;
+  let jwks_uri_plain: Option<String> = at_rest.try_get("jwks_uri")?;
+  let jwks_uri_enc: Option<Vec<u8>> = at_rest.try_get("jwks_uri_enc")?;
+  let dn_plain: Option<String> = at_rest.try_get("tls_client_auth_subject_dn")?;
+  let dn_enc: Option<Vec<u8>> = at_rest.try_get("tls_client_auth_subject_dn_enc")?;
+  let dn_index: Option<String> = at_rest.try_get("tls_client_auth_subject_dn_index")?;
+  let thumb_plain: Option<String> = at_rest.try_get("tls_client_auth_cert_thumbprint")?;
+  let thumb_enc: Option<Vec<u8>> = at_rest.try_get("tls_client_auth_cert_thumbprint_enc")?;
+  let thumb_index: Option<String> = at_rest.try_get("tls_client_auth_cert_thumbprint_index")?;
+  assert_eq!(name_plain, None);
+  assert!(name_enc.is_some());
+  assert_eq!(contacts_plain, None);
+  assert!(contacts_enc.is_some());
+  assert_eq!(jwks_uri_plain, None);
+  assert!(jwks_uri_enc.is_some());
+  assert_eq!(dn_plain, None);
+  assert!(dn_enc.is_some());
+  assert!(dn_index.is_some());
+  assert_eq!(thumb_plain, None);
+  assert!(thumb_enc.is_some());
+  assert!(thumb_index.is_some());
+  // Ciphertext never embeds the cleartext.
+  let enc = name_enc.ok_or("missing client_name_enc")?;
+  assert!(
+    !enc
+      .windows("Test Client".len())
+      .any(|window| window == b"Test Client")
+  );
+
+  let by_client_id = find_active_by_client_id(&pool, &keys, "client-round-trip").await?;
   assert_eq!(by_client_id, Some(created.clone()));
-  let by_id = find_by_id(&pool, created.id).await?;
+  let by_id = find_by_id(&pool, &keys, created.id).await?;
   assert_eq!(by_id, Some(created.clone()));
 
-  let listed = list(&pool, false).await?;
+  let listed = list(&pool, &keys, false).await?;
   assert!(listed.iter().any(|client| client.id == created.id));
 
   // Unknown keys resolve to None, not an error.
-  assert_eq!(find_active_by_client_id(&pool, "nope").await?, None);
-  assert_eq!(find_by_id(&pool, Uuid::new_v4()).await?, None);
+  assert_eq!(find_active_by_client_id(&pool, &keys, "nope").await?, None);
+  assert_eq!(find_by_id(&pool, &keys, Uuid::new_v4()).await?, None);
   Ok(())
 }
 
@@ -102,23 +210,27 @@ async fn client_repo_insert_and_find_round_trip() -> Result<(), TestError> {
 #[tokio::test]
 async fn client_repo_deactivated_client_is_hidden() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
-  let created = insert(&pool, &confidential("client-gone")).await?;
+  let keys = test_keys()?;
+  let created = insert(&pool, &keys, false, &confidential("client-gone")).await?;
 
   assert!(deactivate(&pool, "client-gone").await?);
   // A second deactivate changes nothing.
   assert!(!deactivate(&pool, "client-gone").await?);
 
-  assert_eq!(find_active_by_client_id(&pool, "client-gone").await?, None);
-  let by_id = find_by_id(&pool, created.id).await?;
+  assert_eq!(
+    find_active_by_client_id(&pool, &keys, "client-gone").await?,
+    None
+  );
+  let by_id = find_by_id(&pool, &keys, created.id).await?;
   assert!(matches!(&by_id, Some(client) if !client.active));
   assert!(
-    !list(&pool, false)
+    !list(&pool, &keys, false)
       .await?
       .iter()
       .any(|client| client.client_id == "client-gone")
   );
   assert!(
-    list(&pool, true)
+    list(&pool, &keys, true)
       .await?
       .iter()
       .any(|client| client.client_id == "client-gone")
@@ -130,9 +242,10 @@ async fn client_repo_deactivated_client_is_hidden() -> Result<(), TestError> {
 #[tokio::test]
 async fn client_repo_verify_secret() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
   let mut client = confidential("client-secret");
   client.client_secret_hash = Some(hashed("correct-horse")?);
-  insert(&pool, &client).await?;
+  insert(&pool, &keys, false, &client).await?;
 
   assert_eq!(
     verify_secret(&pool, "client-secret", "correct-horse").await?,
@@ -147,7 +260,7 @@ async fn client_repo_verify_secret() -> Result<(), TestError> {
   // A public client has no stored hash, so it cannot be secret-authenticated.
   let mut public = confidential("client-public");
   public.token_endpoint_auth_method = "none".to_owned();
-  insert(&pool, &public).await?;
+  insert(&pool, &keys, false, &public).await?;
   assert_eq!(
     verify_secret(&pool, "client-public", "anything").await?,
     None
@@ -177,9 +290,10 @@ async fn client_repo_verify_secret() -> Result<(), TestError> {
 #[tokio::test]
 async fn client_repo_update_metadata_allowlist() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
   let mut new = confidential("client-update");
   new.client_secret_hash = Some(hashed("keep-me")?);
-  let created = insert(&pool, &new).await?;
+  let created = insert(&pool, &keys, false, &new).await?;
 
   let meta = ClientMetadata {
     client_name: "Renamed".to_owned(),
@@ -196,11 +310,25 @@ async fn client_repo_update_metadata_allowlist() -> Result<(), TestError> {
     software_id: Some("sw-2".to_owned()),
     dpop_bound_access_tokens: true,
   };
-  let updated = update_metadata(&pool, "client-update", &meta).await?;
+  let updated = update_metadata(&pool, &keys, false, "client-update", &meta).await?;
   let updated = updated.ok_or("update returned None")?;
   assert_eq!(updated.id, created.id);
   assert_eq!(updated.client_name, "Renamed");
   assert_eq!(updated.scope, "api:admin");
+  assert_eq!(updated.contacts, Some(json!(["soc@example.test"])));
+  assert_eq!(
+    updated.request_uris,
+    Some(json!(["https://app.example.test/r"]))
+  );
+  assert_eq!(updated.software_id.as_deref(), Some("sw-2"));
+  assert_eq!(
+    updated.tls_client_auth_subject_dn.as_deref(),
+    Some("CN=client-update")
+  );
+  assert_eq!(
+    updated.tls_client_auth_cert_thumbprint.as_deref(),
+    Some("abc123")
+  );
   assert_eq!(
     updated.jwks_uri.as_deref(),
     Some("https://app.example.test/jwks")
@@ -215,35 +343,96 @@ async fn client_repo_update_metadata_allowlist() -> Result<(), TestError> {
     verify_secret(&pool, "client-update", "keep-me").await?,
     Some(true)
   );
-  assert!(update_metadata(&pool, "missing", &meta).await?.is_none());
+  assert!(
+    update_metadata(&pool, &keys, false, "missing", &meta)
+      .await?
+      .is_none()
+  );
   Ok(())
 }
 
-/// Attached+enabled redirect URIs and registered+enabled scopes are allowed.
+/// Attached+enabled redirect URIs (sealed or legacy plaintext) and registered+
+/// enabled scopes are allowed.
 #[tokio::test]
 async fn client_repo_redirect_and_scope_rules() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
-  insert(&pool, &confidential("client-rules")).await?;
+  let keys = test_keys()?;
+  insert(&pool, &keys, false, &confidential("client-rules")).await?;
   insert_scope(&pool, "api:read", true).await?;
   insert_scope(&pool, "api:disabled", false).await?;
 
-  let uri = redirect_repo::upsert(
+  // Encrypt-only catalog row: `uri_enc` + `uri_index`, plaintext `uri` blanked.
+  let (sealed, _) = redirect_repo::upsert(
     &pool,
-    Uuid::new_v4(),
-    "https://app.example.test/cb",
-    redirect_repo::RedirectKind::Authorize,
-    "",
-    None,
+    &keys,
+    false,
+    &redirect_repo::NewRedirectUri {
+      id: Uuid::new_v4(),
+      uri: "https://app.example.test/cb",
+      kind: redirect_repo::RedirectKind::Authorize,
+      description: "",
+      created_by: None,
+    },
   )
   .await?;
-  redirect_repo::attach(&pool, "client-rules", uri.id, None).await?;
+  redirect_repo::attach(&pool, "client-rules", sealed.id, None).await?;
 
-  assert!(redirect_uri_allowed(&pool, "client-rules", "https://app.example.test/cb").await?);
-  assert!(!redirect_uri_allowed(&pool, "client-rules", "https://evil.example.test/cb").await?);
+  // Legacy NULL-index row: plaintext only, no ciphertext and no index.
+  let legacy_id = Uuid::new_v4();
+  sqlx::query(
+    "INSERT INTO redirect_uris (id, uri, kind, description, created_by, created_at, updated_at) \
+     VALUES ($1, $2, 'authorize', '', NULL, now(), now())",
+  )
+  .bind(legacy_id)
+  .bind("https://legacy.example.test/cb")
+  .execute(&pool)
+  .await?;
+  redirect_repo::attach(&pool, "client-rules", legacy_id, None).await?;
 
-  // Disabling the catalog entry removes it from the effective set.
-  redirect_repo::disable(&pool, uri.id).await?;
-  assert!(!redirect_uri_allowed(&pool, "client-rules", "https://app.example.test/cb").await?);
+  // Both the sealed row and the legacy plaintext row match.
+  assert!(redirect_uri_allowed(&pool, &keys, "client-rules", "https://app.example.test/cb").await?);
+  assert!(
+    redirect_uri_allowed(
+      &pool,
+      &keys,
+      "client-rules",
+      "https://legacy.example.test/cb"
+    )
+    .await?
+  );
+
+  // An unknown URI, and an enabled-but-unattached catalog URI, do not match.
+  let _ = redirect_repo::upsert(
+    &pool,
+    &keys,
+    false,
+    &redirect_repo::NewRedirectUri {
+      id: Uuid::new_v4(),
+      uri: "https://unattached.example.test/cb",
+      kind: redirect_repo::RedirectKind::Authorize,
+      description: "",
+      created_by: None,
+    },
+  )
+  .await?;
+  assert!(
+    !redirect_uri_allowed(&pool, &keys, "client-rules", "https://evil.example.test/cb").await?
+  );
+  assert!(
+    !redirect_uri_allowed(
+      &pool,
+      &keys,
+      "client-rules",
+      "https://unattached.example.test/cb"
+    )
+    .await?
+  );
+
+  // Disabling the sealed catalog entry removes it from the effective set.
+  redirect_repo::disable(&pool, &keys, sealed.id).await?;
+  assert!(
+    !redirect_uri_allowed(&pool, &keys, "client-rules", "https://app.example.test/cb").await?
+  );
 
   // Scope must be registered by the client AND enabled in the catalog.
   assert!(scope_allowed(&pool, "client-rules", "api:read").await?);
@@ -259,9 +448,10 @@ async fn client_repo_redirect_and_scope_rules() -> Result<(), TestError> {
 #[tokio::test]
 async fn client_repo_registration_token_hash() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
   let mut new = confidential("client-reg");
   new.registration_access_token_hash = Some("v1.initial".to_owned());
-  let created = insert(&pool, &new).await?;
+  let created = insert(&pool, &keys, false, &new).await?;
 
   assert_eq!(
     registration_token_hash(&pool, "client-reg")
@@ -282,3 +472,243 @@ async fn client_repo_registration_token_hash() -> Result<(), TestError> {
   let _: &Client = &created;
   Ok(())
 }
+
+/// A duplicate mTLS identity is rejected with the payload-free typed error,
+/// which renders `invalid_client_metadata` (400) — never a 500.
+#[tokio::test]
+async fn client_repo_tls_identity_index_unique() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  let mut first = confidential("client-tls-a");
+  first.tls_client_auth_subject_dn = Some("CN=alpha, O=Example".to_owned());
+  first.tls_client_auth_cert_thumbprint = Some("thumb-alpha".to_owned());
+  insert(&pool, &keys, false, &first).await?;
+
+  // A distinct identity inserts cleanly.
+  let mut other = confidential("client-tls-b");
+  other.tls_client_auth_subject_dn = Some("CN=beta, O=Example".to_owned());
+  other.tls_client_auth_cert_thumbprint = Some("thumb-beta".to_owned());
+  insert(&pool, &keys, false, &other).await?;
+
+  // A duplicate subject DN is rejected without any identity payload.
+  let mut dupe_dn = confidential("client-tls-dupe-dn");
+  dupe_dn.tls_client_auth_subject_dn = Some("CN=alpha, O=Example".to_owned());
+  let error = match insert(&pool, &keys, false, &dupe_dn).await {
+    Ok(_) => return Err("duplicate subject DN accepted".into()),
+    Err(error) => error,
+  };
+  assert!(matches!(error, AppError::DuplicateClientIdentity));
+  assert_eq!(error.code(), OAuthErrorCode::InvalidClientMetadata);
+  assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST);
+  assert!(!format!("{error:?}").contains("CN=alpha"));
+
+  // A duplicate thumbprint is rejected the same way.
+  let mut dupe_thumb = confidential("client-tls-dupe-thumb");
+  dupe_thumb.tls_client_auth_cert_thumbprint = Some("thumb-alpha".to_owned());
+  let error = match insert(&pool, &keys, false, &dupe_thumb).await {
+    Ok(_) => return Err("duplicate thumbprint accepted".into()),
+    Err(error) => error,
+  };
+  assert!(matches!(error, AppError::DuplicateClientIdentity));
+  assert_eq!(error.code(), OAuthErrorCode::InvalidClientMetadata);
+  Ok(())
+}
+
+/// A row with NULL `_enc` (simulated N-1) still reads via the plaintext.
+#[tokio::test]
+async fn client_repo_decrypts_legacy_plaintext_row() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  insert_legacy(
+    &pool,
+    "client-legacy",
+    Some("CN=legacy, O=Example"),
+    Some("thumb-legacy"),
+  )
+  .await?;
+
+  let found = find_active_by_client_id(&pool, &keys, "client-legacy")
+    .await?
+    .ok_or("legacy row missing")?;
+  assert_eq!(found.client_name, "Legacy Client");
+  assert_eq!(
+    found.tls_client_auth_subject_dn.as_deref(),
+    Some("CN=legacy, O=Example")
+  );
+  assert_eq!(
+    found.tls_client_auth_cert_thumbprint.as_deref(),
+    Some("thumb-legacy")
+  );
+  assert_eq!(
+    found.jwks_uri.as_deref(),
+    Some("https://legacy.example.test/jwks")
+  );
+  assert_eq!(found.software_id.as_deref(), Some("sw-legacy"));
+
+  let listed = list(&pool, &keys, false).await?;
+  assert!(
+    listed
+      .iter()
+      .any(|client| client.client_id == "client-legacy")
+  );
+  Ok(())
+}
+
+/// A duplicate identity against a legacy NULL-index row is rejected: the
+/// partial unique index alone would miss it, the dual-predicate pre-check
+/// catches it.
+#[tokio::test]
+async fn client_repo_rejects_duplicate_vs_legacy_identity() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  insert_legacy(&pool, "client-legacy-dn", Some("CN=taken, O=Example"), None).await?;
+  insert_legacy(&pool, "client-legacy-thumb", None, Some("thumb-taken")).await?;
+
+  let mut dupe_dn = confidential("client-new-dn");
+  dupe_dn.tls_client_auth_subject_dn = Some("CN=taken, O=Example".to_owned());
+  let error = match insert(&pool, &keys, false, &dupe_dn).await {
+    Ok(_) => return Err("duplicate legacy DN accepted".into()),
+    Err(error) => error,
+  };
+  assert!(matches!(error, AppError::DuplicateClientIdentity));
+
+  let mut dupe_thumb = confidential("client-new-thumb");
+  dupe_thumb.tls_client_auth_cert_thumbprint = Some("thumb-taken".to_owned());
+  let error = match insert(&pool, &keys, false, &dupe_thumb).await {
+    Ok(_) => return Err("duplicate legacy thumbprint accepted".into()),
+    Err(error) => error,
+  };
+  assert!(matches!(error, AppError::DuplicateClientIdentity));
+  Ok(())
+}
+
+/// `update_metadata` rejects an identity taken by a legacy NULL-index row
+/// (excluding the row itself), yet accepts a rewrite of the client's own
+/// unchanged DN.
+#[tokio::test]
+async fn client_repo_update_metadata_rejects_duplicate_vs_legacy_identity() -> Result<(), TestError>
+{
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  insert_legacy(
+    &pool,
+    "client-legacy-holder",
+    Some("CN=held, O=Example"),
+    None,
+  )
+  .await?;
+
+  let mut new = confidential("client-owned");
+  new.tls_client_auth_subject_dn = Some("CN=owned, O=Example".to_owned());
+  insert(&pool, &keys, false, &new).await?;
+
+  // Rewriting the client's OWN unchanged DN succeeds (self-exclusion).
+  let mut same = ClientMetadata {
+    client_name: "Owned".to_owned(),
+    grant_types: vec!["authorization_code".to_owned()],
+    response_types: vec!["code".to_owned()],
+    scope: "api:read".to_owned(),
+    token_endpoint_auth_method: "tls_client_auth".to_owned(),
+    jwks: None,
+    jwks_uri: None,
+    tls_client_auth_subject_dn: Some("CN=owned, O=Example".to_owned()),
+    tls_client_auth_cert_thumbprint: None,
+    request_uris: None,
+    contacts: None,
+    software_id: None,
+    dpop_bound_access_tokens: false,
+  };
+  let kept = update_metadata(&pool, &keys, false, "client-owned", &same)
+    .await?
+    .ok_or("self-update returned None")?;
+  assert_eq!(
+    kept.tls_client_auth_subject_dn.as_deref(),
+    Some("CN=owned, O=Example")
+  );
+
+  // Claiming the legacy row's DN is rejected.
+  same.tls_client_auth_subject_dn = Some("CN=held, O=Example".to_owned());
+  let error = match update_metadata(&pool, &keys, false, "client-owned", &same).await {
+    Ok(_) => return Err("duplicate legacy DN update accepted".into()),
+    Err(error) => error,
+  };
+  assert!(matches!(error, AppError::DuplicateClientIdentity));
+  Ok(())
+}
+
+/// A corrupt `client_name_enc` fails closed with `PiiDecrypt` (503), never
+/// a 500 and never a plaintext fallback.
+#[tokio::test]
+async fn client_repo_corrupt_ciphertext_fails_closed() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  insert(&pool, &keys, false, &confidential("client-corrupt")).await?;
+
+  sqlx::query("UPDATE oauth_clients SET client_name_enc = decode('00', 'hex') WHERE client_id = 'client-corrupt'")
+    .execute(&pool)
+    .await?;
+  let error = match find_active_by_client_id(&pool, &keys, "client-corrupt").await {
+    Ok(_) => return Err("corrupt ciphertext read succeeded".into()),
+    Err(error) => error,
+  };
+  assert!(matches!(error, AppError::PiiDecrypt));
+  assert_eq!(error.code(), OAuthErrorCode::TemporarilyUnavailable);
+  assert_eq!(error.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
+  Ok(())
+}
+
+/// `Debug` of a populated projection exposes no PII.
+#[tokio::test]
+async fn client_repo_debug_redacts_pii() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  let mut new = confidential("client-debug");
+  new.client_name = "Ada Lovelace".to_owned();
+  new.jwks_uri = Some("https://app.example.test/jwks".to_owned());
+  new.contacts = Some(json!(["ada@example.test"]));
+  new.request_uris = Some(json!(["https://app.example.test/r"]));
+  new.software_id = Some("sw-debug".to_owned());
+  new.tls_client_auth_subject_dn = Some("CN=debug, O=Example".to_owned());
+  new.tls_client_auth_cert_thumbprint = Some("debug-thumb".to_owned());
+  let created = insert(&pool, &keys, false, &new).await?;
+
+  let rendered = format!("{created:?}");
+  for secret in [
+    "Ada Lovelace",
+    "https://app.example.test/jwks",
+    "ada@example.test",
+    "https://app.example.test/r",
+    "sw-debug",
+    "CN=debug, O=Example",
+    "debug-thumb",
+  ] {
+    assert!(!rendered.contains(secret), "Debug leaked PII: {secret}");
+  }
+
+  let meta = ClientMetadata {
+    client_name: "Ada Lovelace".to_owned(),
+    grant_types: vec!["authorization_code".to_owned()],
+    response_types: vec!["code".to_owned()],
+    scope: String::new(),
+    token_endpoint_auth_method: "none".to_owned(),
+    jwks: None,
+    jwks_uri: Some("https://app.example.test/jwks".to_owned()),
+    tls_client_auth_subject_dn: Some("CN=debug, O=Example".to_owned()),
+    tls_client_auth_cert_thumbprint: Some("debug-thumb".to_owned()),
+    request_uris: Some(json!(["https://app.example.test/r"])),
+    contacts: Some(json!(["ada@example.test"])),
+    software_id: Some("sw-debug".to_owned()),
+    dpop_bound_access_tokens: false,
+  };
+  let rendered = format!("{meta:?}");
+  for secret in [
+    "Ada Lovelace",
+    "https://app.example.test/jwks",
+    "ada@example.test",
+    "CN=debug, O=Example",
+    "debug-thumb",
+  ] {
+    assert!(!rendered.contains(secret), "Debug leaked PII: {secret}");
+  }
+  Ok(())
+}

+ 65 - 2
src/db/client_repo/types.rs

@@ -2,6 +2,8 @@
 //! [`ClientMetadata`] update set, [`NewClient`] insert input, and the
 //! [`TokenEndpointAuthMethod`] allowlist.
 
+use std::fmt;
+
 use time::OffsetDateTime;
 use uuid::Uuid;
 
@@ -30,7 +32,12 @@ pub enum ClientRepoError {
 ///
 /// This is the safe projection: it never contains `client_secret_hash` or
 /// `registration_access_token_hash`.
-#[derive(Debug, Clone, PartialEq)]
+///
+/// `Debug` redacts every sealed PII field (RFC 7591 §3.2.2 metadata): the
+/// rendered form carries `[REDACTED]` for `client_name`, `jwks_uri`,
+/// `tls_client_auth_subject_dn`, `tls_client_auth_cert_thumbprint`,
+/// `request_uris`, `contacts`, and `software_id`, so PII never reaches a log.
+#[derive(Clone, PartialEq)]
 pub struct Client {
   /// Surrogate primary key.
   pub id: Uuid,
@@ -74,6 +81,36 @@ pub struct Client {
   pub updated_at: OffsetDateTime,
 }
 
+impl fmt::Debug for Client {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    f.debug_struct("Client")
+      .field("id", &self.id)
+      .field("client_id", &self.client_id)
+      .field("client_secret_expires_at", &self.client_secret_expires_at)
+      .field("client_id_issued_at", &self.client_id_issued_at)
+      .field("client_name", &"[REDACTED]")
+      .field("grant_types", &self.grant_types)
+      .field("response_types", &self.response_types)
+      .field("scope", &self.scope)
+      .field(
+        "token_endpoint_auth_method",
+        &self.token_endpoint_auth_method,
+      )
+      .field("jwks", &self.jwks)
+      .field("jwks_uri", &"[REDACTED]")
+      .field("tls_client_auth_subject_dn", &"[REDACTED]")
+      .field("tls_client_auth_cert_thumbprint", &"[REDACTED]")
+      .field("request_uris", &"[REDACTED]")
+      .field("contacts", &"[REDACTED]")
+      .field("software_id", &"[REDACTED]")
+      .field("dpop_bound_access_tokens", &self.dpop_bound_access_tokens)
+      .field("active", &self.active)
+      .field("created_at", &self.created_at)
+      .field("updated_at", &self.updated_at)
+      .finish()
+  }
+}
+
 impl Client {
   /// Parse this client's registered `token_endpoint_auth_method`.
   ///
@@ -91,7 +128,10 @@ impl Client {
 /// This is exactly the column set [`super::update_metadata`] may write;
 /// identity, secret, issue time, active flag, and registration token are
 /// deliberately absent so a metadata update can never touch them.
-#[derive(Debug, Clone, PartialEq)]
+///
+/// `Debug` redacts the sealed PII subset with the same `[REDACTED]` rule as
+/// [`Client`].
+#[derive(Clone, PartialEq)]
 pub struct ClientMetadata {
   /// Human-readable client name.
   pub client_name: String,
@@ -121,6 +161,29 @@ pub struct ClientMetadata {
   pub dpop_bound_access_tokens: bool,
 }
 
+impl fmt::Debug for ClientMetadata {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    f.debug_struct("ClientMetadata")
+      .field("client_name", &"[REDACTED]")
+      .field("grant_types", &self.grant_types)
+      .field("response_types", &self.response_types)
+      .field("scope", &self.scope)
+      .field(
+        "token_endpoint_auth_method",
+        &self.token_endpoint_auth_method,
+      )
+      .field("jwks", &self.jwks)
+      .field("jwks_uri", &"[REDACTED]")
+      .field("tls_client_auth_subject_dn", &"[REDACTED]")
+      .field("tls_client_auth_cert_thumbprint", &"[REDACTED]")
+      .field("request_uris", &"[REDACTED]")
+      .field("contacts", &"[REDACTED]")
+      .field("software_id", &"[REDACTED]")
+      .field("dpop_bound_access_tokens", &self.dpop_bound_access_tokens)
+      .finish()
+  }
+}
+
 /// Input for [`super::insert`].
 ///
 /// Deliberately does not derive `Debug`, since it carries a secret hash and a

+ 1 - 1
src/db/mod.rs

@@ -11,7 +11,7 @@ pub mod user_repo;
 pub mod user_scope_repo;
 
 pub use client_repo::{Client, ClientMetadata, NewClient, TokenEndpointAuthMethod};
-pub use redirect_repo::{RedirectKind, RedirectUri};
+pub use redirect_repo::{NewRedirectUri, RedirectKind, RedirectUri, UpsertOutcome};
 
 /// Test-only scaffolding shared by the repository suites.
 ///

+ 356 - 82
src/db/redirect_repo.rs

@@ -5,11 +5,24 @@
 //! by DCR are upserted here, then attached. Every query is a parameterized
 //! `sqlx` macro call, so the schema is checked at compile time and no SQL is
 //! ever interpolated.
+//!
+//! The URI itself is PII: it is sealed into `uri_enc` (AES-256-GCM under the
+//! PII key chain) and equality-matched through the keyed blind index
+//! `uri_index` (`"{kid}:{hex}"`), with a legacy-plaintext fallback for rows
+//! whose `uri_index` is still NULL. Plaintext `uri` is written only while
+//! `dual_write` holds. Reads never compare decrypted URIs in SQL; result
+//! sets are ordered by the decrypted URI in Rust.
+
+use std::fmt;
 
 use sqlx::PgPool;
 use time::OffsetDateTime;
 use uuid::Uuid;
 
+use crate::crypto::field::{blind_index_active, blind_index_candidates, open_text, seal_text};
+use crate::error::AppError;
+use crate::keys::KeyRing;
+
 #[cfg(test)]
 mod tests;
 
@@ -43,11 +56,14 @@ impl RedirectKind {
 }
 
 /// A catalog redirect URI.
-#[derive(Debug, Clone, PartialEq)]
+///
+/// `Debug` redacts `uri`: the rendered form carries `[REDACTED]` so the
+/// sealed PII never reaches a log.
+#[derive(Clone, PartialEq)]
 pub struct RedirectUri {
   /// Surrogate primary key.
   pub id: Uuid,
-  /// Normalized-exact URI, unique per kind in the catalog.
+  /// Normalized-exact URI, unique per kind in the catalog (decrypted).
   pub uri: String,
   /// Stored `kind` discriminator; see [`RedirectUri::kind`].
   pub kind: String,
@@ -63,6 +79,21 @@ pub struct RedirectUri {
   pub updated_at: OffsetDateTime,
 }
 
+impl fmt::Debug for RedirectUri {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    f.debug_struct("RedirectUri")
+      .field("id", &self.id)
+      .field("uri", &"[REDACTED]")
+      .field("kind", &self.kind)
+      .field("enabled", &self.enabled)
+      .field("description", &self.description)
+      .field("created_by", &self.created_by)
+      .field("created_at", &self.created_at)
+      .field("updated_at", &self.updated_at)
+      .finish()
+  }
+}
+
 impl RedirectUri {
   /// The typed role this URI plays.
   #[must_use]
@@ -71,54 +102,239 @@ impl RedirectUri {
   }
 }
 
+/// Input for [`upsert`]: the normalized-exact URI plus its catalog identity.
+pub struct NewRedirectUri<'a> {
+  /// Surrogate id used only when the row is new.
+  pub id: Uuid,
+  /// Normalized-exact URI to seal and index.
+  pub uri: &'a str,
+  /// The role the URI plays.
+  pub kind: RedirectKind,
+  /// Human-readable description, stored only for new rows.
+  pub description: &'a str,
+  /// Admin creating the entry, if known.
+  pub created_by: Option<Uuid>,
+}
+
+/// Whether [`upsert`] inserted a row or matched an existing one.
+///
+/// Mirrors the CLI `redirect::upsert` created-vs-updated distinction (`xmax =
+/// 0`): `Created` for a fresh insert, `Updated` for an adopted legacy row, a
+/// partial-index conflict winner, or a re-upsert of an existing row.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum UpsertOutcome {
+  /// A new catalog row was inserted.
+  Created,
+  /// An existing row was matched (and re-sealed) instead.
+  Updated,
+}
+
+/// Resolve the stored URI: open the ciphertext when present, else use the
+/// legacy plaintext. A non-NULL ciphertext that fails to open never falls
+/// back to plaintext; a row with neither representation fails closed.
+///
+/// # Errors
+///
+/// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open
+/// or when the row carries no usable URI at all.
+fn decrypt_stored_uri(
+  keys: &KeyRing,
+  enc: Option<Vec<u8>>,
+  plain: Option<String>,
+) -> Result<String, AppError> {
+  if let Some(blob) = enc {
+    return open_text(keys, &blob).map_err(|_| AppError::PiiDecrypt);
+  }
+  plain.ok_or(AppError::PiiDecrypt)
+}
+
+/// Sealed form of a URI: ciphertext plus its blind indexes.
+struct SealedUri {
+  /// AES-256-GCM ciphertext of the exact URI.
+  enc: Vec<u8>,
+  /// Blind index under the active PII key (`"{kid}:{hex}"`).
+  index: String,
+  /// Blind indexes under every retained PII key, newest first.
+  candidates: Vec<String>,
+}
+
+/// Seal a URI and derive its blind indexes under `keys`.
+///
+/// # Errors
+///
+/// Returns a masked `temporarily_unavailable` error when sealing fails.
+fn seal_uri(keys: &KeyRing, uri: &str) -> Result<SealedUri, AppError> {
+  Ok(SealedUri {
+    enc: seal_text(keys, uri).map_err(AppError::from)?,
+    index: blind_index_active(keys, uri),
+    candidates: blind_index_candidates(keys, uri),
+  })
+}
+
+/// Re-seal an existing catalog row matched by the dual predicate (blind-index
+/// candidates or legacy plaintext) without touching `description`/`enabled`.
+///
+/// Returns the decrypted row, or `None` when no row matches.
+async fn update_existing(
+  pool: &PgPool,
+  keys: &KeyRing,
+  dual_write: bool,
+  new: &NewRedirectUri<'_>,
+) -> Result<Option<RedirectUri>, AppError> {
+  let kind = new.kind.as_str();
+  let sealed = seal_uri(keys, new.uri)?;
+  let plain: Option<&str> = if dual_write { Some(new.uri) } else { None };
+  let row = sqlx::query!(
+    "UPDATE redirect_uris SET uri = $1, uri_enc = $2, uri_index = $3, updated_at = now() \
+     WHERE kind = $4 AND (uri_index = ANY($5) OR uri = $6) \
+     RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at, uri_enc",
+    plain,
+    sealed.enc,
+    sealed.index,
+    kind,
+    &sealed.candidates,
+    new.uri,
+  )
+  .fetch_optional(pool)
+  .await?;
+  row
+    .map(|row| {
+      Ok(RedirectUri {
+        id: row.id,
+        uri: decrypt_stored_uri(keys, row.uri_enc, row.uri)?,
+        kind: row.kind,
+        enabled: row.enabled,
+        description: row.description,
+        created_by: row.created_by,
+        created_at: row.created_at,
+        updated_at: row.updated_at,
+      })
+    })
+    .transpose()
+}
+
 /// Upsert a catalog URI by its normalized-exact value and kind.
 ///
-/// If a row for `(uri, kind)` already exists, it is returned unchanged apart
-/// from `updated_at`; the passed `id` is used only when the row is new. This
-/// lets DCR register a dynamic loopback URI without resurrecting an
-/// admin-disabled entry.
+/// The URI is sealed into `uri_enc` and blind-indexed into `uri_index`;
+/// plaintext `uri` is written only while `dual_write` holds. If a row for
+/// `(uri, kind)` already exists — as an indexed row or as a legacy
+/// NULL-index row — it is re-sealed in place and returned with
+/// [`UpsertOutcome::Updated`] — the passed `id` is used only when the row is
+/// new. `description` and `enabled` are never overwritten, so DCR can
+/// register a dynamic loopback URI without resurrecting an admin-disabled
+/// entry or clobbering its description.
+///
+/// The write runs in two phases because a legacy row carries a NULL
+/// `uri_index` the partial unique index cannot see: first an `UPDATE` over
+/// the dual predicate, then an `INSERT` whose conflict target carries the
+/// partial-index predicate (`WHERE uri_index IS NOT NULL`, required for
+/// index inference — omitting it raises `42P10`). A `23505` from either
+/// unique constraint (a lost insert race against the legacy `(uri, kind)`
+/// constraint or the indexed one) retries the `UPDATE` against the winner.
 ///
 /// # Errors
 ///
-/// Returns a database error if the write fails.
+/// Returns [`AppError::PiiDecrypt`] when a matched row's ciphertext fails to
+/// open, a masked error on any other database failure, or a seal failure as
+/// `temporarily_unavailable` when PII sealing itself fails.
 pub async fn upsert(
   pool: &PgPool,
-  id: Uuid,
-  uri: &str,
-  kind: RedirectKind,
-  description: &str,
-  created_by: Option<Uuid>,
-) -> Result<RedirectUri, sqlx::Error> {
-  sqlx::query_as!(
-    RedirectUri,
-    "INSERT INTO redirect_uris (id, uri, kind, description, created_by, created_at, updated_at) \
-     VALUES ($1, $2, $3, $4, $5, now(), now()) \
-     ON CONFLICT (uri, kind) DO UPDATE SET updated_at = now() \
-     RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at",
-    id,
-    uri,
-    kind.as_str(),
-    description,
-    created_by,
+  keys: &KeyRing,
+  dual_write: bool,
+  new: &NewRedirectUri<'_>,
+) -> Result<(RedirectUri, UpsertOutcome), AppError> {
+  let kind = new.kind.as_str();
+  let sealed = seal_uri(keys, new.uri)?;
+  let plain: Option<&str> = if dual_write { Some(new.uri) } else { None };
+
+  if let Some(row) = update_existing(pool, keys, dual_write, new).await? {
+    return Ok((row, UpsertOutcome::Updated));
+  }
+  let inserted = sqlx::query!(
+    "INSERT INTO redirect_uris (id, uri, kind, description, created_by, created_at, updated_at, \
+       uri_enc, uri_index) \
+     VALUES ($1, $2, $3, $4, $5, now(), now(), $6, $7) \
+     ON CONFLICT (uri_index, kind) WHERE uri_index IS NOT NULL \
+     DO UPDATE SET updated_at = now() \
+     RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at, uri_enc, \
+       (xmax = 0) AS \"inserted!\"",
+    new.id,
+    plain,
+    kind,
+    new.description,
+    new.created_by,
+    sealed.enc,
+    sealed.index,
   )
   .fetch_one(pool)
-  .await
+  .await;
+  match inserted {
+    Ok(row) => {
+      let outcome = if row.inserted {
+        UpsertOutcome::Created
+      } else {
+        UpsertOutcome::Updated
+      };
+      Ok((
+        RedirectUri {
+          id: row.id,
+          uri: decrypt_stored_uri(keys, row.uri_enc, row.uri)?,
+          kind: row.kind,
+          enabled: row.enabled,
+          description: row.description,
+          created_by: row.created_by,
+          created_at: row.created_at,
+          updated_at: row.updated_at,
+        },
+        outcome,
+      ))
+    }
+    Err(sqlx::Error::Database(db)) if db.code().as_deref() == Some("23505") => {
+      match update_existing(pool, keys, dual_write, new).await? {
+        Some(row) => Ok((row, UpsertOutcome::Updated)),
+        None => Err(AppError::server_error()),
+      }
+    }
+    Err(other) => Err(AppError::from(other)),
+  }
 }
 
-/// List catalog URIs ordered by `uri`, optionally including disabled entries.
+/// List catalog URIs ordered by the decrypted `uri`, optionally including
+/// disabled entries.
 ///
 /// # Errors
 ///
-/// Returns a database error if the query fails.
-pub async fn list(pool: &PgPool, include_disabled: bool) -> Result<Vec<RedirectUri>, sqlx::Error> {
-  sqlx::query_as!(
-    RedirectUri,
-    "SELECT id, uri, kind, enabled, description, created_by, created_at, updated_at \
-     FROM redirect_uris WHERE ($1 OR enabled = true) ORDER BY uri",
+/// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open,
+/// or a masked error on any database failure.
+pub async fn list(
+  pool: &PgPool,
+  keys: &KeyRing,
+  include_disabled: bool,
+) -> Result<Vec<RedirectUri>, AppError> {
+  let rows = sqlx::query!(
+    "SELECT id, uri, kind, enabled, description, created_by, created_at, updated_at, uri_enc \
+     FROM redirect_uris WHERE ($1 OR enabled = true)",
     include_disabled,
   )
   .fetch_all(pool)
-  .await
+  .await?;
+  let mut uris = Vec::with_capacity(rows.len());
+  for row in rows {
+    uris.push(RedirectUri {
+      id: row.id,
+      uri: decrypt_stored_uri(keys, row.uri_enc, row.uri)?,
+      kind: row.kind,
+      enabled: row.enabled,
+      description: row.description,
+      created_by: row.created_by,
+      created_at: row.created_at,
+      updated_at: row.updated_at,
+    });
+  }
+  // SQL `ORDER BY uri` is meaningless once plaintext is blanked; sort by the
+  // decrypted value instead.
+  uris.sort_by(|left, right| left.uri.cmp(&right.uri));
+  Ok(uris)
 }
 
 /// Enable or disable a catalog URI. Returns the updated row, or `None` if the
@@ -126,39 +342,64 @@ pub async fn list(pool: &PgPool, include_disabled: bool) -> Result<Vec<RedirectU
 ///
 /// # Errors
 ///
-/// Returns a database error if the update fails.
+/// Returns [`AppError::PiiDecrypt`] when the row's ciphertext fails to open,
+/// or a masked error on any database failure.
 pub async fn set_enabled(
   pool: &PgPool,
+  keys: &KeyRing,
   id: Uuid,
   enabled: bool,
-) -> Result<Option<RedirectUri>, sqlx::Error> {
-  sqlx::query_as!(
-    RedirectUri,
+) -> Result<Option<RedirectUri>, AppError> {
+  let row = sqlx::query!(
     "UPDATE redirect_uris SET enabled = $2, updated_at = now() WHERE id = $1 \
-     RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at",
+     RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at, uri_enc",
     id,
     enabled,
   )
   .fetch_optional(pool)
-  .await
+  .await?;
+  row
+    .map(|row| {
+      Ok(RedirectUri {
+        id: row.id,
+        uri: decrypt_stored_uri(keys, row.uri_enc, row.uri)?,
+        kind: row.kind,
+        enabled: row.enabled,
+        description: row.description,
+        created_by: row.created_by,
+        created_at: row.created_at,
+        updated_at: row.updated_at,
+      })
+    })
+    .transpose()
 }
 
 /// Enable a catalog URI. Returns the updated row, or `None` if unknown.
 ///
 /// # Errors
 ///
-/// Returns a database error if the update fails.
-pub async fn enable(pool: &PgPool, id: Uuid) -> Result<Option<RedirectUri>, sqlx::Error> {
-  set_enabled(pool, id, true).await
+/// Returns [`AppError::PiiDecrypt`] when the row's ciphertext fails to open,
+/// or a masked error on any database failure.
+pub async fn enable(
+  pool: &PgPool,
+  keys: &KeyRing,
+  id: Uuid,
+) -> Result<Option<RedirectUri>, AppError> {
+  set_enabled(pool, keys, id, true).await
 }
 
 /// Disable a catalog URI. Returns the updated row, or `None` if unknown.
 ///
 /// # Errors
 ///
-/// Returns a database error if the update fails.
-pub async fn disable(pool: &PgPool, id: Uuid) -> Result<Option<RedirectUri>, sqlx::Error> {
-  set_enabled(pool, id, false).await
+/// Returns [`AppError::PiiDecrypt`] when the row's ciphertext fails to open,
+/// or a masked error on any database failure.
+pub async fn disable(
+  pool: &PgPool,
+  keys: &KeyRing,
+  id: Uuid,
+) -> Result<Option<RedirectUri>, AppError> {
+  set_enabled(pool, keys, id, false).await
 }
 
 /// Delete a catalog URI only while no client is attached to it.
@@ -244,32 +485,49 @@ pub async fn detach_all(pool: &PgPool, client_id: &str) -> Result<u64, sqlx::Err
   Ok(result.rows_affected())
 }
 
-/// List every URI attached to a client (enabled or not), ordered by `uri`.
+/// List every URI attached to a client (enabled or not), ordered by the
+/// decrypted `uri`.
 ///
 /// This is the management view; use [`effective_for_client`] for matching.
 ///
 /// # Errors
 ///
-/// Returns a database error if the query fails.
+/// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open,
+/// or a masked error on any database failure.
 pub async fn list_for_client(
   pool: &PgPool,
+  keys: &KeyRing,
   client_id: &str,
-) -> Result<Vec<RedirectUri>, sqlx::Error> {
-  sqlx::query_as!(
-    RedirectUri,
+) -> Result<Vec<RedirectUri>, AppError> {
+  let rows = sqlx::query!(
     "SELECT ru.id, ru.uri, ru.kind, ru.enabled, ru.description, ru.created_by, \
-       ru.created_at, ru.updated_at \
+       ru.created_at, ru.updated_at, ru.uri_enc \
      FROM client_redirect_uris AS cru \
      JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id \
-     WHERE cru.client_id = $1 ORDER BY ru.uri",
+     WHERE cru.client_id = $1",
     client_id,
   )
   .fetch_all(pool)
-  .await
+  .await?;
+  let mut uris = Vec::with_capacity(rows.len());
+  for row in rows {
+    uris.push(RedirectUri {
+      id: row.id,
+      uri: decrypt_stored_uri(keys, row.uri_enc, row.uri)?,
+      kind: row.kind,
+      enabled: row.enabled,
+      description: row.description,
+      created_by: row.created_by,
+      created_at: row.created_at,
+      updated_at: row.updated_at,
+    });
+  }
+  uris.sort_by(|left, right| left.uri.cmp(&right.uri));
+  Ok(uris)
 }
 
 /// The effective (attached + enabled) authorization-response URIs for a client,
-/// ordered by `uri`.
+/// ordered by the decrypted `uri`.
 ///
 /// This is the exact set authorize matching (and RFC 7592 GET serialization)
 /// must use. Post-logout URIs are excluded: they are a separate landing kind and
@@ -277,47 +535,70 @@ pub async fn list_for_client(
 ///
 /// # Errors
 ///
-/// Returns a database error if the query fails.
+/// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open,
+/// or a masked error on any database failure.
 pub async fn effective_for_client(
   pool: &PgPool,
+  keys: &KeyRing,
   client_id: &str,
-) -> Result<Vec<RedirectUri>, sqlx::Error> {
-  sqlx::query_as!(
-    RedirectUri,
+) -> Result<Vec<RedirectUri>, AppError> {
+  let rows = sqlx::query!(
     "SELECT ru.id, ru.uri, ru.kind, ru.enabled, ru.description, ru.created_by, \
-       ru.created_at, ru.updated_at \
+       ru.created_at, ru.updated_at, ru.uri_enc \
      FROM client_redirect_uris AS cru \
      JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id \
-     WHERE cru.client_id = $1 AND ru.enabled = true AND ru.kind = 'authorize' \
-     ORDER BY ru.uri",
+     WHERE cru.client_id = $1 AND ru.enabled = true AND ru.kind = 'authorize'",
     client_id,
   )
   .fetch_all(pool)
-  .await
+  .await?;
+  let mut uris = Vec::with_capacity(rows.len());
+  for row in rows {
+    uris.push(RedirectUri {
+      id: row.id,
+      uri: decrypt_stored_uri(keys, row.uri_enc, row.uri)?,
+      kind: row.kind,
+      enabled: row.enabled,
+      description: row.description,
+      created_by: row.created_by,
+      created_at: row.created_at,
+      updated_at: row.updated_at,
+    });
+  }
+  uris.sort_by(|left, right| left.uri.cmp(&right.uri));
+  Ok(uris)
 }
 
-/// The client's effective (attached + enabled) post-logout URIs, ordered.
+/// The client's effective (attached + enabled) post-logout URIs, ordered by
+/// the decrypted value.
 ///
 /// Used at logout to re-validate that the session's frozen target is still
 /// registered for the client before redirecting to it.
 ///
 /// # Errors
 ///
-/// Returns a database error if the query fails.
+/// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open,
+/// or a masked error on any database failure.
 pub async fn effective_post_logout_for_client(
   pool: &PgPool,
+  keys: &KeyRing,
   client_id: &str,
-) -> Result<Vec<String>, sqlx::Error> {
-  sqlx::query_scalar!(
-    "SELECT ru.uri \
+) -> Result<Vec<String>, AppError> {
+  let rows = sqlx::query!(
+    "SELECT ru.uri, ru.uri_enc \
      FROM client_redirect_uris AS cru \
      JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id \
-     WHERE cru.client_id = $1 AND ru.enabled = true AND ru.kind = 'post_logout' \
-     ORDER BY ru.uri",
+     WHERE cru.client_id = $1 AND ru.enabled = true AND ru.kind = 'post_logout'",
     client_id,
   )
   .fetch_all(pool)
-  .await
+  .await?;
+  let mut uris = Vec::with_capacity(rows.len());
+  for row in rows {
+    uris.push(decrypt_stored_uri(keys, row.uri_enc, row.uri)?);
+  }
+  uris.sort();
+  Ok(uris)
 }
 
 /// The client's single effective post-logout URI, if exactly one is registered.
@@ -330,21 +611,14 @@ pub async fn effective_post_logout_for_client(
 ///
 /// # Errors
 ///
-/// Returns a database error if the query fails.
+/// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open,
+/// or a masked error on any database failure.
 pub async fn post_logout_for_client(
   pool: &PgPool,
+  keys: &KeyRing,
   client_id: &str,
-) -> Result<Option<String>, sqlx::Error> {
-  let uris: Vec<String> = sqlx::query_scalar!(
-    "SELECT ru.uri \
-     FROM client_redirect_uris AS cru \
-     JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id \
-     WHERE cru.client_id = $1 AND ru.enabled = true AND ru.kind = 'post_logout' \
-     ORDER BY ru.uri",
-    client_id,
-  )
-  .fetch_all(pool)
-  .await?;
+) -> Result<Option<String>, AppError> {
+  let uris = effective_post_logout_for_client(pool, keys, client_id).await?;
   match uris.as_slice() {
     [only] => Ok(Some(only.clone())),
     _ => Ok(None),

+ 360 - 51
src/db/redirect_repo/tests.rs

@@ -1,23 +1,56 @@
 //! `test(redirect_repo)` — real-Postgres tests for `db::redirect_repo`.
 //!
-//! Covers catalog upsert/list/enable/disable, delete-only-when-unattached,
-//! attachment idempotence, and the effective attached+enabled set. Every test
-//! gets a fresh migrated database from [`crate::db::test_support`].
+//! Covers encrypted catalog upsert/list/enable/disable, delete-only-when-unattached,
+//! attachment idempotence, the effective attached+enabled set, legacy
+//! NULL-index row adoption, ciphertext-at-rest, and fail-closed corrupt rows.
+//! Every test gets a fresh migrated database from [`crate::db::test_support`].
 
-use sqlx::PgPool;
+use secrecy::SecretString;
+use sqlx::{PgPool, Row as _};
 use uuid::Uuid;
 
 use super::{
-  RedirectKind, attach, delete_if_unattached, disable, effective_for_client, enable, list,
-  list_for_client, set_enabled, upsert,
+  NewRedirectUri, RedirectKind, UpsertOutcome, attach, delete_if_unattached, disable,
+  effective_for_client, effective_post_logout_for_client, enable, list, list_for_client,
+  post_logout_for_client, set_enabled, upsert,
 };
 use crate::db::client_repo::{NewClient, insert};
 use crate::db::test_support::{TestError, migrated_pool};
+use crate::error::AppError;
+use crate::keys::{KeyId, KeyRing};
+
+/// Await a fallible repository call that must fail, returning its error.
+async fn expect_err<T>(result: Result<T, AppError>, what: &str) -> Result<AppError, TestError> {
+  match result {
+    Err(error) => Ok(error),
+    Ok(_) => Err(format!("{what} unexpectedly succeeded").into()),
+  }
+}
+fn test_keys() -> Result<KeyRing, TestError> {
+  Ok(KeyRing::from_parts(
+    vec![(KeyId::new("v1"), SecretString::from("redirect-test-pepper"))],
+    vec![(KeyId::new("v1"), SecretString::from("redirect-test-totp"))],
+    vec![(KeyId::new("v1"), SecretString::from("redirect-test-pii"))],
+  )?)
+}
+
+/// Upsert input with a fresh id and no admin attribution.
+fn new_uri<'a>(uri: &'a str, kind: RedirectKind, description: &'a str) -> NewRedirectUri<'a> {
+  NewRedirectUri {
+    id: Uuid::new_v4(),
+    uri,
+    kind,
+    description,
+    created_by: None,
+  }
+}
 
 /// Insert a minimal client so attachment FKs resolve.
-async fn insert_client(pool: &PgPool, client_id: &str) -> Result<(), TestError> {
+async fn insert_client(pool: &PgPool, keys: &KeyRing, client_id: &str) -> Result<(), TestError> {
   insert(
     pool,
+    keys,
+    false,
     &NewClient {
       id: Uuid::new_v4(),
       client_id: client_id.to_owned(),
@@ -49,55 +82,66 @@ async fn insert_client(pool: &PgPool, client_id: &str) -> Result<(), TestError>
 #[tokio::test]
 async fn redirect_repo_catalog_upsert_list_and_enable() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
-  let first = upsert(
+  let keys = test_keys()?;
+  let (first, outcome) = upsert(
     &pool,
-    Uuid::new_v4(),
-    "https://app.example.test/cb",
-    RedirectKind::Authorize,
-    "admin",
-    None,
+    &keys,
+    false,
+    &new_uri(
+      "https://app.example.test/cb",
+      RedirectKind::Authorize,
+      "admin",
+    ),
   )
   .await?;
+  assert_eq!(outcome, UpsertOutcome::Created);
   assert!(first.enabled);
   assert_eq!(first.description, "admin");
 
-  let disabled = disable(&pool, first.id)
+  let disabled = disable(&pool, &keys, first.id)
     .await?
     .ok_or("disable returned None")?;
   assert!(!disabled.enabled);
 
   // Re-upserting the same URI returns the SAME row and does not re-enable it.
-  let again = upsert(
+  let (again, outcome) = upsert(
     &pool,
-    Uuid::new_v4(),
-    "https://app.example.test/cb",
-    RedirectKind::Authorize,
-    "dynamic",
-    None,
+    &keys,
+    false,
+    &new_uri(
+      "https://app.example.test/cb",
+      RedirectKind::Authorize,
+      "dynamic",
+    ),
   )
   .await?;
+  assert_eq!(outcome, UpsertOutcome::Updated);
   assert_eq!(again.id, first.id);
   assert!(!again.enabled, "upsert must not resurrect a disabled entry");
+  assert_eq!(
+    again.description, "admin",
+    "upsert must not overwrite description"
+  );
 
-  let enabled_row = enable(&pool, first.id)
+  let enabled_row = enable(&pool, &keys, first.id)
     .await?
     .ok_or("enable returned None")?;
   assert!(enabled_row.enabled);
   assert!(
-    set_enabled(&pool, first.id, false)
+    set_enabled(&pool, &keys, first.id, false)
       .await?
       .is_some_and(|row| !row.enabled)
   );
   assert!(
-    set_enabled(&pool, first.id, true)
+    set_enabled(&pool, &keys, first.id, true)
       .await?
       .is_some_and(|row| row.enabled)
   );
 
-  let enabled = list(&pool, false).await?;
+  let enabled = list(&pool, &keys, false).await?;
   assert_eq!(enabled.len(), 1);
   assert_eq!(enabled[0].id, first.id);
-  let all = list(&pool, true).await?;
+  let all = list(&pool, &keys, true).await?;
   assert_eq!(all.len(), 1);
   Ok(())
 }
@@ -106,21 +150,20 @@ async fn redirect_repo_catalog_upsert_list_and_enable() -> Result<(), TestError>
 #[tokio::test]
 async fn redirect_repo_delete_only_when_unattached() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
-  insert_client(&pool, "client-a").await?;
-  let uri = upsert(
+  let keys = test_keys()?;
+  insert_client(&pool, &keys, "client-a").await?;
+  let (uri, _) = upsert(
     &pool,
-    Uuid::new_v4(),
-    "https://a.example.test/cb",
-    RedirectKind::Authorize,
-    "",
-    None,
+    &keys,
+    false,
+    &new_uri("https://a.example.test/cb", RedirectKind::Authorize, ""),
   )
   .await?;
 
   assert!(attach(&pool, "client-a", uri.id, None).await?);
   // Attached: deletion is refused (returns false, row survives).
   assert!(!delete_if_unattached(&pool, uri.id).await?);
-  assert_eq!(list_for_client(&pool, "client-a").await?.len(), 1);
+  assert_eq!(list_for_client(&pool, &keys, "client-a").await?.len(), 1);
 
   assert!(super::detach(&pool, "client-a", uri.id).await?);
   assert!(delete_if_unattached(&pool, uri.id).await?);
@@ -134,23 +177,20 @@ async fn redirect_repo_delete_only_when_unattached() -> Result<(), TestError> {
 #[tokio::test]
 async fn redirect_repo_attach_detach_and_effective_set() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
-  insert_client(&pool, "client-b").await?;
-  let live = upsert(
+  let keys = test_keys()?;
+  insert_client(&pool, &keys, "client-b").await?;
+  let (live, _) = upsert(
     &pool,
-    Uuid::new_v4(),
-    "https://b.example.test/live",
-    RedirectKind::Authorize,
-    "",
-    None,
+    &keys,
+    false,
+    &new_uri("https://b.example.test/live", RedirectKind::Authorize, ""),
   )
   .await?;
-  let dead = upsert(
+  let (dead, _) = upsert(
     &pool,
-    Uuid::new_v4(),
-    "https://b.example.test/dead",
-    RedirectKind::Authorize,
-    "",
-    None,
+    &keys,
+    false,
+    &new_uri("https://b.example.test/dead", RedirectKind::Authorize, ""),
   )
   .await?;
 
@@ -158,18 +198,287 @@ async fn redirect_repo_attach_detach_and_effective_set() -> Result<(), TestError
   assert!(attach(&pool, "client-b", dead.id, None).await?);
   // Re-attaching is a no-op.
   assert!(!attach(&pool, "client-b", live.id, None).await?);
-  assert_eq!(list_for_client(&pool, "client-b").await?.len(), 2);
+  assert_eq!(list_for_client(&pool, &keys, "client-b").await?.len(), 2);
 
-  disable(&pool, dead.id).await?;
-  let effective = effective_for_client(&pool, "client-b").await?;
+  disable(&pool, &keys, dead.id).await?;
+  let effective = effective_for_client(&pool, &keys, "client-b").await?;
   assert_eq!(effective.len(), 1);
   assert_eq!(effective[0].id, live.id);
   // Management view still lists the disabled attached URI.
-  assert_eq!(list_for_client(&pool, "client-b").await?.len(), 2);
+  assert_eq!(list_for_client(&pool, &keys, "client-b").await?.len(), 2);
 
   assert!(super::detach(&pool, "client-b", live.id).await?);
   assert!(!super::detach(&pool, "client-b", live.id).await?);
   assert_eq!(super::detach_all(&pool, "client-b").await?, 1);
-  assert!(effective_for_client(&pool, "client-b").await?.is_empty());
+  assert!(
+    effective_for_client(&pool, &keys, "client-b")
+      .await?
+      .is_empty()
+  );
+  Ok(())
+}
+
+/// Encrypt-only upsert round-trips through ciphertext: a duplicate
+/// `(uri, kind)` updates in place (no second row), plaintext stays NULL at
+/// rest, and the list is ordered by the DECRYPTED uri.
+#[tokio::test]
+async fn redirect_repo_upsert_and_list_round_trip() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  let (second, created) = upsert(
+    &pool,
+    &keys,
+    false,
+    &new_uri("https://example.test/b", RedirectKind::Authorize, "second"),
+  )
+  .await?;
+  assert_eq!(created, UpsertOutcome::Created);
+  let (first, created) = upsert(
+    &pool,
+    &keys,
+    false,
+    &new_uri("https://example.test/a", RedirectKind::Authorize, "first"),
+  )
+  .await?;
+  assert_eq!(created, UpsertOutcome::Created);
+
+  // A duplicate (uri, kind) does not create a second row and keeps the
+  // original description.
+  let (repeat, outcome) = upsert(
+    &pool,
+    &keys,
+    false,
+    &new_uri("https://example.test/a", RedirectKind::Authorize, "changed"),
+  )
+  .await?;
+  assert_eq!(outcome, UpsertOutcome::Updated);
+  assert_eq!(repeat.id, first.id);
+  assert_eq!(repeat.description, "first");
+
+  // A disabled entry stays disabled across upserts.
+  disable(&pool, &keys, second.id).await?;
+  let (resurrected, outcome) = upsert(
+    &pool,
+    &keys,
+    false,
+    &new_uri("https://example.test/b", RedirectKind::Authorize, "second"),
+  )
+  .await?;
+  assert_eq!(outcome, UpsertOutcome::Updated);
+  assert_eq!(resurrected.id, second.id);
+  assert!(!resurrected.enabled);
+
+  // Ciphertext at rest: no plaintext URI is stored while dual-write is off.
+  let stored = sqlx::query("SELECT uri, uri_enc, uri_index FROM redirect_uris")
+    .fetch_all(&pool)
+    .await?;
+  assert_eq!(stored.len(), 2);
+  for row in &stored {
+    let plain: Option<String> = row.try_get("uri")?;
+    let enc: Option<Vec<u8>> = row.try_get("uri_enc")?;
+    let index: Option<String> = row.try_get("uri_index")?;
+    assert!(plain.is_none(), "plaintext uri must be NULL at rest");
+    assert!(enc.is_some(), "uri_enc must be sealed");
+    assert!(index.is_some(), "uri_index must be indexed");
+  }
+
+  // The returned list is ordered by the DECRYPTED uri even though the
+  // plaintext column is blank.
+  let all = list(&pool, &keys, true).await?;
+  let uris: Vec<&str> = all.iter().map(|entry| entry.uri.as_str()).collect();
+  assert_eq!(
+    uris,
+    vec!["https://example.test/a", "https://example.test/b"]
+  );
+  assert!(
+    !format!("{first:?}").contains("https://example.test/a"),
+    "RedirectUri Debug must not leak the sealed URI"
+  );
+  Ok(())
+}
+
+/// Upserting a URI that already exists as a legacy NULL-index row updates it
+/// in place (backfilling `uri_enc`/`uri_index`) instead of inserting a
+/// duplicate row.
+#[tokio::test]
+async fn redirect_repo_upsert_updates_legacy_row() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  let legacy_id = Uuid::new_v4();
+  sqlx::query(
+    "INSERT INTO redirect_uris (id, uri, kind, description, created_by, created_at, updated_at) \
+     VALUES ($1, $2, 'authorize', 'legacy', NULL, now(), now())",
+  )
+  .bind(legacy_id)
+  .bind("https://legacy.example.test/cb")
+  .execute(&pool)
+  .await?;
+
+  // The legacy row still reads through the plaintext fallback.
+  let before = list(&pool, &keys, true).await?;
+  assert_eq!(before.len(), 1);
+  assert_eq!(before[0].id, legacy_id);
+  assert_eq!(before[0].uri, "https://legacy.example.test/cb");
+
+  let (adopted, outcome) = upsert(
+    &pool,
+    &keys,
+    false,
+    &new_uri(
+      "https://legacy.example.test/cb",
+      RedirectKind::Authorize,
+      "dynamic",
+    ),
+  )
+  .await?;
+  assert_eq!(outcome, UpsertOutcome::Updated);
+  assert_eq!(adopted.id, legacy_id);
+  assert_eq!(
+    adopted.description, "legacy",
+    "upsert must preserve a legacy description"
+  );
+
+  // No second row was created, and the legacy row now carries ciphertext.
+  let after = list(&pool, &keys, true).await?;
+  assert_eq!(after.len(), 1);
+  let stored = sqlx::query("SELECT uri_enc, uri_index FROM redirect_uris")
+    .fetch_one(&pool)
+    .await?;
+  let enc: Option<Vec<u8>> = stored.try_get("uri_enc")?;
+  let index: Option<String> = stored.try_get("uri_index")?;
+  assert!(enc.is_some(), "legacy row must gain uri_enc");
+  assert!(index.is_some(), "legacy row must gain uri_index");
+  Ok(())
+}
+
+/// The effective set holds only attached+enabled `authorize` URIs (decrypted,
+/// sorted); post-logout URIs surface only through the post-logout readers.
+#[tokio::test]
+async fn redirect_repo_effective_for_client() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  insert_client(&pool, &keys, "client-c").await?;
+  let (zeta, _) = upsert(
+    &pool,
+    &keys,
+    false,
+    &new_uri("https://c.example.test/zeta", RedirectKind::Authorize, ""),
+  )
+  .await?;
+  let (alpha, _) = upsert(
+    &pool,
+    &keys,
+    false,
+    &new_uri("https://c.example.test/alpha", RedirectKind::Authorize, ""),
+  )
+  .await?;
+  let (dead, _) = upsert(
+    &pool,
+    &keys,
+    false,
+    &new_uri("https://c.example.test/dead", RedirectKind::Authorize, ""),
+  )
+  .await?;
+  let (landing, _) = upsert(
+    &pool,
+    &keys,
+    false,
+    &new_uri(
+      "https://c.example.test/landing",
+      RedirectKind::PostLogout,
+      "",
+    ),
+  )
+  .await?;
+  for id in [zeta.id, alpha.id, dead.id, landing.id] {
+    assert!(attach(&pool, "client-c", id, None).await?);
+  }
+  disable(&pool, &keys, dead.id).await?;
+
+  let effective = effective_for_client(&pool, &keys, "client-c").await?;
+  let uris: Vec<&str> = effective.iter().map(|entry| entry.uri.as_str()).collect();
+  assert_eq!(
+    uris,
+    vec![
+      "https://c.example.test/alpha",
+      "https://c.example.test/zeta"
+    ]
+  );
+
+  let post_logout = effective_post_logout_for_client(&pool, &keys, "client-c").await?;
+  assert_eq!(post_logout, vec!["https://c.example.test/landing"]);
+  assert_eq!(
+    post_logout_for_client(&pool, &keys, "client-c")
+      .await?
+      .as_deref(),
+    Some("https://c.example.test/landing")
+  );
+  Ok(())
+}
+
+/// A corrupt `uri_enc` fails closed as `PiiDecrypt` (503), never as a 500 and
+/// never through a plaintext fallback.
+#[tokio::test]
+async fn redirect_repo_corrupt_ciphertext_fails_closed() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  insert_client(&pool, &keys, "client-d").await?;
+  let (row, _) = upsert(
+    &pool,
+    &keys,
+    false,
+    &new_uri("https://d.example.test/cb", RedirectKind::Authorize, ""),
+  )
+  .await?;
+  attach(&pool, "client-d", row.id, None).await?;
+  let (landing, _) = upsert(
+    &pool,
+    &keys,
+    false,
+    &new_uri(
+      "https://d.example.test/landing",
+      RedirectKind::PostLogout,
+      "",
+    ),
+  )
+  .await?;
+  attach(&pool, "client-d", landing.id, None).await?;
+  sqlx::query("UPDATE redirect_uris SET uri_enc = $1")
+    .bind(b"corrupt-blob".to_vec())
+    .execute(&pool)
+    .await?;
+
+  let error = expect_err(list(&pool, &keys, true).await, "list with corrupt row").await?;
+  assert!(
+    matches!(error, AppError::PiiDecrypt),
+    "corrupt uri_enc must map to PiiDecrypt, got {error:?}"
+  );
+  let error = expect_err(
+    effective_for_client(&pool, &keys, "client-d").await,
+    "effective_for_client with corrupt row",
+  )
+  .await?;
+  assert!(
+    matches!(error, AppError::PiiDecrypt),
+    "corrupt uri_enc must map to PiiDecrypt, got {error:?}"
+  );
+  let error = expect_err(
+    effective_post_logout_for_client(&pool, &keys, "client-d").await,
+    "effective_post_logout_for_client with corrupt row",
+  )
+  .await?;
+  assert!(
+    matches!(error, AppError::PiiDecrypt),
+    "corrupt uri_enc must map to PiiDecrypt, got {error:?}"
+  );
+  let error = expect_err(
+    post_logout_for_client(&pool, &keys, "client-d").await,
+    "post_logout_for_client with corrupt row",
+  )
+  .await?;
+  assert!(
+    matches!(error, AppError::PiiDecrypt),
+    "corrupt uri_enc must map to PiiDecrypt, got {error:?}"
+  );
   Ok(())
 }

+ 132 - 29
src/db/user_repo.rs

@@ -1,8 +1,15 @@
 //! `users` repository (email-as-username, Argon2id, encrypted TOTP).
 //!
 //! Admin-provisioned users only; no public sign-up path exists. Email is
-//! normalized to lowercase before every insert/lookup so `citext` uniqueness
-//! and the failed-login counters agree on one canonical account label.
+//! normalized to lowercase before every insert/lookup so the blind index and
+//! the failed-login counters agree on one canonical account label.
+//!
+//! Email is stored sealed (`email_enc`, AES-256-GCM under the PII key chain)
+//! and looked up through a keyed blind index (`email_index`, newest-first
+//! candidates so retained keys keep matching after a rotation). Reads use the
+//! dual predicate `email_index = ANY($candidates) OR (email_enc IS NULL AND
+//! email = $canonical)` so legacy plaintext rows (both new columns NULL)
+//! stay readable. Plaintext `email` is written only while `dual_write` holds.
 //!
 //! The repository never returns [`password_hash`]: [`UserRecord`] omits it and
 //! [`verify_password`] reads it only long enough to run the constant-time
@@ -16,8 +23,12 @@ use time::OffsetDateTime;
 use uuid::Uuid;
 
 use crate::{
-  crypto::secret::{Argon2Params, hash_secret, verify_secret},
+  crypto::{
+    field::{blind_index_active, blind_index_candidates, open_text, seal_text},
+    secret::{Argon2Params, hash_secret, verify_secret},
+  },
   error::AppError,
+  keys::KeyRing,
 };
 
 /// A user to provision. `password_hash` is an Argon2id PHC string produced by
@@ -85,76 +96,150 @@ pub fn normalize_email(email: &str) -> String {
 
 /// Provision a user, normalizing the email and stamping timestamps.
 ///
+/// The canonical email is sealed into `email_enc` and indexed in
+/// `email_index`; plaintext `email` is written only while `dual_write` holds.
+/// A dual-predicate existence check runs before INSERT because the partial
+/// unique index alone cannot catch a duplicate across the encrypt-only
+/// pre-backfill window (legacy rows carry a NULL index).
+///
 /// `password_changed_at` is set when the account is created ready-to-use and
 /// left `NULL` when the account must change its password first.
 ///
 /// # Errors
-/// Returns an error on a duplicate email or any database failure.
-pub async fn create_user(pool: &sqlx::PgPool, new_user: &NewUser) -> Result<UserRecord, AppError> {
-  let email = normalize_email(&new_user.email);
-  let record = sqlx::query_as!(
-    UserRecord,
+/// Returns [`AppError::DuplicateEmail`] on a duplicate email (including a
+/// race lost at INSERT), `temporarily_unavailable` when PII sealing fails,
+/// or a masked error on any other database failure.
+pub async fn create_user(
+  pool: &sqlx::PgPool,
+  keys: &KeyRing,
+  dual_write: bool,
+  new_user: &NewUser,
+) -> Result<UserRecord, AppError> {
+  let canonical = normalize_email(&new_user.email);
+  let candidates = blind_index_candidates(keys, &canonical);
+  let exists = sqlx::query_scalar!(
+    r#"SELECT EXISTS(
+         SELECT 1 FROM users
+          WHERE email_index = ANY($1)
+             OR (email_enc IS NULL AND email = $2::text::citext)
+       ) AS "exists!""#,
+    &candidates,
+    canonical,
+  )
+  .fetch_one(pool)
+  .await?;
+  if exists {
+    return Err(AppError::DuplicateEmail);
+  }
+  let index = blind_index_active(keys, &canonical);
+  let enc = seal_text(keys, &canonical).map_err(AppError::from)?;
+  let plain: Option<&str> = if dual_write { Some(&canonical) } else { None };
+  let row = sqlx::query!(
     r#"INSERT INTO users
-         (id, email, password_hash, is_admin, must_change_password,
-          password_changed_at, created_at, updated_at)
-       VALUES ($1, $2::text::citext, $3, $4, $5,
-               CASE WHEN $5 THEN NULL ELSE now() END, now(), now())
-       RETURNING id, email::text AS "email!", is_admin, must_change_password,
-                 disabled, totp_enabled, password_changed_at"#,
+         (id, email, email_enc, email_index, password_hash, is_admin,
+          must_change_password, password_changed_at, created_at, updated_at)
+       VALUES ($1, $2::text::citext, $3, $4, $5, $6, $7,
+               CASE WHEN $7 THEN NULL ELSE now() END, now(), now())
+       RETURNING id, email::text AS email, email_enc, is_admin,
+                 must_change_password, disabled, totp_enabled, password_changed_at"#,
     Uuid::new_v4(),
-    email,
+    plain,
+    enc,
+    index,
     new_user.password_hash,
     new_user.is_admin,
     new_user.must_change_password,
   )
   .fetch_one(pool)
   .await?;
-  Ok(record)
+  Ok(UserRecord {
+    id: row.id,
+    email: decrypt_stored_email(keys, row.email_enc.as_deref(), row.email.as_deref())?,
+    is_admin: row.is_admin,
+    must_change_password: row.must_change_password,
+    disabled: row.disabled,
+    totp_enabled: row.totp_enabled,
+    password_changed_at: row.password_changed_at,
+  })
 }
 
 /// Find a user by email (case-insensitive); `None` when absent.
 ///
+/// Matches the blind-index candidates first and falls back to the legacy
+/// plaintext predicate only for rows that carry no ciphertext.
+///
 /// # Errors
-/// Returns an error on any database failure.
+/// Returns [`AppError::PiiDecrypt`] when the matched row's ciphertext fails
+/// to open, or a masked error on any database failure.
 pub async fn find_by_email(
   pool: &sqlx::PgPool,
+  keys: &KeyRing,
   email: &str,
 ) -> Result<Option<UserRecord>, AppError> {
-  let email = normalize_email(email);
-  let record = sqlx::query_as!(
-    UserRecord,
-    r#"SELECT id, email::text AS "email!", is_admin, must_change_password,
+  let canonical = normalize_email(email);
+  let candidates = blind_index_candidates(keys, &canonical);
+  let row = sqlx::query!(
+    r#"SELECT id, email::text AS email, email_enc, is_admin, must_change_password,
               disabled, totp_enabled, password_changed_at
          FROM users
-        WHERE email = $1::text::citext"#,
-    email,
+        WHERE email_index = ANY($1)
+           OR (email_enc IS NULL AND email = $2::text::citext)"#,
+    &candidates,
+    canonical,
   )
   .fetch_optional(pool)
   .await?;
-  Ok(record)
+  row
+    .map(|row| {
+      decrypt_stored_email(keys, row.email_enc.as_deref(), row.email.as_deref()).map(|email| {
+        UserRecord {
+          id: row.id,
+          email,
+          is_admin: row.is_admin,
+          must_change_password: row.must_change_password,
+          disabled: row.disabled,
+          totp_enabled: row.totp_enabled,
+          password_changed_at: row.password_changed_at,
+        }
+      })
+    })
+    .transpose()
 }
 
 /// Verify a password against the stored Argon2id hash.
 ///
+/// Uses the same dual predicate as [`find_by_email`], never the legacy
+/// plaintext predicate alone. A tampered `email_enc` on the matched row
+/// fails closed with [`AppError::PiiDecrypt`] even though verification only
+/// needs the password hash.
+///
 /// # Errors
-/// Returns an error when the stored hash is malformed or the query fails.
+/// Returns [`AppError::PiiDecrypt`] on a corrupt row, or a masked error when
+/// the stored hash is malformed or the query fails.
 pub async fn verify_password(
   pool: &sqlx::PgPool,
+  keys: &KeyRing,
   email: &str,
   password: &str,
 ) -> Result<PasswordVerdict, AppError> {
-  let email = normalize_email(email);
+  let canonical = normalize_email(email);
+  let candidates = blind_index_candidates(keys, &canonical);
   let row = sqlx::query!(
-    r#"SELECT password_hash, disabled
+    r#"SELECT password_hash, disabled, email_enc
          FROM users
-        WHERE email = $1::text::citext"#,
-    email,
+        WHERE email_index = ANY($1)
+           OR (email_enc IS NULL AND email = $2::text::citext)"#,
+    &candidates,
+    canonical,
   )
   .fetch_optional(pool)
   .await?;
   let Some(row) = row else {
     return Ok(PasswordVerdict::Unknown);
   };
+  if let Some(blob) = row.email_enc.as_deref() {
+    open_text(keys, blob).map_err(|_| AppError::PiiDecrypt)?;
+  }
   if row.disabled {
     return Ok(PasswordVerdict::Disabled);
   }
@@ -164,6 +249,24 @@ pub async fn verify_password(
   }
 }
 
+/// Resolve the stored email: open the ciphertext when present, else use the
+/// legacy plaintext. A non-NULL ciphertext that fails to open never falls
+/// back to plaintext; a row with neither representation fails closed.
+///
+/// # Errors
+/// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open
+/// or when the row carries no usable email at all.
+fn decrypt_stored_email(
+  keys: &KeyRing,
+  enc: Option<&[u8]>,
+  plain: Option<&str>,
+) -> Result<String, AppError> {
+  if let Some(blob) = enc {
+    return open_text(keys, blob).map_err(|_| AppError::PiiDecrypt);
+  }
+  plain.map(str::to_owned).ok_or(AppError::PiiDecrypt)
+}
+
 mod totp;
 
 pub use totp::{

+ 96 - 3
src/error.rs

@@ -187,6 +187,25 @@ pub enum AppError {
     #[source]
     source: Box<dyn StdError + Send + Sync>,
   },
+  /// A unique email collided (pre-check or `ux_users_email[_index]` 23505).
+  ///
+  /// Payload-free by construction (unit variant): no email ever reaches a
+  /// log, a metric label, or an HTTP body through this variant.
+  #[error("duplicate email")]
+  DuplicateEmail,
+  /// A stored PII ciphertext failed to open (tampered or corrupt).
+  ///
+  /// Fail-closed `temporarily_unavailable` (503), distinguishable from a
+  /// database-outage 503 by the variant. Payload-free by construction.
+  #[error("PII field cannot be decrypted")]
+  PiiDecrypt,
+  /// A client mTLS identity collided (pre-check or `ux_oauth_clients_tls_*`
+  /// 23505).
+  ///
+  /// Payload-free by construction (unit variant): no DN or thumbprint value
+  /// ever reaches a log, a metric label, or an HTTP body through this variant.
+  #[error("duplicate client identity")]
+  DuplicateClientIdentity,
 }
 
 impl AppError {
@@ -339,11 +358,16 @@ impl AppError {
   }
 
   /// The RFC error code this error renders as. Internal failures map to
-  /// `server_error`.
+  /// `server_error`; a duplicate email maps to `invalid_request`, a duplicate
+  /// client mTLS identity maps to `invalid_client_metadata`, and a PII
+  /// decrypt failure maps to `temporarily_unavailable`.
   #[must_use]
   pub fn code(&self) -> OAuthErrorCode {
     match self {
       Self::OAuth { code, .. } => *code,
+      Self::DuplicateEmail => OAuthErrorCode::InvalidRequest,
+      Self::DuplicateClientIdentity => OAuthErrorCode::InvalidClientMetadata,
+      Self::PiiDecrypt => OAuthErrorCode::TemporarilyUnavailable,
       Self::Internal { .. } => OAuthErrorCode::ServerError,
     }
   }
@@ -355,6 +379,17 @@ impl AppError {
       Self::OAuth {
         code, client_auth, ..
       } => status_for(*code, *client_auth),
+      Self::DuplicateEmail => {
+        status_for(OAuthErrorCode::InvalidRequest, ClientAuthMethod::NonBasic)
+      }
+      Self::DuplicateClientIdentity => status_for(
+        OAuthErrorCode::InvalidClientMetadata,
+        ClientAuthMethod::NonBasic,
+      ),
+      Self::PiiDecrypt => status_for(
+        OAuthErrorCode::TemporarilyUnavailable,
+        ClientAuthMethod::NonBasic,
+      ),
       Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
     }
   }
@@ -364,6 +399,9 @@ impl AppError {
   pub fn description(&self) -> &str {
     match self {
       Self::OAuth { description, .. } => description,
+      Self::DuplicateEmail => "The email address is already in use.",
+      Self::DuplicateClientIdentity => OAuthErrorCode::InvalidClientMetadata.default_description(),
+      Self::PiiDecrypt => OAuthErrorCode::TemporarilyUnavailable.default_description(),
       Self::Internal { .. } => OAuthErrorCode::ServerError.default_description(),
     }
   }
@@ -396,6 +434,22 @@ impl IntoResponse for AppError {
         description,
         client_auth,
       } => (code, description, client_auth),
+      // Payload-free typed errors render fixed codes and descriptions.
+      Self::DuplicateEmail => (
+        OAuthErrorCode::InvalidRequest,
+        Cow::Borrowed("The email address is already in use."),
+        ClientAuthMethod::NonBasic,
+      ),
+      Self::DuplicateClientIdentity => (
+        OAuthErrorCode::InvalidClientMetadata,
+        Cow::Borrowed(OAuthErrorCode::InvalidClientMetadata.default_description()),
+        ClientAuthMethod::NonBasic,
+      ),
+      Self::PiiDecrypt => (
+        OAuthErrorCode::TemporarilyUnavailable,
+        Cow::Borrowed(OAuthErrorCode::TemporarilyUnavailable.default_description()),
+        ClientAuthMethod::NonBasic,
+      ),
       // Mask every internal failure: generic code + generic description.
       Self::Internal { .. } => (
         OAuthErrorCode::ServerError,
@@ -442,18 +496,57 @@ impl IntoResponse for AppError {
 impl From<sqlx::Error> for AppError {
   /// Map a database error to a client-safe error: connectivity failures
   /// (pool timeout/close, transport I/O) render `503 temporarily_unavailable`
-  /// (fail closed, matching the Redis mapping); every genuine query or logic
-  /// failure stays a masked `server_error` that never leaks database text.
+  /// (fail closed, matching the Redis mapping); a unique violation on the
+  /// user-email constraints renders the payload-free [`AppError::DuplicateEmail`];
+  /// a unique violation on any client mTLS identity constraint (legacy
+  /// plaintext or blind-index) renders the payload-free
+  /// [`AppError::DuplicateClientIdentity`]; every other genuine query or
+  /// logic failure stays a masked `server_error` that never leaks database text.
   fn from(source: sqlx::Error) -> Self {
     match source {
       sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed | sqlx::Error::Io(_) => {
         Self::temporarily_unavailable()
       }
+      sqlx::Error::Database(db)
+        if db.code().as_deref() == Some("23505")
+          && matches!(
+            db.constraint(),
+            Some("ux_users_email") | Some("ux_users_email_index")
+          ) =>
+      {
+        Self::DuplicateEmail
+      }
+      sqlx::Error::Database(db)
+        if db.code().as_deref() == Some("23505")
+          && matches!(
+            db.constraint(),
+            Some("ux_oauth_clients_tls_subject_dn")
+              | Some("ux_oauth_clients_tls_cert_thumbprint")
+              | Some("ux_oauth_clients_tls_subject_dn_index")
+              | Some("ux_oauth_clients_tls_cert_thumbprint_index")
+          ) =>
+      {
+        Self::DuplicateClientIdentity
+      }
       other => Self::internal(other),
     }
   }
 }
 
+impl From<crate::crypto::field::FieldError> for AppError {
+  /// Map a PII field failure fail-closed: a blob that fails to open becomes
+  /// the distinguishable [`AppError::PiiDecrypt`] (503); a seal or RNG
+  /// failure becomes generic `temporarily_unavailable` (503). Neither carries
+  /// plaintext or key material.
+  fn from(error: crate::crypto::field::FieldError) -> Self {
+    match error {
+      crate::crypto::field::FieldError::Decrypt => Self::PiiDecrypt,
+      crate::crypto::field::FieldError::Encrypt
+      | crate::crypto::field::FieldError::UnavailableRandomness => Self::temporarily_unavailable(),
+    }
+  }
+}
+
 #[cfg(test)]
 mod tests {
   use super::*;

+ 46 - 0
src/jose/jar/request_uri.rs

@@ -92,3 +92,49 @@ async fn resolve_par(
   };
   merge_claims(query, object, &client.client_id)
 }
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  fn fixture(request_uris: Option<Value>) -> Client {
+    Client {
+      id: uuid::Uuid::nil(),
+      client_id: "jar-fixture".to_owned(),
+      client_secret_expires_at: None,
+      client_id_issued_at: time::OffsetDateTime::UNIX_EPOCH,
+      client_name: "JAR Fixture".to_owned(),
+      grant_types: Vec::new(),
+      response_types: Vec::new(),
+      scope: String::new(),
+      token_endpoint_auth_method: "none".to_owned(),
+      jwks: None,
+      jwks_uri: None,
+      tls_client_auth_subject_dn: None,
+      tls_client_auth_cert_thumbprint: None,
+      request_uris,
+      contacts: None,
+      software_id: None,
+      dpop_bound_access_tokens: false,
+      active: true,
+      created_at: time::OffsetDateTime::UNIX_EPOCH,
+      updated_at: time::OffsetDateTime::UNIX_EPOCH,
+    }
+  }
+
+  #[test]
+  fn jar_preregistered_request_uri_matches_decrypted_set() {
+    let uri = "https://client.example.test/req.jwt";
+    let client = fixture(Some(serde_json::json!([uri])));
+    assert!(is_registered_request_uri(&client, uri));
+    assert!(!is_registered_request_uri(
+      &client,
+      "https://evil.example.test/req.jwt"
+    ));
+    assert!(!is_registered_request_uri(
+      &client,
+      "https://client.example.test/req.jwt/extra"
+    ));
+    assert!(!is_registered_request_uri(&fixture(None), uri));
+  }
+}

+ 97 - 9
src/keys.rs

@@ -1,6 +1,7 @@
-//! Versioned [`KeyRing`] for token peppers and TOTP-encryption keys.
+//! Versioned [`KeyRing`] for token peppers, TOTP-encryption keys, and PII keys.
 //!
-//! Holds versioned key ids for the token pepper and the TOTP-encryption key
+//! Holds versioned key ids for the token pepper, the TOTP-encryption key, and
+//! the PII field-encryption key
 //! (newest first, bounded retry across retained ids). Opaque tokens are stored
 //! under `v<N>` key ids and verification retries across retained key ids,
 //! ordered newest-first. Sessions are opaque Redis ids, so there is no session
@@ -121,6 +122,9 @@ pub enum KeyRingError {
   /// No TOTP-encryption key material was supplied.
   #[error("TOTP encryption key material is required")]
   MissingTotpKey,
+  /// No PII field-encryption key material was supplied.
+  #[error("PII encryption key material is required")]
+  MissingPiiKey,
   /// A key id was empty.
   #[error("key id must not be empty")]
   EmptyKeyId,
@@ -132,27 +136,30 @@ pub enum KeyRingError {
   },
 }
 
-/// Versioned key ring for token peppers and TOTP-encryption keys.
+/// Versioned key ring for token peppers, TOTP-encryption keys, and PII keys.
 ///
-/// Both chains are ordered newest-first; the first element is the active key.
+/// Every chain is ordered newest-first; the first element is the active key.
 /// The `Debug` impl never prints key material.
 #[derive(Clone)]
 pub struct KeyRing {
   peppers: Vec<VersionedSecret>,
   totp_keys: Vec<VersionedSecret>,
+  pii_keys: Vec<VersionedSecret>,
 }
 
 impl KeyRing {
-  /// Build a key ring from newest-first pepper and TOTP-encryption chains.
+  /// Build a key ring from newest-first pepper, TOTP-encryption, and PII chains.
   ///
   /// Each chain must be non-empty and free of duplicate ids.
   pub fn from_parts(
     peppers: Vec<(KeyId, SecretString)>,
     totp_keys: Vec<(KeyId, SecretString)>,
+    pii_keys: Vec<(KeyId, SecretString)>,
   ) -> Result<Self, KeyRingError> {
     Ok(Self {
       peppers: build_chain(peppers, KeyRingError::MissingTokenPepper)?,
       totp_keys: build_chain(totp_keys, KeyRingError::MissingTotpKey)?,
+      pii_keys: build_chain(pii_keys, KeyRingError::MissingPiiKey)?,
     })
   }
 
@@ -189,15 +196,34 @@ impl KeyRing {
   pub fn totp_keys_newest_first(&self) -> impl Iterator<Item = (&KeyId, &SecretString)> {
     self.totp_keys.iter().map(|k| (k.id(), k.secret()))
   }
+
+  /// The active PII-encryption key (newest key id), if any.
+  #[must_use]
+  pub fn active_pii_key(&self) -> Option<(&KeyId, &SecretString)> {
+    self.pii_keys.first().map(|k| (k.id(), k.secret()))
+  }
+
+  /// Look up a retained PII-encryption key by key id.
+  #[must_use]
+  pub fn pii_key_by_id(&self, id: &str) -> Option<&SecretString> {
+    find(&self.pii_keys, id)
+  }
+
+  /// Iterate PII-encryption keys ordered newest-first.
+  pub fn pii_keys_newest_first(&self) -> impl Iterator<Item = (&KeyId, &SecretString)> {
+    self.pii_keys.iter().map(|k| (k.id(), k.secret()))
+  }
 }
 
 impl fmt::Debug for KeyRing {
   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     let pepper_ids: Vec<&str> = self.peppers.iter().map(|k| k.id().as_str()).collect();
     let totp_ids: Vec<&str> = self.totp_keys.iter().map(|k| k.id().as_str()).collect();
+    let pii_ids: Vec<&str> = self.pii_keys.iter().map(|k| k.id().as_str()).collect();
     f.debug_struct("KeyRing")
       .field("token_pepper_ids", &pepper_ids)
       .field("totp_key_ids", &totp_ids)
+      .field("pii_key_ids", &pii_ids)
       .finish()
   }
 }
@@ -243,25 +269,58 @@ mod tests {
 
   #[test]
   fn keyring_requires_a_token_pepper() -> Result<(), KeyRingError> {
-    let result = KeyRing::from_parts(Vec::new(), vec![pepper("v1", "totp-secret")]);
+    let result = KeyRing::from_parts(
+      Vec::new(),
+      vec![pepper("v1", "totp-secret")],
+      vec![pepper("v1", "pii-secret")],
+    );
     assert!(matches!(result, Err(KeyRingError::MissingTokenPepper)));
     Ok(())
   }
 
   #[test]
   fn keyring_requires_a_totp_key() -> Result<(), KeyRingError> {
-    let result = KeyRing::from_parts(vec![pepper("v1", "pepper-secret")], Vec::new());
+    let result = KeyRing::from_parts(
+      vec![pepper("v1", "pepper-secret")],
+      Vec::new(),
+      vec![pepper("v1", "pii-secret")],
+    );
     assert!(matches!(result, Err(KeyRingError::MissingTotpKey)));
     Ok(())
   }
 
   #[test]
-  fn keyring_rejects_duplicate_ids() {
+  fn keyring_requires_a_pii_key() -> Result<(), KeyRingError> {
     let result = KeyRing::from_parts(
+      vec![pepper("v1", "pepper-secret")],
+      vec![pepper("v1", "totp-secret")],
+      Vec::new(),
+    );
+    assert!(matches!(result, Err(KeyRingError::MissingPiiKey)));
+    Ok(())
+  }
+
+  #[test]
+  fn keyring_rejects_duplicate_ids() {
+    let duplicate_pepper = KeyRing::from_parts(
       vec![pepper("v1", "a"), pepper("v1", "b")],
       vec![pepper("v1", "c")],
+      vec![pepper("v1", "d")],
+    );
+    assert!(matches!(
+      duplicate_pepper,
+      Err(KeyRingError::DuplicateKeyId { .. })
+    ));
+    // A duplicate inside the PII chain is rejected the same way.
+    let duplicate_pii = KeyRing::from_parts(
+      vec![pepper("v1", "a")],
+      vec![pepper("v1", "b")],
+      vec![pepper("v2", "c"), pepper("v2", "d")],
     );
-    assert!(matches!(result, Err(KeyRingError::DuplicateKeyId { .. })));
+    assert!(matches!(
+      duplicate_pii,
+      Err(KeyRingError::DuplicateKeyId { .. })
+    ));
   }
 
   #[test]
@@ -269,6 +328,7 @@ mod tests {
     let ring = KeyRing::from_parts(
       vec![pepper("v2", "new"), pepper("v1", "old")],
       vec![pepper("v1", "totp")],
+      vec![pepper("v1", "pii")],
     )?;
     let active = ring.active_pepper();
     assert!(matches!(active, Some((id, _)) if id.as_str() == "v2"));
@@ -278,6 +338,34 @@ mod tests {
     Ok(())
   }
 
+  #[test]
+  fn keyring_resolves_retained_pii_id() -> Result<(), KeyRingError> {
+    let ring = KeyRing::from_parts(
+      vec![pepper("v1", "pepper-secret")],
+      vec![pepper("v1", "totp-secret")],
+      vec![
+        pepper("v2", "new-pii-secret"),
+        pepper("v1", "old-pii-secret"),
+      ],
+    )?;
+    let active = ring.active_pii_key();
+    assert!(matches!(active, Some((id, _)) if id.as_str() == "v2"));
+    let retained = ring.pii_key_by_id("v1");
+    assert!(matches!(retained, Some(secret) if secret.expose_secret() == "old-pii-secret"));
+    assert!(ring.pii_key_by_id("v9").is_none());
+    let ids: Vec<&str> = ring
+      .pii_keys_newest_first()
+      .map(|(id, _)| id.as_str())
+      .collect();
+    assert_eq!(ids, vec!["v2", "v1"]);
+    // `Debug` exposes ids only, never material from any chain.
+    let debug = format!("{ring:?}");
+    assert!(debug.contains("v2"), "debug must include the pii key id");
+    assert!(!debug.contains("new-pii-secret") && !debug.contains("old-pii-secret"));
+    assert!(!debug.contains("pepper-secret") && !debug.contains("totp-secret"));
+    Ok(())
+  }
+
   #[test]
   fn keygen_purpose_documents_no_as_signing_key() {
     assert!(KEYGEN_PURPOSE.contains("never an AS signing key"));

+ 20 - 0
src/middleware/audit.rs

@@ -70,6 +70,14 @@ pub enum AuditEvent<'a> {
     /// Newly registered client identifier.
     client_id: &'a str,
   },
+  /// A stored PII ciphertext failed to open (fail-closed decrypt failure).
+  ///
+  /// Payload-free by construction: only the request id is carried, never
+  /// the value that failed to open and never any plaintext.
+  PiiDecryptFailure {
+    /// Correlating request id, when one is in scope.
+    request_id: Option<&'a str>,
+  },
 }
 
 impl AuditEvent<'_> {
@@ -84,6 +92,7 @@ impl AuditEvent<'_> {
       Self::TokenRevoke { .. } => "token.revoke",
       Self::RefreshReuseDetected { .. } => "token.refresh_reuse_detected",
       Self::DynamicClientRegistered { .. } => "dcr.register",
+      Self::PiiDecryptFailure { .. } => "pii.decrypt_failure",
     }
   }
 
@@ -170,6 +179,13 @@ impl AuditEvent<'_> {
         client_id = %client_id,
         "dynamic client registered"
       ),
+      Self::PiiDecryptFailure { request_id } => info!(
+        target: "audit",
+        event = self.name(),
+        request_id = ?request_id,
+        outcome = "failure",
+        "pii decryption failed"
+      ),
     }
   }
 }
@@ -240,5 +256,9 @@ mod audit_tests {
       .name(),
       "dcr.register"
     );
+    assert_eq!(
+      AuditEvent::PiiDecryptFailure { request_id: None }.name(),
+      "pii.decrypt_failure"
+    );
   }
 }

+ 2 - 0
src/middleware/ratelimit/test_support.rs

@@ -71,6 +71,7 @@ pub(super) fn test_config(redis_url: &str, rate_limit: Value) -> Result<Config,
     "redis": {"url": redis_url, "durability": "waitaof"},
     "token_pepper": "ratelimit-test-pepper-0123456789abcdef",
     "totp_encryption_key": "ratelimit-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "ratelimit-test-pii-key-0123456789abcdef",
     "rate_limit": rate_limit,
   });
   let source = ::config::Config::try_from(&fixture)?;
@@ -90,6 +91,7 @@ fn build_state(
     redis: RedisConn::Single(manager),
     config,
     keys,
+    transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
     clock: Clock::system(),
   }
 }

+ 22 - 8
src/protocol/authorize.rs

@@ -37,6 +37,8 @@ use crate::{
 pub struct AuthorizeInput<'a> {
   /// Postgres pool for the client, redirect, and scope catalog reads.
   pub pool: &'a PgPool,
+  /// Versioned PII keys for the encrypted client lookup.
+  pub keys: &'a crate::keys::KeyRing,
   /// Redis connection for PAR `request_uri` consumption.
   pub conn: &'a mut RedisConn,
   /// Redis store handle backing the PAR lookup.
@@ -100,6 +102,20 @@ impl AuthorizeFailure {
     }
   }
 
+  /// Whether this failure carries a stored-value decrypt failure: the
+  /// handler audits it as `pii.decrypt_failure` (the repository never emits).
+  #[must_use]
+  pub fn is_pii_decrypt(&self) -> bool {
+    matches!(
+      self,
+      Self::Page(AppError::PiiDecrypt)
+        | Self::Redirect {
+          error: AppError::PiiDecrypt,
+          ..
+        }
+    )
+  }
+
   /// Render the failure as an HTTP response (page or redirect).
   #[must_use]
   pub fn into_response(self) -> Response {
@@ -126,9 +142,8 @@ impl AuthorizeFailure {
 pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, AuthorizeFailure> {
   let client_id = present(input.query.get("client_id"))
     .ok_or_else(|| page(AppError::invalid_request("client_id is required")))?;
-  let client = client_repo::find_active_by_client_id(input.pool, client_id)
+  let client = client_repo::find_active_by_client_id(input.pool, input.keys, client_id)
     .await
-    .map_err(AppError::from)
     .map_err(AuthorizeFailure::Page)?
     .ok_or_else(|| page(AppError::invalid_client_non_basic()))?;
 
@@ -154,9 +169,8 @@ pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, Aut
 
   let redirect_uri = present(merged.get("redirect_uri"))
     .ok_or_else(|| page(AppError::invalid_request("redirect_uri is required")))?;
-  let effective = redirect_repo::effective_for_client(input.pool, &client.client_id)
+  let effective = redirect_repo::effective_for_client(input.pool, input.keys, &client.client_id)
     .await
-    .map_err(AppError::from)
     .map_err(AuthorizeFailure::Page)?;
   let registered: Vec<&str> = effective.iter().map(|entry| entry.uri.as_str()).collect();
   match redirect_match::match_redirect_uri(&registered, redirect_uri) {
@@ -223,10 +237,10 @@ pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, Aut
   verify_method(S256_METHOD).map_err(|_| redirect_err(AppError::server_error()))?;
   check_challenge_shape(challenge).map_err(redirect_err)?;
 
-  let post_logout_uri = redirect_repo::post_logout_for_client(input.pool, &client.client_id)
-    .await
-    .map_err(AppError::from)
-    .map_err(AuthorizeFailure::Page)?;
+  let post_logout_uri =
+    redirect_repo::post_logout_for_client(input.pool, input.keys, &client.client_id)
+      .await
+      .map_err(AuthorizeFailure::Page)?;
 
   Ok(ValidatedRequest {
     client_id: client.client_id,

+ 192 - 16
src/protocol/authorize/tests.rs

@@ -10,10 +10,12 @@ use crate::{
   config::DurabilityMode,
   crypto::pkce::challenge_from_verifier,
   db::{client_repo::NewClient, redirect_repo, scope_repo, test_support::migrated_pool},
-  error::OAuthErrorCode,
+  error::{AppError, OAuthErrorCode},
+  keys::{KeyId, KeyRing},
   state::RedisConn,
   store::Store,
 };
+use secrecy::SecretString;
 
 /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
 type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
@@ -59,18 +61,38 @@ fn fixture(client_id: &str, public: bool) -> NewClient {
 }
 
 /// Register a client, one redirect URI, and the `api:read` catalog scope.
-async fn provision(pool: &sqlx::PgPool, client_id: &str, public: bool) -> TestResult {
-  crate::db::client_repo::insert(pool, &fixture(client_id, public)).await?;
-  let uri = redirect_repo::upsert(
+async fn provision(
+  pool: &sqlx::PgPool,
+  keys: &KeyRing,
+  client_id: &str,
+  public: bool,
+) -> TestResult {
+  provision_at(pool, keys, client_id, public, REDIRECT).await
+}
+
+/// Register a client with an explicit redirect URI (encrypt-only at rest).
+async fn provision_at(
+  pool: &sqlx::PgPool,
+  keys: &KeyRing,
+  client_id: &str,
+  public: bool,
+  uri: &str,
+) -> TestResult {
+  crate::db::client_repo::insert(pool, keys, false, &fixture(client_id, public)).await?;
+  let (row, _) = redirect_repo::upsert(
     pool,
-    Uuid::new_v4(),
-    REDIRECT,
-    redirect_repo::RedirectKind::Authorize,
-    "authorize test",
-    None,
+    keys,
+    false,
+    &redirect_repo::NewRedirectUri {
+      id: Uuid::new_v4(),
+      uri,
+      kind: redirect_repo::RedirectKind::Authorize,
+      description: "authorize test",
+      created_by: None,
+    },
   )
   .await?;
-  redirect_repo::attach(pool, client_id, uri.id, None).await?;
+  redirect_repo::attach(pool, client_id, row.id, None).await?;
   scope_repo::upsert(pool, "api:read", "read api").await?;
   Ok(())
 }
@@ -97,6 +119,18 @@ fn test_store() -> Store {
   )
 }
 
+/// Test PII key ring for the encrypted client projection.
+fn test_keys() -> TestResult<KeyRing> {
+  Ok(KeyRing::from_parts(
+    vec![(
+      KeyId::new("v1"),
+      SecretString::from("authorize-test-pepper"),
+    )],
+    vec![(KeyId::new("v1"), SecretString::from("authorize-test-totp"))],
+    vec![(KeyId::new("v1"), SecretString::from("authorize-test-pii"))],
+  )?)
+}
+
 /// A valid plain-query authorize map for the provisioned client.
 fn valid_query(client_id: &str, challenge: &str) -> HashMap<String, String> {
   query(&[
@@ -114,7 +148,8 @@ fn valid_query(client_id: &str, challenge: &str) -> HashMap<String, String> {
 #[tokio::test]
 async fn authorize_validate_public_client_plain_query_proceeds() -> TestResult {
   let (_db, pool) = migrated_pool().await?;
-  provision(&pool, "authz-public", true).await?;
+  let keys = test_keys()?;
+  provision(&pool, &keys, "authz-public", true).await?;
   let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
   let params = valid_query("authz-public", &challenge);
   let mut conn = offline_redis()?;
@@ -122,6 +157,7 @@ async fn authorize_validate_public_client_plain_query_proceeds() -> TestResult {
 
   let validated = validate(AuthorizeInput {
     pool: &pool,
+    keys: &keys,
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
@@ -142,6 +178,7 @@ async fn authorize_validate_public_client_plain_query_proceeds() -> TestResult {
 #[tokio::test]
 async fn authorize_validate_unknown_client_shows_page() -> TestResult {
   let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
   let mut params = valid_query("authz-ghost", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
   params.remove("scope");
   let mut conn = offline_redis()?;
@@ -149,6 +186,7 @@ async fn authorize_validate_unknown_client_shows_page() -> TestResult {
 
   match validate(AuthorizeInput {
     pool: &pool,
+    keys: &keys,
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
@@ -170,7 +208,8 @@ async fn authorize_validate_unknown_client_shows_page() -> TestResult {
 #[tokio::test]
 async fn authorize_validate_unregistered_redirect_shows_page() -> TestResult {
   let (_db, pool) = migrated_pool().await?;
-  provision(&pool, "authz-redirect", true).await?;
+  let keys = test_keys()?;
+  provision(&pool, &keys, "authz-redirect", true).await?;
   let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
   let mut params = valid_query("authz-redirect", &challenge);
   params.insert(
@@ -182,6 +221,7 @@ async fn authorize_validate_unregistered_redirect_shows_page() -> TestResult {
 
   match validate(AuthorizeInput {
     pool: &pool,
+    keys: &keys,
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
@@ -204,7 +244,8 @@ async fn authorize_validate_unregistered_redirect_shows_page() -> TestResult {
 #[tokio::test]
 async fn authorize_validate_token_response_type_redirects() -> TestResult {
   let (_db, pool) = migrated_pool().await?;
-  provision(&pool, "authz-rt", true).await?;
+  let keys = test_keys()?;
+  provision(&pool, &keys, "authz-rt", true).await?;
   let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
   let mut params = valid_query("authz-rt", &challenge);
   params.insert("response_type".to_owned(), "token".to_owned());
@@ -213,6 +254,7 @@ async fn authorize_validate_token_response_type_redirects() -> TestResult {
 
   match validate(AuthorizeInput {
     pool: &pool,
+    keys: &keys,
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
@@ -254,7 +296,8 @@ async fn authorize_validate_token_response_type_redirects() -> TestResult {
 #[tokio::test]
 async fn authorize_validate_unknown_scope_redirects() -> TestResult {
   let (_db, pool) = migrated_pool().await?;
-  provision(&pool, "authz-scope", true).await?;
+  let keys = test_keys()?;
+  provision(&pool, &keys, "authz-scope", true).await?;
   let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
   let mut params = valid_query("authz-scope", &challenge);
   params.insert("scope".to_owned(), "admin:all".to_owned());
@@ -263,6 +306,7 @@ async fn authorize_validate_unknown_scope_redirects() -> TestResult {
 
   match validate(AuthorizeInput {
     pool: &pool,
+    keys: &keys,
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
@@ -305,7 +349,8 @@ async fn authorize_validate_unknown_scope_redirects() -> TestResult {
 #[tokio::test]
 async fn authorize_validate_confidential_plain_query_rejected() -> TestResult {
   let (_db, pool) = migrated_pool().await?;
-  provision(&pool, "authz-conf", false).await?;
+  let keys = test_keys()?;
+  provision(&pool, &keys, "authz-conf", false).await?;
   let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
   let params = valid_query("authz-conf", &challenge);
   let mut conn = offline_redis()?;
@@ -313,6 +358,7 @@ async fn authorize_validate_confidential_plain_query_rejected() -> TestResult {
 
   match validate(AuthorizeInput {
     pool: &pool,
+    keys: &keys,
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
@@ -331,11 +377,140 @@ async fn authorize_validate_confidential_plain_query_rejected() -> TestResult {
   }
 }
 
+/// An exact registered redirect matches after decryption; a prefix/evil
+/// URI still fails closed; a corrupt `uri_enc` fails as 503 `PiiDecrypt`.
+#[tokio::test]
+async fn authorize_redirect_exact_match() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  provision(&pool, &keys, "authz-exact", true).await?;
+  let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
+  let mut conn = offline_redis()?;
+  let store = test_store();
+
+  let params = valid_query("authz-exact", &challenge);
+  let validated = validate(AuthorizeInput {
+    pool: &pool,
+    keys: &keys,
+    conn: &mut conn,
+    store: &store,
+    issuer: ISSUER,
+    query: &params,
+  })
+  .await
+  .map_err(|failure| format!("exact redirect rejected: {}", failure.code()))?;
+  assert_eq!(validated.redirect_uri, REDIRECT);
+
+  for hostile in [
+    "https://evil.example.test/cb",
+    "https://app.example.test/cb/extra",
+  ] {
+    let mut params = valid_query("authz-exact", &challenge);
+    params.insert("redirect_uri".to_owned(), hostile.to_owned());
+    match validate(AuthorizeInput {
+      pool: &pool,
+      keys: &keys,
+      conn: &mut conn,
+      store: &store,
+      issuer: ISSUER,
+      query: &params,
+    })
+    .await
+    {
+      Ok(_) => return Err(format!("hostile redirect accepted: {hostile}").into()),
+      Err(failure) => {
+        assert_eq!(failure.code(), OAuthErrorCode::InvalidRequest);
+        let response = failure.into_response();
+        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+        assert!(response.headers().get(header::LOCATION).is_none());
+      }
+    }
+  }
+
+  let corrupt: &[u8] = b"corrupt-blob";
+  sqlx::query("UPDATE redirect_uris SET uri_enc = $1")
+    .bind(corrupt)
+    .execute(&pool)
+    .await
+    .map_err(|error| format!("corrupt fixture failed: {error}"))?;
+  match validate(AuthorizeInput {
+    pool: &pool,
+    keys: &keys,
+    conn: &mut conn,
+    store: &store,
+    issuer: ISSUER,
+    query: &params,
+  })
+  .await
+  {
+    Ok(_) => Err("corrupt uri_enc accepted".into()),
+    Err(AuthorizeFailure::Page(error)) => {
+      assert!(matches!(error, AppError::PiiDecrypt));
+      assert_eq!(error.code(), OAuthErrorCode::TemporarilyUnavailable);
+      assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE);
+      Ok(())
+    }
+    Err(other) => Err(format!("expected a PiiDecrypt page, got {}", other.code()).into()),
+  }
+}
+
+/// A loopback registration matches a request with a random port (RFC 8252
+/// §7.3) after decryption; a different path still fails closed.
+#[tokio::test]
+async fn authorize_loopback_allows_port() -> TestResult {
+  const LOOPBACK: &str = "http://127.0.0.1:8080/cb";
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  provision_at(&pool, &keys, "authz-loop", true, LOOPBACK).await?;
+  let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
+  let mut conn = offline_redis()?;
+  let store = test_store();
+
+  let mut params = valid_query("authz-loop", &challenge);
+  params.insert(
+    "redirect_uri".to_owned(),
+    "http://127.0.0.1:59231/cb".to_owned(),
+  );
+  let validated = validate(AuthorizeInput {
+    pool: &pool,
+    keys: &keys,
+    conn: &mut conn,
+    store: &store,
+    issuer: ISSUER,
+    query: &params,
+  })
+  .await
+  .map_err(|failure| format!("loopback port rejected: {}", failure.code()))?;
+  assert_eq!(validated.redirect_uri, "http://127.0.0.1:59231/cb");
+
+  params.insert(
+    "redirect_uri".to_owned(),
+    "http://127.0.0.1:59231/other".to_owned(),
+  );
+  match validate(AuthorizeInput {
+    pool: &pool,
+    keys: &keys,
+    conn: &mut conn,
+    store: &store,
+    issuer: ISSUER,
+    query: &params,
+  })
+  .await
+  {
+    Ok(_) => Err("loopback wrong-path accepted".into()),
+    Err(failure) => {
+      assert_eq!(failure.code(), OAuthErrorCode::InvalidRequest);
+      Ok(())
+    }
+  }
+}
+
 /// A missing PKCE challenge redirects with `invalid_request` (+ `state`).
 #[tokio::test]
 async fn authorize_validate_missing_challenge_redirects() -> TestResult {
   let (_db, pool) = migrated_pool().await?;
-  provision(&pool, "authz-pkce", true).await?;
+  let keys = test_keys()?;
+  provision(&pool, &keys, "authz-pkce", true).await?;
   let mut params = valid_query("authz-pkce", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
   params.remove("code_challenge");
   params.remove("code_challenge_method");
@@ -344,6 +519,7 @@ async fn authorize_validate_missing_challenge_redirects() -> TestResult {
 
   match validate(AuthorizeInput {
     pool: &pool,
+    keys: &keys,
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,

+ 13 - 8
src/protocol/client_auth.rs

@@ -26,6 +26,7 @@ use crate::{
   db::client_repo::ClientRepoError,
   db::{Client, TokenEndpointAuthMethod, client_repo},
   error::AppError,
+  keys::KeyRing,
   protocol::mtls,
   state::RedisConn,
   store::Store,
@@ -128,6 +129,8 @@ pub struct AuthenticatedClient {
 pub struct AuthStores<'a> {
   /// Postgres pool for client lookup and secret verification.
   pub pool: &'a PgPool,
+  /// Versioned PII keys for the encrypted client lookup.
+  pub keys: &'a KeyRing,
   /// Redis connection for the assertion `jti` replay store.
   pub conn: &'a mut RedisConn,
   /// Key layout plus retry/durability knobs for the replay store.
@@ -183,7 +186,7 @@ pub async fn authenticate_client(
     {
       return Err(invalid_client_for(true));
     }
-    let client = lookup_client(stores.pool, &creds.client_id, true).await?;
+    let client = lookup_client(stores.pool, stores.keys, &creds.client_id, true).await?;
     require_method(&client, TokenEndpointAuthMethod::ClientSecretBasic, true)?;
     authenticate_secret(
       stores.pool,
@@ -197,7 +200,7 @@ pub async fn authenticate_client(
     let Some(client_id) = req.client_id else {
       return Err(invalid_client_for(false));
     };
-    let client = lookup_client(stores.pool, client_id, false).await?;
+    let client = lookup_client(stores.pool, stores.keys, client_id, false).await?;
     require_method(&client, TokenEndpointAuthMethod::ClientSecretPost, false)?;
     authenticate_secret(stores.pool, &client, secret.expose_secret(), ctx.now, false).await
   } else if let Some(assertion_str) = req.assertion {
@@ -210,11 +213,11 @@ pub async fn authenticate_client(
       (None, Some(iss)) => iss,
       (None, None) => return Err(invalid_client_for(false)),
     };
-    let client = lookup_client(stores.pool, &client_id, false).await?;
+    let client = lookup_client(stores.pool, stores.keys, &client_id, false).await?;
     require_method(&client, TokenEndpointAuthMethod::PrivateKeyJwt, false)?;
     assertion::authenticate(stores, &client, assertion_str, ctx).await
   } else if let Some(client_id) = req.client_id {
-    let client = lookup_client(stores.pool, client_id, false).await?;
+    let client = lookup_client(stores.pool, stores.keys, client_id, false).await?;
     match client.auth_method() {
       Ok(TokenEndpointAuthMethod::None) => Ok(AuthenticatedClient {
         client,
@@ -222,7 +225,7 @@ pub async fn authenticate_client(
       }),
       Ok(
         TokenEndpointAuthMethod::TlsClientAuth | TokenEndpointAuthMethod::SelfSignedTlsClientAuth,
-      ) => authenticate_tls(stores.pool, &client, req.peer_certs).await,
+      ) => authenticate_tls(stores.pool, stores.keys, &client, req.peer_certs).await,
       Ok(_) | Err(_) => Err(invalid_client_for(false)),
     }
   } else {
@@ -243,13 +246,14 @@ fn invalid_client_for(used_basic: bool) -> AppError {
 /// whether the id exists).
 async fn lookup_client(
   pool: &PgPool,
+  keys: &KeyRing,
   client_id: &str,
   used_basic: bool,
 ) -> Result<Client, AppError> {
-  match client_repo::find_active_by_client_id(pool, client_id).await {
+  match client_repo::find_active_by_client_id(pool, keys, client_id).await {
     Ok(Some(client)) => Ok(client),
     Ok(None) => Err(invalid_client_for(used_basic)),
-    Err(error) => Err(AppError::from(error)),
+    Err(error) => Err(error),
   }
 }
 
@@ -304,6 +308,7 @@ async fn authenticate_secret(
 /// or unregistered certificate fails closed as `invalid_client`.
 async fn authenticate_tls(
   pool: &PgPool,
+  keys: &KeyRing,
   client: &Client,
   peer_certs: Option<&PeerCertificates>,
 ) -> Result<AuthenticatedClient, AppError> {
@@ -311,7 +316,7 @@ async fn authenticate_tls(
     .auth_method()
     .map_err(|_| AppError::invalid_client_non_basic())?;
   let leaf = peer_certs.and_then(|certs| certs.chain().first());
-  mtls::authenticate(pool, client, leaf)
+  mtls::authenticate(pool, keys, client, leaf)
     .await
     .map_err(mtls::MtlsError::into_app_error)?;
   Ok(AuthenticatedClient {

+ 31 - 1
src/protocol/client_auth/tests_containers.rs

@@ -29,6 +29,7 @@ use crate::{
   crypto::secret::{Argon2Params, hash_secret},
   db::{NewClient, TokenEndpointAuthMethod, client_repo, test_support::Db},
   error::{AppError, OAuthErrorCode},
+  keys::{KeyId, KeyRing},
   state::RedisConn,
   store::Store,
   test_guard::{ContainerGuard, Lease},
@@ -49,6 +50,9 @@ enum TestError {
   /// A database operation failed.
   #[error("database error: {0}")]
   Database(#[from] sqlx::Error),
+  /// An application error from a repository helper.
+  #[error("app error: {0}")]
+  App(#[from] AppError),
   /// A crypto, encoding, or JWT operation failed.
   #[error("test crypto error: {0}")]
   Crypto(String),
@@ -66,6 +70,7 @@ struct Harness {
   conn: RedisConn,
   store: Store,
   endpoint: String,
+  keys: KeyRing,
 }
 
 /// Fresh Postgres database plus a throwaway Redis for one test.
@@ -102,6 +107,18 @@ async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
     conn: RedisConn::Single(manager),
     store,
     endpoint: token_endpoint("https://auth.example.com"),
+    keys: KeyRing::from_parts(
+      vec![(
+        KeyId::new("v1"),
+        SecretString::from("client-auth-test-pepper"),
+      )],
+      vec![(
+        KeyId::new("v1"),
+        SecretString::from("client-auth-test-totp"),
+      )],
+      vec![(KeyId::new("v1"), SecretString::from("client-auth-test-pii"))],
+    )
+    .map_err(|error| TestError::Crypto(format!("{error:?}")))?,
   };
   Ok((guarded, h))
 }
@@ -110,6 +127,7 @@ async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
 fn stores_of(h: &mut Harness) -> AuthStores<'_> {
   AuthStores {
     pool: &h.pool,
+    keys: &h.keys,
     conn: &mut h.conn,
     store: &h.store,
   }
@@ -136,6 +154,18 @@ async fn insert_client(
   jwks: Option<serde_json::Value>,
   jwks_uri: Option<&str>,
 ) -> TestResult {
+  let keys = KeyRing::from_parts(
+    vec![(
+      KeyId::new("v1"),
+      SecretString::from("client-auth-test-pepper"),
+    )],
+    vec![(
+      KeyId::new("v1"),
+      SecretString::from("client-auth-test-totp"),
+    )],
+    vec![(KeyId::new("v1"), SecretString::from("client-auth-test-pii"))],
+  )
+  .map_err(|error| TestError::Crypto(format!("{error:?}")))?;
   let params =
     Argon2Params::new(8, 1, 1, 32).map_err(|error| TestError::Crypto(format!("{error}")))?;
   let secret_hash = secret
@@ -162,7 +192,7 @@ async fn insert_client(
     dpop_bound_access_tokens: false,
     registration_access_token_hash: None,
   };
-  client_repo::insert(pool, &new).await?;
+  client_repo::insert(pool, &keys, false, &new).await?;
   Ok(())
 }
 

+ 63 - 0
src/protocol/client_auth/tests_containers/assertions.rs

@@ -5,6 +5,7 @@ use super::{
   insert_client, stores_of,
 };
 use crate::db::TokenEndpointAuthMethod;
+use crate::error::{AppError, OAuthErrorCode};
 use crate::protocol::client_auth::assertion::validate_assertion;
 use crate::protocol::client_auth::authenticate_client;
 
@@ -115,3 +116,65 @@ async fn client_auth_jwks_uri_private_ip_rejected() -> TestResult {
     Ok(_) => Err(TestError::Unexpected("SSRF jwks_uri accepted".to_owned())),
   }
 }
+
+/// An encrypted `jwks_uri` decrypts before the SSRF-guarded fetch (a
+/// private-IP URI fails as `invalid_client`, proving the fetch ran on the
+/// plaintext); a corrupt `jwks_uri_enc` fails as 503 `PiiDecrypt`.
+#[tokio::test]
+async fn private_key_jwt_jwks_uri_fetch() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let endpoint = h.endpoint.clone();
+  let client_id = "client-auth-ct-jwks-fetch";
+  insert_client(
+    &h.pool,
+    client_id,
+    TokenEndpointAuthMethod::PrivateKeyJwt,
+    None,
+    None,
+    None,
+    Some("https://127.0.0.1:9/jwks.json"),
+  )
+  .await?;
+  let sealed: bool = sqlx::query_scalar(
+    "SELECT jwks_uri_enc IS NOT NULL AND jwks_uri IS NULL FROM oauth_clients WHERE client_id = $1",
+  )
+  .bind(client_id)
+  .fetch_one(&h.pool)
+  .await
+  .map_err(TestError::Database)?;
+  if !sealed {
+    return Err(TestError::Unexpected(
+      "jwks_uri not encrypt-only at rest".to_owned(),
+    ));
+  }
+  let req = assertion_request(client_id, "aaa.bbb.ccc");
+  match authenticate_client(&mut stores_of(&mut h), &ctx_of(&endpoint), &req).await {
+    Err(error) => expect_invalid_client(error, 400),
+    Ok(_) => Err(TestError::Unexpected("SSRF jwks_uri accepted".to_owned())),
+  }?;
+
+  let corrupt: &[u8] = b"corrupt-blob";
+  sqlx::query("UPDATE oauth_clients SET jwks_uri_enc = $1 WHERE client_id = $2")
+    .bind(corrupt)
+    .bind(client_id)
+    .execute(&h.pool)
+    .await?;
+  let req = assertion_request(client_id, "aaa.bbb.ccc");
+  match authenticate_client(&mut stores_of(&mut h), &ctx_of(&endpoint), &req).await {
+    Ok(_) => Err(TestError::Unexpected(
+      "corrupt jwks_uri_enc accepted".to_owned(),
+    )),
+    Err(error) => {
+      if !matches!(error, AppError::PiiDecrypt) {
+        return Err(TestError::Unexpected(format!(
+          "corrupt jwks_uri_enc must be PiiDecrypt, got {}",
+          error.code()
+        )));
+      }
+      if error.code() != OAuthErrorCode::TemporarilyUnavailable || error.status().as_u16() != 503 {
+        return Err(TestError::Unexpected("PiiDecrypt must be 503".to_owned()));
+      }
+      Ok(())
+    }
+  }
+}

+ 1 - 0
src/protocol/code/tests.rs

@@ -61,6 +61,7 @@ fn test_ring() -> Result<KeyRing, TestError> {
   Ok(KeyRing::from_parts(
     vec![(KeyId::new("v1"), SecretString::from("code-issue-pepper"))],
     vec![(KeyId::new("v1"), SecretString::from("code-issue-totp"))],
+    vec![(KeyId::new("v1"), SecretString::from("code-issue-pii"))],
   )?)
 }
 

+ 23 - 9
src/protocol/dcr.rs

@@ -100,6 +100,8 @@ pub struct RegisterRequest {
   pub dpop_bound_access_tokens: Option<bool>,
   /// Contact addresses, stored verbatim.
   pub contacts: Option<serde_json::Value>,
+  /// Pre-registered JAR `request_uri` values (RFC 9101), stored verbatim.
+  pub request_uris: Option<serde_json::Value>,
   /// Software identifier, stored verbatim.
   pub software_id: Option<String>,
 }
@@ -161,6 +163,7 @@ struct ValidatedRegistration {
   subject_dn: Option<String>,
   dpop_bound: bool,
   contacts: Option<serde_json::Value>,
+  request_uris: Option<serde_json::Value>,
   software_id: Option<String>,
   redirect_uris: Vec<String>,
 }
@@ -243,13 +246,19 @@ pub async fn register_client(
     jwks_uri: validated.jwks_uri.clone(),
     tls_client_auth_subject_dn: validated.subject_dn.clone(),
     tls_client_auth_cert_thumbprint: None,
-    request_uris: None,
+    request_uris: validated.request_uris.clone(),
     contacts: validated.contacts.clone(),
     software_id: validated.software_id.clone(),
     dpop_bound_access_tokens: validated.dpop_bound,
     registration_access_token_hash: Some(registration_hash),
   };
-  let row = client_repo::insert(stores.pool, &new).await?;
+  let row = client_repo::insert(
+    stores.pool,
+    &ctx.config.keys,
+    ctx.config.pii_dual_write,
+    &new,
+  )
+  .await?;
 
   for id in &redirect_ids {
     redirect_repo::attach(stores.pool, &client_id, *id, None).await?;
@@ -445,6 +454,7 @@ async fn validate_metadata(
     subject_dn,
     dpop_bound,
     contacts: request.contacts.clone(),
+    request_uris: request.request_uris.clone(),
     software_id: request.software_id.clone(),
     redirect_uris,
   })
@@ -479,7 +489,7 @@ async fn resolve_redirects(
   config: &Config,
   redirect_uris: &[String],
 ) -> Result<Vec<Uuid>, AppError> {
-  let catalog = redirect_repo::list(pool, false).await?;
+  let catalog = redirect_repo::list(pool, &config.keys, false).await?;
   let mut ids = Vec::with_capacity(redirect_uris.len());
   for uri in redirect_uris {
     if let Some(hit) = catalog.iter().find(|row| row.uri == *uri) {
@@ -507,13 +517,17 @@ async fn resolve_redirects(
         }
       }
     }
-    let row = redirect_repo::upsert(
+    let (row, _) = redirect_repo::upsert(
       pool,
-      Uuid::new_v4(),
-      uri,
-      redirect_repo::RedirectKind::Authorize,
-      DYNAMIC_REDIRECT_DESCRIPTION,
-      None,
+      &config.keys,
+      config.pii_dual_write,
+      &redirect_repo::NewRedirectUri {
+        id: Uuid::new_v4(),
+        uri,
+        kind: redirect_repo::RedirectKind::Authorize,
+        description: DYNAMIC_REDIRECT_DESCRIPTION,
+        created_by: None,
+      },
     )
     .await?;
     if !row.enabled {

+ 9 - 8
src/protocol/dcr/manage.rs

@@ -234,10 +234,9 @@ pub async fn read_client(
     registration_access_token,
   )
   .await?;
-  let row = active_row(stores.pool, client_id).await?;
-  let effective = redirect_repo::effective_for_client(stores.pool, client_id)
-    .await
-    .map_err(AppError::from)?;
+  let row = active_row(stores.pool, &ctx.config.keys, client_id).await?;
+  let effective =
+    redirect_repo::effective_for_client(stores.pool, &ctx.config.keys, client_id).await?;
   Ok(respond(
     &row,
     effective.into_iter().map(|uri| uri.uri).collect(),
@@ -249,10 +248,12 @@ pub async fn read_client(
 
 /// Load an active client row; a client that vanished after authorization
 /// reads as `401` (fail closed, no oracle).
-async fn active_row(pool: &PgPool, client_id: &str) -> Result<Client, ManageError> {
-  let row = client_repo::find_active_by_client_id(pool, client_id)
-    .await
-    .map_err(AppError::from)?;
+async fn active_row(
+  pool: &PgPool,
+  keys: &crate::keys::KeyRing,
+  client_id: &str,
+) -> Result<Client, ManageError> {
+  let row = client_repo::find_active_by_client_id(pool, keys, client_id).await?;
   row.ok_or_else(|| AppError::invalid_client_bearer().into())
 }
 

+ 53 - 7
src/protocol/dcr/manage/support.rs

@@ -129,6 +129,7 @@ fn test_config(pg_url: &str, redis_url: &str, policy: &str) -> Result<Config, Te
     "redis": {"url": redis_url, "durability": "waitaof"},
     "token_pepper": "dcr-test-pepper-0123456789abcdef",
     "totp_encryption_key": "dcr-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "dcr-test-pii-key-0123456789abcdef",
     "argon2": {"memory_kib": 8192, "iterations": 1, "parallelism": 1},
     "allow_open_registration": false,
     "dcr_redirect_policy": policy,
@@ -166,14 +167,22 @@ pub(super) fn manage_parts(harness: &mut Harness) -> (ManageStores<'_>, ManageCo
 }
 
 /// Seed one enabled catalog redirect URI.
-pub(super) async fn seed_redirect(pool: &PgPool, uri: &str) -> TestResult {
+pub(super) async fn seed_redirect(
+  pool: &PgPool,
+  keys: &crate::keys::KeyRing,
+  uri: &str,
+) -> TestResult {
   redirect_repo::upsert(
     pool,
-    Uuid::new_v4(),
-    uri,
-    redirect_repo::RedirectKind::Authorize,
-    "dcr manage test",
-    None,
+    keys,
+    false,
+    &redirect_repo::NewRedirectUri {
+      id: Uuid::new_v4(),
+      uri,
+      kind: redirect_repo::RedirectKind::Authorize,
+      description: "dcr manage test",
+      created_by: None,
+    },
   )
   .await?;
   Ok(())
@@ -219,6 +228,7 @@ fn dpop_request(redirect_uri: &str, scope: Option<&str>) -> RegisterRequest {
     tls_client_auth_subject_dn: None,
     dpop_bound_access_tokens: Some(true),
     contacts: None,
+    request_uris: None,
     software_id: None,
   }
 }
@@ -229,7 +239,7 @@ pub(super) async fn register(
   redirect_uri: &str,
   scope: Option<&str>,
 ) -> Result<Registered, TestError> {
-  seed_redirect(&harness.pool, redirect_uri).await?;
+  seed_redirect(&harness.pool, &harness.config.keys, redirect_uri).await?;
   if let Some(name) = scope {
     seed_scope(&harness.pool, name).await?;
   }
@@ -248,6 +258,42 @@ pub(super) async fn register(
   })
 }
 
+/// Register one mTLS client through the production path.
+pub(super) async fn register_mtls(
+  harness: &mut Harness,
+  redirect_uri: &str,
+  subject_dn: &str,
+) -> Result<Registered, TestError> {
+  seed_redirect(&harness.pool, &harness.config.keys, redirect_uri).await?;
+  let iat = issue_iat(harness, 3).await?;
+  let request = RegisterRequest {
+    redirect_uris: Some(vec![redirect_uri.to_owned()]),
+    client_name: Some("dcr manage mtls client".to_owned()),
+    token_endpoint_auth_method: Some("tls_client_auth".to_owned()),
+    grant_types: Some(vec!["authorization_code".to_owned()]),
+    response_types: Some(vec!["code".to_owned()]),
+    scope: None,
+    jwks: None,
+    jwks_uri: None,
+    tls_client_auth_subject_dn: Some(subject_dn.to_owned()),
+    dpop_bound_access_tokens: None,
+    contacts: None,
+    request_uris: None,
+    software_id: None,
+  };
+  let response = {
+    let (mut stores, ctx) = register_parts(harness);
+    register_client(&mut stores, &ctx, Some(&iat), &request)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("mtls register failed: {error}")))?
+  };
+  Ok(Registered {
+    client_id: response.client_id,
+    secret: response.client_secret,
+    reg_token: response.registration_access_token,
+  })
+}
+
 /// Assert a management failure is `401 invalid_client`.
 pub(super) fn expect_unauthorized(error: ManageError) -> TestResult {
   match error {

+ 50 - 0
src/protocol/dcr/manage/tests.rs

@@ -76,6 +76,55 @@ async fn dcr_manage_read_returns_metadata() -> TestResult {
   Ok(())
 }
 
+#[tokio::test]
+async fn dcr_manage_get() -> TestResult {
+  let (_guard, mut harness) = harness("catalog").await?;
+  let registered = register(&mut harness, "https://app.example.test/cb", Some("read")).await?;
+
+  let response = {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    read_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some(&registered.reg_token),
+    )
+    .await
+    .map_err(|error| super::support::TestError::Unexpected(format!("get failed: {error:?}")))?
+  };
+
+  if response.client_id != registered.client_id {
+    return Err(super::support::TestError::Unexpected(format!(
+      "wrong client_id: {}",
+      response.client_id
+    )));
+  }
+  if response.client_name != "dcr manage client" {
+    return Err(super::support::TestError::Unexpected(format!(
+      "wrong name: {}",
+      response.client_name
+    )));
+  }
+  if response.redirect_uris != vec!["https://app.example.test/cb".to_owned()] {
+    return Err(super::support::TestError::Unexpected(format!(
+      "wrong redirects: {:?}",
+      response.redirect_uris
+    )));
+  }
+  if response.scope != "read" {
+    return Err(super::support::TestError::Unexpected(format!(
+      "wrong scope: {}",
+      response.scope
+    )));
+  }
+  if response.client_secret.is_some() || response.registration_access_token.is_some() {
+    return Err(super::support::TestError::Unexpected(
+      "GET must not return credentials".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
 #[tokio::test]
 async fn dcr_manage_read_missing_token_401() -> TestResult {
   let (_guard, mut harness) = harness("catalog").await?;
@@ -176,6 +225,7 @@ async fn dcr_manage_update_bad_redirect_rejected() -> TestResult {
     tls_client_auth_subject_dn: None,
     dpop_bound_access_tokens: Some(true),
     contacts: None,
+    request_uris: None,
     software_id: None,
   };
   let (mut stores, ctx) = manage_parts(&mut harness);

+ 12 - 5
src/protocol/dcr/manage/tests_delete.rs

@@ -29,6 +29,7 @@ async fn dcr_manage_cross_client_forbidden() -> TestResult {
     tls_client_auth_subject_dn: None,
     dpop_bound_access_tokens: Some(true),
     contacts: None,
+    request_uris: None,
     software_id: None,
   };
 
@@ -70,7 +71,9 @@ async fn dcr_manage_cross_client_forbidden() -> TestResult {
     Err(error) => expect_forbidden(error)?,
   }
 
-  let row = client_repo::find_active_by_client_id(&harness.pool, &second.client_id).await?;
+  let row =
+    client_repo::find_active_by_client_id(&harness.pool, &harness.config.keys, &second.client_id)
+      .await?;
   if row.is_none() {
     return Err(TestError::Unexpected(
       "victim client changed by cross-client calls".to_owned(),
@@ -96,15 +99,19 @@ async fn dcr_manage_delete_deactivates() -> TestResult {
     .map_err(|error| TestError::Unexpected(format!("delete failed: {error:?}")))?;
   }
 
-  if client_repo::find_active_by_client_id(&harness.pool, &registered.client_id)
-    .await?
-    .is_some()
+  if client_repo::find_active_by_client_id(
+    &harness.pool,
+    &harness.config.keys,
+    &registered.client_id,
+  )
+  .await?
+  .is_some()
   {
     return Err(TestError::Unexpected(
       "deleted client still active".to_owned(),
     ));
   }
-  let listed = client_repo::list(&harness.pool, true).await?;
+  let listed = client_repo::list(&harness.pool, &harness.config.keys, true).await?;
   let kept = listed
     .iter()
     .find(|row| row.client_id == registered.client_id)

+ 134 - 10
src/protocol/dcr/manage/tests_update.rs

@@ -2,7 +2,7 @@
 
 use super::read_client;
 use super::support::{
-  TestError, TestResult, harness, manage_parts, register, seed_redirect, seed_scope,
+  TestError, TestResult, harness, manage_parts, register, register_mtls, seed_redirect, seed_scope,
 };
 use super::update::update_client;
 use crate::db::{client_repo, redirect_repo};
@@ -25,6 +25,7 @@ fn put_request(
     tls_client_auth_subject_dn: None,
     dpop_bound_access_tokens: Some(true),
     contacts: None,
+    request_uris: None,
     software_id: None,
   }
 }
@@ -33,13 +34,22 @@ fn put_request(
 async fn dcr_manage_update_full_replace_rotates() -> TestResult {
   let (_guard, mut harness) = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", Some("read")).await?;
-  seed_redirect(&harness.pool, "https://app.example.test/other").await?;
+  seed_redirect(
+    &harness.pool,
+    &harness.config.keys,
+    "https://app.example.test/other",
+  )
+  .await?;
   seed_scope(&harness.pool, "write").await?;
-  let issued_before = client_repo::find_active_by_client_id(&harness.pool, &registered.client_id)
-    .await?
-    .ok_or_else(|| TestError::Unexpected("client row missing".to_owned()))?
-    .client_id_issued_at
-    .unix_timestamp();
+  let issued_before = client_repo::find_active_by_client_id(
+    &harness.pool,
+    &harness.config.keys,
+    &registered.client_id,
+  )
+  .await?
+  .ok_or_else(|| TestError::Unexpected("client row missing".to_owned()))?
+  .client_id_issued_at
+  .unix_timestamp();
 
   let request = put_request(
     vec!["https://app.example.test/other".to_owned()],
@@ -147,8 +157,18 @@ async fn dcr_manage_update_full_replace_rotates() -> TestResult {
 async fn dcr_manage_update_replaces_redirect_attachments() -> TestResult {
   let (_guard, mut harness) = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/a", None).await?;
-  seed_redirect(&harness.pool, "https://app.example.test/b").await?;
-  seed_redirect(&harness.pool, "https://app.example.test/c").await?;
+  seed_redirect(
+    &harness.pool,
+    &harness.config.keys,
+    "https://app.example.test/b",
+  )
+  .await?;
+  seed_redirect(
+    &harness.pool,
+    &harness.config.keys,
+    "https://app.example.test/c",
+  )
+  .await?;
 
   let request = put_request(
     vec![
@@ -174,7 +194,9 @@ async fn dcr_manage_update_replaces_redirect_attachments() -> TestResult {
       .ok_or_else(|| TestError::Unexpected("missing rotated token".to_owned()))?
   };
 
-  let effective = redirect_repo::effective_for_client(&harness.pool, &registered.client_id).await?;
+  let effective =
+    redirect_repo::effective_for_client(&harness.pool, &harness.config.keys, &registered.client_id)
+      .await?;
   let uris: Vec<String> = effective.into_iter().map(|row| row.uri).collect();
   if uris
     != vec![
@@ -188,6 +210,7 @@ async fn dcr_manage_update_replaces_redirect_attachments() -> TestResult {
   }
   if client_repo::redirect_uri_allowed(
     &harness.pool,
+    &harness.config.keys,
     &registered.client_id,
     "https://app.example.test/a",
   )
@@ -241,3 +264,104 @@ async fn dcr_manage_update_omitted_fields_clear() -> TestResult {
   }
   Ok(())
 }
+
+#[tokio::test]
+async fn dcr_manage_update_rename() -> TestResult {
+  let (_guard, mut harness) = harness("catalog").await?;
+  let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
+
+  let request = put_request(
+    vec!["https://app.example.test/cb".to_owned()],
+    Some("renamed via manage"),
+    None,
+  );
+  let token = {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    let response = update_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some(&registered.reg_token),
+      &request,
+    )
+    .await
+    .map_err(|error| TestError::Unexpected(format!("update failed: {error:?}")))?;
+    if response.client_name != "renamed via manage" {
+      return Err(TestError::Unexpected(format!(
+        "PUT did not rename: {}",
+        response.client_name
+      )));
+    }
+    response
+      .registration_access_token
+      .ok_or_else(|| TestError::Unexpected("missing rotated token".to_owned()))?
+  };
+
+  let (mut stores, ctx) = manage_parts(&mut harness);
+  let reread = read_client(&mut stores, &ctx, &registered.client_id, Some(&token))
+    .await
+    .map_err(|error| TestError::Unexpected(format!("reread failed: {error:?}")))?;
+  if reread.client_name != "renamed via manage" {
+    return Err(TestError::Unexpected(format!(
+      "GET did not return the renamed value: {}",
+      reread.client_name
+    )));
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn dcr_manage_update_same_mtls_identity_ok() -> TestResult {
+  let (_guard, mut harness) = harness("catalog").await?;
+  let subject_dn = "CN=manage-mtls, O=Example";
+  let registered = register_mtls(&mut harness, "https://app.example.test/cb", subject_dn).await?;
+
+  let request = crate::protocol::dcr::RegisterRequest {
+    redirect_uris: Some(vec!["https://app.example.test/cb".to_owned()]),
+    client_name: Some("dcr manage mtls client".to_owned()),
+    token_endpoint_auth_method: Some("tls_client_auth".to_owned()),
+    grant_types: Some(vec!["authorization_code".to_owned()]),
+    response_types: Some(vec!["code".to_owned()]),
+    scope: None,
+    jwks: None,
+    jwks_uri: None,
+    tls_client_auth_subject_dn: Some(subject_dn.to_owned()),
+    dpop_bound_access_tokens: None,
+    contacts: None,
+    request_uris: None,
+    software_id: None,
+  };
+  let token = {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    let response = update_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some(&registered.reg_token),
+      &request,
+    )
+    .await
+    .map_err(|error| TestError::Unexpected(format!("unchanged identity rejected: {error:?}")))?;
+    if response.tls_client_auth_subject_dn.as_deref() != Some(subject_dn) {
+      return Err(TestError::Unexpected(format!(
+        "subject DN changed: {:?}",
+        response.tls_client_auth_subject_dn
+      )));
+    }
+    response
+      .registration_access_token
+      .ok_or_else(|| TestError::Unexpected("missing rotated token".to_owned()))?
+  };
+
+  let (mut stores, ctx) = manage_parts(&mut harness);
+  let reread = read_client(&mut stores, &ctx, &registered.client_id, Some(&token))
+    .await
+    .map_err(|error| TestError::Unexpected(format!("reread failed: {error:?}")))?;
+  if reread.tls_client_auth_subject_dn.as_deref() != Some(subject_dn) {
+    return Err(TestError::Unexpected(format!(
+      "GET returned a different DN: {:?}",
+      reread.tls_client_auth_subject_dn
+    )));
+  }
+  Ok(())
+}

+ 13 - 9
src/protocol/dcr/manage/update.rs

@@ -54,15 +54,20 @@ pub async fn update_client(
     jwks_uri: validated.jwks_uri.clone(),
     tls_client_auth_subject_dn: validated.subject_dn.clone(),
     tls_client_auth_cert_thumbprint: None,
-    request_uris: None,
+    request_uris: validated.request_uris.clone(),
     contacts: validated.contacts.clone(),
     software_id: validated.software_id.clone(),
     dpop_bound_access_tokens: validated.dpop_bound,
   };
-  client_repo::update_metadata(stores.pool, client_id, &meta)
-    .await
-    .map_err(AppError::from)?
-    .ok_or_else(AppError::invalid_client_bearer)?;
+  client_repo::update_metadata(
+    stores.pool,
+    &ctx.config.keys,
+    ctx.config.pii_dual_write,
+    client_id,
+    &meta,
+  )
+  .await?
+  .ok_or_else(AppError::invalid_client_bearer)?;
 
   redirect_repo::detach_all(stores.pool, client_id)
     .await
@@ -77,10 +82,9 @@ pub async fn update_client(
   let rotated_secret =
     rotate_secret_if_configured(stores, ctx, client_id, validated.method).await?;
 
-  let row = super::active_row(stores.pool, client_id).await?;
-  let effective = redirect_repo::effective_for_client(stores.pool, client_id)
-    .await
-    .map_err(AppError::from)?;
+  let row = super::active_row(stores.pool, &ctx.config.keys, client_id).await?;
+  let effective =
+    redirect_repo::effective_for_client(stores.pool, &ctx.config.keys, client_id).await?;
   Ok(respond(
     &row,
     effective.into_iter().map(|uri| uri.uri).collect(),

+ 225 - 17
src/protocol/dcr/tests.rs

@@ -124,6 +124,7 @@ fn test_config(
     "redis": {"url": redis_url, "durability": "waitaof"},
     "token_pepper": "dcr-test-pepper-0123456789abcdef",
     "totp_encryption_key": "dcr-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "dcr-test-pii-key-0123456789abcdef",
     "argon2": {"memory_kib": 8192, "iterations": 1, "parallelism": 1},
     "allow_open_registration": open,
     "dcr_redirect_policy": policy,
@@ -150,14 +151,18 @@ fn parts_of(harness: &mut Harness) -> (RegisterStores<'_>, RegisterContext<'_>)
 }
 
 /// Seed one enabled catalog redirect URI.
-async fn seed_redirect(pool: &PgPool, uri: &str) -> TestResult {
+async fn seed_redirect(pool: &PgPool, keys: &crate::keys::KeyRing, uri: &str) -> TestResult {
   redirect_repo::upsert(
     pool,
-    Uuid::new_v4(),
-    uri,
-    redirect_repo::RedirectKind::Authorize,
-    "dcr test",
-    None,
+    keys,
+    false,
+    &redirect_repo::NewRedirectUri {
+      id: Uuid::new_v4(),
+      uri,
+      kind: redirect_repo::RedirectKind::Authorize,
+      description: "dcr test",
+      created_by: None,
+    },
   )
   .await?;
   Ok(())
@@ -204,6 +209,7 @@ fn dpop_request(redirect_uri: &str, scope: Option<&str>) -> RegisterRequest {
     tls_client_auth_subject_dn: None,
     dpop_bound_access_tokens: Some(true),
     contacts: None,
+    request_uris: None,
     software_id: None,
   }
 }
@@ -228,7 +234,12 @@ fn expect_error(error: AppError, code: OAuthErrorCode, status: u16) -> TestResul
 #[tokio::test]
 async fn dcr_register_valid_201() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
-  seed_redirect(&harness.pool, "https://app.example.test/cb").await?;
+  seed_redirect(
+    &harness.pool,
+    &harness.config.keys,
+    "https://app.example.test/cb",
+  )
+  .await?;
   seed_scope(&harness.pool, "read").await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let request = dpop_request("https://app.example.test/cb", Some("read"));
@@ -310,6 +321,168 @@ async fn dcr_register_valid_201() -> TestResult {
   Ok(())
 }
 
+#[tokio::test]
+async fn dcr_register_round_trip() -> TestResult {
+  let (_guard, mut harness) = harness(false, "catalog").await?;
+  seed_redirect(
+    &harness.pool,
+    &harness.config.keys,
+    "https://app.example.test/cb",
+  )
+  .await?;
+  let iat = issue_iat(&mut harness, 3).await?;
+  let contacts = serde_json::json!(["admin@example.test"]);
+  let request_uris = serde_json::json!(["https://app.example.test/request.jwt"]);
+  let mut request = dpop_request("https://app.example.test/cb", None);
+  request.client_name = Some("round trip client".to_owned());
+  request.contacts = Some(contacts.clone());
+  request.request_uris = Some(request_uris.clone());
+
+  let response = {
+    let (mut stores, ctx) = parts_of(&mut harness);
+    register_client(&mut stores, &ctx, Some(&iat), &request)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("register failed: {error}")))?
+  };
+
+  let row =
+    client_repo::find_active_by_client_id(&harness.pool, &harness.config.keys, &response.client_id)
+      .await?
+      .ok_or_else(|| TestError::Unexpected("registered row missing".to_owned()))?;
+  if row.client_name != "round trip client" {
+    return Err(TestError::Unexpected(format!(
+      "client_name not round-tripped: {}",
+      row.client_name
+    )));
+  }
+  if row.contacts.as_ref() != Some(&contacts) {
+    return Err(TestError::Unexpected(format!(
+      "contacts not round-tripped: {:?}",
+      row.contacts
+    )));
+  }
+  if row.request_uris.as_ref() != Some(&request_uris) {
+    return Err(TestError::Unexpected(format!(
+      "request_uris not round-tripped: {:?}",
+      row.request_uris
+    )));
+  }
+  if response.client_name != "round trip client" {
+    return Err(TestError::Unexpected(format!(
+      "response lost the name: {}",
+      response.client_name
+    )));
+  }
+
+  let name_enc_present: bool = sqlx::query_scalar(
+    "SELECT client_name_enc IS NOT NULL FROM oauth_clients WHERE client_id = $1",
+  )
+  .bind(&response.client_id)
+  .fetch_one(&harness.pool)
+  .await?;
+  let name_plain_present: bool =
+    sqlx::query_scalar("SELECT client_name IS NOT NULL FROM oauth_clients WHERE client_id = $1")
+      .bind(&response.client_id)
+      .fetch_one(&harness.pool)
+      .await?;
+  let uris_enc_present: bool = sqlx::query_scalar(
+    "SELECT request_uris_enc IS NOT NULL FROM oauth_clients WHERE client_id = $1",
+  )
+  .bind(&response.client_id)
+  .fetch_one(&harness.pool)
+  .await?;
+  let uris_plain_present: bool =
+    sqlx::query_scalar("SELECT request_uris IS NOT NULL FROM oauth_clients WHERE client_id = $1")
+      .bind(&response.client_id)
+      .fetch_one(&harness.pool)
+      .await?;
+  if !name_enc_present || name_plain_present {
+    return Err(TestError::Unexpected(
+      "client_name was not sealed with encrypt-only mode".to_owned(),
+    ));
+  }
+  if !uris_enc_present || uris_plain_present {
+    return Err(TestError::Unexpected(
+      "request_uris was not sealed with encrypt-only mode".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn dcr_register_duplicate_mtls_identity_is_400() -> TestResult {
+  let (_guard, mut harness) = harness(false, "catalog").await?;
+  seed_redirect(
+    &harness.pool,
+    &harness.config.keys,
+    "https://app.example.test/cb",
+  )
+  .await?;
+  let iat = issue_iat(&mut harness, 5).await?;
+  let mut request = dpop_request("https://app.example.test/cb", None);
+  request.token_endpoint_auth_method = Some("tls_client_auth".to_owned());
+  request.dpop_bound_access_tokens = None;
+  request.tls_client_auth_subject_dn = Some("CN=duplicate-client, O=Example".to_owned());
+
+  {
+    let (mut stores, ctx) = parts_of(&mut harness);
+    register_client(&mut stores, &ctx, Some(&iat), &request)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("first register failed: {error}")))?;
+  }
+
+  let error = {
+    let (mut stores, ctx) = parts_of(&mut harness);
+    match register_client(&mut stores, &ctx, Some(&iat), &request).await {
+      Ok(_) => {
+        return Err(TestError::Unexpected(
+          "duplicate mTLS identity accepted".to_owned(),
+        ));
+      }
+      Err(error) => error,
+    }
+  };
+
+  if error.code() != OAuthErrorCode::InvalidClientMetadata {
+    return Err(TestError::Unexpected(format!(
+      "wrong duplicate code: {}",
+      error.code()
+    )));
+  }
+  if error.status().as_u16() != 400 {
+    return Err(TestError::Unexpected(format!(
+      "wrong duplicate status: {}",
+      error.status()
+    )));
+  }
+  let description = error.description().to_owned();
+  if description.contains("CN=") || description.contains("duplicate-client") {
+    return Err(TestError::Unexpected(format!(
+      "duplicate description leaks the identity: {description}"
+    )));
+  }
+  if description != OAuthErrorCode::InvalidClientMetadata.default_description() {
+    return Err(TestError::Unexpected(format!(
+      "duplicate description is not the static default: {description}"
+    )));
+  }
+
+  use axum::response::IntoResponse as _;
+  use http_body_util::BodyExt as _;
+  let rendered = error.into_response();
+  let body = match rendered.into_body().collect().await {
+    Ok(collected) => collected.to_bytes(),
+    Err(_) => axum::body::Bytes::new(),
+  };
+  let body = String::from_utf8_lossy(&body);
+  if body.contains("CN=") || body.contains("duplicate-client") || body.contains("Example") {
+    return Err(TestError::Unexpected(format!(
+      "400 body leaks the identity: {body}"
+    )));
+  }
+  Ok(())
+}
+
 #[tokio::test]
 async fn dcr_register_missing_iat_401() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
@@ -353,7 +526,12 @@ async fn dcr_register_bad_redirect_rejected() -> TestResult {
 #[tokio::test]
 async fn dcr_register_bad_auth_method_rejected() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
-  seed_redirect(&harness.pool, "https://app.example.test/cb").await?;
+  seed_redirect(
+    &harness.pool,
+    &harness.config.keys,
+    "https://app.example.test/cb",
+  )
+  .await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let mut request = dpop_request("https://app.example.test/cb", None);
   request.token_endpoint_auth_method = Some("magic_auth".to_owned());
@@ -369,7 +547,12 @@ async fn dcr_register_bad_auth_method_rejected() -> TestResult {
 #[tokio::test]
 async fn dcr_register_bad_grant_rejected() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
-  seed_redirect(&harness.pool, "https://app.example.test/cb").await?;
+  seed_redirect(
+    &harness.pool,
+    &harness.config.keys,
+    "https://app.example.test/cb",
+  )
+  .await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let mut request = dpop_request("https://app.example.test/cb", None);
   request.grant_types = Some(vec!["client_credentials".to_owned()]);
@@ -385,7 +568,12 @@ async fn dcr_register_bad_grant_rejected() -> TestResult {
 #[tokio::test]
 async fn dcr_register_bad_scope_rejected() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
-  seed_redirect(&harness.pool, "https://app.example.test/cb").await?;
+  seed_redirect(
+    &harness.pool,
+    &harness.config.keys,
+    "https://app.example.test/cb",
+  )
+  .await?;
   seed_scope(&harness.pool, "read").await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let request = dpop_request("https://app.example.test/cb", Some("admin"));
@@ -399,7 +587,12 @@ async fn dcr_register_bad_scope_rejected() -> TestResult {
 #[tokio::test]
 async fn dcr_register_confidential_without_binding_rejected() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
-  seed_redirect(&harness.pool, "https://app.example.test/cb").await?;
+  seed_redirect(
+    &harness.pool,
+    &harness.config.keys,
+    "https://app.example.test/cb",
+  )
+  .await?;
   let iat = issue_iat(&mut harness, 5).await?;
 
   let mut bare_secret = dpop_request("https://app.example.test/cb", None);
@@ -446,6 +639,7 @@ async fn dcr_register_open_anonymous_201() -> TestResult {
     tls_client_auth_subject_dn: None,
     dpop_bound_access_tokens: None,
     contacts: None,
+    request_uris: None,
     software_id: None,
   };
   let response = {
@@ -460,7 +654,9 @@ async fn dcr_register_open_anonymous_201() -> TestResult {
       "public client was issued a secret".to_owned(),
     ));
   }
-  let effective = redirect_repo::effective_for_client(&harness.pool, &response.client_id).await?;
+  let effective =
+    redirect_repo::effective_for_client(&harness.pool, &harness.config.keys, &response.client_id)
+      .await?;
   if effective.len() != 1 || effective[0].uri != "http://127.0.0.1:6420/cb" {
     return Err(TestError::Unexpected(format!(
       "loopback URI not attached: {:?}",
@@ -473,7 +669,12 @@ async fn dcr_register_open_anonymous_201() -> TestResult {
 #[tokio::test]
 async fn dcr_register_mtls_client_201() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
-  seed_redirect(&harness.pool, "https://app.example.test/cb").await?;
+  seed_redirect(
+    &harness.pool,
+    &harness.config.keys,
+    "https://app.example.test/cb",
+  )
+  .await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let request = RegisterRequest {
     redirect_uris: Some(vec!["https://app.example.test/cb".to_owned()]),
@@ -487,6 +688,7 @@ async fn dcr_register_mtls_client_201() -> TestResult {
     tls_client_auth_subject_dn: Some("CN=mtls-client, O=Example".to_owned()),
     dpop_bound_access_tokens: None,
     contacts: None,
+    request_uris: None,
     software_id: None,
   };
   let response = {
@@ -501,9 +703,10 @@ async fn dcr_register_mtls_client_201() -> TestResult {
       "mTLS client was issued a secret".to_owned(),
     ));
   }
-  let row = client_repo::find_active_by_client_id(&harness.pool, &response.client_id)
-    .await?
-    .ok_or_else(|| TestError::Unexpected("mtls client row missing".to_owned()))?;
+  let row =
+    client_repo::find_active_by_client_id(&harness.pool, &harness.config.keys, &response.client_id)
+      .await?
+      .ok_or_else(|| TestError::Unexpected("mtls client row missing".to_owned()))?;
   if row.tls_client_auth_subject_dn.as_deref() != Some("CN=mtls-client, O=Example") {
     return Err(TestError::Unexpected(format!(
       "subject DN not persisted: {:?}",
@@ -516,7 +719,12 @@ async fn dcr_register_mtls_client_201() -> TestResult {
 #[tokio::test]
 async fn dcr_register_jwks_xor_rejected() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
-  seed_redirect(&harness.pool, "https://app.example.test/cb").await?;
+  seed_redirect(
+    &harness.pool,
+    &harness.config.keys,
+    "https://app.example.test/cb",
+  )
+  .await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let mut request = dpop_request("https://app.example.test/cb", None);
   request.jwks = Some(serde_json::json!({"keys": []}));

+ 3 - 2
src/protocol/device.rs

@@ -245,7 +245,7 @@ pub async fn authorize_device(
       AppError::invalid_client_non_basic()
     }
   };
-  let client = client_repo::find_active_by_client_id(stores.pool, inputs.client_id)
+  let client = client_repo::find_active_by_client_id(stores.pool, stores.keys, inputs.client_id)
     .await?
     .ok_or_else(unknown_client)?;
   let method = client.auth_method().map_err(|_| unknown_client())?;
@@ -292,7 +292,7 @@ pub async fn authorize_device(
     }
     TokenEndpointAuthMethod::TlsClientAuth | TokenEndpointAuthMethod::SelfSignedTlsClientAuth => {
       let leaf = inputs.peer_certs.and_then(|chain| chain.chain().first());
-      let cnf = mtls::authenticate(stores.pool, &client, leaf)
+      let cnf = mtls::authenticate(stores.pool, stores.keys, &client, leaf)
         .await
         .map_err(mtls::MtlsError::into_app_error)?;
       DeviceBinding {
@@ -377,6 +377,7 @@ async fn authenticate_confidential(
   };
   let mut auth = AuthStores {
     pool: stores.pool,
+    keys: stores.keys,
     conn: &mut *stores.conn,
     store: stores.store,
   };

+ 17 - 2
src/protocol/device/tests.rs

@@ -120,6 +120,7 @@ async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
     "redis": {"url": redis_url, "durability": "waitaof"},
     "token_pepper": "device-test-pepper-0123456789abcdef",
     "totp_encryption_key": "device-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "device-test-pii-key-0123456789abcdef",
     "rate_limit": {"enabled": true, "window_secs": 60, "per_ip": 120,
       "per_client": 300, "login_per_ip": 20, "token_per_client": 60},
   });
@@ -140,6 +141,7 @@ async fn harness() -> Result<(ContainerGuard, Harness), TestError> {
 
 async fn insert_client(
   pool: &PgPool,
+  keys: &crate::keys::KeyRing,
   client_id: &str,
   method: TokenEndpointAuthMethod,
   secret: Option<&str>,
@@ -171,7 +173,7 @@ async fn insert_client(
     dpop_bound_access_tokens: false,
     registration_access_token_hash: None,
   };
-  client_repo::insert(pool, &new).await?;
+  client_repo::insert(pool, keys, false, &new).await?;
   Ok(())
 }
 
@@ -257,6 +259,7 @@ async fn test_device_authorize_public_client_issues_code_and_interval() -> TestR
   let client_id = unique("public");
   insert_client(
     &h.pool,
+    &h.config.keys,
     &client_id,
     TokenEndpointAuthMethod::None,
     None,
@@ -357,6 +360,7 @@ async fn test_device_authorize_confidential_client_secret_post() -> TestResult {
   let client_id = unique("conf");
   insert_client(
     &h.pool,
+    &h.config.keys,
     &client_id,
     TokenEndpointAuthMethod::ClientSecretPost,
     Some("post-secret"),
@@ -415,6 +419,7 @@ async fn test_device_authorize_public_client_without_dpop_rejected() -> TestResu
   let client_id = unique("nodpop");
   insert_client(
     &h.pool,
+    &h.config.keys,
     &client_id,
     TokenEndpointAuthMethod::None,
     None,
@@ -450,6 +455,7 @@ async fn test_device_authorize_over_registered_scope_rejected() -> TestResult {
   let client_id = unique("scope");
   insert_client(
     &h.pool,
+    &h.config.keys,
     &client_id,
     TokenEndpointAuthMethod::None,
     None,
@@ -488,6 +494,7 @@ async fn test_device_authorize_approval_transitions_state() -> TestResult {
   let client_id = unique("approve");
   insert_client(
     &h.pool,
+    &h.config.keys,
     &client_id,
     TokenEndpointAuthMethod::None,
     None,
@@ -566,7 +573,15 @@ async fn test_device_authorize_unknown_and_expired_user_code_rejected() -> TestR
 
   // An issued code whose records vanished (TTL expiry) resolves to nothing.
   let client_id = unique("expired");
-  insert_client(&h.pool, &client_id, TokenEndpointAuthMethod::None, None, "").await?;
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::None,
+    None,
+    "",
+  )
+  .await?;
   let key = fresh_key()?;
   let htu = device_htu(&h.config);
   let proof = mint_proof(&key, &htu, &unique("jti"), now_unix())?;

+ 4 - 0
src/protocol/introspect.rs

@@ -144,6 +144,8 @@ pub struct IntrospectHttpInputs<'a> {
 pub struct IntrospectStores<'a> {
   /// Postgres pool for client lookup and secret verification.
   pub pool: &'a PgPool,
+  /// Versioned PII keys for the encrypted client lookup.
+  pub keys: &'a crate::keys::KeyRing,
   /// Redis connection for token records and sidecars.
   pub conn: &'a mut RedisConn,
   /// Key layout plus retry/durability knobs.
@@ -247,6 +249,7 @@ async fn authenticate(
   };
   let mut auth_stores = AuthStores {
     pool: stores.pool,
+    keys: stores.keys,
     conn: &mut *stores.conn,
     store: stores.store,
   };
@@ -461,6 +464,7 @@ pub async fn handler(
   let mut conn = state.redis.clone();
   let mut stores = IntrospectStores {
     pool: &state.pool,
+    keys: &state.keys,
     conn: &mut conn,
     store: &store,
   };

+ 10 - 1
src/protocol/introspect/tests.rs

@@ -98,6 +98,7 @@ async fn harness() -> TestResult<(ContainerGuard, Harness)> {
     "redis": {"url": redis_url, "durability": "waitaof"},
     "token_pepper": "introspect-test-pepper-0123456789abcdef",
     "totp_encryption_key": "introspect-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "introspect-test-pii-key-0123456789abcdef",
   });
   let source = ::config::Config::try_from(&fixture)?;
   let config = Config::from_source(source)?;
@@ -115,6 +116,7 @@ async fn harness() -> TestResult<(ContainerGuard, Harness)> {
 
 async fn insert_client(
   pool: &PgPool,
+  keys: &crate::keys::KeyRing,
   client_id: &str,
   method: TokenEndpointAuthMethod,
   secret: Option<&str>,
@@ -143,7 +145,7 @@ async fn insert_client(
     dpop_bound_access_tokens: false,
     registration_access_token_hash: None,
   };
-  client_repo::insert(pool, &new).await?;
+  client_repo::insert(pool, keys, false, &new).await?;
   Ok(())
 }
 
@@ -190,6 +192,7 @@ async fn run_core(
   let service = token_service(h)?;
   let mut stores = IntrospectStores {
     pool: &h.pool,
+    keys: &h.config.keys,
     conn: &mut h.conn,
     store: &h.store,
   };
@@ -217,6 +220,7 @@ fn app_state(h: &Harness) -> AppState {
     redis: h.conn.clone(),
     config: Arc::clone(&h.config),
     keys: Arc::new(h.config.keys.clone()),
+    transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
     clock: Clock::system(),
   }
 }
@@ -253,6 +257,7 @@ async fn test_introspect_active_access_token_reports_shape() -> TestResult {
   let client_id = unique("owner");
   insert_client(
     &h.pool,
+    &h.config.keys,
     &client_id,
     TokenEndpointAuthMethod::ClientSecretPost,
     Some("secret-a"),
@@ -304,6 +309,7 @@ async fn test_introspect_unknown_token_is_inactive_200() -> TestResult {
   let client_id = unique("unknown");
   insert_client(
     &h.pool,
+    &h.config.keys,
     &client_id,
     TokenEndpointAuthMethod::ClientSecretBasic,
     Some("secret-u"),
@@ -342,6 +348,7 @@ async fn test_introspect_foreign_client_token_is_inactive_200() -> TestResult {
   let owner = unique("owner");
   insert_client(
     &h.pool,
+    &h.config.keys,
     &owner,
     TokenEndpointAuthMethod::ClientSecretBasic,
     Some("owner-secret"),
@@ -350,6 +357,7 @@ async fn test_introspect_foreign_client_token_is_inactive_200() -> TestResult {
   let foreign = unique("foreign");
   insert_client(
     &h.pool,
+    &h.config.keys,
     &foreign,
     TokenEndpointAuthMethod::ClientSecretBasic,
     Some("foreign-secret"),
@@ -377,6 +385,7 @@ async fn test_introspect_revoked_token_is_inactive() -> TestResult {
   let client_id = unique("revoked");
   insert_client(
     &h.pool,
+    &h.config.keys,
     &client_id,
     TokenEndpointAuthMethod::ClientSecretPost,
     Some("secret-r"),

+ 2 - 0
src/protocol/metadata/tests.rs

@@ -38,6 +38,7 @@ fn test_config() -> Result<Config, ConfigError> {
     "redis": {"url": "redis://127.0.0.1:1", "durability": "waitaof"},
     "token_pepper": "metadata-test-pepper-0123456789abcdef",
     "totp_encryption_key": "metadata-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "metadata-test-pii-key-0123456789abcdef",
   });
   let source = ::config::Config::try_from(&fixture)?;
   Config::from_source(source)
@@ -61,6 +62,7 @@ fn test_state(pool: &sqlx::PgPool) -> TestResult<AppState> {
     redis: RedisConn::Single(manager),
     config,
     keys,
+    transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
     clock: Clock::system(),
   })
 }

+ 5 - 1
src/protocol/mtls.rs

@@ -24,7 +24,7 @@ use crate::error::{AppError, OAuthErrorCode};
 
 /// mTLS client-authentication failures.
 ///
-/// Every variant except [`MtlsError::Database`] and
+/// Every variant except [`MtlsError::Database`], [`MtlsError::App`], and
 /// [`MtlsError::TrustBundle`] maps to `invalid_client`
 /// (see [`MtlsError::into_app_error`]); no certificate bytes, subject
 /// details, or database text are exposed to the client.
@@ -54,6 +54,9 @@ pub enum MtlsError {
   /// A client lookup failed.
   #[error("database error: {0}")]
   Database(#[from] sqlx::Error),
+  /// An encrypted client lookup failed (fail-closed PII or store error).
+  #[error("client lookup failed")]
+  App(#[from] AppError),
 }
 
 impl MtlsError {
@@ -62,6 +65,7 @@ impl MtlsError {
   pub fn into_app_error(self) -> AppError {
     match self {
       Self::Database(source) => AppError::from(source),
+      Self::App(error) => error,
       Self::TrustBundle(_) => AppError::from_code(OAuthErrorCode::ServerError),
       Self::MissingCertificate
       | Self::UnknownCertificate

+ 6 - 3
src/protocol/mtls/select.rs

@@ -14,6 +14,7 @@ use super::{
   cert::{subject_dn, thumbprint_b64url},
 };
 use crate::db::client_repo::{self, Client, TokenEndpointAuthMethod};
+use crate::keys::KeyRing;
 
 /// Select the unique client enrolled with this certificate thumbprint.
 pub fn select_by_thumbprint<'a>(
@@ -49,6 +50,7 @@ pub fn select_by_subject_dn<'a>(clients: &'a [Client], dn: &str) -> Result<&'a C
 /// subject-DN matching for `tls_client_auth`. Returns the `cnf` to bind.
 pub async fn authenticate(
   pool: &PgPool,
+  keys: &KeyRing,
   client: &Client,
   leaf: Option<&CertificateDer<'_>>,
 ) -> Result<MtlsCnf, MtlsError> {
@@ -79,7 +81,7 @@ pub async fn authenticate(
       if registered != dn {
         return Err(MtlsError::UnknownCertificate);
       }
-      let clients = client_repo::list(pool, false).await?;
+      let clients = client_repo::list(pool, keys, false).await?;
       select_by_subject_dn(&clients, &dn)?;
       Ok(MtlsCnf {
         x5t_s256: thumbprint_b64url(der.as_ref()),
@@ -95,11 +97,12 @@ pub async fn authenticate(
 /// Load the client by `client_id`, then [`authenticate`] it with the leaf.
 pub async fn authenticate_by_id(
   pool: &PgPool,
+  keys: &KeyRing,
   client_id: &str,
   leaf: Option<&CertificateDer<'_>>,
 ) -> Result<MtlsCnf, MtlsError> {
-  let client = client_repo::find_active_by_client_id(pool, client_id)
+  let client = client_repo::find_active_by_client_id(pool, keys, client_id)
     .await?
     .ok_or(MtlsError::UnknownClient)?;
-  authenticate(pool, &client, leaf).await
+  authenticate(pool, keys, &client, leaf).await
 }

Деякі файли не було показано, через те що забагато файлів було змінено