Skip to main content

huskarl_core/crypto/cipher/
refreshable.rs

1use std::{borrow::Cow, pin::Pin, sync::Arc};
2
3use crate::{
4    crypto::{
5        KeyMatchStrength,
6        cipher::{
7            AeadCipherSelector, AeadDecryptor, AeadEncryptor, AeadOutput, AeadSealer,
8            AeadSealerSelector, AeadUnsealer, AeadV1Cipher, CipherMatch, DecryptError,
9        },
10        refreshable::Refreshable,
11    },
12    error::Error,
13    platform::{MaybeSendBoxFuture, MaybeSendFuture, MaybeSendSync},
14};
15
16/// An AEAD cipher that holds a hot-swappable inner cipher behind an
17/// [`ArcSwap`](arc_swap::ArcSwap).
18///
19/// This is the pure **mechanism** layer — it knows how to atomically swap the
20/// inner cipher by re-invoking a factory closure. Concurrent refresh attempts
21/// are serialised so that only one factory call runs at a time; waiters that
22/// arrive while a refresh is in flight adopt the result.
23///
24/// Policy concerns (TTL, failure backoff) are handled by
25/// [`ScheduledRefreshCipher`](super::ScheduledRefreshCipher), which wraps the
26/// same mechanism.
27///
28/// This allows runtime rotation of encryption keys (e.g. from a KMS or secret
29/// manager) without restarting the application.
30///
31/// For emitting, go through the selector API
32/// ([`select_cipher`](AeadCipherSelector::select_cipher) /
33/// [`select_sealer`](AeadSealerSelector::select_sealer)), which hands back a frozen
34/// snapshot. Using the type directly as an [`AeadEncryptor`] takes a fresh snapshot
35/// per call, so reading metadata and then encrypting can straddle a rotation — the
36/// hazard the selector exists to prevent (see [composing crypto
37/// strategies](crate::_docs::explanation::crypto_strategies)); the bare impl exists
38/// only for composition and erasure.
39///
40/// All clones share the same underlying state, so a refresh performed through
41/// any clone is visible to all others — a single `RefreshableCipher` can be
42/// cloned into a sealer and an unsealer while one handle is kept for refresh.
43#[derive(Debug)]
44pub struct RefreshableCipher<C> {
45    inner: Arc<Refreshable<C>>,
46}
47
48impl<C> Clone for RefreshableCipher<C> {
49    fn clone(&self) -> Self {
50        Self {
51            inner: Arc::clone(&self.inner),
52        }
53    }
54}
55
56#[bon::bon]
57impl<C: std::fmt::Debug + MaybeSendSync + 'static> RefreshableCipher<C> {
58    /// Creates a new [`RefreshableCipher`] using the given factory.
59    ///
60    /// The factory is called immediately to produce the initial cipher. The
61    /// same factory is called on subsequent refreshes via
62    /// [`refresh`](Self::refresh).
63    ///
64    /// # Errors
65    ///
66    /// Returns an error if the initial factory call fails.
67    #[builder]
68    pub async fn new(
69        factory: impl Fn() -> Pin<Box<dyn MaybeSendFuture<Output = Result<C, Error>>>>
70        + MaybeSendSync
71        + 'static,
72    ) -> Result<Self, Error> {
73        let refreshable = Refreshable::builder().factory(factory).build().await?;
74        Ok(Self {
75            inner: Arc::new(refreshable),
76        })
77    }
78
79    /// Refreshes the cipher by re-invoking the factory and atomically swapping
80    /// the inner value.
81    ///
82    /// Concurrent callers are serialised — only one factory call runs at a time.
83    /// If another task already refreshed while this one was waiting for the lock,
84    /// the new value is adopted without a redundant fetch.
85    ///
86    /// Returns `Ok(true)` if new key material was fetched by this call, or
87    /// `Ok(false)` if another task already refreshed concurrently.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the factory call fails.
92    pub async fn refresh(&self) -> Result<bool, Error> {
93        self.inner.refresh().await
94    }
95}
96
97impl<C: AeadEncryptor + 'static> AeadEncryptor for RefreshableCipher<C> {
98    fn enc_algorithm(&self) -> Cow<'_, str> {
99        Cow::Owned(self.inner.load().enc_algorithm().into_owned())
100    }
101
102    fn key_id(&self) -> Option<Cow<'_, str>> {
103        self.inner
104            .load()
105            .key_id()
106            .map(|kid| Cow::Owned(kid.into_owned()))
107    }
108
109    fn encrypt<'a>(
110        &'a self,
111        plaintext: &'a [u8],
112        aad: &'a [u8],
113    ) -> MaybeSendBoxFuture<'a, Result<AeadOutput, Error>> {
114        Box::pin(async move { self.inner.load_full().encrypt(plaintext, aad).await })
115    }
116}
117
118impl<C: AeadDecryptor + 'static> AeadDecryptor for RefreshableCipher<C> {
119    fn cipher_match(&self, m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
120        self.inner.load().cipher_match(m)
121    }
122
123    fn decrypt<'a>(
124        &'a self,
125        cipher_match: Option<&'a CipherMatch<'a>>,
126        nonce: &'a [u8],
127        ciphertext: &'a [u8],
128        tag: &'a [u8],
129        aad: &'a [u8],
130    ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
131        Box::pin(async move {
132            self.inner
133                .load_full()
134                .decrypt(cipher_match, nonce, ciphertext, tag, aad)
135                .await
136        })
137    }
138
139    fn try_refresh(&self) -> MaybeSendBoxFuture<'_, bool> {
140        // `is_ok`, not `unwrap_or(false)`: `refresh` returns `Ok(false)` when
141        // another task refreshed concurrently — the key material *is* fresh, so
142        // the contract ("true if loaded or concurrently loaded") requires `true`.
143        Box::pin(async move { self.refresh().await.is_ok() })
144    }
145}
146
147impl<C: AeadEncryptor + 'static> AeadCipherSelector for RefreshableCipher<C> {
148    fn select_cipher(&self) -> MaybeSendBoxFuture<'_, Arc<dyn AeadEncryptor>> {
149        // Hand back the current key as one frozen snapshot: reading its metadata
150        // and encrypting against it then describe and use the same key, even if a
151        // rotation lands afterwards.
152        let encryptor: Arc<dyn AeadEncryptor> = self.inner.load_full();
153        Box::pin(async move { encryptor })
154    }
155}
156
157impl<C: AeadEncryptor + 'static> AeadSealerSelector for RefreshableCipher<C> {
158    fn select_sealer(&self) -> MaybeSendBoxFuture<'_, Arc<dyn AeadSealer>> {
159        // Frame the frozen snapshot as a v1 sealer. Because it is one fixed key,
160        // `key_id`/`enc_algorithm` and `seal` on the returned sealer agree even
161        // across a concurrent rotation.
162        let sealer: Arc<dyn AeadSealer> = Arc::new(AeadV1Cipher::new(self.inner.load_full()));
163        Box::pin(async move { sealer })
164    }
165}
166
167impl<C: AeadDecryptor + 'static> AeadUnsealer for RefreshableCipher<C> {
168    fn unseal<'a>(
169        &'a self,
170        cipher_match: Option<&'a CipherMatch<'a>>,
171        bundle: &'a [u8],
172        aad: &'a [u8],
173    ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
174        Box::pin(async move {
175            AeadV1Cipher::new(self.inner.load_full())
176                .unseal(cipher_match, bundle, aad)
177                .await
178        })
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use std::sync::atomic::{AtomicU32, Ordering};
185
186    use super::*;
187
188    #[derive(Debug)]
189    struct KeyedCipher {
190        kid: String,
191    }
192
193    impl AeadEncryptor for KeyedCipher {
194        fn enc_algorithm(&self) -> Cow<'_, str> {
195            "mock".into()
196        }
197
198        fn key_id(&self) -> Option<Cow<'_, str>> {
199            Some(Cow::Borrowed(&self.kid))
200        }
201
202        fn encrypt<'a>(
203            &'a self,
204            plaintext: &'a [u8],
205            _aad: &'a [u8],
206        ) -> MaybeSendBoxFuture<'a, Result<AeadOutput, Error>> {
207            Box::pin(async move {
208                Ok(AeadOutput {
209                    nonce: vec![],
210                    ciphertext: plaintext.to_vec(),
211                    tag: self.kid.clone().into_bytes(),
212                })
213            })
214        }
215    }
216
217    impl AeadDecryptor for KeyedCipher {
218        fn cipher_match(&self, m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
219            m.strength_for("mock", Some(&self.kid))
220        }
221
222        fn decrypt<'a>(
223            &'a self,
224            _cipher_match: Option<&'a CipherMatch<'a>>,
225            _nonce: &'a [u8],
226            ciphertext: &'a [u8],
227            tag: &'a [u8],
228            _aad: &'a [u8],
229        ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
230            Box::pin(async move {
231                if tag == self.kid.as_bytes() {
232                    Ok(ciphertext.to_vec())
233                } else {
234                    Err(Error::from(crate::error::ErrorKind::Crypto).into())
235                }
236            })
237        }
238    }
239
240    async fn versioned_cipher() -> RefreshableCipher<KeyedCipher> {
241        let counter = Arc::new(AtomicU32::new(0));
242        let factory =
243            move || -> Pin<Box<dyn MaybeSendFuture<Output = Result<KeyedCipher, Error>>>> {
244                let counter = Arc::clone(&counter);
245                Box::pin(async move {
246                    let n = counter.fetch_add(1, Ordering::SeqCst);
247                    Ok(KeyedCipher {
248                        kid: format!("v{n}"),
249                    })
250                })
251            };
252        RefreshableCipher::builder()
253            .factory(factory)
254            .build()
255            .await
256            .unwrap()
257    }
258
259    #[tokio::test]
260    async fn refresh_swaps_cipher_for_all_clones() {
261        let cipher = versioned_cipher().await;
262        let clone = cipher.clone();
263
264        assert_eq!(cipher.key_id().as_deref(), Some("v0"));
265        assert!(cipher.refresh().await.unwrap());
266        assert_eq!(clone.key_id().as_deref(), Some("v1"));
267    }
268
269    #[tokio::test]
270    async fn try_refresh_swaps_through_dyn_decryptor() {
271        let cipher = versioned_cipher().await;
272        let output = cipher.encrypt(b"hello", b"").await.unwrap();
273
274        let decryptor: Arc<dyn AeadDecryptor> = Arc::new(cipher);
275        decryptor
276            .decrypt(None, &output.nonce, &output.ciphertext, &output.tag, b"")
277            .await
278            .unwrap();
279
280        assert!(decryptor.try_refresh().await);
281
282        // The old bundle was sealed with v0; after refresh only v1 decrypts.
283        let err = decryptor
284            .decrypt(None, &output.nonce, &output.ciphertext, &output.tag, b"")
285            .await
286            .unwrap_err();
287        let DecryptError::Other { source } = err else {
288            unreachable!("expected DecryptError::Other, got {err:?}");
289        };
290        assert_eq!(source.kind(), crate::error::ErrorKind::Crypto);
291
292        let m = CipherMatch::builder().kid("v1").build();
293        assert_eq!(decryptor.cipher_match(&m), Some(KeyMatchStrength::ByKeyId));
294    }
295}