rotate.rs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. //! `pii rotate`: re-encrypt sealed PII under the active PII key.
  2. //!
  3. //! The field AEAD key derives from the master PII key, so ciphertext is NOT
  4. //! key-stable: retiring the old key without re-encryption would make reads
  5. //! fail. The blind index is key-dependent too, so every `_index` is recomputed
  6. //! under the active key. Run under a write freeze with the previous key
  7. //! retained; retire it only after this command succeeds everywhere.
  8. //!
  9. //! Each table is scanned in bounded batches. Every batch runs in its own
  10. //! transaction holding `pg_advisory_xact_lock` on the connection that owns
  11. //! that transaction, so a unique-index collision rolls the whole batch back
  12. //! instead of partially overwriting. Rows whose blob already names the active
  13. //! key are skipped, making a re-run a no-op. Plaintext columns are never
  14. //! touched and only counts leave this module.
  15. use sqlx::{PgPool, Row as _};
  16. use uuid::Uuid;
  17. use super::super::context::{CliError, audit};
  18. use super::rotate_clients::rotate_clients;
  19. use super::{RotateReport, malformed, seal_failed};
  20. use crate::crypto::field::{blind_index_active, open_text, seal_text};
  21. use crate::db::user_repo::normalize_email;
  22. use crate::keys::KeyRing;
  23. /// Rows scanned per batch transaction.
  24. pub(super) const BATCH_SIZE: usize = 100;
  25. /// Field-blob format version (mirrors the codec; unparseable blobs abort).
  26. const FIELD_BLOB_VERSION: u8 = 1;
  27. /// Advisory-lock key for the `users` batches, stable across processes.
  28. const USERS_LOCK: &str = "d_oauth_pii_rotate_users";
  29. /// Advisory-lock key for the `redirect_uris` batches, stable across processes.
  30. const REDIRECTS_LOCK: &str = "d_oauth_pii_rotate_redirects";
  31. /// Re-encrypt every stale PII blob under the active key, re-indexing as it goes.
  32. ///
  33. /// A blob that no longer opens (previous key already retired) aborts the batch
  34. /// fail-closed instead of writing a wrong value; a unique-index collision on
  35. /// the recomputed index rolls the batch back the same way.
  36. ///
  37. /// # Errors
  38. /// Returns [`CliError::Crypto`] when no active PII key is configured or a
  39. /// stored blob cannot be opened, and [`CliError::Database`] for PostgreSQL
  40. /// failures (including unique-index collisions).
  41. pub(super) async fn rotate_with(pool: &PgPool, keys: &KeyRing) -> Result<RotateReport, CliError> {
  42. let mut report = RotateReport::default();
  43. rotate_users(pool, keys, &mut report).await?;
  44. rotate_clients(pool, keys, &mut report).await?;
  45. rotate_redirects(pool, keys, &mut report).await?;
  46. audit("pii.rotate", "pii");
  47. Ok(report)
  48. }
  49. /// The active PII key id, fail-closed when unconfigured.
  50. pub(super) fn active_kid(keys: &KeyRing) -> Result<String, CliError> {
  51. keys
  52. .active_pii_key()
  53. .map(|(id, _)| id.as_str().to_owned())
  54. .ok_or_else(|| CliError::Crypto("no active PII encryption key configured".to_owned()))
  55. }
  56. /// The key id named inside a field blob (`None` when the header is corrupt).
  57. pub(super) fn blob_kid(blob: &[u8]) -> Option<&str> {
  58. if blob.len() < 2 || blob[0] != FIELD_BLOB_VERSION {
  59. return None;
  60. }
  61. let end = 2 + usize::from(blob[1]);
  62. if blob.len() < end {
  63. return None;
  64. }
  65. core::str::from_utf8(blob.get(2..end)?).ok()
  66. }
  67. /// Re-encrypt stale `users.email_enc` rows, one locked batch at a time.
  68. async fn rotate_users(
  69. pool: &PgPool,
  70. keys: &KeyRing,
  71. report: &mut RotateReport,
  72. ) -> Result<(), CliError> {
  73. let active_kid = active_kid(keys)?;
  74. let mut after = Uuid::nil();
  75. loop {
  76. let mut tx = pool.begin().await?;
  77. sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))")
  78. .bind(USERS_LOCK)
  79. .execute(&mut *tx)
  80. .await?;
  81. let rows = sqlx::query(
  82. "SELECT id, email_enc FROM users WHERE email_enc IS NOT NULL AND id > $1 \
  83. ORDER BY id LIMIT $2",
  84. )
  85. .bind(after)
  86. .bind(BATCH_SIZE as i64)
  87. .fetch_all(&mut *tx)
  88. .await?;
  89. if rows.is_empty() {
  90. tx.commit().await?;
  91. break;
  92. }
  93. let fetched = rows.len();
  94. for row in &rows {
  95. let id: Uuid = row.try_get("id")?;
  96. let blob: Vec<u8> = row.try_get("email_enc")?;
  97. let kid = blob_kid(&blob).ok_or_else(|| malformed("users.email_enc"))?;
  98. if kid == active_kid {
  99. after = id;
  100. continue;
  101. }
  102. let opened = open_text(keys, &blob).map_err(|_| malformed("users.email_enc"))?;
  103. let canonical = normalize_email(&opened);
  104. let resealed = seal_text(keys, &canonical).map_err(|_| seal_failed())?;
  105. let index = blind_index_active(keys, &canonical);
  106. sqlx::query("UPDATE users SET email_enc = $2, email_index = $3 WHERE id = $1")
  107. .bind(id)
  108. .bind(&resealed)
  109. .bind(&index)
  110. .execute(&mut *tx)
  111. .await?;
  112. report.users_rotated = report.users_rotated.saturating_add(1);
  113. after = id;
  114. }
  115. tx.commit().await?;
  116. if fetched < BATCH_SIZE {
  117. break;
  118. }
  119. }
  120. Ok(())
  121. }
  122. /// Re-encrypt stale `redirect_uris.uri_enc` rows, one locked batch at a time.
  123. async fn rotate_redirects(
  124. pool: &PgPool,
  125. keys: &KeyRing,
  126. report: &mut RotateReport,
  127. ) -> Result<(), CliError> {
  128. let active_kid = active_kid(keys)?;
  129. let mut after = Uuid::nil();
  130. loop {
  131. let mut tx = pool.begin().await?;
  132. sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))")
  133. .bind(REDIRECTS_LOCK)
  134. .execute(&mut *tx)
  135. .await?;
  136. let rows = sqlx::query(
  137. "SELECT id, uri_enc FROM redirect_uris WHERE uri_enc IS NOT NULL AND id > $1 \
  138. ORDER BY id LIMIT $2",
  139. )
  140. .bind(after)
  141. .bind(BATCH_SIZE as i64)
  142. .fetch_all(&mut *tx)
  143. .await?;
  144. if rows.is_empty() {
  145. tx.commit().await?;
  146. break;
  147. }
  148. let fetched = rows.len();
  149. for row in &rows {
  150. let id: Uuid = row.try_get("id")?;
  151. let blob: Vec<u8> = row.try_get("uri_enc")?;
  152. let kid = blob_kid(&blob).ok_or_else(|| malformed("redirect_uris.uri_enc"))?;
  153. if kid == active_kid {
  154. after = id;
  155. continue;
  156. }
  157. let opened = open_text(keys, &blob).map_err(|_| malformed("redirect_uris.uri_enc"))?;
  158. let resealed = seal_text(keys, &opened).map_err(|_| seal_failed())?;
  159. let index = blind_index_active(keys, &opened);
  160. sqlx::query("UPDATE redirect_uris SET uri_enc = $2, uri_index = $3 WHERE id = $1")
  161. .bind(id)
  162. .bind(&resealed)
  163. .bind(&index)
  164. .execute(&mut *tx)
  165. .await?;
  166. report.redirects_rotated = report.redirects_rotated.saturating_add(1);
  167. after = id;
  168. }
  169. tx.commit().await?;
  170. if fetched < BATCH_SIZE {
  171. break;
  172. }
  173. }
  174. Ok(())
  175. }