Kaynağa Gözat

Added encryption

Efren Yevale Varela 4 hafta önce
ebeveyn
işleme
50269aa5bf
100 değiştirilmiş dosya ile 9632 ekleme ve 992 silme
  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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -16,12 +16,23 @@
       },
       },
       {
       {
         "ordinal": 1,
         "ordinal": 1,
-        "name": "email!",
+        "name": "email",
         "type_info": "Text",
         "type_info": "Text",
         "origin": "Expression"
         "origin": "Expression"
       },
       },
       {
       {
         "ordinal": 2,
         "ordinal": 2,
+        "name": "email_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "email_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
         "name": "is_admin",
         "name": "is_admin",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -32,7 +43,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 3,
+        "ordinal": 4,
         "name": "must_change_password",
         "name": "must_change_password",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -43,7 +54,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 4,
+        "ordinal": 5,
         "name": "disabled",
         "name": "disabled",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -54,7 +65,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 5,
+        "ordinal": 6,
         "name": "totp_enabled",
         "name": "totp_enabled",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -65,7 +76,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 6,
+        "ordinal": 7,
         "name": "password_changed_at",
         "name": "password_changed_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -80,6 +91,8 @@
       "Left": [
       "Left": [
         "Uuid",
         "Uuid",
         "Text",
         "Text",
+        "Bytea",
+        "Text",
         "Text",
         "Text",
         "Bool",
         "Bool",
         "Bool"
         "Bool"
@@ -88,6 +101,7 @@
     "nullable": [
     "nullable": [
       false,
       false,
       null,
       null,
+      true,
       false,
       false,
       false,
       false,
       false,
       false,
@@ -95,5 +109,5 @@
       true
       true
     ]
     ]
   },
   },
-  "hash": "714904ff3cb3d812603a8daf505e556a38be72cc0b1388fb6b5929033b8c28f1"
+  "hash": "16dda6ce1d97cfc90378c74045a6601a67f1ad83aa2a3b8e25a67d879f74f82f"
 }
 }

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

@@ -1,6 +1,6 @@
 {
 {
   "db_name": "PostgreSQL",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -60,6 +60,17 @@
       },
       },
       {
       {
         "ordinal": 5,
         "ordinal": 5,
+        "name": "client_name_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
         "name": "grant_types",
         "name": "grant_types",
         "type_info": "TextArray",
         "type_info": "TextArray",
         "origin": {
         "origin": {
@@ -70,7 +81,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 6,
+        "ordinal": 7,
         "name": "response_types",
         "name": "response_types",
         "type_info": "TextArray",
         "type_info": "TextArray",
         "origin": {
         "origin": {
@@ -81,7 +92,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 7,
+        "ordinal": 8,
         "name": "scope",
         "name": "scope",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "origin": {
@@ -92,7 +103,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 8,
+        "ordinal": 9,
         "name": "token_endpoint_auth_method",
         "name": "token_endpoint_auth_method",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "origin": {
@@ -103,7 +114,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 9,
+        "ordinal": 10,
         "name": "jwks",
         "name": "jwks",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "origin": {
@@ -114,7 +125,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 10,
+        "ordinal": 11,
         "name": "jwks_uri",
         "name": "jwks_uri",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "tls_client_auth_subject_dn",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "tls_client_auth_cert_thumbprint",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "request_uris",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "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",
         "name": "contacts",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "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",
         "name": "software_id",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "dpop_bound_access_tokens",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -191,7 +268,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 17,
+        "ordinal": 24,
         "name": "active",
         "name": "active",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -202,7 +279,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 18,
+        "ordinal": 25,
         "name": "created_at",
         "name": "created_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -213,7 +290,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 19,
+        "ordinal": 26,
         "name": "updated_at",
         "name": "updated_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -226,19 +303,28 @@
     ],
     ],
     "parameters": {
     "parameters": {
       "Left": [
       "Left": [
+        "Uuid",
         "Text",
         "Text",
-        "Text",
+        "Bytea",
         "TextArray",
         "TextArray",
         "TextArray",
         "TextArray",
         "Text",
         "Text",
         "Text",
         "Text",
         "Jsonb",
         "Jsonb",
         "Text",
         "Text",
+        "Bytea",
         "Text",
         "Text",
+        "Bytea",
+        "Text",
+        "Text",
+        "Bytea",
         "Text",
         "Text",
         "Jsonb",
         "Jsonb",
+        "Bytea",
         "Jsonb",
         "Jsonb",
+        "Bytea",
         "Text",
         "Text",
+        "Bytea",
         "Bool"
         "Bool"
       ]
       ]
     },
     },
@@ -247,11 +333,18 @@
       false,
       false,
       true,
       true,
       false,
       false,
+      true,
+      true,
       false,
       false,
       false,
       false,
       false,
       false,
       false,
       false,
-      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
       true,
       true,
       true,
       true,
       true,
       true,
@@ -265,5 +358,5 @@
       false
       false
     ]
     ]
   },
   },
-  "hash": "40cfcf7198a65db344c84765f67d8bc6b89088132aa9a0c5b66e0b35487c6244"
+  "hash": "1cf0676f9a41ce8b8e07e77673d31d225ec331c2fbfc7d6e6ac9fe9598570a21"
 }
 }

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

@@ -1,6 +1,6 @@
 {
 {
   "db_name": "PostgreSQL",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -60,6 +60,17 @@
       },
       },
       {
       {
         "ordinal": 5,
         "ordinal": 5,
+        "name": "client_name_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
         "name": "grant_types",
         "name": "grant_types",
         "type_info": "TextArray",
         "type_info": "TextArray",
         "origin": {
         "origin": {
@@ -70,7 +81,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 6,
+        "ordinal": 7,
         "name": "response_types",
         "name": "response_types",
         "type_info": "TextArray",
         "type_info": "TextArray",
         "origin": {
         "origin": {
@@ -81,7 +92,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 7,
+        "ordinal": 8,
         "name": "scope",
         "name": "scope",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "origin": {
@@ -92,7 +103,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 8,
+        "ordinal": 9,
         "name": "token_endpoint_auth_method",
         "name": "token_endpoint_auth_method",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "origin": {
@@ -103,7 +114,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 9,
+        "ordinal": 10,
         "name": "jwks",
         "name": "jwks",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "origin": {
@@ -114,7 +125,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 10,
+        "ordinal": 11,
         "name": "jwks_uri",
         "name": "jwks_uri",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "tls_client_auth_subject_dn",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "tls_client_auth_cert_thumbprint",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "request_uris",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "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",
         "name": "contacts",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "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",
         "name": "software_id",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "dpop_bound_access_tokens",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -191,7 +268,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 17,
+        "ordinal": 24,
         "name": "active",
         "name": "active",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -202,7 +279,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 18,
+        "ordinal": 25,
         "name": "created_at",
         "name": "created_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -213,7 +290,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 19,
+        "ordinal": 26,
         "name": "updated_at",
         "name": "updated_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -234,11 +311,18 @@
       false,
       false,
       true,
       true,
       false,
       false,
+      true,
+      true,
       false,
       false,
       false,
       false,
       false,
       false,
       false,
       false,
-      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
       true,
       true,
       true,
       true,
       true,
       true,
@@ -252,5 +336,5 @@
       false
       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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -60,6 +60,17 @@
       },
       },
       {
       {
         "ordinal": 5,
         "ordinal": 5,
+        "name": "client_name_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
         "name": "grant_types",
         "name": "grant_types",
         "type_info": "TextArray",
         "type_info": "TextArray",
         "origin": {
         "origin": {
@@ -70,7 +81,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 6,
+        "ordinal": 7,
         "name": "response_types",
         "name": "response_types",
         "type_info": "TextArray",
         "type_info": "TextArray",
         "origin": {
         "origin": {
@@ -81,7 +92,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 7,
+        "ordinal": 8,
         "name": "scope",
         "name": "scope",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "origin": {
@@ -92,7 +103,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 8,
+        "ordinal": 9,
         "name": "token_endpoint_auth_method",
         "name": "token_endpoint_auth_method",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "origin": {
@@ -103,7 +114,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 9,
+        "ordinal": 10,
         "name": "jwks",
         "name": "jwks",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "origin": {
@@ -114,7 +125,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 10,
+        "ordinal": 11,
         "name": "jwks_uri",
         "name": "jwks_uri",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "tls_client_auth_subject_dn",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "tls_client_auth_cert_thumbprint",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "request_uris",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "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",
         "name": "contacts",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "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",
         "name": "software_id",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "dpop_bound_access_tokens",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -191,7 +268,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 17,
+        "ordinal": 24,
         "name": "active",
         "name": "active",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -202,7 +279,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 18,
+        "ordinal": 25,
         "name": "created_at",
         "name": "created_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -213,7 +290,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 19,
+        "ordinal": 26,
         "name": "updated_at",
         "name": "updated_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -234,11 +311,18 @@
       false,
       false,
       true,
       true,
       false,
       false,
+      true,
+      true,
       false,
       false,
       false,
       false,
       false,
       false,
       false,
       false,
-      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
       true,
       true,
       true,
       true,
       true,
       true,
@@ -252,5 +336,5 @@
       false
       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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -60,6 +60,17 @@
       },
       },
       {
       {
         "ordinal": 5,
         "ordinal": 5,
+        "name": "client_name_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
         "name": "grant_types",
         "name": "grant_types",
         "type_info": "TextArray",
         "type_info": "TextArray",
         "origin": {
         "origin": {
@@ -70,7 +81,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 6,
+        "ordinal": 7,
         "name": "response_types",
         "name": "response_types",
         "type_info": "TextArray",
         "type_info": "TextArray",
         "origin": {
         "origin": {
@@ -81,7 +92,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 7,
+        "ordinal": 8,
         "name": "scope",
         "name": "scope",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "origin": {
@@ -92,7 +103,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 8,
+        "ordinal": 9,
         "name": "token_endpoint_auth_method",
         "name": "token_endpoint_auth_method",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "origin": {
@@ -103,7 +114,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 9,
+        "ordinal": 10,
         "name": "jwks",
         "name": "jwks",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "origin": {
@@ -114,7 +125,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 10,
+        "ordinal": 11,
         "name": "jwks_uri",
         "name": "jwks_uri",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "tls_client_auth_subject_dn",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "tls_client_auth_cert_thumbprint",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "request_uris",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "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",
         "name": "contacts",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "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",
         "name": "software_id",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "dpop_bound_access_tokens",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -191,7 +268,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 17,
+        "ordinal": 24,
         "name": "active",
         "name": "active",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -202,7 +279,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 18,
+        "ordinal": 25,
         "name": "created_at",
         "name": "created_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -213,7 +290,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 19,
+        "ordinal": 26,
         "name": "updated_at",
         "name": "updated_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -234,11 +311,18 @@
       false,
       false,
       true,
       true,
       false,
       false,
+      true,
+      true,
       false,
       false,
       false,
       false,
       false,
       false,
       false,
       false,
-      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
       true,
       true,
       true,
       true,
       true,
       true,
@@ -252,5 +336,5 @@
       false
       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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -90,6 +90,17 @@
             "name": "updated_at"
             "name": "updated_at"
           }
           }
         }
         }
+      },
+      {
+        "ordinal": 8,
+        "name": "uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri_enc"
+          }
+        }
       }
       }
     ],
     ],
     "parameters": {
     "parameters": {
@@ -100,14 +111,15 @@
     },
     },
     "nullable": [
     "nullable": [
       false,
       false,
-      false,
+      true,
       false,
       false,
       false,
       false,
       false,
       false,
       true,
       true,
       false,
       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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -90,6 +90,17 @@
             "name": "updated_at"
             "name": "updated_at"
           }
           }
         }
         }
+      },
+      {
+        "ordinal": 8,
+        "name": "uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri_enc"
+          }
+        }
       }
       }
     ],
     ],
     "parameters": {
     "parameters": {
@@ -99,14 +110,15 @@
     },
     },
     "nullable": [
     "nullable": [
       false,
       false,
-      false,
+      true,
       false,
       false,
       false,
       false,
       false,
       false,
       true,
       true,
       false,
       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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -90,6 +90,17 @@
             "name": "updated_at"
             "name": "updated_at"
           }
           }
         }
         }
+      },
+      {
+        "ordinal": 8,
+        "name": "uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri_enc"
+          }
+        }
       }
       }
     ],
     ],
     "parameters": {
     "parameters": {
@@ -99,14 +110,15 @@
     },
     },
     "nullable": [
     "nullable": [
       false,
       false,
-      false,
+      true,
       false,
       false,
       false,
       false,
       false,
       false,
       true,
       true,
       false,
       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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -16,12 +16,23 @@
       },
       },
       {
       {
         "ordinal": 1,
         "ordinal": 1,
-        "name": "email!",
+        "name": "email",
         "type_info": "Text",
         "type_info": "Text",
         "origin": "Expression"
         "origin": "Expression"
       },
       },
       {
       {
         "ordinal": 2,
         "ordinal": 2,
+        "name": "email_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "email_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
         "name": "is_admin",
         "name": "is_admin",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -32,7 +43,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 3,
+        "ordinal": 4,
         "name": "must_change_password",
         "name": "must_change_password",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -43,7 +54,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 4,
+        "ordinal": 5,
         "name": "disabled",
         "name": "disabled",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -54,7 +65,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 5,
+        "ordinal": 6,
         "name": "totp_enabled",
         "name": "totp_enabled",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -65,7 +76,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 6,
+        "ordinal": 7,
         "name": "password_changed_at",
         "name": "password_changed_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -78,12 +89,14 @@
     ],
     ],
     "parameters": {
     "parameters": {
       "Left": [
       "Left": [
+        "TextArray",
         "Text"
         "Text"
       ]
       ]
     },
     },
     "nullable": [
     "nullable": [
       false,
       false,
       null,
       null,
+      true,
       false,
       false,
       false,
       false,
       false,
       false,
@@ -91,5 +104,5 @@
       true
       true
     ]
     ]
   },
   },
-  "hash": "915a3721e78651ff39ca47db647cc1282ad0ddc8b508257bb17c3e4f049b4e41"
+  "hash": "8ca892dd486bcbaffb23e52023edf7e93e7193993f903bbafdc8c82e71cb310c"
 }
 }

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

@@ -1,6 +1,6 @@
 {
 {
   "db_name": "PostgreSQL",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -24,17 +24,30 @@
             "name": "disabled"
             "name": "disabled"
           }
           }
         }
         }
+      },
+      {
+        "ordinal": 2,
+        "name": "email_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "email_enc"
+          }
+        }
       }
       }
     ],
     ],
     "parameters": {
     "parameters": {
       "Left": [
       "Left": [
+        "TextArray",
         "Text"
         "Text"
       ]
       ]
     },
     },
     "nullable": [
     "nullable": [
       false,
       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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -13,6 +13,7 @@
     "parameters": {
     "parameters": {
       "Left": [
       "Left": [
         "Text",
         "Text",
+        "TextArray",
         "Text"
         "Text"
       ]
       ]
     },
     },
@@ -20,5 +21,5 @@
       null
       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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -90,27 +90,40 @@
             "name": "updated_at"
             "name": "updated_at"
           }
           }
         }
         }
+      },
+      {
+        "ordinal": 8,
+        "name": "uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri_enc"
+          }
+        }
       }
       }
     ],
     ],
     "parameters": {
     "parameters": {
       "Left": [
       "Left": [
-        "Uuid",
         "Text",
         "Text",
+        "Bytea",
         "Text",
         "Text",
         "Text",
         "Text",
-        "Uuid"
+        "TextArray",
+        "Text"
       ]
       ]
     },
     },
     "nullable": [
     "nullable": [
       false,
       false,
-      false,
+      true,
       false,
       false,
       false,
       false,
       false,
       false,
       true,
       true,
       false,
       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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -90,6 +90,17 @@
             "name": "updated_at"
             "name": "updated_at"
           }
           }
         }
         }
+      },
+      {
+        "ordinal": 8,
+        "name": "uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri_enc"
+          }
+        }
       }
       }
     ],
     ],
     "parameters": {
     "parameters": {
@@ -99,14 +110,15 @@
     },
     },
     "nullable": [
     "nullable": [
       false,
       false,
-      false,
+      true,
       false,
       false,
       false,
       false,
       false,
       false,
       true,
       true,
       false,
       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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -60,6 +60,17 @@
       },
       },
       {
       {
         "ordinal": 5,
         "ordinal": 5,
+        "name": "client_name_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
         "name": "grant_types",
         "name": "grant_types",
         "type_info": "TextArray",
         "type_info": "TextArray",
         "origin": {
         "origin": {
@@ -70,7 +81,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 6,
+        "ordinal": 7,
         "name": "response_types",
         "name": "response_types",
         "type_info": "TextArray",
         "type_info": "TextArray",
         "origin": {
         "origin": {
@@ -81,7 +92,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 7,
+        "ordinal": 8,
         "name": "scope",
         "name": "scope",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "origin": {
@@ -92,7 +103,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 8,
+        "ordinal": 9,
         "name": "token_endpoint_auth_method",
         "name": "token_endpoint_auth_method",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "origin": {
@@ -103,7 +114,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 9,
+        "ordinal": 10,
         "name": "jwks",
         "name": "jwks",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "origin": {
@@ -114,7 +125,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 10,
+        "ordinal": 11,
         "name": "jwks_uri",
         "name": "jwks_uri",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "tls_client_auth_subject_dn",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "tls_client_auth_cert_thumbprint",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "request_uris",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "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",
         "name": "contacts",
         "type_info": "Jsonb",
         "type_info": "Jsonb",
         "origin": {
         "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",
         "name": "software_id",
         "type_info": "Text",
         "type_info": "Text",
         "origin": {
         "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",
         "name": "dpop_bound_access_tokens",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -191,7 +268,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 17,
+        "ordinal": 24,
         "name": "active",
         "name": "active",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -202,7 +279,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 18,
+        "ordinal": 25,
         "name": "created_at",
         "name": "created_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -213,7 +290,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 19,
+        "ordinal": 26,
         "name": "updated_at",
         "name": "updated_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -231,17 +308,26 @@
         "Text",
         "Text",
         "Timestamptz",
         "Timestamptz",
         "Text",
         "Text",
+        "Bytea",
         "TextArray",
         "TextArray",
         "TextArray",
         "TextArray",
         "Text",
         "Text",
         "Text",
         "Text",
         "Jsonb",
         "Jsonb",
         "Text",
         "Text",
+        "Bytea",
         "Text",
         "Text",
+        "Bytea",
+        "Text",
+        "Text",
+        "Bytea",
         "Text",
         "Text",
         "Jsonb",
         "Jsonb",
+        "Bytea",
         "Jsonb",
         "Jsonb",
+        "Bytea",
         "Text",
         "Text",
+        "Bytea",
         "Text",
         "Text",
         "Bool"
         "Bool"
       ]
       ]
@@ -251,11 +337,18 @@
       false,
       false,
       true,
       true,
       false,
       false,
+      true,
+      true,
       false,
       false,
       false,
       false,
       false,
       false,
       false,
       false,
-      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
       true,
       true,
       true,
       true,
       true,
       true,
@@ -269,5 +362,5 @@
       false
       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). |
 | 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. |
 | 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. |
 | 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. |
 | 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`. |
 | 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). |
 | 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_REDIS__DURABILITY=waitaof
 export D_OAUTH_TOKEN_PEPPER='replace-with-32-bytes-of-random'
 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_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
 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).
   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
 - There is no default credential and no HTTP admin API. Provision through the
   CLI.
   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
 - Production requires `issuer` on `https` and a database URL with
   `sslmode=verify-full`.
   `sslmode=verify-full`.
 - Redis must run with `noeviction` and persistence. The server asserts this at
 - 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`.
 - Users and entitlements: `user create|enroll-totp|disable|grant-scope|revoke-scope|scopes`.
 - Admin: `admin bootstrap` (see [RUNBOOK.md](RUNBOOK.md)).
 - Admin: `admin bootstrap` (see [RUNBOOK.md](RUNBOOK.md)).
 - Declarative apply: `config sync --file` and `config check`.
 - Declarative apply: `config sync --file` and `config check`.
+- PII migration and rotation: `pii backfill`, `pii verify`, and `pii rotate`,
+  plus `totp rewrap` for the TOTP key. See [docs/OPERATIONS.md](docs/OPERATIONS.md).
 - DCR initial access: `initial-access-token issue`.
 - DCR initial access: `initial-access-token issue`.
 - Utilities: `keygen` and `healthcheck`.
 - Utilities: `keygen` and `healthcheck`.
 
 
@@ -262,4 +266,4 @@ Full flags are in [docs/OPERATIONS.md](docs/OPERATIONS.md).
 
 
 ## License
 ## 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`
   `d-oauth-server migrate`
 - Zero enabled admins exist (a migrated database seeds zero users).
 - 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
 ## 1. Bootstrap the first admin
 
 
 Interactive (password read from stdin, never argv, never logged):
 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
 # one-shot `gensecrets` service materializes DEV-ONLY placeholder values as
 # 0400 files owned by 65532 in the `devsecrets` volume, mounted at
 # 0400 files owned by 65532 in the `devsecrets` volume, mounted at
 # /run/secrets; the app and migrate services read them through
 # /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
 name: d-oauth
 
 
@@ -77,9 +78,9 @@ services:
         chmod 600 /certs/tls.key
         chmod 600 /certs/tls.key
         chmod 644 /certs/tls.crt
         chmod 644 /certs/tls.crt
 
 
-  # One-shot: DEV-ONLY pepper/TOTP key files, shaped like the cluster's tmpfs
-  # secret volume (0400, owned by 65532). Values are throwaway placeholders;
-  # never reuse them outside this local compose file.
+  # 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:
   gensecrets:
     image: alpine:3.21
     image: alpine:3.21
     volumes:
     volumes:
@@ -91,9 +92,10 @@ services:
         if [ ! -f /run/secrets/token-pepper ]; then
         if [ ! -f /run/secrets/token-pepper ]; then
           printf '%s' 'dev-only-pepper-ROTATE-IN-PROD-000000' > /run/secrets/token-pepper
           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-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
         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.
   # Exactly-once migration (sqlx advisory lock). Replicas never migrate.
   migrate:
   migrate:
@@ -124,6 +126,7 @@ services:
       D_OAUTH_REDIS__DURABILITY: waitaof
       D_OAUTH_REDIS__DURABILITY: waitaof
       D_OAUTH_TOKEN_PEPPER_FILE: /run/secrets/token-pepper
       D_OAUTH_TOKEN_PEPPER_FILE: /run/secrets/token-pepper
       D_OAUTH_TOTP_ENCRYPTION_KEY_FILE: /run/secrets/totp-encryption-key
       D_OAUTH_TOTP_ENCRYPTION_KEY_FILE: /run/secrets/totp-encryption-key
+      D_OAUTH_PII_ENCRYPTION_KEY_FILE: /run/secrets/pii-encryption-key
     volumes:
     volumes:
       - certs:/certs:ro
       - certs:/certs:ro
       - devsecrets:/run/secrets:ro
       - devsecrets:/run/secrets:ro
@@ -154,6 +157,7 @@ services:
       D_OAUTH_REDIS__DURABILITY: waitaof
       D_OAUTH_REDIS__DURABILITY: waitaof
       D_OAUTH_TOKEN_PEPPER_FILE: /run/secrets/token-pepper
       D_OAUTH_TOKEN_PEPPER_FILE: /run/secrets/token-pepper
       D_OAUTH_TOTP_ENCRYPTION_KEY_FILE: /run/secrets/totp-encryption-key
       D_OAUTH_TOTP_ENCRYPTION_KEY_FILE: /run/secrets/totp-encryption-key
+      D_OAUTH_PII_ENCRYPTION_KEY_FILE: /run/secrets/pii-encryption-key
     expose:
     expose:
       - 8080
       - 8080
       - 8081
       - 8081

+ 6 - 2
deploy/k8s/deployment.yaml

@@ -16,8 +16,8 @@
 #   Kubernetes API). `networkpolicy.yaml` default-denies pod egress except DNS
 #   Kubernetes API). `networkpolicy.yaml` default-denies pod egress except DNS
 #   + Postgres + Redis.
 #   + Postgres + Redis.
 # - Secret material is file-mounted, never env-injected: the token pepper,
 # - 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`
 #   0400`, group 65532 via `fsGroup`) and read through their `*_FILE`
 #   variables. The passwordless DSNs come from the ConfigMap. The inline
 #   variables. The passwordless DSNs come from the ConfigMap. The inline
 #   `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` forms remain
 #   `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` forms remain
@@ -103,6 +103,8 @@ spec:
               value: /run/secrets/token-pepper
               value: /run/secrets/token-pepper
             - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
             - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
               value: /run/secrets/totp-encryption-key
               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`
           # Liveness is dependency-free by construction (`/health/live`
           # touches no Postgres/Redis), so a dependency outage never
           # touches no Postgres/Redis), so a dependency outage never
           # triggers a restart storm — readiness absorbs it instead.
           # triggers a restart storm — readiness absorbs it instead.
@@ -172,6 +174,8 @@ spec:
                 path: token-pepper
                 path: token-pepper
               - key: totp-encryption-key
               - key: totp-encryption-key
                 path: totp-encryption-key
                 path: totp-encryption-key
+              - key: pii-encryption-key
+                path: pii-encryption-key
       topologySpreadConstraints:
       topologySpreadConstraints:
         - maxSkew: 1
         - maxSkew: 1
           topologyKey: topology.kubernetes.io/zone
           topologyKey: topology.kubernetes.io/zone

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

@@ -10,9 +10,9 @@
 #
 #
 # Hardened like the Deployment: non-root 65532,
 # Hardened like the Deployment: non-root 65532,
 # read-only rootfs, ALL capabilities dropped, `automountServiceAccountToken:
 # 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
 # digest; `pin-digest` rewrites the real digest in `kustomization.yaml` for
 # every workload.
 # every workload.
 apiVersion: batch/v1
 apiVersion: batch/v1
@@ -66,6 +66,8 @@ spec:
               value: /run/secrets/token-pepper
               value: /run/secrets/token-pepper
             - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
             - name: D_OAUTH_TOTP_ENCRYPTION_KEY_FILE
               value: /run/secrets/totp-encryption-key
               value: /run/secrets/totp-encryption-key
+            - name: D_OAUTH_PII_ENCRYPTION_KEY_FILE
+              value: /run/secrets/pii-encryption-key
           resources:
           resources:
             requests:
             requests:
               cpu: 50m
               cpu: 50m
@@ -104,3 +106,5 @@ spec:
                 path: token-pepper
                 path: token-pepper
               - key: totp-encryption-key
               - key: totp-encryption-key
                 path: 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
 #   token-pepper        -> D_OAUTH_TOKEN_PEPPER_FILE
 #   totp-encryption-key -> D_OAUTH_TOTP_ENCRYPTION_KEY_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
 #   database-password   -> D_OAUTH_DATABASE__PASSWORD_FILE
 #   redis-password      -> D_OAUTH_REDIS__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
 # 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
 # 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
 # `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"
   token-pepper: "REPLACE_ME__32-bytes-of-random"
   # 256-bit key encrypting TOTP secrets at rest.
   # 256-bit key encrypting TOTP secrets at rest.
   totp-encryption-key: "REPLACE_ME__32-bytes-of-random"
   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 |
 | 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/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/` | 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/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/db/` | Postgres repositories for the six domain tables. See [DATABASE.md](DATABASE.md). |
 | `src/store/` | Redis ephemeral state and the durability barrier. See [REDIS.md](REDIS.md). |
 | `src/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
 ### 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
 - `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
   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
   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
 ### 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
 - 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
   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.
   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
   `X-Content-Type-Options: nosniff` on its own probe responses, with a
   regression test pinning both.
   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
 ### Fixed
 
 
 - `GET /.well-known/oauth-authorization-server` reads the enabled scope catalog
 - `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
 A secret may be given inline or as a mounted file. Inline wins when both are
 set. Mounted files are read and trimmed of trailing newlines.
 set. Mounted files are read and trimmed of trailing newlines.
 
 
-The key ring follows the same pattern: `token_pepper_file` and
-`totp_encryption_key_file` (environment spellings `D_OAUTH_TOKEN_PEPPER_FILE`
-and `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`) name mounted files. The cluster
-Deployment uses those file forms so the material never enters the process
-environment; mount them `0400` on a tmpfs (the shipped manifests project them
-at `/run/secrets` with group `65532`). The inline forms are retained and win
-when both are set, so an existing environment-based deployment still starts.
+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
 ## 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` | none | Inline AES-256-GCM key for TOTP secrets. Required to enroll TOTP. |
 | `totp_encryption_key_file` | none | Mounted TOTP key. |
 | `totp_encryption_key_file` | none | Mounted TOTP key. |
 | `totp_key_id` | `v1` | Version id carried in the encrypted TOTP blob. |
 | `totp_key_id` | `v1` | Version id carried in the encrypted TOTP blob. |
-
-The token pepper is required: without it the key ring cannot be built and the
-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
 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
 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
 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
 - `trusted_proxy` is unset by default, so forwarded client-cert headers from an
   untrusted hop are stripped before any handler reads them.
   untrusted hop are stripped before any handler reads them.
 - CORS and open registration are off by default.
 - CORS and open registration are off by default.
-- File-backed pepper/TOTP keys are read from a `0400` tmpfs file, never from
-  the process environment.
+- File-backed pepper/TOTP/PII keys are read from a `0400` tmpfs file, never
+  from the process environment.
 
 
 ## Edge cases
 ## Edge cases
 
 
 - `D_OAUTH_CONFIG` pointing at a missing file is not an error; the file is
 - `D_OAUTH_CONFIG` pointing at a missing file is not an error; the file is
   optional.
   optional.
 - An inline `token_pepper` of only whitespace is treated as absent.
 - An inline `token_pepper` of only whitespace is treated as absent.
+- 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`),
 - An unreadable `*_file` path is a load-time error (`ConfigError::SecretFile`),
   never a silent fallback to an absent key.
   never a silent fallback to an absent key.
 - `scope delete` and `redirect-uri delete` disable a referenced row instead of
 - `scope delete` and `redirect-uri delete` disable a referenced row instead of

+ 2 - 1
docs/CONTRIBUTING.md

@@ -104,8 +104,9 @@ Every module has a single responsibility:
 | Area | Path | Owns |
 | Area | Path | Owns |
 | --- | --- | --- |
 | --- | --- | --- |
 | Configuration | `src/config.rs`, `src/config/` | Layered env/file load and fail-fast validation. |
 | 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. |
 | 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. |
 | JOSE | `src/jose/` | DPoP, JAR, `private_key_jwt` verification, JWK parsing. |
 | Postgres repositories | `src/db/` | The six domain tables. |
 | Postgres repositories | `src/db/` | The six domain tables. |
 | Redis store | `src/store/` | Ephemeral state: codes, tokens, grants, sessions, PAR, device, DCR, rate limits. |
 | 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_hash` | `text` | Argon2id hash, never a raw secret. |
 | `client_secret_expires_at` | `timestamptz` | Optional expiry. |
 | `client_secret_expires_at` | `timestamptz` | Optional expiry. |
 | `client_id_issued_at` | `timestamptz` | Issue time. |
 | `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. |
 | `grant_types` | `text[]` | Registered grant types. |
 | `response_types` | `text[]` | Registered response types. |
 | `response_types` | `text[]` | Registered response types. |
 | `scope` | `text` | Registered scope string. |
 | `scope` | `text` | Registered scope string. |
 | `token_endpoint_auth_method` | `text` | One of the supported methods. |
 | `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). |
 | `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` | `jsonb` | Pre-registered JAR request URIs. |
+| `request_uris_enc` | `bytea` | Sealed request URIs (JSON ciphertext). |
 | `contacts` | `jsonb` | Metadata only. |
 | `contacts` | `jsonb` | Metadata only. |
+| `contacts_enc` | `bytea` | Sealed contacts (JSON ciphertext). |
 | `software_id` | `text` | Metadata only. |
 | `software_id` | `text` | Metadata only. |
+| `software_id_enc` | `bytea` | Sealed software id. |
 | `active` | `bool` | Soft disable, default true. |
 | `active` | `bool` | Soft disable, default true. |
 | `registration_access_token_hash` | `text` | Keyed hash of the RFC 7592 management token. |
 | `registration_access_token_hash` | `text` | Keyed hash of the RFC 7592 management token. |
 | `dpop_bound_access_tokens` | `bool` | Whether this client's tokens require DPoP proof (`migrations/1791244800_client_dpop_bound.sql`). |
 | `dpop_bound_access_tokens` | `bool` | Whether this client's tokens require DPoP proof (`migrations/1791244800_client_dpop_bound.sql`). |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 
 
-Two partial unique indexes enforce that a client certificate authenticates at
-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
 - `ux_oauth_clients_tls_subject_dn` on `tls_client_auth_subject_dn` where it is
   not null.
   not null.
 - `ux_oauth_clients_tls_cert_thumbprint` on `tls_client_auth_cert_thumbprint`
 - `ux_oauth_clients_tls_cert_thumbprint` on `tls_client_auth_cert_thumbprint`
   where it is not null.
   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
 The `UNIQUE (client_id)` is a table constraint, not merely an index, because
 Postgres accepts only a unique constraint as a foreign-key target.
 Postgres accepts only a unique constraint as a foreign-key target.
@@ -61,7 +75,9 @@ One row per resource owner.
 | Column | Type | Notes |
 | Column | Type | Notes |
 | --- | --- | --- |
 | --- | --- | --- |
 | `id` | `uuid` | Primary key. |
 | `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. |
 | `password_hash` | `text` | Argon2id PHC string. |
 | `totp_secret_enc` | `bytea` | AES-256-GCM encrypted TOTP secret, never plaintext. |
 | `totp_secret_enc` | `bytea` | AES-256-GCM encrypted TOTP secret, never plaintext. |
 | `totp_enabled` | `bool` | Default false. |
 | `totp_enabled` | `bool` | Default false. |
@@ -72,6 +88,10 @@ One row per resource owner.
 | `disabled` | `bool` | Default false. |
 | `disabled` | `bool` | Default false. |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 | `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
 There are deliberately no failed-login or lockout columns. Those counters are
 security-ephemeral and live in Redis.
 security-ephemeral and live in Redis.
 
 
@@ -110,13 +130,20 @@ is unique per `(uri, kind)`.
 | Column | Type | Notes |
 | Column | Type | Notes |
 | --- | --- | --- |
 | --- | --- | --- |
 | `id` | `uuid` | Primary key. |
 | `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`. |
 | `kind` | `text` | `authorize` (default) or `post_logout`. |
 | `enabled` | `bool` | Default true. |
 | `enabled` | `bool` | Default true. |
 | `description` | `text` | Default empty. |
 | `description` | `text` | Default empty. |
 | `created_by` | `uuid` | Optional creator. |
 | `created_by` | `uuid` | Optional creator. |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 | `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
 `kind` separates the two browser landings: `authorize` URIs are valid
 authorization-response callbacks (they carry `code`/`state`); `post_logout` URIs
 authorization-response callbacks (they carry `code`/`state`); `post_logout` URIs
 are bare navigation targets used only by `GET /logout`. Both kinds are matched
 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
 superuser or trusted-extension privileges, so the migration role must have
 them, or an operator must install the extension out of band before migrating.
 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
 ## Migration lifecycle
 
 
 - Migrations are versioned, forward-only sqlx migrations compiled into the
 - 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
 A migration that drops or renames a column in one step breaks the old replicas
 during the rollout. Do not do it.
 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
 ## Security properties
 
 
 - Only hashed or encrypted secrets are stored: client secrets in
 - 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
 - The `registration_access_token_hash` column is the one token hash in
   Postgres, so RFC 7592 client management survives a Redis loss.
   Postgres, so RFC 7592 client management survives a Redis loss.
 - Foreign keys with `ON DELETE CASCADE` keep the join tables consistent, and a
 - 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
 - Custody the backup key separately from the database, and never co-locate it
   with the master keys ([SECURITY.md](SECURITY.md#master-key-custody)).
   with the master keys ([SECURITY.md](SECURITY.md#master-key-custody)).
 - Store backups off-site and test restores.
 - 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
 ### 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 |
 | `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
 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
 ### 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/1791158400_users.sql` for the other five tables and `citext`.
 - `migrations/1791244800_client_dpop_bound.sql` for `oauth_clients.dpop_bound_access_tokens`.
 - `migrations/1791244800_client_dpop_bound.sql` for `oauth_clients.dpop_bound_access_tokens`.
 - `migrations/1791331200_redirect_uri_kind.sql` for `redirect_uris.kind`.
 - `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/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/state.rs` for `MIGRATOR`.
 - `src/cli/ops.rs` for the `migrate` command.
 - `src/cli/ops.rs` for the `migrate` command.
 - `src/state/health.rs` for the readiness schema check.
 - `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
 `jwks_uri`. `healthcheck` probes the health listener over raw HTTP and exits 0
 or 1; it is the container `HEALTHCHECK`.
 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
 ## config sync file format
 
 
 The file is JSON. Every section is optional and defaults to empty.
 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`,
 `user.revoke_scope`, `scope.create`, `scope.enable`, `scope.disable`,
 `scope.delete`, `redirect_uri.create`, `redirect_uri.enable`,
 `scope.delete`, `redirect_uri.create`, `redirect_uri.enable`,
 `redirect_uri.disable`, `redirect_uri.delete`, `client.attach_redirect_uri`,
 `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`,
 HTTP events on the same target include `login.success`, `login.failure`,
 `consent`, `token.issue`, `token.revoke`, `token.refresh_reuse_detected`, and
 `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
 (`ttl.refresh_secs`, default 30 days) before removing it. New tokens are always
 minted under the active pepper.
 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
 ### TLS certificate
 
 
 `serve` loads the certificate chain and key once at startup. Rotate by
 `serve` loads the certificate chain and key once at startup. Rotate by
 replacing the mounted material and performing a rolling restart; there is no
 replacing the mounted material and performing a rolling restart; there is no
 hot reload, by design.
 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
 ## Backup and restore
 
 
 Postgres holds all durable state, so a normal backup covers it:
 Postgres holds all durable state, so a normal backup covers it:
@@ -268,7 +355,10 @@ survives a Redis loss.
   `RUNBOOK.md`.
   `RUNBOOK.md`.
 - Routine provisioning: `config sync` for scopes, redirect URIs, and
 - Routine provisioning: `config sync` for scopes, redirect URIs, and
   entitlements; `user create` for users.
   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.
 - Backup: `pg_dump` Postgres; Redis is not backed up.
 - Access: prefer running admin commands as a one-shot Job (or the local CLI
 - 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
   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/ops.rs`, `src/cli/user.rs`, `src/cli/scope.rs`,
   `src/cli/redirect.rs`, `src/cli/sync.rs`, `src/cli/admin.rs`,
   `src/cli/redirect.rs`, `src/cli/sync.rs`, `src/cli/admin.rs`,
   `src/cli/secret.rs` for implementations.
   `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.
 - `src/middleware/audit.rs` for the HTTP audit events.
 - `RUNBOOK.md` for the bootstrap and replacement procedure.
 - `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
 - Opaque access and refresh tokens, and the authorization codes and device codes
   that yield them.
   that yield them.
 - Client secrets, user passwords, TOTP secrets, and DCR initial-access tokens.
 - 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
 - The integrity of the authorization decision: which client may act for which
   user with which scopes.
   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. |
 | Application-level RCE in a parser (form bodies, DPoP/JAR/`private_key_jwt` JWTs, DCR JSON, `jwks_uri`/`request_uri` fetches) | `#![forbid(unsafe_code)]`, no `unwrap`/`panic` in request paths, rustls/ring (no OpenSSL), fuzz targets for token-form/DCR-JSON/JAR-claims | Fuzzing is not exhaustive. |
 | Dependency / supply-chain compromise | Committed `Cargo.lock`, `cargo deny`/`cargo audit` in CI, SBOM + provenance + keyless cosign, digest-pinned bases | A zero-day in a trusted crate. |
 | Dependency / supply-chain compromise | Committed `Cargo.lock`, `cargo deny`/`cargo audit` in CI, SBOM + provenance + keyless cosign, digest-pinned bases | A zero-day in a trusted crate. |
 | Container escape via kernel or runtime bug | Non-root UID 65532, read-only root filesystem, all capabilities dropped, `allowPrivilegeEscalation: false`, `seccompProfile: RuntimeDefault` | A kernel LPE or runc bug, mitigated but not eliminated. |
 | Container escape via kernel or runtime bug | Non-root UID 65532, read-only root filesystem, all capabilities dropped, `allowPrivilegeEscalation: false`, `seccompProfile: RuntimeDefault` | A kernel LPE or runc bug, mitigated but not eliminated. |
-| Secret exfiltration from the process environment | Secrets are injected at runtime, not baked into image layers; the pepper/TOTP key live outside the database; 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. |
 | Exposed management surface | The plaintext health listener (8081) is unauthenticated but not published in the Service (only 8080 is) | A misconfigured Service/Ingress could expose it; it must never be internet-facing. |
 | Host access via mounted volumes | `/certs` is mounted read-only at mode `0400`; no Docker socket is mounted | Operator misconfiguration. |
 | Host access via mounted volumes | `/certs` is mounted read-only at mode `0400`; no Docker socket is mounted | Operator misconfiguration. |
 | 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`). |
 | 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,
 Last reviewed: 2026-10-05. File-mounted DB/Redis passwords, digest pinning,
 NetworkPolicy, `automountServiceAccountToken: false`, and scheduled fuzzing are
 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
 ## Toplevel rules
 
 
@@ -162,37 +163,81 @@ Reference: RFC 9126, RFC 9101.
 
 
 ## Key ring and rotation
 ## 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
 - Rotating the pepper requires retaining the old key for at least the refresh
   lifetime so live tokens still verify.
   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.
 - `Debug` prints key ids only, never key material.
 - There is no authorization-server JWT-signing key and no `jwks_uri`. The
 - 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.
   `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
 ## 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
 - **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 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
   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
 - Losing the token pepper invalidates every live opaque access and refresh
   token, because only keyed HMAC hashes are persisted and the pepper is the
   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
 - 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
   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
 - **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
   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
   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
 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
 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
 The operator procedure for custody and KMS/HSM sourcing lives in
 [DEPLOYMENT.md](DEPLOYMENT.md#master-key-custody-and-kmshsm).
 [DEPLOYMENT.md](DEPLOYMENT.md#master-key-custody-and-kmshsm).
@@ -311,6 +357,9 @@ security rationale and checklist.
   Treat the grant family as compromised.
   Treat the grant family as compromised.
 - Repeated `login.failure` for one subject means a credential-stuffing attempt.
 - Repeated `login.failure` for one subject means a credential-stuffing attempt.
 - `admin.bootstrap.force` means an admin was created despite an existing one.
 - `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
 - A spike in `503` on security endpoints means a dependency is down; the server
   is failing closed, not open.
   is failing closed, not open.
 
 
@@ -338,19 +387,19 @@ store can be shown untouched.
 | --- | --- | --- |
 | --- | --- | --- |
 | User passwords | Argon2id PHC, salted | Offline cracking only. |
 | User passwords | Argon2id PHC, salted | Offline cracking only. |
 | Client secrets | Argon2id PHC | 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. |
 | 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. |
 | 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. |
 | 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. |
 | `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. |
 | 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
 ### 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
    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
    keep live tokens verifying, which is the opposite of what a breach response
    wants; a full disclosure drops it.
    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
    RFC 7592 `PUT /register/{client_id}`, which mints a fresh secret when the
    method is secret-based and returns it once.
    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
    rotates the registration access token on every update, and the response
    carries the only copy. Do not re-issue DCR tokens by replaying an
    carries the only copy. Do not re-issue DCR tokens by replaying an
    initial-access token; it is single-use and consumed at registration.
    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
 ## File and directory references
 
 
 - `src/crypto/` for token minting, hashing, TOTP, and constant-time compare.
 - `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/keys.rs` for the key ring.
 - `src/protocol/token/types.rs` for the sender-constraint matrix.
 - `src/protocol/token/types.rs` for the sender-constraint matrix.
 - `src/jose/dpop.rs`, `src/jose/jar.rs`,
 - `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`.
 - PDB: `minAvailable: 2`.
 - Service: never exposes the health port (8081).
 - Service: never exposes the health port (8081).
 - Hardening: `automountServiceAccountToken: false` and `fsGroup: 65532`; every
 - 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
 - ConfigMap: the ConfigMap referenced by a workload `envFrom` must provide the
   passwordless `D_OAUTH_DATABASE__URL` / `D_OAUTH_REDIS__URL` DSNs, and neither
   passwordless `D_OAUTH_DATABASE__URL` / `D_OAUTH_REDIS__URL` DSNs, and neither
   may embed a password (it belongs in the Secret, read through the
   may embed a password (it belongs in the Secret, read through the
@@ -59,7 +64,15 @@ import yaml
 HEALTH_PORT = 8081
 HEALTH_PORT = 8081
 PUBLIC_PORT = 8080
 PUBLIC_PORT = 8080
 TERMINATION_GRACE_MIN = 45
 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-----")
 PEM_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----|-----BEGIN CERTIFICATE-----")
 DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$")
 DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$")
 HIGH_ENTROPY_VALUE_RE = re.compile(r"^[A-Za-z0-9+/=_-]{32,}$")
 HIGH_ENTROPY_VALUE_RE = re.compile(r"^[A-Za-z0-9+/=_-]{32,}$")
@@ -80,10 +93,27 @@ SECRET_VOLUME_MODE = 0o400
 FILE_SECRET_ENV_KEYS = {
 FILE_SECRET_ENV_KEYS = {
   "D_OAUTH_TOKEN_PEPPER_FILE": "token-pepper",
   "D_OAUTH_TOKEN_PEPPER_FILE": "token-pepper",
   "D_OAUTH_TOTP_ENCRYPTION_KEY_FILE": "totp-encryption-key",
   "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_DATABASE__PASSWORD_FILE": "database-password",
   "D_OAUTH_REDIS__PASSWORD_FILE": "redis-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")
 PASSWORDLESS_DSN_KEYS = ("D_OAUTH_DATABASE__URL", "D_OAUTH_REDIS__URL")
 REQUIRED_EGRESS_PORTS = (53, 5432, 6379)
 REQUIRED_EGRESS_PORTS = (53, 5432, 6379)
 METADATA_CIDR = "169.254.0.0/16"
 METADATA_CIDR = "169.254.0.0/16"
@@ -276,6 +306,24 @@ def check_file_mounted_secrets(
     entry = envs.get(env_name) or {}
     entry = envs.get(env_name) or {}
     if (entry.get("value") or "").strip() != expected:
     if (entry.get("value") or "").strip() != expected:
       fails.add(f"{workload}/{cname}: {env_name} must point at {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]:
 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 admin;
 mod context;
 mod context;
 mod ops;
 mod ops;
+mod pii;
 mod redirect;
 mod redirect;
 mod scope;
 mod scope;
 mod secret;
 mod secret;
 mod sync;
 mod sync;
+mod totp;
 mod user;
 mod user;
 
 
 #[cfg(test)]
 #[cfg(test)]
@@ -104,6 +106,18 @@ pub enum Command {
     #[command(subcommand)]
     #[command(subcommand)]
     command: AdminCommand,
     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.
   /// Issue a one-time DCR initial-access token.
   InitialAccessToken {
   InitialAccessToken {
     /// Initial-access-token subcommand.
     /// 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.
 /// `initial-access-token` subcommands.
 #[derive(Debug, Subcommand)]
 #[derive(Debug, Subcommand)]
 pub enum InitialAccessTokenCommand {
 pub enum InitialAccessTokenCommand {
@@ -418,6 +452,16 @@ pub async fn run(cli: Cli) -> Result<i32, CliError> {
       run_admin(command).await?;
       run_admin(command).await?;
       Ok(0)
       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 } => {
     Command::InitialAccessToken { command } => {
       run_initial_access_token(command).await?;
       run_initial_access_token(command).await?;
       Ok(0)
       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> {
 async fn run_initial_access_token(command: InitialAccessTokenCommand) -> Result<(), CliError> {
   match command {
   match command {
     InitialAccessTokenCommand::Issue { uses, ttl, label } => {
     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 base64::Engine as _;
 use ring::rand::{SecureRandom as _, SystemRandom};
 use ring::rand::{SecureRandom as _, SystemRandom};
-use sqlx::PgPool;
+use sqlx::{PgConnection, PgPool};
 use uuid::Uuid;
 use uuid::Uuid;
 
 
 use super::context::{CliError, audit, require_nonempty};
 use super::context::{CliError, audit, require_nonempty};
 use super::secret;
 use super::secret;
+use super::user::map_user_repo_error;
 use crate::{config::Config, db::user_repo};
 use crate::{config::Config, db::user_repo};
 
 
 /// Bytes of entropy in a generated bootstrap password (256-bit).
 /// 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))
   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
 /// # Errors
 /// Returns [`CliError::Input`] for an empty email/password,
 /// Returns [`CliError::Input`] for an empty email/password,
@@ -147,23 +149,57 @@ pub async fn bootstrap(
     }
     }
     BootstrapPassword::Generate => (generate_password()?, true),
     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 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?;
     .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 =
   let existing: i64 =
     sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_admin AND NOT disabled")
     sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_admin AND NOT disabled")
-      .fetch_one(&mut *tx)
+      .fetch_one(&mut *conn)
       .await?;
       .await?;
 
 
   if existing > 0 && !force {
   if existing > 0 && !force {
-    drop(tx);
-    audit("admin.bootstrap.refused", &email);
+    audit("admin.bootstrap.refused", email);
     return Err(CliError::AlreadyExists(format!(
     return Err(CliError::AlreadyExists(format!(
       "an administrator already exists ({existing}); use `user create --admin` for more admins"
       "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. \
        creating one more. This override is audited. \
        Prefer `user create --admin` for routine provisioning."
        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
   .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")
   let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_admin AND NOT disabled")
-    .fetch_one(pool)
+    .fetch_one(&mut *conn)
     .await?;
     .await?;
-  audit("admin.bootstrap.created", &email);
+  audit("admin.bootstrap.created", email);
   audit("admin.count.changed", &total.to_string());
   audit("admin.count.changed", &total.to_string());
 
 
   Ok(BootstrapOutcome {
   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.
   /// A unique record already exists.
   #[error("already exists: {0}")]
   #[error("already exists: {0}")]
   AlreadyExists(String),
   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).
   /// A cryptographic operation failed (non-secret detail only).
   #[error("crypto failure: {0}")]
   #[error("crypto failure: {0}")]
   Crypto(String),
   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.
 //! `redirect-uri` and `client` subcommands plus the shared URI upsert.
 
 
-use sqlx::{PgPool, Row as _};
+use sqlx::Row as _;
 use uuid::Uuid;
 use uuid::Uuid;
 
 
 use super::{
 use super::{
@@ -8,6 +8,10 @@ use super::{
   context::{CliError, Context, audit},
   context::{CliError, Context, audit},
   scope::Upsert,
   scope::Upsert,
 };
 };
+use crate::{
+  crypto::field::blind_index_candidates,
+  db::{NewRedirectUri, RedirectKind, UpsertOutcome, redirect_repo},
+};
 
 
 /// Run a `redirect-uri` subcommand.
 /// Run a `redirect-uri` subcommand.
 ///
 ///
@@ -20,7 +24,7 @@ pub async fn run_uri(context: &Context, command: RedirectUriCommand) -> Result<(
       kind,
       kind,
       description,
       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::Created => println!("created redirect uri {uri}"),
         Upsert::Updated => println!("updated redirect uri {uri}"),
         Upsert::Updated => println!("updated redirect uri {uri}"),
         Upsert::Unchanged => println!("redirect uri {uri} unchanged"),
         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> {
 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");
     println!("redirect uri catalog is empty");
     return Ok(());
     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(())
   Ok(())
 }
 }
@@ -81,16 +81,11 @@ async fn set_enabled(
   kind: &str,
   kind: &str,
   enabled: bool,
   enabled: bool,
 ) -> Result<(), CliError> {
 ) -> 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})")));
     return Err(CliError::NotFound(format!("redirect uri {uri} ({kind})")));
   }
   }
   let operation = if enabled {
   let operation = if enabled {
@@ -107,59 +102,27 @@ async fn set_enabled(
 }
 }
 
 
 async fn delete(context: &Context, uri: &str, kind: &str) -> Result<(), CliError> {
 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(());
     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?
     .await?
-    .rows_affected();
-  if affected == 0 {
+    .is_none()
+  {
     return Err(CliError::NotFound(format!("redirect uri {uri} ({kind})")));
     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(())
   Ok(())
 }
 }
 
 
 async fn attach(context: &Context, client_id: &str, uri: &str, kind: &str) -> Result<(), CliError> {
 async fn attach(context: &Context, client_id: &str, uri: &str, kind: &str) -> Result<(), CliError> {
   ensure_client_exists(context, client_id).await?;
   ensure_client_exists(context, client_id).await?;
   let redirect_id = find_redirect_id(context, uri, kind).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}"));
   audit("client.attach_redirect_uri", &format!("{client_id}:{uri}"));
   println!("attached {uri} ({kind}) to {client_id}");
   println!("attached {uri} ({kind}) to {client_id}");
   Ok(())
   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> {
 async fn detach(context: &Context, client_id: &str, uri: &str, kind: &str) -> Result<(), CliError> {
   let redirect_id = find_redirect_id(context, uri, kind).await?;
   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}"));
   audit("client.detach_redirect_uri", &format!("{client_id}:{uri}"));
   println!("detached {uri} ({kind}) from {client_id}");
   println!("detached {uri} ({kind}) from {client_id}");
   Ok(())
   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> {
 async fn list_for_client(context: &Context, client_id: &str) -> Result<(), CliError> {
   ensure_client_exists(context, client_id).await?;
   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() {
   if uris.is_empty() {
     println!("{client_id} has no attached redirect URIs");
     println!("{client_id} has no attached redirect URIs");
   } else {
   } else {
-    for (uri, kind) in uris {
-      println!("{uri}\t{kind}");
+    for entry in uris {
+      println!("{}\t{}", entry.uri, entry.kind);
     }
     }
   }
   }
   Ok(())
   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
 /// # Errors
-/// Returns [`CliError::Database`] on failure.
+/// Returns [`CliError`] on a repository or database failure.
 pub async fn upsert(
 pub async fn upsert(
-  pool: &PgPool,
+  context: &Context,
   uri: &str,
   uri: &str,
   kind: &str,
   kind: &str,
   description: Option<&str>,
   description: Option<&str>,
   enabled: bool,
   enabled: bool,
 ) -> Result<Upsert, CliError> {
 ) -> 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.
 /// 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
 /// # Errors
 /// Returns [`CliError::NotFound`] when the URI is not in the catalog.
 /// 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> {
 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.
 /// 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
 //! Secret handling for the CLI: password hashing, token hashing, TOTP-secret
 //! encryption, and ES256 keygen.
 //! 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::{
 use base64::{
   Engine as _, engine::general_purpose::STANDARD, engine::general_purpose::URL_SAFE_NO_PAD,
   Engine as _, engine::general_purpose::STANDARD, engine::general_purpose::URL_SAFE_NO_PAD,
@@ -21,21 +25,32 @@ use ring::{
 use secrecy::ExposeSecret as _;
 use secrecy::ExposeSecret as _;
 #[cfg(test)]
 #[cfg(test)]
 use sha2::{Digest as _, Sha256};
 use sha2::{Digest as _, Sha256};
+use zeroize::Zeroizing;
 
 
 use super::context::CliError;
 use super::context::CliError;
 use crate::config::Config;
 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;
 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.
 /// Legacy blob version (bare-`SHA256(totp_key)` writes): decrypt-only.
 #[cfg(test)]
 #[cfg(test)]
 const TOTP_BLOB_V1: u8 = 1;
 const TOTP_BLOB_V1: u8 = 1;
 /// HKDF domain-separation label binding the derived key to TOTP AES-GCM use.
 /// HKDF domain-separation label binding the derived key to TOTP AES-GCM use.
 const HKDF_INFO: &[u8] = b"totp-gcm-v2";
 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.
 /// AES-GCM nonce length.
 const NONCE_LEN: usize = 12;
 const NONCE_LEN: usize = 12;
 /// AES-256 key length.
 /// AES-256 key length.
 const KEY_LEN: usize = 32;
 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.
 /// A generated ES256 keypair, serialized for test fixtures / DPoP clients.
 pub struct KeypairPem {
 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.
 /// 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
 /// # Errors
 /// Returns [`CliError::Crypto`] when no TOTP key is configured or the AEAD
 /// Returns [`CliError::Crypto`] when no TOTP key is configured or the AEAD
 /// fails.
 /// 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
   let (key_id, secret) = config
     .keys
     .keys
     .active_totp_key()
     .active_totp_key()
@@ -127,11 +154,99 @@ pub fn encrypt_totp(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliErro
   Ok(blob)
   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`].
 /// 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
 /// Currently exercised only by tests; the login/verification path (a later
 /// task) is the production consumer.
 /// task) is the production consumer.
@@ -141,7 +256,9 @@ pub fn encrypt_totp(config: &Config, plaintext: &str) -> Result<Vec<u8>, CliErro
 /// authentication failure.
 /// authentication failure.
 #[cfg(test)]
 #[cfg(test)]
 pub fn decrypt_totp(config: &Config, blob: &[u8]) -> Result<String, CliError> {
 pub fn decrypt_totp(config: &Config, blob: &[u8]) -> Result<String, CliError> {
-  if blob.len() < 2 + NONCE_LEN || (blob[0] != TOTP_BLOB_VERSION && 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()));
     return Err(CliError::Crypto("malformed TOTP blob".to_owned()));
   }
   }
   let version = blob[0];
   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 {
   if blob.len() < header + NONCE_LEN {
     return Err(CliError::Crypto("malformed TOTP blob".to_owned()));
     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()))?;
     .map_err(|_| CliError::Crypto("malformed TOTP key id".to_owned()))?;
   let secret = config
   let secret = config
     .keys
     .keys
     .totp_key_by_id(kid)
     .totp_key_by_id(kid)
     .ok_or_else(|| CliError::Crypto(format!("unknown TOTP key id: {kid}")))?;
     .ok_or_else(|| CliError::Crypto(format!("unknown TOTP key id: {kid}")))?;
   let ikm = secret.expose_secret().as_bytes();
   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];
   let mut nonce_bytes = [0u8; NONCE_LEN];
   nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
   nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
@@ -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_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)]
 #[cfg(test)]
 fn open_with_key(
 fn open_with_key(
   key: &[u8; KEY_LEN],
   key: &[u8; KEY_LEN],
   nonce_bytes: &[u8; NONCE_LEN],
   nonce_bytes: &[u8; NONCE_LEN],
   ciphertext: &[u8],
   ciphertext: &[u8],
 ) -> Result<String, CliError> {
 ) -> 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 mut in_out = ciphertext.to_vec();
   let unbound = UnboundKey::new(&AES_256_GCM, key)
   let unbound = UnboundKey::new(&AES_256_GCM, key)
     .map_err(|_| CliError::Crypto("TOTP key rejected by AEAD".to_owned()))?;
     .map_err(|_| CliError::Crypto("TOTP key rejected by AEAD".to_owned()))?;
@@ -188,12 +366,11 @@ fn open_with_key(
   let plaintext = opening
   let plaintext = opening
     .open_in_place(
     .open_in_place(
       Nonce::assume_unique_for_key(*nonce_bytes),
       Nonce::assume_unique_for_key(*nonce_bytes),
-      Aad::empty(),
+      Aad::from(aad),
       &mut in_out,
       &mut in_out,
     )
     )
     .map_err(|_| CliError::Crypto("TOTP secret decryption failed".to_owned()))?;
     .map_err(|_| CliError::Crypto("TOTP secret decryption failed".to_owned()))?;
-  String::from_utf8(plaintext.to_vec())
-    .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).
 /// 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> {
 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 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];
   let mut key = [0u8; KEY_LEN];
   okm.fill(&mut key)?;
   okm.fill(&mut key)?;
   Ok(key)
   Ok(key)

+ 4 - 11
src/cli/sync.rs

@@ -12,6 +12,7 @@ use super::{
   context::{CliError, Context, audit},
   context::{CliError, Context, audit},
   redirect, scope,
   redirect, scope,
 };
 };
+use crate::db::redirect_repo;
 
 
 /// Top-level declarative file.
 /// Top-level declarative file.
 #[derive(Debug, Default, Deserialize)]
 #[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 {
   for entry in &file.redirect_uris {
     match redirect::upsert(
     match redirect::upsert(
-      context.pool(),
+      context,
       &entry.uri,
       &entry.uri,
       &entry.kind,
       &entry.kind,
       entry.description.as_deref(),
       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 {
   for entry in &file.client_redirect_uris {
     redirect::ensure_client_exists(context, &entry.client_id).await?;
     redirect::ensure_client_exists(context, &entry.client_id).await?;
     let redirect_id = redirect::find_redirect_id(context, &entry.uri, &entry.kind).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());
   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"},
     "redis": {"url": redis_url, "durability": "waitaof"},
     "token_pepper": "cli-test-pepper-0123456789abcdef",
     "token_pepper": "cli-test-pepper-0123456789abcdef",
     "totp_encryption_key": "cli-test-totp-key-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 source = ::config::Config::try_from(&fixture)?;
   Ok(Config::from_source(source)?)
   Ok(Config::from_source(source)?)

+ 569 - 19
src/cli/tests.rs

@@ -5,6 +5,13 @@ use std::sync::Arc;
 
 
 use base64::Engine as _;
 use base64::Engine as _;
 use clap::Parser 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::{
 use testcontainers::{
   GenericImage, ImageExt,
   GenericImage, ImageExt,
   core::{IntoContainerPort, WaitFor},
   core::{IntoContainerPort, WaitFor},
@@ -13,7 +20,7 @@ use testcontainers::{
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 
 
 use super::{
 use super::{
-  Cli, ScopeCommand, admin, ops, scope, secret,
+  Cli, ClientCommand, RedirectUriCommand, ScopeCommand, admin, ops, redirect, scope, secret,
   sync::{RedirectEntry, ScopeEntry, SyncFile},
   sync::{RedirectEntry, ScopeEntry, SyncFile},
   test_support::{self, TestError},
   test_support::{self, TestError},
   user,
   user,
@@ -21,7 +28,8 @@ use super::{
 use crate::{
 use crate::{
   config::Config,
   config::Config,
   crypto::totp,
   crypto::totp,
-  db::user_repo,
+  db::{client_repo, redirect_repo, user_repo},
+  keys::{KeyId, KeyRing},
   state::{AppState, RedisConn},
   state::{AppState, RedisConn},
   ui::login::{
   ui::login::{
     CHANGE_PASSWORD_PATH, ConfirmOutcome, LoginAttempt, LoginDeps, LoginOutcome, PasswordOutcome,
     CHANGE_PASSWORD_PATH, ConfirmOutcome, LoginAttempt, LoginDeps, LoginOutcome, PasswordOutcome,
@@ -57,6 +65,7 @@ async fn bootstrap_setup(
     "redis": {"url": redis_url, "durability": "waitaof"},
     "redis": {"url": redis_url, "durability": "waitaof"},
     "token_pepper": "bootstrap-test-pepper-0123456789abcdef",
     "token_pepper": "bootstrap-test-pepper-0123456789abcdef",
     "totp_encryption_key": "bootstrap-test-totp-key-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},
     "argon2": {"memory_kib": 8192, "iterations": 1, "parallelism": 1},
   });
   });
   let source = ::config::Config::try_from(&fixture)?;
   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");
   let email = test_support::unique_email("create");
   user::create(&context, &email, true, PASSWORD).await?;
   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.starts_with("$argon2id$"));
   assert!(!hash.contains(PASSWORD));
   assert!(!hash.contains(PASSWORD));
   Ok(())
   Ok(())
@@ -107,6 +115,53 @@ async fn cli_user_create_duplicate_email_fails() -> Result<(), TestError> {
   Ok(())
   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]
 #[tokio::test]
 async fn cli_initial_access_token_stores_only_hash() -> Result<(), TestError> {
 async fn cli_initial_access_token_stores_only_hash() -> Result<(), TestError> {
   let harness = test_support::harness().await?;
   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?;
   user::create(&context, &email, false, PASSWORD).await?;
 
 
   let enrollment = user::enroll_totp(&context, &email).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) =
   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())
       .fetch_one(context.pool())
       .await?;
       .await?;
 
 
@@ -170,6 +228,205 @@ async fn cli_user_enroll_totp_stores_encrypted_secret() -> Result<(), TestError>
   Ok(())
   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]
 #[tokio::test]
 async fn cli_scope_create_and_grant_take_effect() -> Result<(), TestError> {
 async fn cli_scope_create_and_grant_take_effect() -> Result<(), TestError> {
   let (_harness, context) = test_support::context().await?;
   let (_harness, context) = test_support::context().await?;
@@ -236,6 +493,298 @@ async fn cli_config_sync_is_idempotent() -> Result<(), TestError> {
   Ok(())
   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]
 #[tokio::test]
 async fn cli_mutations_emit_audit_events() -> Result<(), TestError> {
 async fn cli_mutations_emit_audit_events() -> Result<(), TestError> {
   let (_harness, context) = test_support::context().await?;
   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(
   let row: (bool, bool, bool, Option<OffsetDateTime>) = sqlx::query_as(
     "SELECT is_admin, must_change_password, totp_enabled, password_changed_at
     "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)
   .fetch_one(&pool)
   .await?;
   .await?;
   assert!(row.0, "bootstrap must create an admin");
   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"
     "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_eq!(verdict, user_repo::PasswordVerdict::Matched);
   assert!(user_repo::has_any_admin(&pool).await?);
   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"))?;
     .map_err(|_| TestError::Missing("generated password must be base64"))?;
   assert_eq!(raw.len(), 32, "generated passwords are 256-bit");
   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);
   assert_eq!(verdict, user_repo::PasswordVerdict::Matched);
 
 
   let log = test_support::captured(&capture.buffer);
   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"));
       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?
     .await?
     .ok_or(TestError::Missing("bootstrap admin"))?;
     .ok_or(TestError::Missing("bootstrap admin"))?;
   assert!(!user.must_change_password);
   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"},
     "redis": {"url": "redis://127.0.0.1:6379", "durability": "waitaof"},
     "token_pepper": "cli-test-pepper-0123456789abcdef",
     "token_pepper": "cli-test-pepper-0123456789abcdef",
     "totp_encryption_key": "cli-test-totp-key-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 source = ::config::Config::try_from(&fixture)?;
   let config = Config::from_source(source)?;
   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},
   context::{CliError, Context, audit, require_nonempty},
   secret,
   secret,
 };
 };
+use crate::{db::user_repo, error::AppError};
 
 
 /// The one-time TOTP enrollment material printed to the operator.
 /// The one-time TOTP enrollment material printed to the operator.
 pub struct Enrollment {
 pub struct Enrollment {
@@ -32,31 +33,24 @@ pub async fn create(
   let email = normalized_email(email)?;
   let email = normalized_email(email)?;
   require_nonempty(password, "password")?;
   require_nonempty(password, "password")?;
   let password_hash = secret::hash_password(context.config(), 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.
 /// 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 email = normalized_email(email)?;
   let id = find_user_id(context, &email).await?;
   let id = find_user_id(context, &email).await?;
   let totp_secret = secret::generate_totp_secret()?;
   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);
   audit("user.enroll_totp", &email);
   Ok(Enrollment {
   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> {
 pub async fn disable(context: &Context, email: &str) -> Result<(), CliError> {
   let email = normalized_email(email)?;
   let email = normalized_email(email)?;
   let id = find_user_id(context, &email).await?;
   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);
   audit("user.disable", &email);
   println!("disabled user {email}");
   println!("disabled user {email}");
   Ok(())
   Ok(())
@@ -161,16 +158,27 @@ pub async fn list_scopes(context: &Context, email: &str) -> Result<Vec<String>,
   Ok(scopes)
   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
 /// # 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> {
 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> {
 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,
   pub redis: RedisConfig,
   /// Versioned token-pepper and TOTP-encryption keys.
   /// Versioned token-pepper and TOTP-encryption keys.
   pub keys: KeyRing,
   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.
   /// Per-endpoint failure policy.
   pub failure_policy: FailurePolicy,
   pub failure_policy: FailurePolicy,
   /// Token/request lifetimes.
   /// Token/request lifetimes.
@@ -118,6 +121,7 @@ impl fmt::Debug for Config {
       .field("database", &self.database)
       .field("database", &self.database)
       .field("redis", &self.redis)
       .field("redis", &self.redis)
       .field("keys", &self.keys)
       .field("keys", &self.keys)
+      .field("pii_dual_write", &self.pii_dual_write)
       .field("failure_policy", &self.failure_policy)
       .field("failure_policy", &self.failure_policy)
       .field("ttl", &self.ttl)
       .field("ttl", &self.ttl)
       .field("argon2", &self.argon2)
       .field("argon2", &self.argon2)
@@ -315,6 +319,10 @@ pub enum ConfigError {
   /// The key ring could not be constructed.
   /// The key ring could not be constructed.
   #[error("key ring error: {0}")]
   #[error("key ring error: {0}")]
   KeyRing(#[from] crate::keys::KeyRingError),
   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.
 /// 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,
   str::FromStr,
 };
 };
 
 
-use secrecy::SecretString;
+use secrecy::{ExposeSecret, SecretString};
 
 
 use super::{
 use super::{
   Config, ConfigError, TlsConfig,
   Config, ConfigError, TlsConfig,
@@ -21,7 +21,7 @@ use super::{
   scope::{ScopeDefinition, TokenExchangeRule},
   scope::{ScopeDefinition, TokenExchangeRule},
   types::{Argon2Params, RateLimitConfig, TtlConfig},
   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_BIND_ADDR: &str = "0.0.0.0:8080";
 const DEFAULT_HEALTH_ADDR: &str = "0.0.0.0:8081";
 const DEFAULT_HEALTH_ADDR: &str = "0.0.0.0:8081";
@@ -76,6 +76,7 @@ impl Config {
       cookie_secure: raw.cookie_secure.unwrap_or(true),
       cookie_secure: raw.cookie_secure.unwrap_or(true),
       trusted_proxy,
       trusted_proxy,
       allow_open_registration: raw.allow_open_registration,
       allow_open_registration: raw.allow_open_registration,
+      pii_dual_write: raw.pii_dual_write,
       dcr_redirect_policy: raw.dcr_redirect_policy,
       dcr_redirect_policy: raw.dcr_redirect_policy,
       cors_allowed_origins: raw.cors_allowed_origins,
       cors_allowed_origins: raw.cors_allowed_origins,
       scope_registry,
       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.as_deref(),
     raw.totp_encryption_key_file.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 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 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
   let peppers = pepper
     .map(|secret| vec![(pepper_id, secret)])
     .map(|secret| vec![(pepper_id, secret)])
     .unwrap_or_default();
     .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(
 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: Option<String>,
   pub totp_encryption_key_file: Option<PathBuf>,
   pub totp_encryption_key_file: Option<PathBuf>,
   pub totp_key_id: Option<String>,
   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 failure_policy: RawFailurePolicy,
   pub ttl: RawTtlConfig,
   pub ttl: RawTtlConfig,
   pub argon2: RawArgon2,
   pub argon2: RawArgon2,

+ 185 - 0
src/config/tests.rs

@@ -32,6 +32,8 @@ fn base() -> Map<String, Value> {
     "token_pepper_id": "v1",
     "token_pepper_id": "v1",
     "totp_encryption_key": "SUPER_SECRET_TOTP_KEY",
     "totp_encryption_key": "SUPER_SECRET_TOTP_KEY",
     "totp_key_id": "v1",
     "totp_key_id": "v1",
+    "pii_encryption_key": "SUPER_SECRET_PII_KEY",
+    "pii_key_id": "v1",
     "cookie_secure": true,
     "cookie_secure": true,
     "allow_open_registration": false,
     "allow_open_registration": false,
     "dcr_redirect_policy": "catalog",
     "dcr_redirect_policy": "catalog",
@@ -249,6 +251,7 @@ fn config_debug_redacts_secret_values() -> Result<(), Box<dyn Error>> {
   let rendered = format!("{cfg:?}");
   let rendered = format!("{cfg:?}");
   assert!(!rendered.contains("SUPER_SECRET_PEPPER_VALUE"));
   assert!(!rendered.contains("SUPER_SECRET_PEPPER_VALUE"));
   assert!(!rendered.contains("SUPER_SECRET_TOTP_KEY"));
   assert!(!rendered.contains("SUPER_SECRET_TOTP_KEY"));
+  assert!(!rendered.contains("SUPER_SECRET_PII_KEY"));
   assert!(!rendered.contains("DB_PASSWORD_XYZ"));
   assert!(!rendered.contains("DB_PASSWORD_XYZ"));
   assert!(rendered.contains("REDACTED"));
   assert!(rendered.contains("REDACTED"));
   Ok(())
   Ok(())
@@ -288,6 +291,188 @@ fn config_env_double_underscore_maps_to_nested_snake_keys() -> Result<(), Box<dy
   Ok(())
   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]
 #[test]
 fn read_secret_file_preserves_surrounding_spaces() -> Result<(), Box<dyn Error>> {
 fn read_secret_file_preserves_surrounding_spaces() -> Result<(), Box<dyn Error>> {
   use secrecy::ExposeSecret as _;
   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.
 //! 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.
 //! comparison goes through `ct`; intermediates are `zeroize`d.
 
 
 pub mod ct;
 pub mod ct;
+pub mod field;
 pub mod pkce;
 pub mod pkce;
 pub mod secret;
 pub mod secret;
 pub mod tokens;
 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
 //! SHA-1, 6 digits, with the validator accepting one step of look-ahead and
 //! look-behind.
 //! look-behind.
 //!
 //!
-//! The encrypted-secret blob layout mirrors `cli::secret` (`version(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
 //! No error variant carries a secret, code, or key: failures collapse to
 //! static messages.
 //! static messages.
@@ -30,6 +35,7 @@ use ring::{
 use secrecy::ExposeSecret as _;
 use secrecy::ExposeSecret as _;
 use sha2::{Digest as _, Sha256};
 use sha2::{Digest as _, Sha256};
 use totp_rs::{Algorithm, Builder, Secret};
 use totp_rs::{Algorithm, Builder, Secret};
+use zeroize::Zeroizing;
 
 
 use crate::{crypto::ct::ct_eq, keys::KeyRing};
 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;
 pub const RECOVERY_CODE_COUNT: usize = 10;
 /// Current encrypted-secret blob format version (HKDF-SHA256 writes).
 /// Current encrypted-secret blob format version (HKDF-SHA256 writes).
 const TOTP_BLOB_VERSION: u8 = 2;
 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.
 /// Legacy blob version (bare-`SHA256(totp_key)` writes): decrypt-only.
 const TOTP_BLOB_V1: u8 = 1;
 const TOTP_BLOB_V1: u8 = 1;
 /// HKDF domain-separation label binding the derived key to TOTP AES-GCM use.
 /// HKDF domain-separation label binding the derived key to TOTP AES-GCM use.
 const HKDF_INFO: &[u8] = b"totp-gcm-v2";
 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.
 /// AES-GCM nonce length.
 const NONCE_LEN: usize = 12;
 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.
 /// Failures from TOTP enrollment and verification.
 ///
 ///
@@ -82,10 +99,23 @@ pub enum TotpError {
 }
 }
 
 
 impl From<TotpError> for crate::error::AppError {
 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 {
   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.
 /// 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
 /// # Errors
 ///
 ///
 /// Returns [`TotpError::Encrypt`] when no TOTP key is configured or the AEAD
 /// Returns [`TotpError::Encrypt`] when no TOTP key is configured or the AEAD
 /// fails.
 /// 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 (key_id, secret) = keys.active_totp_key().ok_or(TotpError::Encrypt)?;
   let kid = key_id.as_str().as_bytes();
   let kid = key_id.as_str().as_bytes();
   if kid.len() > usize::from(u8::MAX) {
   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)
   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
 /// Decrypt a secret previously produced by [`encrypt_secret`], trying the key
 /// named in the blob (retained keys keep decrypting after rotation).
 /// named in the blob (retained keys keep decrypting after rotation).
 ///
 ///
-/// Version-2 blobs open under the HKDF-SHA256 key; legacy version-1 blobs
-/// first try the HKDF key, then fall back to the bare-`SHA256(totp_key)` key,
-/// so rows written before the HKDF migration keep decrypting.
+/// 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
 /// # Errors
 ///
 ///
 /// Returns [`TotpError::Decrypt`] for a malformed blob, an unknown key id, or
 /// Returns [`TotpError::Decrypt`] for a malformed blob, an unknown key id, or
 /// an authentication failure. The reason is never distinguished.
 /// an authentication failure. The reason is never distinguished.
 pub fn decrypt_secret(keys: &KeyRing, blob: &[u8]) -> Result<String, TotpError> {
 pub fn decrypt_secret(keys: &KeyRing, blob: &[u8]) -> Result<String, TotpError> {
-  if blob.len() < 2 + NONCE_LEN || (blob[0] != TOTP_BLOB_VERSION && 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);
     return Err(TotpError::Decrypt);
   }
   }
   let version = blob[0];
   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 {
   if blob.len() < header + NONCE_LEN {
     return Err(TotpError::Decrypt);
     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 secret = keys.totp_key_by_id(kid).ok_or(TotpError::Decrypt)?;
   let ikm = secret.expose_secret().as_bytes();
   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];
   let mut nonce_bytes = [0u8; NONCE_LEN];
   nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
   nonce_bytes.copy_from_slice(&blob[header..header + NONCE_LEN]);
   let ciphertext = &blob[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_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.
 /// Open `ciphertext` (with trailing GCM tag) under `key`, returning UTF-8.
 ///
 ///
 /// # Errors
 /// # Errors
@@ -376,17 +552,34 @@ fn open_with_key(
   nonce_bytes: &[u8; NONCE_LEN],
   nonce_bytes: &[u8; NONCE_LEN],
   ciphertext: &[u8],
   ciphertext: &[u8],
 ) -> Result<String, TotpError> {
 ) -> 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 mut in_out = ciphertext.to_vec();
   let opening =
   let opening =
     LessSafeKey::new(UnboundKey::new(&AES_256_GCM, key).map_err(|_| TotpError::Decrypt)?);
     LessSafeKey::new(UnboundKey::new(&AES_256_GCM, key).map_err(|_| TotpError::Decrypt)?);
   let plaintext = opening
   let plaintext = opening
     .open_in_place(
     .open_in_place(
       Nonce::assume_unique_for_key(*nonce_bytes),
       Nonce::assume_unique_for_key(*nonce_bytes),
-      Aad::empty(),
+      Aad::from(aad),
       &mut in_out,
       &mut in_out,
     )
     )
     .map_err(|_| TotpError::Decrypt)?;
     .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`).
 /// 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
 /// 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).
 /// (RFC 5869, empty salt, info `totp-gcm-v2` domain-separating TOTP use).
 fn derive_key(totp_key: &[u8]) -> Result<[u8; 32], ring::error::Unspecified> {
 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 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];
   let mut key = [0u8; 32];
   okm.fill(&mut key)?;
   okm.fill(&mut key)?;
   Ok(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,
   derive_key, derive_key_legacy, encrypt_secret, generate_recovery_codes, generate_secret,
   normalize_recovery_code, otpauth_uri, qr_svg, replay_fingerprint, verify_window,
   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.
 /// Test KeyRing with one pepper generation and one TOTP-encryption key.
 fn test_ring() -> Result<KeyRing, TotpError> {
 fn test_ring() -> Result<KeyRing, TotpError> {
@@ -21,6 +24,10 @@ fn test_ring() -> Result<KeyRing, TotpError> {
       KeyId::new("v1"),
       KeyId::new("v1"),
       SecretString::from("test-totp-key-0123456789abcdef"),
       SecretString::from("test-totp-key-0123456789abcdef"),
     )],
     )],
+    vec![(
+      KeyId::new("v1"),
+      SecretString::from("test-pii-key-0123456789abcdef"),
+    )],
   )
   )
   .map_err(|_| TotpError::Encrypt)
   .map_err(|_| TotpError::Encrypt)
 }
 }
@@ -100,7 +107,7 @@ fn login_totp_secret_round_trip() -> Result<(), TotpError> {
   if secret.len() != 32 {
   if secret.len() != 32 {
     return Err(TotpError::InvalidSecret);
     return Err(TotpError::InvalidSecret);
   }
   }
-  let blob = encrypt_secret(&ring, &secret)?;
+  let blob = encrypt_secret(&ring, &secret, true)?;
   let back = decrypt_secret(&ring, &blob)?;
   let back = decrypt_secret(&ring, &blob)?;
   if back != secret {
   if back != secret {
     return Err(TotpError::Decrypt);
     return Err(TotpError::Decrypt);
@@ -115,7 +122,7 @@ fn login_totp_secret_round_trip() -> Result<(), TotpError> {
 #[test]
 #[test]
 fn login_totp_decrypt_rejects_tampered_and_foreign_blobs() -> Result<(), TotpError> {
 fn login_totp_decrypt_rejects_tampered_and_foreign_blobs() -> Result<(), TotpError> {
   let ring = test_ring()?;
   let ring = test_ring()?;
-  let mut blob = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ")?;
+  let mut blob = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ", true)?;
   let last = blob.len() - 1;
   let last = blob.len() - 1;
   blob[last] ^= 0x01;
   blob[last] ^= 0x01;
   if decrypt_secret(&ring, &blob).is_ok() {
   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(
   let foreign = KeyRing::from_parts(
     vec![(KeyId::new("v1"), SecretString::from("other-pepper"))],
     vec![(KeyId::new("v1"), SecretString::from("other-pepper"))],
     vec![(KeyId::new("v1"), SecretString::from("other-totp-key"))],
     vec![(KeyId::new("v1"), SecretString::from("other-totp-key"))],
+    vec![(KeyId::new("v1"), SecretString::from("other-pii-key"))],
   )
   )
   .map_err(|_| TotpError::Encrypt)?;
   .map_err(|_| TotpError::Encrypt)?;
-  let blob = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ")?;
+  let blob = encrypt_secret(&ring, "GEZDGNBVGY3TQOJQ", true)?;
   if decrypt_secret(&foreign, &blob).is_ok() {
   if decrypt_secret(&foreign, &blob).is_ok() {
     return Err(TotpError::Decrypt);
     return Err(TotpError::Decrypt);
   }
   }
@@ -248,14 +256,109 @@ fn login_totp_hkdf_differs_from_legacy_sha256() -> Result<(), TotpError> {
   Ok(())
   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]
 #[test]
 fn login_totp_new_blobs_use_version_two() -> Result<(), TotpError> {
 fn login_totp_new_blobs_use_version_two() -> Result<(), TotpError> {
   let ring = test_ring()?;
   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);
     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);
     return Err(TotpError::Decrypt);
   }
   }
   Ok(())
   Ok(())
@@ -309,3 +412,37 @@ fn login_totp_decrypts_legacy_v1_sha256_blob() -> Result<(), TotpError> {
   }
   }
   Ok(())
   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
 //! `sqlx` macro call, so the schema is checked at compile time and no SQL is
 //! ever interpolated.
 //! 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
 //! The metadata projection ([`Client`]) deliberately omits
 //! `client_secret_hash` and `registration_access_token_hash`: the secret hash
 //! `client_secret_hash` and `registration_access_token_hash`: the secret hash
 //! is selected only by [`verify_secret`], and the registration hash only by
 //! is selected only by [`verify_secret`], and the registration hash only by
@@ -21,154 +29,520 @@ use sqlx::PgPool;
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 use uuid::Uuid;
 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::crypto::secret::verify_secret as verify_argon2;
+use crate::error::AppError;
+use crate::keys::KeyRing;
 
 
 /// Insert a new client and return its safe projection.
 /// 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
 /// # 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, \
     "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, \
      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, \
      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.id,
     &new.client_id,
     &new.client_id,
     new.client_secret_hash.as_deref(),
     new.client_secret_hash.as_deref(),
     new.client_secret_expires_at,
     new.client_secret_expires_at,
-    &new.client_name,
+    name_plain,
+    sealed.name_enc,
     &new.grant_types,
     &new.grant_types,
     &new.response_types,
     &new.response_types,
     &new.scope,
     &new.scope,
     &new.token_endpoint_auth_method,
     &new.token_endpoint_auth_method,
     new.jwks.clone(),
     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.registration_access_token_hash.as_deref(),
     new.dpop_bound_access_tokens,
     new.dpop_bound_access_tokens,
   )
   )
   .fetch_one(pool)
   .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`.
 /// Find an active client by its public `client_id`.
 ///
 ///
 /// # Errors
 /// # 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(
 pub async fn find_active_by_client_id(
   pool: &PgPool,
   pool: &PgPool,
+  keys: &KeyRing,
   client_id: &str,
   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, \
     "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",
      FROM oauth_clients WHERE client_id = $1 AND active = true",
     client_id,
     client_id,
   )
   )
   .fetch_optional(pool)
   .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.
 /// Find a client by its surrogate id, active or not.
 ///
 ///
 /// # Errors
 /// # 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, \
     "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",
      FROM oauth_clients WHERE id = $1",
     id,
     id,
   )
   )
   .fetch_optional(pool)
   .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.
 /// List clients ordered by issue time, optionally including deactivated ones.
 ///
 ///
 /// # Errors
 /// # 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, \
     "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",
      FROM oauth_clients WHERE ($1 OR active = true) ORDER BY created_at, client_id",
     include_inactive,
     include_inactive,
   )
   )
   .fetch_all(pool)
   .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.
 /// Full-replace the allowlisted metadata of an active client.
 ///
 ///
 /// Never touches identity, secret, issue time, active flag, or registration
 /// Never touches identity, secret, issue time, active flag, or registration
 /// token. Returns the updated projection, or `None` if no active client has
 /// 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
 /// # 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(
 pub async fn update_metadata(
   pool: &PgPool,
   pool: &PgPool,
+  keys: &KeyRing,
+  dual_write: bool,
   client_id: &str,
   client_id: &str,
   meta: &ClientMetadata,
   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,
     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.grant_types,
     &meta.response_types,
     &meta.response_types,
     &meta.scope,
     &meta.scope,
     &meta.token_endpoint_auth_method,
     &meta.token_endpoint_auth_method,
     meta.jwks.clone(),
     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,
     meta.dpop_bound_access_tokens,
   )
   )
   .fetch_optional(pool)
   .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.
 /// 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.
 /// 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
 /// # Errors
 ///
 ///
 /// Returns a database error if the query fails.
 /// Returns a database error if the query fails.
 pub async fn redirect_uri_allowed(
 pub async fn redirect_uri_allowed(
   pool: &PgPool,
   pool: &PgPool,
+  keys: &KeyRing,
   client_id: &str,
   client_id: &str,
   uri: &str,
   uri: &str,
 ) -> Result<bool, sqlx::Error> {
 ) -> Result<bool, sqlx::Error> {
+  let candidates = blind_index_candidates(keys, uri);
   sqlx::query_scalar!(
   sqlx::query_scalar!(
     "SELECT EXISTS ( \
     "SELECT EXISTS ( \
        SELECT 1 FROM client_redirect_uris AS cru \
        SELECT 1 FROM client_redirect_uris AS cru \
        JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id \
        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,
     client_id,
+    &candidates,
     uri,
     uri,
   )
   )
   .fetch_one(pool)
   .fetch_one(pool)
@@ -336,3 +719,186 @@ pub async fn set_registration_token_hash(
   .await?;
   .await?;
   Ok(result.rows_affected() > 0)
   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`.
 //! `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
 //! projection rules, the metadata allowlist, the redirect/scope rules, and
 //! registration-token hash storage. Every test gets a fresh migrated database
 //! registration-token hash storage. Every test gets a fresh migrated database
 //! from [`crate::db::test_support`].
 //! from [`crate::db::test_support`].
 
 
+use secrecy::SecretString;
 use serde_json::json;
 use serde_json::json;
 use sqlx::PgPool;
 use sqlx::PgPool;
 use uuid::Uuid;
 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::crypto::secret::{Argon2Params, hash_secret};
 use crate::db::redirect_repo;
 use crate::db::redirect_repo;
 use crate::db::test_support::{TestError, migrated_pool};
 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.
 /// Low-cost Argon2id parameters keep the tests fast.
 fn params() -> Result<Argon2Params, TestError> {
 fn params() -> Result<Argon2Params, TestError> {
@@ -64,36 +82,126 @@ async fn insert_scope(pool: &PgPool, name: &str, enabled: bool) -> Result<(), sq
   .map(|_| ())
   .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.
 /// Insert + find by both keys round-trips the full metadata projection.
 #[tokio::test]
 #[tokio::test]
 async fn client_repo_insert_and_find_round_trip() -> Result<(), TestError> {
 async fn client_repo_insert_and_find_round_trip() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
   let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
   let mut new = confidential("client-round-trip");
   let mut new = confidential("client-round-trip");
   new.client_secret_hash = Some(hashed("s3cret")?);
   new.client_secret_hash = Some(hashed("s3cret")?);
   new.jwks = Some(json!({"keys": []}));
   new.jwks = Some(json!({"keys": []}));
+  new.jwks_uri = Some("https://app.example.test/jwks".to_owned());
   new.contacts = Some(json!(["ops@example.test"]));
   new.contacts = Some(json!(["ops@example.test"]));
   new.software_id = Some("sw-1".to_owned());
   new.software_id = Some("sw-1".to_owned());
   new.request_uris = Some(json!(["https://app.example.test/request"]));
   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_id, "client-round-trip");
   assert_eq!(created.client_name, "Test Client");
   assert_eq!(created.client_name, "Test Client");
   assert!(created.active);
   assert!(created.active);
   assert_eq!(created.grant_types, vec!["authorization_code".to_owned()]);
   assert_eq!(created.grant_types, vec!["authorization_code".to_owned()]);
   assert_eq!(created.scope, "api:read api:write");
   assert_eq!(created.scope, "api:read api:write");
   assert_eq!(created.jwks, Some(json!({"keys": []})));
   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()));
   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()));
   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));
   assert!(listed.iter().any(|client| client.id == created.id));
 
 
   // Unknown keys resolve to None, not an error.
   // 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(())
   Ok(())
 }
 }
 
 
@@ -102,23 +210,27 @@ async fn client_repo_insert_and_find_round_trip() -> Result<(), TestError> {
 #[tokio::test]
 #[tokio::test]
 async fn client_repo_deactivated_client_is_hidden() -> Result<(), TestError> {
 async fn client_repo_deactivated_client_is_hidden() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
   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?);
   assert!(deactivate(&pool, "client-gone").await?);
   // A second deactivate changes nothing.
   // A second deactivate changes nothing.
   assert!(!deactivate(&pool, "client-gone").await?);
   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!(matches!(&by_id, Some(client) if !client.active));
   assert!(
   assert!(
-    !list(&pool, false)
+    !list(&pool, &keys, false)
       .await?
       .await?
       .iter()
       .iter()
       .any(|client| client.client_id == "client-gone")
       .any(|client| client.client_id == "client-gone")
   );
   );
   assert!(
   assert!(
-    list(&pool, true)
+    list(&pool, &keys, true)
       .await?
       .await?
       .iter()
       .iter()
       .any(|client| client.client_id == "client-gone")
       .any(|client| client.client_id == "client-gone")
@@ -130,9 +242,10 @@ async fn client_repo_deactivated_client_is_hidden() -> Result<(), TestError> {
 #[tokio::test]
 #[tokio::test]
 async fn client_repo_verify_secret() -> Result<(), TestError> {
 async fn client_repo_verify_secret() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
   let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
   let mut client = confidential("client-secret");
   let mut client = confidential("client-secret");
   client.client_secret_hash = Some(hashed("correct-horse")?);
   client.client_secret_hash = Some(hashed("correct-horse")?);
-  insert(&pool, &client).await?;
+  insert(&pool, &keys, false, &client).await?;
 
 
   assert_eq!(
   assert_eq!(
     verify_secret(&pool, "client-secret", "correct-horse").await?,
     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.
   // A public client has no stored hash, so it cannot be secret-authenticated.
   let mut public = confidential("client-public");
   let mut public = confidential("client-public");
   public.token_endpoint_auth_method = "none".to_owned();
   public.token_endpoint_auth_method = "none".to_owned();
-  insert(&pool, &public).await?;
+  insert(&pool, &keys, false, &public).await?;
   assert_eq!(
   assert_eq!(
     verify_secret(&pool, "client-public", "anything").await?,
     verify_secret(&pool, "client-public", "anything").await?,
     None
     None
@@ -177,9 +290,10 @@ async fn client_repo_verify_secret() -> Result<(), TestError> {
 #[tokio::test]
 #[tokio::test]
 async fn client_repo_update_metadata_allowlist() -> Result<(), TestError> {
 async fn client_repo_update_metadata_allowlist() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
   let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
   let mut new = confidential("client-update");
   let mut new = confidential("client-update");
   new.client_secret_hash = Some(hashed("keep-me")?);
   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 {
   let meta = ClientMetadata {
     client_name: "Renamed".to_owned(),
     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()),
     software_id: Some("sw-2".to_owned()),
     dpop_bound_access_tokens: true,
     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")?;
   let updated = updated.ok_or("update returned None")?;
   assert_eq!(updated.id, created.id);
   assert_eq!(updated.id, created.id);
   assert_eq!(updated.client_name, "Renamed");
   assert_eq!(updated.client_name, "Renamed");
   assert_eq!(updated.scope, "api:admin");
   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!(
   assert_eq!(
     updated.jwks_uri.as_deref(),
     updated.jwks_uri.as_deref(),
     Some("https://app.example.test/jwks")
     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?,
     verify_secret(&pool, "client-update", "keep-me").await?,
     Some(true)
     Some(true)
   );
   );
-  assert!(update_metadata(&pool, "missing", &meta).await?.is_none());
+  assert!(
+    update_metadata(&pool, &keys, false, "missing", &meta)
+      .await?
+      .is_none()
+  );
   Ok(())
   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]
 #[tokio::test]
 async fn client_repo_redirect_and_scope_rules() -> Result<(), TestError> {
 async fn client_repo_redirect_and_scope_rules() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
   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:read", true).await?;
   insert_scope(&pool, "api:disabled", false).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,
     &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?;
   .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.
   // Scope must be registered by the client AND enabled in the catalog.
   assert!(scope_allowed(&pool, "client-rules", "api:read").await?);
   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]
 #[tokio::test]
 async fn client_repo_registration_token_hash() -> Result<(), TestError> {
 async fn client_repo_registration_token_hash() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
   let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
   let mut new = confidential("client-reg");
   let mut new = confidential("client-reg");
   new.registration_access_token_hash = Some("v1.initial".to_owned());
   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!(
   assert_eq!(
     registration_token_hash(&pool, "client-reg")
     registration_token_hash(&pool, "client-reg")
@@ -282,3 +472,243 @@ async fn client_repo_registration_token_hash() -> Result<(), TestError> {
   let _: &Client = &created;
   let _: &Client = &created;
   Ok(())
   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
 //! [`ClientMetadata`] update set, [`NewClient`] insert input, and the
 //! [`TokenEndpointAuthMethod`] allowlist.
 //! [`TokenEndpointAuthMethod`] allowlist.
 
 
+use std::fmt;
+
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 use uuid::Uuid;
 use uuid::Uuid;
 
 
@@ -30,7 +32,12 @@ pub enum ClientRepoError {
 ///
 ///
 /// This is the safe projection: it never contains `client_secret_hash` or
 /// This is the safe projection: it never contains `client_secret_hash` or
 /// `registration_access_token_hash`.
 /// `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 {
 pub struct Client {
   /// Surrogate primary key.
   /// Surrogate primary key.
   pub id: Uuid,
   pub id: Uuid,
@@ -74,6 +81,36 @@ pub struct Client {
   pub updated_at: OffsetDateTime,
   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 {
 impl Client {
   /// Parse this client's registered `token_endpoint_auth_method`.
   /// 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;
 /// This is exactly the column set [`super::update_metadata`] may write;
 /// identity, secret, issue time, active flag, and registration token are
 /// identity, secret, issue time, active flag, and registration token are
 /// deliberately absent so a metadata update can never touch them.
 /// 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 {
 pub struct ClientMetadata {
   /// Human-readable client name.
   /// Human-readable client name.
   pub client_name: String,
   pub client_name: String,
@@ -121,6 +161,29 @@ pub struct ClientMetadata {
   pub dpop_bound_access_tokens: bool,
   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`].
 /// Input for [`super::insert`].
 ///
 ///
 /// Deliberately does not derive `Debug`, since it carries a secret hash and a
 /// 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 mod user_scope_repo;
 
 
 pub use client_repo::{Client, ClientMetadata, NewClient, TokenEndpointAuthMethod};
 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.
 /// 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
 //! 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
 //! `sqlx` macro call, so the schema is checked at compile time and no SQL is
 //! ever interpolated.
 //! 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 sqlx::PgPool;
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 use uuid::Uuid;
 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)]
 #[cfg(test)]
 mod tests;
 mod tests;
 
 
@@ -43,11 +56,14 @@ impl RedirectKind {
 }
 }
 
 
 /// A catalog redirect URI.
 /// 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 {
 pub struct RedirectUri {
   /// Surrogate primary key.
   /// Surrogate primary key.
   pub id: Uuid,
   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,
   pub uri: String,
   /// Stored `kind` discriminator; see [`RedirectUri::kind`].
   /// Stored `kind` discriminator; see [`RedirectUri::kind`].
   pub kind: String,
   pub kind: String,
@@ -63,6 +79,21 @@ pub struct RedirectUri {
   pub updated_at: OffsetDateTime,
   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 {
 impl RedirectUri {
   /// The typed role this URI plays.
   /// The typed role this URI plays.
   #[must_use]
   #[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.
 /// 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
 /// # 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(
 pub async fn upsert(
   pool: &PgPool,
   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)
   .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
 /// # 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,
     include_disabled,
   )
   )
   .fetch_all(pool)
   .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
 /// 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
 /// # 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(
 pub async fn set_enabled(
   pool: &PgPool,
   pool: &PgPool,
+  keys: &KeyRing,
   id: Uuid,
   id: Uuid,
   enabled: bool,
   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 \
     "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,
     id,
     enabled,
     enabled,
   )
   )
   .fetch_optional(pool)
   .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.
 /// Enable a catalog URI. Returns the updated row, or `None` if unknown.
 ///
 ///
 /// # Errors
 /// # 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.
 /// Disable a catalog URI. Returns the updated row, or `None` if unknown.
 ///
 ///
 /// # Errors
 /// # 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.
 /// 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())
   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.
 /// This is the management view; use [`effective_for_client`] for matching.
 ///
 ///
 /// # Errors
 /// # 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(
 pub async fn list_for_client(
   pool: &PgPool,
   pool: &PgPool,
+  keys: &KeyRing,
   client_id: &str,
   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, \
     "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 \
      FROM client_redirect_uris AS cru \
      JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id \
      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,
     client_id,
   )
   )
   .fetch_all(pool)
   .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,
 /// 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)
 /// 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
 /// 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
 /// # 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(
 pub async fn effective_for_client(
   pool: &PgPool,
   pool: &PgPool,
+  keys: &KeyRing,
   client_id: &str,
   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, \
     "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 \
      FROM client_redirect_uris AS cru \
      JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id \
      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,
     client_id,
   )
   )
   .fetch_all(pool)
   .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
 /// Used at logout to re-validate that the session's frozen target is still
 /// registered for the client before redirecting to it.
 /// registered for the client before redirecting to it.
 ///
 ///
 /// # Errors
 /// # 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(
 pub async fn effective_post_logout_for_client(
   pool: &PgPool,
   pool: &PgPool,
+  keys: &KeyRing,
   client_id: &str,
   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 \
      FROM client_redirect_uris AS cru \
      JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id \
      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,
     client_id,
   )
   )
   .fetch_all(pool)
   .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.
 /// 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
 /// # 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(
 pub async fn post_logout_for_client(
   pool: &PgPool,
   pool: &PgPool,
+  keys: &KeyRing,
   client_id: &str,
   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() {
   match uris.as_slice() {
     [only] => Ok(Some(only.clone())),
     [only] => Ok(Some(only.clone())),
     _ => Ok(None),
     _ => Ok(None),

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

@@ -1,23 +1,56 @@
 //! `test(redirect_repo)` — real-Postgres tests for `db::redirect_repo`.
 //! `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 uuid::Uuid;
 
 
 use super::{
 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::client_repo::{NewClient, insert};
 use crate::db::test_support::{TestError, migrated_pool};
 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.
 /// 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(
   insert(
     pool,
     pool,
+    keys,
+    false,
     &NewClient {
     &NewClient {
       id: Uuid::new_v4(),
       id: Uuid::new_v4(),
       client_id: client_id.to_owned(),
       client_id: client_id.to_owned(),
@@ -49,55 +82,66 @@ async fn insert_client(pool: &PgPool, client_id: &str) -> Result<(), TestError>
 #[tokio::test]
 #[tokio::test]
 async fn redirect_repo_catalog_upsert_list_and_enable() -> Result<(), TestError> {
 async fn redirect_repo_catalog_upsert_list_and_enable() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
   let (_db, pool) = migrated_pool().await?;
-  let first = upsert(
+  let keys = test_keys()?;
+  let (first, outcome) = upsert(
     &pool,
     &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?;
   .await?;
+  assert_eq!(outcome, UpsertOutcome::Created);
   assert!(first.enabled);
   assert!(first.enabled);
   assert_eq!(first.description, "admin");
   assert_eq!(first.description, "admin");
 
 
-  let disabled = disable(&pool, first.id)
+  let disabled = disable(&pool, &keys, first.id)
     .await?
     .await?
     .ok_or("disable returned None")?;
     .ok_or("disable returned None")?;
   assert!(!disabled.enabled);
   assert!(!disabled.enabled);
 
 
   // Re-upserting the same URI returns the SAME row and does not re-enable it.
   // Re-upserting the same URI returns the SAME row and does not re-enable it.
-  let again = upsert(
+  let (again, outcome) = upsert(
     &pool,
     &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?;
   .await?;
+  assert_eq!(outcome, UpsertOutcome::Updated);
   assert_eq!(again.id, first.id);
   assert_eq!(again.id, first.id);
   assert!(!again.enabled, "upsert must not resurrect a disabled entry");
   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?
     .await?
     .ok_or("enable returned None")?;
     .ok_or("enable returned None")?;
   assert!(enabled_row.enabled);
   assert!(enabled_row.enabled);
   assert!(
   assert!(
-    set_enabled(&pool, first.id, false)
+    set_enabled(&pool, &keys, first.id, false)
       .await?
       .await?
       .is_some_and(|row| !row.enabled)
       .is_some_and(|row| !row.enabled)
   );
   );
   assert!(
   assert!(
-    set_enabled(&pool, first.id, true)
+    set_enabled(&pool, &keys, first.id, true)
       .await?
       .await?
       .is_some_and(|row| row.enabled)
       .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.len(), 1);
   assert_eq!(enabled[0].id, first.id);
   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);
   assert_eq!(all.len(), 1);
   Ok(())
   Ok(())
 }
 }
@@ -106,21 +150,20 @@ async fn redirect_repo_catalog_upsert_list_and_enable() -> Result<(), TestError>
 #[tokio::test]
 #[tokio::test]
 async fn redirect_repo_delete_only_when_unattached() -> Result<(), TestError> {
 async fn redirect_repo_delete_only_when_unattached() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
   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,
     &pool,
-    Uuid::new_v4(),
-    "https://a.example.test/cb",
-    RedirectKind::Authorize,
-    "",
-    None,
+    &keys,
+    false,
+    &new_uri("https://a.example.test/cb", RedirectKind::Authorize, ""),
   )
   )
   .await?;
   .await?;
 
 
   assert!(attach(&pool, "client-a", uri.id, None).await?);
   assert!(attach(&pool, "client-a", uri.id, None).await?);
   // Attached: deletion is refused (returns false, row survives).
   // Attached: deletion is refused (returns false, row survives).
   assert!(!delete_if_unattached(&pool, uri.id).await?);
   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!(super::detach(&pool, "client-a", uri.id).await?);
   assert!(delete_if_unattached(&pool, 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]
 #[tokio::test]
 async fn redirect_repo_attach_detach_and_effective_set() -> Result<(), TestError> {
 async fn redirect_repo_attach_detach_and_effective_set() -> Result<(), TestError> {
   let (_db, pool) = migrated_pool().await?;
   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,
     &pool,
-    Uuid::new_v4(),
-    "https://b.example.test/live",
-    RedirectKind::Authorize,
-    "",
-    None,
+    &keys,
+    false,
+    &new_uri("https://b.example.test/live", RedirectKind::Authorize, ""),
   )
   )
   .await?;
   .await?;
-  let dead = upsert(
+  let (dead, _) = upsert(
     &pool,
     &pool,
-    Uuid::new_v4(),
-    "https://b.example.test/dead",
-    RedirectKind::Authorize,
-    "",
-    None,
+    &keys,
+    false,
+    &new_uri("https://b.example.test/dead", RedirectKind::Authorize, ""),
   )
   )
   .await?;
   .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?);
   assert!(attach(&pool, "client-b", dead.id, None).await?);
   // Re-attaching is a no-op.
   // Re-attaching is a no-op.
   assert!(!attach(&pool, "client-b", live.id, None).await?);
   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.len(), 1);
   assert_eq!(effective[0].id, live.id);
   assert_eq!(effective[0].id, live.id);
   // Management view still lists the disabled attached URI.
   // 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!(!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_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(())
   Ok(())
 }
 }

+ 132 - 29
src/db/user_repo.rs

@@ -1,8 +1,15 @@
 //! `users` repository (email-as-username, Argon2id, encrypted TOTP).
 //! `users` repository (email-as-username, Argon2id, encrypted TOTP).
 //!
 //!
 //! Admin-provisioned users only; no public sign-up path exists. Email is
 //! 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
 //! The repository never returns [`password_hash`]: [`UserRecord`] omits it and
 //! [`verify_password`] reads it only long enough to run the constant-time
 //! [`verify_password`] reads it only long enough to run the constant-time
@@ -16,8 +23,12 @@ use time::OffsetDateTime;
 use uuid::Uuid;
 use uuid::Uuid;
 
 
 use crate::{
 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,
   error::AppError,
+  keys::KeyRing,
 };
 };
 
 
 /// A user to provision. `password_hash` is an Argon2id PHC string produced by
 /// 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.
 /// 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
 /// `password_changed_at` is set when the account is created ready-to-use and
 /// left `NULL` when the account must change its password first.
 /// left `NULL` when the account must change its password first.
 ///
 ///
 /// # Errors
 /// # 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
     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(),
     Uuid::new_v4(),
-    email,
+    plain,
+    enc,
+    index,
     new_user.password_hash,
     new_user.password_hash,
     new_user.is_admin,
     new_user.is_admin,
     new_user.must_change_password,
     new_user.must_change_password,
   )
   )
   .fetch_one(pool)
   .fetch_one(pool)
   .await?;
   .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.
 /// 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
 /// # 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(
 pub async fn find_by_email(
   pool: &sqlx::PgPool,
   pool: &sqlx::PgPool,
+  keys: &KeyRing,
   email: &str,
   email: &str,
 ) -> Result<Option<UserRecord>, AppError> {
 ) -> 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
               disabled, totp_enabled, password_changed_at
          FROM users
          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)
   .fetch_optional(pool)
   .await?;
   .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.
 /// 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
 /// # 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(
 pub async fn verify_password(
   pool: &sqlx::PgPool,
   pool: &sqlx::PgPool,
+  keys: &KeyRing,
   email: &str,
   email: &str,
   password: &str,
   password: &str,
 ) -> Result<PasswordVerdict, AppError> {
 ) -> Result<PasswordVerdict, AppError> {
-  let email = normalize_email(email);
+  let canonical = normalize_email(email);
+  let candidates = blind_index_candidates(keys, &canonical);
   let row = sqlx::query!(
   let row = sqlx::query!(
-    r#"SELECT password_hash, disabled
+    r#"SELECT password_hash, disabled, email_enc
          FROM users
          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)
   .fetch_optional(pool)
   .await?;
   .await?;
   let Some(row) = row else {
   let Some(row) = row else {
     return Ok(PasswordVerdict::Unknown);
     return Ok(PasswordVerdict::Unknown);
   };
   };
+  if let Some(blob) = row.email_enc.as_deref() {
+    open_text(keys, blob).map_err(|_| AppError::PiiDecrypt)?;
+  }
   if row.disabled {
   if row.disabled {
     return Ok(PasswordVerdict::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;
 mod totp;
 
 
 pub use totp::{
 pub use totp::{

+ 96 - 3
src/error.rs

@@ -187,6 +187,25 @@ pub enum AppError {
     #[source]
     #[source]
     source: Box<dyn StdError + Send + Sync>,
     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 {
 impl AppError {
@@ -339,11 +358,16 @@ impl AppError {
   }
   }
 
 
   /// The RFC error code this error renders as. Internal failures map to
   /// 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]
   #[must_use]
   pub fn code(&self) -> OAuthErrorCode {
   pub fn code(&self) -> OAuthErrorCode {
     match self {
     match self {
       Self::OAuth { code, .. } => *code,
       Self::OAuth { code, .. } => *code,
+      Self::DuplicateEmail => OAuthErrorCode::InvalidRequest,
+      Self::DuplicateClientIdentity => OAuthErrorCode::InvalidClientMetadata,
+      Self::PiiDecrypt => OAuthErrorCode::TemporarilyUnavailable,
       Self::Internal { .. } => OAuthErrorCode::ServerError,
       Self::Internal { .. } => OAuthErrorCode::ServerError,
     }
     }
   }
   }
@@ -355,6 +379,17 @@ impl AppError {
       Self::OAuth {
       Self::OAuth {
         code, client_auth, ..
         code, client_auth, ..
       } => status_for(*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,
       Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
     }
     }
   }
   }
@@ -364,6 +399,9 @@ impl AppError {
   pub fn description(&self) -> &str {
   pub fn description(&self) -> &str {
     match self {
     match self {
       Self::OAuth { description, .. } => description,
       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(),
       Self::Internal { .. } => OAuthErrorCode::ServerError.default_description(),
     }
     }
   }
   }
@@ -396,6 +434,22 @@ impl IntoResponse for AppError {
         description,
         description,
         client_auth,
         client_auth,
       } => (code, 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.
       // Mask every internal failure: generic code + generic description.
       Self::Internal { .. } => (
       Self::Internal { .. } => (
         OAuthErrorCode::ServerError,
         OAuthErrorCode::ServerError,
@@ -442,18 +496,57 @@ impl IntoResponse for AppError {
 impl From<sqlx::Error> for AppError {
 impl From<sqlx::Error> for AppError {
   /// Map a database error to a client-safe error: connectivity failures
   /// Map a database error to a client-safe error: connectivity failures
   /// (pool timeout/close, transport I/O) render `503 temporarily_unavailable`
   /// (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 {
   fn from(source: sqlx::Error) -> Self {
     match source {
     match source {
       sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed | sqlx::Error::Io(_) => {
       sqlx::Error::PoolTimedOut | sqlx::Error::PoolClosed | sqlx::Error::Io(_) => {
         Self::temporarily_unavailable()
         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),
       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)]
 #[cfg(test)]
 mod tests {
 mod tests {
   use super::*;
   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)
   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
 //! (newest first, bounded retry across retained ids). Opaque tokens are stored
 //! under `v<N>` key ids and verification retries across retained key ids,
 //! 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
 //! 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.
   /// No TOTP-encryption key material was supplied.
   #[error("TOTP encryption key material is required")]
   #[error("TOTP encryption key material is required")]
   MissingTotpKey,
   MissingTotpKey,
+  /// No PII field-encryption key material was supplied.
+  #[error("PII encryption key material is required")]
+  MissingPiiKey,
   /// A key id was empty.
   /// A key id was empty.
   #[error("key id must not be empty")]
   #[error("key id must not be empty")]
   EmptyKeyId,
   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.
 /// The `Debug` impl never prints key material.
 #[derive(Clone)]
 #[derive(Clone)]
 pub struct KeyRing {
 pub struct KeyRing {
   peppers: Vec<VersionedSecret>,
   peppers: Vec<VersionedSecret>,
   totp_keys: Vec<VersionedSecret>,
   totp_keys: Vec<VersionedSecret>,
+  pii_keys: Vec<VersionedSecret>,
 }
 }
 
 
 impl KeyRing {
 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.
   /// Each chain must be non-empty and free of duplicate ids.
   pub fn from_parts(
   pub fn from_parts(
     peppers: Vec<(KeyId, SecretString)>,
     peppers: Vec<(KeyId, SecretString)>,
     totp_keys: Vec<(KeyId, SecretString)>,
     totp_keys: Vec<(KeyId, SecretString)>,
+    pii_keys: Vec<(KeyId, SecretString)>,
   ) -> Result<Self, KeyRingError> {
   ) -> Result<Self, KeyRingError> {
     Ok(Self {
     Ok(Self {
       peppers: build_chain(peppers, KeyRingError::MissingTokenPepper)?,
       peppers: build_chain(peppers, KeyRingError::MissingTokenPepper)?,
       totp_keys: build_chain(totp_keys, KeyRingError::MissingTotpKey)?,
       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)> {
   pub fn totp_keys_newest_first(&self) -> impl Iterator<Item = (&KeyId, &SecretString)> {
     self.totp_keys.iter().map(|k| (k.id(), k.secret()))
     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 {
 impl fmt::Debug for KeyRing {
   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
   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 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 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")
     f.debug_struct("KeyRing")
       .field("token_pepper_ids", &pepper_ids)
       .field("token_pepper_ids", &pepper_ids)
       .field("totp_key_ids", &totp_ids)
       .field("totp_key_ids", &totp_ids)
+      .field("pii_key_ids", &pii_ids)
       .finish()
       .finish()
   }
   }
 }
 }
@@ -243,25 +269,58 @@ mod tests {
 
 
   #[test]
   #[test]
   fn keyring_requires_a_token_pepper() -> Result<(), KeyRingError> {
   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)));
     assert!(matches!(result, Err(KeyRingError::MissingTokenPepper)));
     Ok(())
     Ok(())
   }
   }
 
 
   #[test]
   #[test]
   fn keyring_requires_a_totp_key() -> Result<(), KeyRingError> {
   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)));
     assert!(matches!(result, Err(KeyRingError::MissingTotpKey)));
     Ok(())
     Ok(())
   }
   }
 
 
   #[test]
   #[test]
-  fn keyring_rejects_duplicate_ids() {
+  fn keyring_requires_a_pii_key() -> Result<(), KeyRingError> {
     let result = KeyRing::from_parts(
     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", "a"), pepper("v1", "b")],
       vec![pepper("v1", "c")],
       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]
   #[test]
@@ -269,6 +328,7 @@ mod tests {
     let ring = KeyRing::from_parts(
     let ring = KeyRing::from_parts(
       vec![pepper("v2", "new"), pepper("v1", "old")],
       vec![pepper("v2", "new"), pepper("v1", "old")],
       vec![pepper("v1", "totp")],
       vec![pepper("v1", "totp")],
+      vec![pepper("v1", "pii")],
     )?;
     )?;
     let active = ring.active_pepper();
     let active = ring.active_pepper();
     assert!(matches!(active, Some((id, _)) if id.as_str() == "v2"));
     assert!(matches!(active, Some((id, _)) if id.as_str() == "v2"));
@@ -278,6 +338,34 @@ mod tests {
     Ok(())
     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]
   #[test]
   fn keygen_purpose_documents_no_as_signing_key() {
   fn keygen_purpose_documents_no_as_signing_key() {
     assert!(KEYGEN_PURPOSE.contains("never an 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.
     /// Newly registered client identifier.
     client_id: &'a str,
     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<'_> {
 impl AuditEvent<'_> {
@@ -84,6 +92,7 @@ impl AuditEvent<'_> {
       Self::TokenRevoke { .. } => "token.revoke",
       Self::TokenRevoke { .. } => "token.revoke",
       Self::RefreshReuseDetected { .. } => "token.refresh_reuse_detected",
       Self::RefreshReuseDetected { .. } => "token.refresh_reuse_detected",
       Self::DynamicClientRegistered { .. } => "dcr.register",
       Self::DynamicClientRegistered { .. } => "dcr.register",
+      Self::PiiDecryptFailure { .. } => "pii.decrypt_failure",
     }
     }
   }
   }
 
 
@@ -170,6 +179,13 @@ impl AuditEvent<'_> {
         client_id = %client_id,
         client_id = %client_id,
         "dynamic client registered"
         "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(),
       .name(),
       "dcr.register"
       "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"},
     "redis": {"url": redis_url, "durability": "waitaof"},
     "token_pepper": "ratelimit-test-pepper-0123456789abcdef",
     "token_pepper": "ratelimit-test-pepper-0123456789abcdef",
     "totp_encryption_key": "ratelimit-test-totp-key-0123456789abcdef",
     "totp_encryption_key": "ratelimit-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "ratelimit-test-pii-key-0123456789abcdef",
     "rate_limit": rate_limit,
     "rate_limit": rate_limit,
   });
   });
   let source = ::config::Config::try_from(&fixture)?;
   let source = ::config::Config::try_from(&fixture)?;
@@ -90,6 +91,7 @@ fn build_state(
     redis: RedisConn::Single(manager),
     redis: RedisConn::Single(manager),
     config,
     config,
     keys,
     keys,
+    transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
     clock: Clock::system(),
     clock: Clock::system(),
   }
   }
 }
 }

+ 22 - 8
src/protocol/authorize.rs

@@ -37,6 +37,8 @@ use crate::{
 pub struct AuthorizeInput<'a> {
 pub struct AuthorizeInput<'a> {
   /// Postgres pool for the client, redirect, and scope catalog reads.
   /// Postgres pool for the client, redirect, and scope catalog reads.
   pub pool: &'a PgPool,
   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.
   /// Redis connection for PAR `request_uri` consumption.
   pub conn: &'a mut RedisConn,
   pub conn: &'a mut RedisConn,
   /// Redis store handle backing the PAR lookup.
   /// 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).
   /// Render the failure as an HTTP response (page or redirect).
   #[must_use]
   #[must_use]
   pub fn into_response(self) -> Response {
   pub fn into_response(self) -> Response {
@@ -126,9 +142,8 @@ impl AuthorizeFailure {
 pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, AuthorizeFailure> {
 pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, AuthorizeFailure> {
   let client_id = present(input.query.get("client_id"))
   let client_id = present(input.query.get("client_id"))
     .ok_or_else(|| page(AppError::invalid_request("client_id is required")))?;
     .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
     .await
-    .map_err(AppError::from)
     .map_err(AuthorizeFailure::Page)?
     .map_err(AuthorizeFailure::Page)?
     .ok_or_else(|| page(AppError::invalid_client_non_basic()))?;
     .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"))
   let redirect_uri = present(merged.get("redirect_uri"))
     .ok_or_else(|| page(AppError::invalid_request("redirect_uri is required")))?;
     .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
     .await
-    .map_err(AppError::from)
     .map_err(AuthorizeFailure::Page)?;
     .map_err(AuthorizeFailure::Page)?;
   let registered: Vec<&str> = effective.iter().map(|entry| entry.uri.as_str()).collect();
   let registered: Vec<&str> = effective.iter().map(|entry| entry.uri.as_str()).collect();
   match redirect_match::match_redirect_uri(&registered, redirect_uri) {
   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()))?;
   verify_method(S256_METHOD).map_err(|_| redirect_err(AppError::server_error()))?;
   check_challenge_shape(challenge).map_err(redirect_err)?;
   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 {
   Ok(ValidatedRequest {
     client_id: client.client_id,
     client_id: client.client_id,

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

@@ -10,10 +10,12 @@ use crate::{
   config::DurabilityMode,
   config::DurabilityMode,
   crypto::pkce::challenge_from_verifier,
   crypto::pkce::challenge_from_verifier,
   db::{client_repo::NewClient, redirect_repo, scope_repo, test_support::migrated_pool},
   db::{client_repo::NewClient, redirect_repo, scope_repo, test_support::migrated_pool},
-  error::OAuthErrorCode,
+  error::{AppError, OAuthErrorCode},
+  keys::{KeyId, KeyRing},
   state::RedisConn,
   state::RedisConn,
   store::Store,
   store::Store,
 };
 };
+use secrecy::SecretString;
 
 
 /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
 /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
 type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
 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.
 /// 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,
     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?;
   .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?;
   scope_repo::upsert(pool, "api:read", "read api").await?;
   Ok(())
   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.
 /// A valid plain-query authorize map for the provisioned client.
 fn valid_query(client_id: &str, challenge: &str) -> HashMap<String, String> {
 fn valid_query(client_id: &str, challenge: &str) -> HashMap<String, String> {
   query(&[
   query(&[
@@ -114,7 +148,8 @@ fn valid_query(client_id: &str, challenge: &str) -> HashMap<String, String> {
 #[tokio::test]
 #[tokio::test]
 async fn authorize_validate_public_client_plain_query_proceeds() -> TestResult {
 async fn authorize_validate_public_client_plain_query_proceeds() -> TestResult {
   let (_db, pool) = migrated_pool().await?;
   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 challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
   let params = valid_query("authz-public", &challenge);
   let params = valid_query("authz-public", &challenge);
   let mut conn = offline_redis()?;
   let mut conn = offline_redis()?;
@@ -122,6 +157,7 @@ async fn authorize_validate_public_client_plain_query_proceeds() -> TestResult {
 
 
   let validated = validate(AuthorizeInput {
   let validated = validate(AuthorizeInput {
     pool: &pool,
     pool: &pool,
+    keys: &keys,
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
@@ -142,6 +178,7 @@ async fn authorize_validate_public_client_plain_query_proceeds() -> TestResult {
 #[tokio::test]
 #[tokio::test]
 async fn authorize_validate_unknown_client_shows_page() -> TestResult {
 async fn authorize_validate_unknown_client_shows_page() -> TestResult {
   let (_db, pool) = migrated_pool().await?;
   let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
   let mut params = valid_query("authz-ghost", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
   let mut params = valid_query("authz-ghost", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
   params.remove("scope");
   params.remove("scope");
   let mut conn = offline_redis()?;
   let mut conn = offline_redis()?;
@@ -149,6 +186,7 @@ async fn authorize_validate_unknown_client_shows_page() -> TestResult {
 
 
   match validate(AuthorizeInput {
   match validate(AuthorizeInput {
     pool: &pool,
     pool: &pool,
+    keys: &keys,
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
@@ -170,7 +208,8 @@ async fn authorize_validate_unknown_client_shows_page() -> TestResult {
 #[tokio::test]
 #[tokio::test]
 async fn authorize_validate_unregistered_redirect_shows_page() -> TestResult {
 async fn authorize_validate_unregistered_redirect_shows_page() -> TestResult {
   let (_db, pool) = migrated_pool().await?;
   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 challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
   let mut params = valid_query("authz-redirect", &challenge);
   let mut params = valid_query("authz-redirect", &challenge);
   params.insert(
   params.insert(
@@ -182,6 +221,7 @@ async fn authorize_validate_unregistered_redirect_shows_page() -> TestResult {
 
 
   match validate(AuthorizeInput {
   match validate(AuthorizeInput {
     pool: &pool,
     pool: &pool,
+    keys: &keys,
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
@@ -204,7 +244,8 @@ async fn authorize_validate_unregistered_redirect_shows_page() -> TestResult {
 #[tokio::test]
 #[tokio::test]
 async fn authorize_validate_token_response_type_redirects() -> TestResult {
 async fn authorize_validate_token_response_type_redirects() -> TestResult {
   let (_db, pool) = migrated_pool().await?;
   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 challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
   let mut params = valid_query("authz-rt", &challenge);
   let mut params = valid_query("authz-rt", &challenge);
   params.insert("response_type".to_owned(), "token".to_owned());
   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 {
   match validate(AuthorizeInput {
     pool: &pool,
     pool: &pool,
+    keys: &keys,
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
@@ -254,7 +296,8 @@ async fn authorize_validate_token_response_type_redirects() -> TestResult {
 #[tokio::test]
 #[tokio::test]
 async fn authorize_validate_unknown_scope_redirects() -> TestResult {
 async fn authorize_validate_unknown_scope_redirects() -> TestResult {
   let (_db, pool) = migrated_pool().await?;
   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 challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
   let mut params = valid_query("authz-scope", &challenge);
   let mut params = valid_query("authz-scope", &challenge);
   params.insert("scope".to_owned(), "admin:all".to_owned());
   params.insert("scope".to_owned(), "admin:all".to_owned());
@@ -263,6 +306,7 @@ async fn authorize_validate_unknown_scope_redirects() -> TestResult {
 
 
   match validate(AuthorizeInput {
   match validate(AuthorizeInput {
     pool: &pool,
     pool: &pool,
+    keys: &keys,
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
@@ -305,7 +349,8 @@ async fn authorize_validate_unknown_scope_redirects() -> TestResult {
 #[tokio::test]
 #[tokio::test]
 async fn authorize_validate_confidential_plain_query_rejected() -> TestResult {
 async fn authorize_validate_confidential_plain_query_rejected() -> TestResult {
   let (_db, pool) = migrated_pool().await?;
   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 challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
   let params = valid_query("authz-conf", &challenge);
   let params = valid_query("authz-conf", &challenge);
   let mut conn = offline_redis()?;
   let mut conn = offline_redis()?;
@@ -313,6 +358,7 @@ async fn authorize_validate_confidential_plain_query_rejected() -> TestResult {
 
 
   match validate(AuthorizeInput {
   match validate(AuthorizeInput {
     pool: &pool,
     pool: &pool,
+    keys: &keys,
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     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`).
 /// A missing PKCE challenge redirects with `invalid_request` (+ `state`).
 #[tokio::test]
 #[tokio::test]
 async fn authorize_validate_missing_challenge_redirects() -> TestResult {
 async fn authorize_validate_missing_challenge_redirects() -> TestResult {
   let (_db, pool) = migrated_pool().await?;
   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");
   let mut params = valid_query("authz-pkce", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
   params.remove("code_challenge");
   params.remove("code_challenge");
   params.remove("code_challenge_method");
   params.remove("code_challenge_method");
@@ -344,6 +519,7 @@ async fn authorize_validate_missing_challenge_redirects() -> TestResult {
 
 
   match validate(AuthorizeInput {
   match validate(AuthorizeInput {
     pool: &pool,
     pool: &pool,
+    keys: &keys,
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,

+ 13 - 8
src/protocol/client_auth.rs

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

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

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

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

@@ -5,6 +5,7 @@ use super::{
   insert_client, stores_of,
   insert_client, stores_of,
 };
 };
 use crate::db::TokenEndpointAuthMethod;
 use crate::db::TokenEndpointAuthMethod;
+use crate::error::{AppError, OAuthErrorCode};
 use crate::protocol::client_auth::assertion::validate_assertion;
 use crate::protocol::client_auth::assertion::validate_assertion;
 use crate::protocol::client_auth::authenticate_client;
 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())),
     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(
   Ok(KeyRing::from_parts(
     vec![(KeyId::new("v1"), SecretString::from("code-issue-pepper"))],
     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-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>,
   pub dpop_bound_access_tokens: Option<bool>,
   /// Contact addresses, stored verbatim.
   /// Contact addresses, stored verbatim.
   pub contacts: Option<serde_json::Value>,
   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.
   /// Software identifier, stored verbatim.
   pub software_id: Option<String>,
   pub software_id: Option<String>,
 }
 }
@@ -161,6 +163,7 @@ struct ValidatedRegistration {
   subject_dn: Option<String>,
   subject_dn: Option<String>,
   dpop_bound: bool,
   dpop_bound: bool,
   contacts: Option<serde_json::Value>,
   contacts: Option<serde_json::Value>,
+  request_uris: Option<serde_json::Value>,
   software_id: Option<String>,
   software_id: Option<String>,
   redirect_uris: Vec<String>,
   redirect_uris: Vec<String>,
 }
 }
@@ -243,13 +246,19 @@ pub async fn register_client(
     jwks_uri: validated.jwks_uri.clone(),
     jwks_uri: validated.jwks_uri.clone(),
     tls_client_auth_subject_dn: validated.subject_dn.clone(),
     tls_client_auth_subject_dn: validated.subject_dn.clone(),
     tls_client_auth_cert_thumbprint: None,
     tls_client_auth_cert_thumbprint: None,
-    request_uris: None,
+    request_uris: validated.request_uris.clone(),
     contacts: validated.contacts.clone(),
     contacts: validated.contacts.clone(),
     software_id: validated.software_id.clone(),
     software_id: validated.software_id.clone(),
     dpop_bound_access_tokens: validated.dpop_bound,
     dpop_bound_access_tokens: validated.dpop_bound,
     registration_access_token_hash: Some(registration_hash),
     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 {
   for id in &redirect_ids {
     redirect_repo::attach(stores.pool, &client_id, *id, None).await?;
     redirect_repo::attach(stores.pool, &client_id, *id, None).await?;
@@ -445,6 +454,7 @@ async fn validate_metadata(
     subject_dn,
     subject_dn,
     dpop_bound,
     dpop_bound,
     contacts: request.contacts.clone(),
     contacts: request.contacts.clone(),
+    request_uris: request.request_uris.clone(),
     software_id: request.software_id.clone(),
     software_id: request.software_id.clone(),
     redirect_uris,
     redirect_uris,
   })
   })
@@ -479,7 +489,7 @@ async fn resolve_redirects(
   config: &Config,
   config: &Config,
   redirect_uris: &[String],
   redirect_uris: &[String],
 ) -> Result<Vec<Uuid>, AppError> {
 ) -> 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());
   let mut ids = Vec::with_capacity(redirect_uris.len());
   for uri in redirect_uris {
   for uri in redirect_uris {
     if let Some(hit) = catalog.iter().find(|row| row.uri == *uri) {
     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,
       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?;
     .await?;
     if !row.enabled {
     if !row.enabled {

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

@@ -234,10 +234,9 @@ pub async fn read_client(
     registration_access_token,
     registration_access_token,
   )
   )
   .await?;
   .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(
   Ok(respond(
     &row,
     &row,
     effective.into_iter().map(|uri| uri.uri).collect(),
     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
 /// Load an active client row; a client that vanished after authorization
 /// reads as `401` (fail closed, no oracle).
 /// 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())
   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"},
     "redis": {"url": redis_url, "durability": "waitaof"},
     "token_pepper": "dcr-test-pepper-0123456789abcdef",
     "token_pepper": "dcr-test-pepper-0123456789abcdef",
     "totp_encryption_key": "dcr-test-totp-key-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},
     "argon2": {"memory_kib": 8192, "iterations": 1, "parallelism": 1},
     "allow_open_registration": false,
     "allow_open_registration": false,
     "dcr_redirect_policy": policy,
     "dcr_redirect_policy": policy,
@@ -166,14 +167,22 @@ pub(super) fn manage_parts(harness: &mut Harness) -> (ManageStores<'_>, ManageCo
 }
 }
 
 
 /// Seed one enabled catalog redirect URI.
 /// 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(
   redirect_repo::upsert(
     pool,
     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?;
   .await?;
   Ok(())
   Ok(())
@@ -219,6 +228,7 @@ fn dpop_request(redirect_uri: &str, scope: Option<&str>) -> RegisterRequest {
     tls_client_auth_subject_dn: None,
     tls_client_auth_subject_dn: None,
     dpop_bound_access_tokens: Some(true),
     dpop_bound_access_tokens: Some(true),
     contacts: None,
     contacts: None,
+    request_uris: None,
     software_id: None,
     software_id: None,
   }
   }
 }
 }
@@ -229,7 +239,7 @@ pub(super) async fn register(
   redirect_uri: &str,
   redirect_uri: &str,
   scope: Option<&str>,
   scope: Option<&str>,
 ) -> Result<Registered, TestError> {
 ) -> 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 {
   if let Some(name) = scope {
     seed_scope(&harness.pool, name).await?;
     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`.
 /// Assert a management failure is `401 invalid_client`.
 pub(super) fn expect_unauthorized(error: ManageError) -> TestResult {
 pub(super) fn expect_unauthorized(error: ManageError) -> TestResult {
   match error {
   match error {

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

@@ -76,6 +76,55 @@ async fn dcr_manage_read_returns_metadata() -> TestResult {
   Ok(())
   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]
 #[tokio::test]
 async fn dcr_manage_read_missing_token_401() -> TestResult {
 async fn dcr_manage_read_missing_token_401() -> TestResult {
   let (_guard, mut harness) = harness("catalog").await?;
   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,
     tls_client_auth_subject_dn: None,
     dpop_bound_access_tokens: Some(true),
     dpop_bound_access_tokens: Some(true),
     contacts: None,
     contacts: None,
+    request_uris: None,
     software_id: None,
     software_id: None,
   };
   };
   let (mut stores, ctx) = manage_parts(&mut harness);
   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,
     tls_client_auth_subject_dn: None,
     dpop_bound_access_tokens: Some(true),
     dpop_bound_access_tokens: Some(true),
     contacts: None,
     contacts: None,
+    request_uris: None,
     software_id: None,
     software_id: None,
   };
   };
 
 
@@ -70,7 +71,9 @@ async fn dcr_manage_cross_client_forbidden() -> TestResult {
     Err(error) => expect_forbidden(error)?,
     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() {
   if row.is_none() {
     return Err(TestError::Unexpected(
     return Err(TestError::Unexpected(
       "victim client changed by cross-client calls".to_owned(),
       "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:?}")))?;
     .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(
     return Err(TestError::Unexpected(
       "deleted client still active".to_owned(),
       "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
   let kept = listed
     .iter()
     .iter()
     .find(|row| row.client_id == registered.client_id)
     .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::read_client;
 use super::support::{
 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 super::update::update_client;
 use crate::db::{client_repo, redirect_repo};
 use crate::db::{client_repo, redirect_repo};
@@ -25,6 +25,7 @@ fn put_request(
     tls_client_auth_subject_dn: None,
     tls_client_auth_subject_dn: None,
     dpop_bound_access_tokens: Some(true),
     dpop_bound_access_tokens: Some(true),
     contacts: None,
     contacts: None,
+    request_uris: None,
     software_id: None,
     software_id: None,
   }
   }
 }
 }
@@ -33,13 +34,22 @@ fn put_request(
 async fn dcr_manage_update_full_replace_rotates() -> TestResult {
 async fn dcr_manage_update_full_replace_rotates() -> TestResult {
   let (_guard, mut harness) = harness("catalog").await?;
   let (_guard, mut harness) = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/cb", Some("read")).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?;
   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(
   let request = put_request(
     vec!["https://app.example.test/other".to_owned()],
     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 {
 async fn dcr_manage_update_replaces_redirect_attachments() -> TestResult {
   let (_guard, mut harness) = harness("catalog").await?;
   let (_guard, mut harness) = harness("catalog").await?;
   let registered = register(&mut harness, "https://app.example.test/a", None).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(
   let request = put_request(
     vec![
     vec![
@@ -174,7 +194,9 @@ async fn dcr_manage_update_replaces_redirect_attachments() -> TestResult {
       .ok_or_else(|| TestError::Unexpected("missing rotated token".to_owned()))?
       .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();
   let uris: Vec<String> = effective.into_iter().map(|row| row.uri).collect();
   if uris
   if uris
     != vec![
     != vec![
@@ -188,6 +210,7 @@ async fn dcr_manage_update_replaces_redirect_attachments() -> TestResult {
   }
   }
   if client_repo::redirect_uri_allowed(
   if client_repo::redirect_uri_allowed(
     &harness.pool,
     &harness.pool,
+    &harness.config.keys,
     &registered.client_id,
     &registered.client_id,
     "https://app.example.test/a",
     "https://app.example.test/a",
   )
   )
@@ -241,3 +264,104 @@ async fn dcr_manage_update_omitted_fields_clear() -> TestResult {
   }
   }
   Ok(())
   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(),
     jwks_uri: validated.jwks_uri.clone(),
     tls_client_auth_subject_dn: validated.subject_dn.clone(),
     tls_client_auth_subject_dn: validated.subject_dn.clone(),
     tls_client_auth_cert_thumbprint: None,
     tls_client_auth_cert_thumbprint: None,
-    request_uris: None,
+    request_uris: validated.request_uris.clone(),
     contacts: validated.contacts.clone(),
     contacts: validated.contacts.clone(),
     software_id: validated.software_id.clone(),
     software_id: validated.software_id.clone(),
     dpop_bound_access_tokens: validated.dpop_bound,
     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)
   redirect_repo::detach_all(stores.pool, client_id)
     .await
     .await
@@ -77,10 +82,9 @@ pub async fn update_client(
   let rotated_secret =
   let rotated_secret =
     rotate_secret_if_configured(stores, ctx, client_id, validated.method).await?;
     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(
   Ok(respond(
     &row,
     &row,
     effective.into_iter().map(|uri| uri.uri).collect(),
     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"},
     "redis": {"url": redis_url, "durability": "waitaof"},
     "token_pepper": "dcr-test-pepper-0123456789abcdef",
     "token_pepper": "dcr-test-pepper-0123456789abcdef",
     "totp_encryption_key": "dcr-test-totp-key-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},
     "argon2": {"memory_kib": 8192, "iterations": 1, "parallelism": 1},
     "allow_open_registration": open,
     "allow_open_registration": open,
     "dcr_redirect_policy": policy,
     "dcr_redirect_policy": policy,
@@ -150,14 +151,18 @@ fn parts_of(harness: &mut Harness) -> (RegisterStores<'_>, RegisterContext<'_>)
 }
 }
 
 
 /// Seed one enabled catalog redirect URI.
 /// 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(
   redirect_repo::upsert(
     pool,
     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?;
   .await?;
   Ok(())
   Ok(())
@@ -204,6 +209,7 @@ fn dpop_request(redirect_uri: &str, scope: Option<&str>) -> RegisterRequest {
     tls_client_auth_subject_dn: None,
     tls_client_auth_subject_dn: None,
     dpop_bound_access_tokens: Some(true),
     dpop_bound_access_tokens: Some(true),
     contacts: None,
     contacts: None,
+    request_uris: None,
     software_id: None,
     software_id: None,
   }
   }
 }
 }
@@ -228,7 +234,12 @@ fn expect_error(error: AppError, code: OAuthErrorCode, status: u16) -> TestResul
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_valid_201() -> TestResult {
 async fn dcr_register_valid_201() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
   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?;
   seed_scope(&harness.pool, "read").await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let request = dpop_request("https://app.example.test/cb", Some("read"));
   let request = dpop_request("https://app.example.test/cb", Some("read"));
@@ -310,6 +321,168 @@ async fn dcr_register_valid_201() -> TestResult {
   Ok(())
   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]
 #[tokio::test]
 async fn dcr_register_missing_iat_401() -> TestResult {
 async fn dcr_register_missing_iat_401() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
   let (_guard, mut harness) = harness(false, "catalog").await?;
@@ -353,7 +526,12 @@ async fn dcr_register_bad_redirect_rejected() -> TestResult {
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_bad_auth_method_rejected() -> TestResult {
 async fn dcr_register_bad_auth_method_rejected() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
   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 iat = issue_iat(&mut harness, 3).await?;
   let mut request = dpop_request("https://app.example.test/cb", None);
   let mut request = dpop_request("https://app.example.test/cb", None);
   request.token_endpoint_auth_method = Some("magic_auth".to_owned());
   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]
 #[tokio::test]
 async fn dcr_register_bad_grant_rejected() -> TestResult {
 async fn dcr_register_bad_grant_rejected() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
   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 iat = issue_iat(&mut harness, 3).await?;
   let mut request = dpop_request("https://app.example.test/cb", None);
   let mut request = dpop_request("https://app.example.test/cb", None);
   request.grant_types = Some(vec!["client_credentials".to_owned()]);
   request.grant_types = Some(vec!["client_credentials".to_owned()]);
@@ -385,7 +568,12 @@ async fn dcr_register_bad_grant_rejected() -> TestResult {
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_bad_scope_rejected() -> TestResult {
 async fn dcr_register_bad_scope_rejected() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
   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?;
   seed_scope(&harness.pool, "read").await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let iat = issue_iat(&mut harness, 3).await?;
   let request = dpop_request("https://app.example.test/cb", Some("admin"));
   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]
 #[tokio::test]
 async fn dcr_register_confidential_without_binding_rejected() -> TestResult {
 async fn dcr_register_confidential_without_binding_rejected() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
   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 iat = issue_iat(&mut harness, 5).await?;
 
 
   let mut bare_secret = dpop_request("https://app.example.test/cb", None);
   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,
     tls_client_auth_subject_dn: None,
     dpop_bound_access_tokens: None,
     dpop_bound_access_tokens: None,
     contacts: None,
     contacts: None,
+    request_uris: None,
     software_id: None,
     software_id: None,
   };
   };
   let response = {
   let response = {
@@ -460,7 +654,9 @@ async fn dcr_register_open_anonymous_201() -> TestResult {
       "public client was issued a secret".to_owned(),
       "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" {
   if effective.len() != 1 || effective[0].uri != "http://127.0.0.1:6420/cb" {
     return Err(TestError::Unexpected(format!(
     return Err(TestError::Unexpected(format!(
       "loopback URI not attached: {:?}",
       "loopback URI not attached: {:?}",
@@ -473,7 +669,12 @@ async fn dcr_register_open_anonymous_201() -> TestResult {
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_mtls_client_201() -> TestResult {
 async fn dcr_register_mtls_client_201() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
   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 iat = issue_iat(&mut harness, 3).await?;
   let request = RegisterRequest {
   let request = RegisterRequest {
     redirect_uris: Some(vec!["https://app.example.test/cb".to_owned()]),
     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()),
     tls_client_auth_subject_dn: Some("CN=mtls-client, O=Example".to_owned()),
     dpop_bound_access_tokens: None,
     dpop_bound_access_tokens: None,
     contacts: None,
     contacts: None,
+    request_uris: None,
     software_id: None,
     software_id: None,
   };
   };
   let response = {
   let response = {
@@ -501,9 +703,10 @@ async fn dcr_register_mtls_client_201() -> TestResult {
       "mTLS client was issued a secret".to_owned(),
       "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") {
   if row.tls_client_auth_subject_dn.as_deref() != Some("CN=mtls-client, O=Example") {
     return Err(TestError::Unexpected(format!(
     return Err(TestError::Unexpected(format!(
       "subject DN not persisted: {:?}",
       "subject DN not persisted: {:?}",
@@ -516,7 +719,12 @@ async fn dcr_register_mtls_client_201() -> TestResult {
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_jwks_xor_rejected() -> TestResult {
 async fn dcr_register_jwks_xor_rejected() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
   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 iat = issue_iat(&mut harness, 3).await?;
   let mut request = dpop_request("https://app.example.test/cb", None);
   let mut request = dpop_request("https://app.example.test/cb", None);
   request.jwks = Some(serde_json::json!({"keys": []}));
   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()
       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?
     .await?
     .ok_or_else(unknown_client)?;
     .ok_or_else(unknown_client)?;
   let method = client.auth_method().map_err(|_| unknown_client())?;
   let method = client.auth_method().map_err(|_| unknown_client())?;
@@ -292,7 +292,7 @@ pub async fn authorize_device(
     }
     }
     TokenEndpointAuthMethod::TlsClientAuth | TokenEndpointAuthMethod::SelfSignedTlsClientAuth => {
     TokenEndpointAuthMethod::TlsClientAuth | TokenEndpointAuthMethod::SelfSignedTlsClientAuth => {
       let leaf = inputs.peer_certs.and_then(|chain| chain.chain().first());
       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
         .await
         .map_err(mtls::MtlsError::into_app_error)?;
         .map_err(mtls::MtlsError::into_app_error)?;
       DeviceBinding {
       DeviceBinding {
@@ -377,6 +377,7 @@ async fn authenticate_confidential(
   };
   };
   let mut auth = AuthStores {
   let mut auth = AuthStores {
     pool: stores.pool,
     pool: stores.pool,
+    keys: stores.keys,
     conn: &mut *stores.conn,
     conn: &mut *stores.conn,
     store: stores.store,
     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"},
     "redis": {"url": redis_url, "durability": "waitaof"},
     "token_pepper": "device-test-pepper-0123456789abcdef",
     "token_pepper": "device-test-pepper-0123456789abcdef",
     "totp_encryption_key": "device-test-totp-key-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,
     "rate_limit": {"enabled": true, "window_secs": 60, "per_ip": 120,
       "per_client": 300, "login_per_ip": 20, "token_per_client": 60},
       "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(
 async fn insert_client(
   pool: &PgPool,
   pool: &PgPool,
+  keys: &crate::keys::KeyRing,
   client_id: &str,
   client_id: &str,
   method: TokenEndpointAuthMethod,
   method: TokenEndpointAuthMethod,
   secret: Option<&str>,
   secret: Option<&str>,
@@ -171,7 +173,7 @@ async fn insert_client(
     dpop_bound_access_tokens: false,
     dpop_bound_access_tokens: false,
     registration_access_token_hash: None,
     registration_access_token_hash: None,
   };
   };
-  client_repo::insert(pool, &new).await?;
+  client_repo::insert(pool, keys, false, &new).await?;
   Ok(())
   Ok(())
 }
 }
 
 
@@ -257,6 +259,7 @@ async fn test_device_authorize_public_client_issues_code_and_interval() -> TestR
   let client_id = unique("public");
   let client_id = unique("public");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
+    &h.config.keys,
     &client_id,
     &client_id,
     TokenEndpointAuthMethod::None,
     TokenEndpointAuthMethod::None,
     None,
     None,
@@ -357,6 +360,7 @@ async fn test_device_authorize_confidential_client_secret_post() -> TestResult {
   let client_id = unique("conf");
   let client_id = unique("conf");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
+    &h.config.keys,
     &client_id,
     &client_id,
     TokenEndpointAuthMethod::ClientSecretPost,
     TokenEndpointAuthMethod::ClientSecretPost,
     Some("post-secret"),
     Some("post-secret"),
@@ -415,6 +419,7 @@ async fn test_device_authorize_public_client_without_dpop_rejected() -> TestResu
   let client_id = unique("nodpop");
   let client_id = unique("nodpop");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
+    &h.config.keys,
     &client_id,
     &client_id,
     TokenEndpointAuthMethod::None,
     TokenEndpointAuthMethod::None,
     None,
     None,
@@ -450,6 +455,7 @@ async fn test_device_authorize_over_registered_scope_rejected() -> TestResult {
   let client_id = unique("scope");
   let client_id = unique("scope");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
+    &h.config.keys,
     &client_id,
     &client_id,
     TokenEndpointAuthMethod::None,
     TokenEndpointAuthMethod::None,
     None,
     None,
@@ -488,6 +494,7 @@ async fn test_device_authorize_approval_transitions_state() -> TestResult {
   let client_id = unique("approve");
   let client_id = unique("approve");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
+    &h.config.keys,
     &client_id,
     &client_id,
     TokenEndpointAuthMethod::None,
     TokenEndpointAuthMethod::None,
     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.
   // An issued code whose records vanished (TTL expiry) resolves to nothing.
   let client_id = unique("expired");
   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 key = fresh_key()?;
   let htu = device_htu(&h.config);
   let htu = device_htu(&h.config);
   let proof = mint_proof(&key, &htu, &unique("jti"), now_unix())?;
   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> {
 pub struct IntrospectStores<'a> {
   /// Postgres pool for client lookup and secret verification.
   /// Postgres pool for client lookup and secret verification.
   pub pool: &'a PgPool,
   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.
   /// Redis connection for token records and sidecars.
   pub conn: &'a mut RedisConn,
   pub conn: &'a mut RedisConn,
   /// Key layout plus retry/durability knobs.
   /// Key layout plus retry/durability knobs.
@@ -247,6 +249,7 @@ async fn authenticate(
   };
   };
   let mut auth_stores = AuthStores {
   let mut auth_stores = AuthStores {
     pool: stores.pool,
     pool: stores.pool,
+    keys: stores.keys,
     conn: &mut *stores.conn,
     conn: &mut *stores.conn,
     store: stores.store,
     store: stores.store,
   };
   };
@@ -461,6 +464,7 @@ pub async fn handler(
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let mut stores = IntrospectStores {
   let mut stores = IntrospectStores {
     pool: &state.pool,
     pool: &state.pool,
+    keys: &state.keys,
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     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"},
     "redis": {"url": redis_url, "durability": "waitaof"},
     "token_pepper": "introspect-test-pepper-0123456789abcdef",
     "token_pepper": "introspect-test-pepper-0123456789abcdef",
     "totp_encryption_key": "introspect-test-totp-key-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 source = ::config::Config::try_from(&fixture)?;
   let config = Config::from_source(source)?;
   let config = Config::from_source(source)?;
@@ -115,6 +116,7 @@ async fn harness() -> TestResult<(ContainerGuard, Harness)> {
 
 
 async fn insert_client(
 async fn insert_client(
   pool: &PgPool,
   pool: &PgPool,
+  keys: &crate::keys::KeyRing,
   client_id: &str,
   client_id: &str,
   method: TokenEndpointAuthMethod,
   method: TokenEndpointAuthMethod,
   secret: Option<&str>,
   secret: Option<&str>,
@@ -143,7 +145,7 @@ async fn insert_client(
     dpop_bound_access_tokens: false,
     dpop_bound_access_tokens: false,
     registration_access_token_hash: None,
     registration_access_token_hash: None,
   };
   };
-  client_repo::insert(pool, &new).await?;
+  client_repo::insert(pool, keys, false, &new).await?;
   Ok(())
   Ok(())
 }
 }
 
 
@@ -190,6 +192,7 @@ async fn run_core(
   let service = token_service(h)?;
   let service = token_service(h)?;
   let mut stores = IntrospectStores {
   let mut stores = IntrospectStores {
     pool: &h.pool,
     pool: &h.pool,
+    keys: &h.config.keys,
     conn: &mut h.conn,
     conn: &mut h.conn,
     store: &h.store,
     store: &h.store,
   };
   };
@@ -217,6 +220,7 @@ fn app_state(h: &Harness) -> AppState {
     redis: h.conn.clone(),
     redis: h.conn.clone(),
     config: Arc::clone(&h.config),
     config: Arc::clone(&h.config),
     keys: Arc::new(h.config.keys.clone()),
     keys: Arc::new(h.config.keys.clone()),
+    transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
     clock: Clock::system(),
     clock: Clock::system(),
   }
   }
 }
 }
@@ -253,6 +257,7 @@ async fn test_introspect_active_access_token_reports_shape() -> TestResult {
   let client_id = unique("owner");
   let client_id = unique("owner");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
+    &h.config.keys,
     &client_id,
     &client_id,
     TokenEndpointAuthMethod::ClientSecretPost,
     TokenEndpointAuthMethod::ClientSecretPost,
     Some("secret-a"),
     Some("secret-a"),
@@ -304,6 +309,7 @@ async fn test_introspect_unknown_token_is_inactive_200() -> TestResult {
   let client_id = unique("unknown");
   let client_id = unique("unknown");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
+    &h.config.keys,
     &client_id,
     &client_id,
     TokenEndpointAuthMethod::ClientSecretBasic,
     TokenEndpointAuthMethod::ClientSecretBasic,
     Some("secret-u"),
     Some("secret-u"),
@@ -342,6 +348,7 @@ async fn test_introspect_foreign_client_token_is_inactive_200() -> TestResult {
   let owner = unique("owner");
   let owner = unique("owner");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
+    &h.config.keys,
     &owner,
     &owner,
     TokenEndpointAuthMethod::ClientSecretBasic,
     TokenEndpointAuthMethod::ClientSecretBasic,
     Some("owner-secret"),
     Some("owner-secret"),
@@ -350,6 +357,7 @@ async fn test_introspect_foreign_client_token_is_inactive_200() -> TestResult {
   let foreign = unique("foreign");
   let foreign = unique("foreign");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
+    &h.config.keys,
     &foreign,
     &foreign,
     TokenEndpointAuthMethod::ClientSecretBasic,
     TokenEndpointAuthMethod::ClientSecretBasic,
     Some("foreign-secret"),
     Some("foreign-secret"),
@@ -377,6 +385,7 @@ async fn test_introspect_revoked_token_is_inactive() -> TestResult {
   let client_id = unique("revoked");
   let client_id = unique("revoked");
   insert_client(
   insert_client(
     &h.pool,
     &h.pool,
+    &h.config.keys,
     &client_id,
     &client_id,
     TokenEndpointAuthMethod::ClientSecretPost,
     TokenEndpointAuthMethod::ClientSecretPost,
     Some("secret-r"),
     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"},
     "redis": {"url": "redis://127.0.0.1:1", "durability": "waitaof"},
     "token_pepper": "metadata-test-pepper-0123456789abcdef",
     "token_pepper": "metadata-test-pepper-0123456789abcdef",
     "totp_encryption_key": "metadata-test-totp-key-0123456789abcdef",
     "totp_encryption_key": "metadata-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "metadata-test-pii-key-0123456789abcdef",
   });
   });
   let source = ::config::Config::try_from(&fixture)?;
   let source = ::config::Config::try_from(&fixture)?;
   Config::from_source(source)
   Config::from_source(source)
@@ -61,6 +62,7 @@ fn test_state(pool: &sqlx::PgPool) -> TestResult<AppState> {
     redis: RedisConn::Single(manager),
     redis: RedisConn::Single(manager),
     config,
     config,
     keys,
     keys,
+    transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
     clock: Clock::system(),
     clock: Clock::system(),
   })
   })
 }
 }

+ 5 - 1
src/protocol/mtls.rs

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

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

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

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor