huskarl_core/crypto/cipher/
scheduled.rs1use 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::ScheduledRefreshable,
11 },
12 error::Error,
13 platform::{Duration, MaybeSendBoxFuture, MaybeSendFuture, MaybeSendSync},
14};
15
16#[derive(Debug)]
37pub struct ScheduledRefreshCipher<C> {
38 inner: Arc<ScheduledRefreshable<C>>,
39}
40
41impl<C> Clone for ScheduledRefreshCipher<C> {
42 fn clone(&self) -> Self {
43 Self {
44 inner: Arc::clone(&self.inner),
45 }
46 }
47}
48
49#[bon::bon]
50impl<C: std::fmt::Debug + MaybeSendSync + 'static> ScheduledRefreshCipher<C> {
51 #[builder]
60 pub async fn new(
61 factory: impl Fn() -> Pin<Box<dyn MaybeSendFuture<Output = Result<C, Error>>>>
62 + MaybeSendSync
63 + 'static,
64 #[builder(default = Duration::from_hours(1))]
66 ttl: Duration,
67 #[builder(default = Duration::from_secs(30))]
69 failure_backoff: Duration,
70 #[builder(default = Duration::from_mins(1))]
72 min_refresh_interval: Duration,
73 ) -> Result<Self, Error> {
74 let inner = ScheduledRefreshable::builder()
75 .factory(factory)
76 .ttl(ttl)
77 .failure_backoff(failure_backoff)
78 .min_refresh_interval(min_refresh_interval)
79 .build()
80 .await?;
81 Ok(Self {
82 inner: Arc::new(inner),
83 })
84 }
85
86 pub async fn refresh_if_stale(&self) -> bool {
100 self.inner.refresh_if_stale().await
101 }
102
103 pub async fn refresh(&self) -> Result<bool, Error> {
112 self.inner.refresh().await
113 }
114}
115
116impl<C: AeadEncryptor + 'static> AeadEncryptor for ScheduledRefreshCipher<C> {
117 fn enc_algorithm(&self) -> Cow<'_, str> {
118 Cow::Owned(self.inner.load().enc_algorithm().into_owned())
119 }
120
121 fn key_id(&self) -> Option<Cow<'_, str>> {
122 self.inner
123 .load()
124 .key_id()
125 .map(|kid| Cow::Owned(kid.into_owned()))
126 }
127
128 fn encrypt<'a>(
129 &'a self,
130 plaintext: &'a [u8],
131 aad: &'a [u8],
132 ) -> MaybeSendBoxFuture<'a, Result<AeadOutput, Error>> {
133 Box::pin(async move { self.inner.load_full().encrypt(plaintext, aad).await })
134 }
135}
136
137impl<C: AeadDecryptor + 'static> AeadDecryptor for ScheduledRefreshCipher<C> {
138 fn cipher_match(&self, m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
139 self.inner.load().cipher_match(m)
140 }
141
142 fn decrypt<'a>(
143 &'a self,
144 cipher_match: Option<&'a CipherMatch<'a>>,
145 nonce: &'a [u8],
146 ciphertext: &'a [u8],
147 tag: &'a [u8],
148 aad: &'a [u8],
149 ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
150 Box::pin(async move {
151 self.inner.poll_refresh_ahead().await;
156 self.inner
157 .load_full()
158 .decrypt(cipher_match, nonce, ciphertext, tag, aad)
159 .await
160 })
161 }
162
163 fn try_refresh(&self) -> MaybeSendBoxFuture<'_, bool> {
164 Box::pin(self.inner.try_refresh_on_miss())
169 }
170}
171
172impl<C: AeadEncryptor + 'static> AeadCipherSelector for ScheduledRefreshCipher<C> {
173 fn select_cipher(&self) -> MaybeSendBoxFuture<'_, Arc<dyn AeadEncryptor>> {
174 Box::pin(async move {
175 self.inner.poll_refresh_ahead().await;
178 let encryptor: Arc<dyn AeadEncryptor> = self.inner.load_full();
179 encryptor
180 })
181 }
182}
183
184impl<C: AeadEncryptor + 'static> AeadSealerSelector for ScheduledRefreshCipher<C> {
185 fn select_sealer(&self) -> MaybeSendBoxFuture<'_, Arc<dyn AeadSealer>> {
186 Box::pin(async move {
187 self.inner.poll_refresh_ahead().await;
190 let sealer: Arc<dyn AeadSealer> = Arc::new(AeadV1Cipher::new(self.inner.load_full()));
191 sealer
192 })
193 }
194}
195
196impl<C: AeadDecryptor + 'static> AeadUnsealer for ScheduledRefreshCipher<C> {
197 fn unseal<'a>(
198 &'a self,
199 cipher_match: Option<&'a CipherMatch<'a>>,
200 bundle: &'a [u8],
201 aad: &'a [u8],
202 ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
203 Box::pin(async move {
204 self.inner.poll_refresh_ahead().await;
207 AeadV1Cipher::new(self.inner.load_full())
208 .unseal(cipher_match, bundle, aad)
209 .await
210 })
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use std::sync::atomic::{AtomicUsize, Ordering};
217
218 use rstest::rstest;
219
220 use super::*;
221
222 #[derive(Debug)]
227 struct FakeCipher {
228 kid: String,
229 }
230
231 impl AeadEncryptor for FakeCipher {
232 fn enc_algorithm(&self) -> Cow<'_, str> {
233 "A256GCM".into()
234 }
235 fn key_id(&self) -> Option<Cow<'_, str>> {
236 Some(Cow::Borrowed(&self.kid))
237 }
238 fn encrypt<'a>(
239 &'a self,
240 plaintext: &'a [u8],
241 _aad: &'a [u8],
242 ) -> MaybeSendBoxFuture<'a, Result<AeadOutput, Error>> {
243 let ciphertext = plaintext.to_vec();
244 let tag = self.kid.clone().into_bytes();
245 Box::pin(async move {
246 Ok(AeadOutput {
247 nonce: Vec::new(),
248 ciphertext,
249 tag,
250 })
251 })
252 }
253 }
254
255 impl AeadDecryptor for FakeCipher {
256 fn cipher_match(&self, _m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
257 Some(KeyMatchStrength::ByAlgorithm)
258 }
259 fn decrypt<'a>(
260 &'a self,
261 _cipher_match: Option<&'a CipherMatch<'a>>,
262 _nonce: &'a [u8],
263 ciphertext: &'a [u8],
264 _tag: &'a [u8],
265 _aad: &'a [u8],
266 ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
267 let plaintext = ciphertext.to_vec();
268 Box::pin(async move { Ok(plaintext) })
269 }
270 }
271
272 async fn generational_cipher(
274 ttl: Duration,
275 min_refresh_interval: Duration,
276 ) -> ScheduledRefreshCipher<FakeCipher> {
277 let counter = Arc::new(AtomicUsize::new(0));
278 ScheduledRefreshCipher::builder()
279 .factory(move || {
280 let n = counter.fetch_add(1, Ordering::SeqCst);
281 Box::pin(async move {
282 Ok(FakeCipher {
283 kid: format!("gen-{n}"),
284 })
285 })
286 })
287 .ttl(ttl)
288 .min_refresh_interval(min_refresh_interval)
289 .build()
290 .await
291 .unwrap()
292 }
293
294 fn current_kid(cipher: &ScheduledRefreshCipher<FakeCipher>) -> Option<String> {
295 cipher.key_id().map(std::borrow::Cow::into_owned)
296 }
297
298 #[tokio::test]
299 async fn delegates_encryptor_metadata_to_inner() {
300 let cipher = generational_cipher(Duration::from_hours(1), Duration::from_secs(0)).await;
301 assert_eq!(cipher.enc_algorithm().as_ref(), "A256GCM");
302 assert_eq!(current_kid(&cipher).as_deref(), Some("gen-0"));
303 }
304
305 #[tokio::test]
306 async fn encrypt_and_decrypt_delegate_to_inner() {
307 let cipher = generational_cipher(Duration::from_hours(1), Duration::from_secs(0)).await;
308 let out = cipher.encrypt(b"plaintext", b"aad").await.unwrap();
309 let recovered = cipher
310 .decrypt(None, &out.nonce, &out.ciphertext, &out.tag, b"aad")
311 .await
312 .unwrap();
313 assert_eq!(recovered, b"plaintext");
314 }
315
316 #[tokio::test]
317 async fn cipher_match_delegates_to_inner() {
318 let cipher = generational_cipher(Duration::from_hours(1), Duration::from_secs(0)).await;
319 let m = CipherMatch {
320 enc: Some("A256GCM"),
321 kid: None,
322 };
323 assert_eq!(cipher.cipher_match(&m), Some(KeyMatchStrength::ByAlgorithm));
324 }
325
326 #[tokio::test]
327 async fn forced_refresh_bypasses_policy() {
328 let cipher = generational_cipher(Duration::from_hours(1), Duration::from_mins(1)).await;
330 assert_eq!(current_kid(&cipher).as_deref(), Some("gen-0"));
331 assert!(cipher.refresh().await.unwrap());
332 assert_eq!(current_kid(&cipher).as_deref(), Some("gen-1"));
333 }
334
335 #[rstest]
338 #[case::blocked_while_fresh(Duration::from_hours(1), false, "gen-0")]
339 #[case::allowed_when_stale(Duration::from_secs(0), true, "gen-1")]
340 #[tokio::test]
341 async fn refresh_if_stale_respects_ttl_policy(
342 #[case] ttl: Duration,
343 #[case] expected_refreshed: bool,
344 #[case] expected_kid: &str,
345 ) {
346 let cipher = generational_cipher(ttl, Duration::from_secs(0)).await;
347 assert_eq!(cipher.refresh_if_stale().await, expected_refreshed);
348 assert_eq!(current_kid(&cipher).as_deref(), Some(expected_kid));
349 }
350
351 #[tokio::test]
355 async fn decryptor_try_refresh_is_not_ttl_gated() {
356 let cipher = generational_cipher(Duration::from_hours(1), Duration::from_secs(0)).await;
359 assert!(AeadDecryptor::try_refresh(&cipher).await);
360 assert_eq!(current_kid(&cipher).as_deref(), Some("gen-1"));
361 }
362
363 #[derive(Debug)]
366 struct RetiringDecryptor {
367 holds_key: bool,
368 }
369
370 impl AeadDecryptor for RetiringDecryptor {
371 fn cipher_match(&self, _m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
372 self.holds_key.then_some(KeyMatchStrength::ByAlgorithm)
373 }
374
375 fn decrypt<'a>(
376 &'a self,
377 _cipher_match: Option<&'a CipherMatch<'a>>,
378 _nonce: &'a [u8],
379 ciphertext: &'a [u8],
380 _tag: &'a [u8],
381 _aad: &'a [u8],
382 ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
383 let holds_key = self.holds_key;
384 let plaintext = ciphertext.to_vec();
385 Box::pin(async move {
386 if holds_key {
387 Ok(plaintext)
388 } else {
389 Err(DecryptError::NoMatchingKey)
390 }
391 })
392 }
393 }
394
395 #[allow(clippy::type_complexity)]
396 fn retiring_decryptor() -> (
397 Arc<AtomicUsize>,
398 impl Fn() -> Pin<Box<dyn MaybeSendFuture<Output = Result<RetiringDecryptor, Error>>>>
399 + MaybeSendSync
400 + 'static,
401 ) {
402 let builds = Arc::new(AtomicUsize::new(0));
403 let counter = builds.clone();
404 let factory = move || {
405 let n = counter.fetch_add(1, Ordering::SeqCst);
406 Box::pin(async move { Ok(RetiringDecryptor { holds_key: n == 0 }) })
407 as Pin<Box<dyn MaybeSendFuture<Output = Result<RetiringDecryptor, Error>>>>
408 };
409 (builds, factory)
410 }
411
412 #[tokio::test]
413 async fn stale_keyset_reloads_before_decrypt_and_retires_the_key() {
414 let (builds, factory) = retiring_decryptor();
418 let cipher: ScheduledRefreshCipher<RetiringDecryptor> = ScheduledRefreshCipher::builder()
419 .factory(factory)
420 .ttl(Duration::ZERO)
421 .min_refresh_interval(Duration::ZERO)
422 .build()
423 .await
424 .unwrap();
425
426 cipher
427 .decrypt(None, b"", b"data", b"", b"")
428 .await
429 .expect_err("the retired decryption key is dropped by the staleness reload");
430 assert_eq!(
431 builds.load(Ordering::SeqCst),
432 2,
433 "initial build + one reload"
434 );
435 }
436
437 #[tokio::test]
438 async fn fresh_keyset_decrypts_without_reload() {
439 let (builds, factory) = retiring_decryptor();
440 let cipher: ScheduledRefreshCipher<RetiringDecryptor> = ScheduledRefreshCipher::builder()
441 .factory(factory)
442 .ttl(Duration::from_hours(1))
443 .build()
444 .await
445 .unwrap();
446
447 let recovered = cipher.decrypt(None, b"", b"data", b"", b"").await.unwrap();
448 assert_eq!(recovered, b"data");
449 assert_eq!(builds.load(Ordering::SeqCst), 1, "no reload while fresh");
450 }
451
452 #[tokio::test]
453 async fn select_cipher_hands_back_the_current_snapshot() {
454 let cipher = generational_cipher(Duration::from_hours(1), Duration::from_secs(0)).await;
455 assert_eq!(
456 cipher.select_cipher().await.key_id().as_deref(),
457 Some("gen-0")
458 );
459 }
460
461 #[tokio::test]
462 async fn select_cipher_reloads_a_stale_key_during_selection() {
463 let cipher = generational_cipher(Duration::ZERO, Duration::ZERO).await;
465 assert_eq!(
466 cipher.select_cipher().await.key_id().as_deref(),
467 Some("gen-1")
468 );
469 }
470
471 #[tokio::test]
476 async fn selected_sealer_kid_and_ciphertext_agree_across_a_rotation() {
477 let cipher = generational_cipher(Duration::from_hours(1), Duration::ZERO).await;
478
479 let sealer = cipher.select_sealer().await;
480 let kid = sealer.key_id().map(std::borrow::Cow::into_owned);
481 assert_eq!(kid.as_deref(), Some("gen-0"));
482
483 assert!(cipher.refresh().await.unwrap());
485 assert_eq!(current_kid(&cipher).as_deref(), Some("gen-1"));
486
487 let bundle = sealer.seal(b"payload", b"aad").await.unwrap();
490 let tag = &bundle[bundle.len() - 5..];
492 assert_eq!(tag, b"gen-0");
493 }
494}