Skip to main content

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}