Skip to main content

secrets_core/
barrier.rs

1use async_trait::async_trait;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5
6use crate::crypto::Aead;
7use crate::storage::{StorageBackend, StorageEntry, StorageResult};
8
9/// Wraps a raw `StorageBackend`, transparently AEAD-encrypting/decrypting
10/// values. Paths are left in plaintext (same as Vault's own barrier).
11pub struct Barrier<B: StorageBackend> {
12    inner: B,
13    aead: Arc<dyn Aead>,
14}
15
16impl<B: StorageBackend> Barrier<B> {
17    pub fn new(inner: B, aead: Arc<dyn Aead>) -> Self {
18        Self { inner, aead }
19    }
20}
21
22/// What a rewrap pass did. `unchanged` is the steady state — everything is
23/// already on the active key — and `contended` counts values a concurrent
24/// write claimed first, which is benign: that write used the active key too.
25#[derive(Debug, Default, Clone, Serialize, Deserialize)]
26pub struct RewrapReport {
27    pub scanned: usize,
28    pub rewrapped: usize,
29    pub unchanged: usize,
30    pub contended: usize,
31    pub failed: usize,
32}
33
34/// Re-encrypting the whole store under a new master key. Separate from
35/// `StorageBackend` because only the barrier knows about keys, and exposed as
36/// a trait so the server can hold it behind an `Arc` without naming the
37/// concrete backend.
38#[async_trait]
39pub trait KeyRotation: Send + Sync {
40    /// Rewraps every value not already sealed under the active key.
41    /// Idempotent and safe to re-run: a second pass reports everything as
42    /// `unchanged`.
43    async fn rewrap_all(&self) -> StorageResult<RewrapReport>;
44
45    /// The active key's derived id, so an operator can confirm which key a
46    /// replica is actually sealing with.
47    fn active_key_id(&self) -> String;
48}
49
50#[async_trait]
51impl<B: StorageBackend> KeyRotation for Barrier<B> {
52    async fn rewrap_all(&self) -> StorageResult<RewrapReport> {
53        let mut report = RewrapReport::default();
54
55        // Reads the raw backend, not ourselves: rewrapping is the one
56        // operation that has to see ciphertext.
57        for path in self.inner.list("").await? {
58            report.scanned += 1;
59            let Some(entry) = self.inner.get(&path).await? else {
60                continue; // deleted while we were scanning
61            };
62            if self.aead.is_current(&entry.value) {
63                report.unchanged += 1;
64                continue;
65            }
66
67            let Ok(plaintext) = self.aead.open(&entry.value) else {
68                // A value whose key is no longer in the ring. Keep going —
69                // stopping would leave the rest of the store un-rotated, and
70                // the count is what tells the operator to restore the key.
71                tracing::error!(path, "cannot rewrap: no key in the ring opens this value");
72                report.failed += 1;
73                continue;
74            };
75            let resealed = self
76                .aead
77                .seal(&plaintext)
78                .map_err(|e| crate::storage::StorageError::Backend(e.to_string()))?;
79
80            // Conditional on the ciphertext we read. Without this, a write
81            // landing between the read and the write would be overwritten by
82            // a re-encryption of the value it replaced.
83            let replaced = self
84                .inner
85                .replace_if_unchanged(
86                    &path,
87                    &entry.value,
88                    StorageEntry {
89                        value: resealed,
90                        expires_at: entry.expires_at,
91                    },
92                )
93                .await?;
94            if replaced {
95                report.rewrapped += 1;
96            } else {
97                report.contended += 1;
98            }
99        }
100
101        tracing::info!(
102            scanned = report.scanned,
103            rewrapped = report.rewrapped,
104            failed = report.failed,
105            "rewrap pass complete"
106        );
107        Ok(report)
108    }
109
110    fn active_key_id(&self) -> String {
111        self.aead.active_key_id()
112    }
113}
114
115#[async_trait]
116impl<B: StorageBackend> StorageBackend for Barrier<B> {
117    async fn get(&self, path: &str) -> StorageResult<Option<StorageEntry>> {
118        let Some(entry) = self.inner.get(path).await? else {
119            return Ok(None);
120        };
121        let plaintext = self
122            .aead
123            .open(&entry.value)
124            .map_err(|e| crate::storage::StorageError::Backend(e.to_string()))?;
125        Ok(Some(StorageEntry {
126            value: plaintext,
127            expires_at: entry.expires_at,
128        }))
129    }
130
131    async fn put(&self, path: &str, entry: StorageEntry) -> StorageResult<()> {
132        let ciphertext = self
133            .aead
134            .seal(&entry.value)
135            .map_err(|e| crate::storage::StorageError::Backend(e.to_string()))?;
136        self.inner
137            .put(
138                path,
139                StorageEntry {
140                    value: ciphertext,
141                    expires_at: entry.expires_at,
142                },
143            )
144            .await
145    }
146
147    async fn delete(&self, path: &str) -> StorageResult<()> {
148        self.inner.delete(path).await
149    }
150
151    async fn list(&self, prefix: &str) -> StorageResult<Vec<String>> {
152        self.inner.list(prefix).await
153    }
154
155    // Neither of these touches stored values, so the barrier has nothing to
156    // encrypt or decrypt — but it must still pass them through, or wrapping a
157    // backend would silently downgrade it to the permissive defaults.
158    async fn ping(&self) -> StorageResult<()> {
159        self.inner.ping().await
160    }
161
162    async fn list_expired(
163        &self,
164        prefix: &str,
165        now: chrono::DateTime<chrono::Utc>,
166    ) -> StorageResult<Vec<String>> {
167        // Expiry is stored in plaintext alongside the ciphertext precisely so
168        // it can be queried without unsealing anything.
169        self.inner.list_expired(prefix, now).await
170    }
171
172    async fn replace_if_unchanged(
173        &self,
174        path: &str,
175        expected: &[u8],
176        entry: StorageEntry,
177    ) -> StorageResult<bool> {
178        let ciphertext = self
179            .aead
180            .seal(&entry.value)
181            .map_err(|e| crate::storage::StorageError::Backend(e.to_string()))?;
182        self.inner
183            .replace_if_unchanged(
184                path,
185                expected,
186                StorageEntry {
187                    value: ciphertext,
188                    expires_at: entry.expires_at,
189                },
190            )
191            .await
192    }
193
194    async fn try_acquire_lock(&self, key: &str) -> StorageResult<bool> {
195        self.inner.try_acquire_lock(key).await
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::crypto::{Aes256GcmAead, KeyRing};
203    use crate::storage::StorageResult;
204    use std::collections::HashMap;
205    use std::sync::Mutex;
206
207    const OLD: [u8; 32] = [7u8; 32];
208    const NEW: [u8; 32] = [11u8; 32];
209
210    #[derive(Default)]
211    struct MemStorage(Mutex<HashMap<String, StorageEntry>>);
212
213    #[async_trait]
214    impl StorageBackend for MemStorage {
215        async fn get(&self, path: &str) -> StorageResult<Option<StorageEntry>> {
216            Ok(self.0.lock().unwrap().get(path).cloned())
217        }
218        async fn put(&self, path: &str, entry: StorageEntry) -> StorageResult<()> {
219            self.0.lock().unwrap().insert(path.to_string(), entry);
220            Ok(())
221        }
222        async fn delete(&self, path: &str) -> StorageResult<()> {
223            self.0.lock().unwrap().remove(path);
224            Ok(())
225        }
226        async fn list(&self, prefix: &str) -> StorageResult<Vec<String>> {
227            Ok(self
228                .0
229                .lock()
230                .unwrap()
231                .keys()
232                .filter(|k| k.starts_with(prefix))
233                .cloned()
234                .collect())
235        }
236    }
237
238    fn entry(value: &[u8]) -> StorageEntry {
239        StorageEntry {
240            value: value.to_vec(),
241            expires_at: None,
242        }
243    }
244
245    #[tokio::test]
246    async fn rewrap_moves_pre_rotation_values_onto_the_active_key() {
247        // Seed the raw store exactly as version 0.1 would have left it.
248        let raw = MemStorage::default();
249        let legacy = Aes256GcmAead::new(&OLD);
250        raw.put("secret/a", entry(&legacy.seal(b"alpha").unwrap()))
251            .await
252            .unwrap();
253
254        let barrier = Barrier::new(raw, Arc::new(KeyRing::new(&NEW, &[OLD])));
255        assert_eq!(barrier.get("secret/a").await.unwrap().unwrap().value, b"alpha");
256
257        let report = barrier.rewrap_all().await.unwrap();
258        assert_eq!((report.scanned, report.rewrapped, report.failed), (1, 1, 0));
259        assert_eq!(barrier.get("secret/a").await.unwrap().unwrap().value, b"alpha");
260
261        // The point of the exercise: the old key can now be dropped.
262        let rotated = Barrier::new(barrier.inner, Arc::new(KeyRing::new(&NEW, &[])));
263        assert_eq!(rotated.get("secret/a").await.unwrap().unwrap().value, b"alpha");
264    }
265
266    #[tokio::test]
267    async fn rewrap_is_idempotent() {
268        let barrier = Barrier::new(MemStorage::default(), Arc::new(KeyRing::new(&NEW, &[OLD])));
269        barrier.put("secret/a", entry(b"alpha")).await.unwrap();
270
271        let first = barrier.rewrap_all().await.unwrap();
272        assert_eq!(first.unchanged, 1, "a fresh write is already on the active key");
273
274        let second = barrier.rewrap_all().await.unwrap();
275        assert_eq!((second.rewrapped, second.unchanged), (0, 1));
276    }
277
278    /// A value whose key was dropped must be counted and reported, not
279    /// silently skipped and not fatal to the rest of the pass.
280    #[tokio::test]
281    async fn rewrap_reports_values_it_cannot_open() {
282        let raw = MemStorage::default();
283        let orphan = KeyRing::new(&OLD, &[]);
284        raw.put("secret/orphan", entry(&orphan.seal(b"lost").unwrap()))
285            .await
286            .unwrap();
287
288        let barrier = Barrier::new(raw, Arc::new(KeyRing::new(&NEW, &[])));
289        barrier.put("secret/fine", entry(b"kept")).await.unwrap();
290
291        let report = barrier.rewrap_all().await.unwrap();
292        assert_eq!(report.failed, 1, "the orphan should be reported");
293        assert_eq!(report.unchanged, 1, "the healthy value should still be seen");
294        assert_eq!(barrier.get("secret/fine").await.unwrap().unwrap().value, b"kept");
295    }
296
297    /// The race rewrap has to survive: a write lands between our read and our
298    /// write. The conditional replace must decline rather than resurrect the
299    /// stale plaintext.
300    #[tokio::test]
301    async fn conditional_replace_declines_when_the_value_moved() {
302        let raw = MemStorage::default();
303        raw.put("secret/a", entry(b"first")).await.unwrap();
304
305        let replaced = raw
306            .replace_if_unchanged("secret/a", b"stale-expectation", entry(b"second"))
307            .await
308            .unwrap();
309        assert!(!replaced);
310        assert_eq!(raw.get("secret/a").await.unwrap().unwrap().value, b"first");
311
312        let replaced = raw
313            .replace_if_unchanged("secret/a", b"first", entry(b"second"))
314            .await
315            .unwrap();
316        assert!(replaced);
317        assert_eq!(raw.get("secret/a").await.unwrap().unwrap().value, b"second");
318    }
319}