cratefield_secrets/rotate.rs
1//! Key rotation (issue #42): the data key, and the wrapping under the
2//! master key.
3//!
4//! Keys that never rotate are an audit finding; rotation that has never
5//! been rehearsed is an outage. Both operations therefore take a `plan`
6//! flag that answers "what would this do" without doing it, and both are
7//! written so that **reads keep working throughout** — a secret row names
8//! the key that sealed it, so the old key stays readable until nothing
9//! references it.
10//!
11//! What each one is for:
12//!
13//! - [`rotate_dek`] replaces the data key. Every live secret is
14//! re-encrypted, so the old key protects nothing afterwards. This is
15//! the yearly rotation and the one to run after an incident.
16//! - [`rewrap`] leaves the data keys alone and re-wraps them under the
17//! master key's current material. Nothing is re-encrypted, because
18//! nothing needs to be: the secrets are sealed under the DEKs, not
19//! under the KEK.
20
21use cratefield_core::Statement;
22use sea_query::Value as SeaValue;
23
24use crate::{Access, Actor, SecretStore, SecretsError};
25
26/// What a rotation did, or would do with `plan`.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct RotationReport {
29 /// The key that was active before.
30 pub from_key: Option<String>,
31 /// The key that is active after. `None` in a plan.
32 pub to_key: Option<String>,
33 /// Secret versions re-encrypted, or that would be.
34 pub reencrypted: usize,
35 /// Whether the old key was retired: true only when nothing
36 /// references it any more.
37 pub retired_old: bool,
38 /// `true` when nothing was changed.
39 pub planned: bool,
40}
41
42/// What a re-wrap did, or would do.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct RewrapReport {
45 /// Data keys re-wrapped, or that would be.
46 pub keys: usize,
47 /// The master key reference they now point at.
48 pub key_ref: String,
49 pub planned: bool,
50}
51
52impl SecretStore {
53 /// Rotates this store's data key: a new key becomes active, every
54 /// live secret version is re-encrypted under it, and the old key is
55 /// retired once nothing references it.
56 ///
57 /// Reads keep working throughout, because each secret row names the
58 /// key that sealed it and the old key row stays until the end. A
59 /// rotation interrupted half way leaves a store whose secrets are
60 /// split across two keys, which is a valid state: running it again
61 /// finishes the job.
62 ///
63 /// # Errors
64 ///
65 /// [`SecretsError`]. The attempt is audited either way.
66 pub async fn rotate_dek(
67 &self,
68 actor: &Actor,
69 plan: bool,
70 ) -> Result<RotationReport, SecretsError> {
71 let result = self.rotate_dek_inner(plan).await;
72 self.audit_action(Access::RotateDek, actor, result.is_ok())
73 .await?;
74 result
75 }
76
77 async fn rotate_dek_inner(&self, plan: bool) -> Result<RotationReport, SecretsError> {
78 let (old_key_id, old_dek) = self.active_key().await?;
79 let live = self.live_versions().await?;
80
81 if plan {
82 return Ok(RotationReport {
83 from_key: Some(old_key_id),
84 to_key: None,
85 reencrypted: live.len(),
86 retired_old: false,
87 planned: true,
88 });
89 }
90
91 // Retire the old key and install the new one together, so there
92 // is never a moment with two active keys (which key would a
93 // concurrent `put` pick?) or none (a concurrent `put` would
94 // provision a third).
95 let (new_key_id, new_dek, insert) = self.prepare_key("active").await?;
96 self.db()
97 .batch_atomic(&[
98 Statement::with_values(
99 format!(
100 "UPDATE harness_secret_keys SET state = 'retiring' \
101 WHERE key_id = ? AND {}",
102 crate::scoped_store()
103 ),
104 vec![text(&old_key_id), text(self.id().as_str())],
105 ),
106 insert,
107 ])
108 .await?;
109
110 // Re-encrypt one version at a time. Each row is independently
111 // valid before and after, so a read in between sees either the
112 // old key or the new one, and both are present.
113 let mut reencrypted = 0;
114 for (name, version) in &live {
115 let plaintext = self
116 .open_version(name, *version, &old_key_id, &old_dek)
117 .await?;
118 let (nonce, ciphertext) =
119 self.seal(name, *version, &new_key_id, &new_dek, plaintext.expose())?;
120 self.db()
121 .execute(&Statement::with_values(
122 format!(
123 "UPDATE harness_secrets SET key_id = ?, nonce = ?, ciphertext = ? \
124 WHERE name = ? AND version = ? AND {}",
125 crate::scoped_store()
126 ),
127 vec![
128 text(&new_key_id),
129 blob(nonce),
130 blob(ciphertext),
131 text(name),
132 SeaValue::BigInt(Some(i64::from(*version))),
133 text(self.id().as_str()),
134 ],
135 ))
136 .await?;
137 reencrypted += 1;
138 }
139
140 // Retire the old key only when nothing points at it. A
141 // soft-deleted secret still references its key, so a store with
142 // deleted rows keeps the old key `retiring` — deliberately: its
143 // ciphertexts are still there, and a key nothing can read is not
144 // the same as a key nobody needs.
145 let remaining = self.versions_under(&old_key_id).await?;
146 let retired_old = remaining == 0;
147 if retired_old {
148 self.db()
149 .execute(&Statement::with_values(
150 format!(
151 "UPDATE harness_secret_keys SET state = 'retired' \
152 WHERE key_id = ? AND {}",
153 crate::scoped_store()
154 ),
155 vec![text(&old_key_id), text(self.id().as_str())],
156 ))
157 .await?;
158 }
159
160 Ok(RotationReport {
161 from_key: Some(old_key_id),
162 to_key: Some(new_key_id),
163 reencrypted,
164 retired_old,
165 planned: false,
166 })
167 }
168
169 /// Re-wraps every data key under the master key's current material,
170 /// without touching a single secret. After a KMS rotates its key
171 /// material in place, old wrapped keys still unwrap; this makes sure
172 /// none of them depends on the old material any more.
173 ///
174 /// # Errors
175 ///
176 /// [`SecretsError`]. The attempt is audited either way.
177 pub async fn rewrap(&self, actor: &Actor, plan: bool) -> Result<RewrapReport, SecretsError> {
178 let result = self.rewrap_inner(plan).await;
179 self.audit_action(Access::Rewrap, actor, result.is_ok())
180 .await?;
181 result
182 }
183
184 async fn rewrap_inner(&self, plan: bool) -> Result<RewrapReport, SecretsError> {
185 let rows = self
186 .db()
187 .query(&Statement::with_values(
188 format!(
189 "SELECT key_id FROM harness_secret_keys \
190 WHERE state <> 'retired' AND {} ORDER BY key_id",
191 crate::scoped_store()
192 ),
193 vec![text(self.id().as_str())],
194 ))
195 .await?;
196 let key_ids: Vec<String> = rows
197 .rows
198 .iter()
199 .filter_map(|row| row.get::<String>("key_id"))
200 .collect();
201
202 if plan {
203 return Ok(RewrapReport {
204 keys: key_ids.len(),
205 key_ref: self.kms().key_ref().to_owned(),
206 planned: true,
207 });
208 }
209
210 for key_id in &key_ids {
211 // Unwrap under whatever wrapped it, wrap again under the
212 // current master key. The data key itself does not change,
213 // so nothing sealed under it has to be touched.
214 let dek = self.key(key_id).await?;
215 let wrapped = self.kms().wrap(&dek).await?;
216 self.db()
217 .execute(&Statement::with_values(
218 format!(
219 "UPDATE harness_secret_keys SET wrapped_dek = ?, kms_provider = ?, \
220 kms_key_ref = ? WHERE key_id = ? AND {}",
221 crate::scoped_store()
222 ),
223 vec![
224 blob(wrapped),
225 text(self.kms().provider()),
226 text(self.kms().key_ref()),
227 text(key_id),
228 text(self.id().as_str()),
229 ],
230 ))
231 .await?;
232 }
233
234 Ok(RewrapReport {
235 keys: key_ids.len(),
236 key_ref: self.kms().key_ref().to_owned(),
237 planned: false,
238 })
239 }
240
241 /// Every live secret version, oldest name first. Soft-deleted
242 /// versions are left alone: they are not readable, so re-encrypting
243 /// them would only move unreadable bytes around.
244 async fn live_versions(&self) -> Result<Vec<(String, u32)>, SecretsError> {
245 let rows = self
246 .db()
247 .query(&Statement::with_values(
248 format!(
249 "SELECT name, version FROM harness_secrets \
250 WHERE deleted_at IS NULL AND {} ORDER BY name ASC, version ASC",
251 crate::scoped_store()
252 ),
253 vec![text(self.id().as_str())],
254 ))
255 .await?;
256 let mut out = Vec::with_capacity(rows.rows.len());
257 for row in &rows.rows {
258 let name: String = row
259 .get("name")
260 .ok_or_else(|| SecretsError::Invalid("a secret row has no name".to_owned()))?;
261 let version: i64 = row
262 .get("version")
263 .ok_or_else(|| SecretsError::Invalid("a secret row has no version".to_owned()))?;
264 let version = u32::try_from(version)
265 .map_err(|_| SecretsError::Invalid("a version is out of range".to_owned()))?;
266 out.push((name, version));
267 }
268 Ok(out)
269 }
270
271 async fn versions_under(&self, key_id: &str) -> Result<i64, SecretsError> {
272 let rows = self
273 .db()
274 .query(&Statement::with_values(
275 format!(
276 "SELECT COUNT(*) AS n FROM harness_secrets WHERE key_id = ? AND {}",
277 crate::scoped_store()
278 ),
279 vec![text(key_id), text(self.id().as_str())],
280 ))
281 .await?;
282 Ok(rows
283 .first()
284 .and_then(|row| row.get::<i64>("n"))
285 .unwrap_or(0))
286 }
287}
288
289fn text(value: &str) -> SeaValue {
290 SeaValue::String(Some(Box::new(value.to_owned())))
291}
292
293fn blob(value: Vec<u8>) -> SeaValue {
294 SeaValue::Bytes(Some(Box::new(value)))
295}