1use std::{borrow::Cow, sync::Arc};
2
3use futures_util::future::join_all;
4
5use crate::{
6 crypto::{
7 KeyMatchStrength,
8 cipher::{AeadDecryptor, AeadEncryptor, AeadOutput, CipherMatch, DecryptError},
9 },
10 error::Error,
11 platform::MaybeSendBoxFuture,
12};
13
14#[derive(Debug)]
45pub struct MultiKeyDecryptor {
46 decryptors: Vec<Arc<dyn AeadDecryptor>>,
47}
48
49impl MultiKeyDecryptor {
50 #[must_use]
52 pub fn new(decryptors: Vec<Arc<dyn AeadDecryptor>>) -> Self {
53 Self { decryptors }
54 }
55}
56
57enum SelectedDecryptor<'a> {
58 ByKeyId(&'a Arc<dyn AeadDecryptor>),
60 ByAlgorithm(Vec<&'a Arc<dyn AeadDecryptor>>),
62 None,
64}
65
66impl MultiKeyDecryptor {
67 fn select<'a>(&'a self, m: &CipherMatch<'_>) -> SelectedDecryptor<'a> {
68 let mut by_algorithm: Vec<&'a Arc<dyn AeadDecryptor>> = Vec::new();
69
70 for decryptor in &self.decryptors {
71 match decryptor.cipher_match(m) {
72 Some(KeyMatchStrength::ByKeyId) => {
73 return SelectedDecryptor::ByKeyId(decryptor);
74 }
75 Some(KeyMatchStrength::ByAlgorithm) => {
76 by_algorithm.push(decryptor);
77 }
78 None => {}
79 }
80 }
81
82 if by_algorithm.is_empty() {
83 SelectedDecryptor::None
84 } else {
85 SelectedDecryptor::ByAlgorithm(by_algorithm)
86 }
87 }
88
89 async fn try_decrypt(
90 decryptors: impl Iterator<Item = &Arc<dyn AeadDecryptor>>,
91 cipher_match: Option<&CipherMatch<'_>>,
92 nonce: &[u8],
93 ciphertext: &[u8],
94 tag: &[u8],
95 aad: &[u8],
96 ) -> Result<Vec<u8>, DecryptError> {
97 let mut last_retryable = None;
98 let mut last_non_retryable = None;
99
100 for decryptor in decryptors {
101 match decryptor
102 .decrypt(cipher_match, nonce, ciphertext, tag, aad)
103 .await
104 {
105 Ok(plaintext) => return Ok(plaintext),
106 Err(DecryptError::NoMatchingKey) => {}
109 Err(e) => {
110 if e.is_retryable() {
111 last_retryable = Some(e);
112 } else {
113 last_non_retryable = Some(e);
114 }
115 }
116 }
117 }
118
119 Err(last_non_retryable
120 .or(last_retryable)
121 .unwrap_or(DecryptError::NoMatchingKey))
122 }
123}
124
125impl AeadDecryptor for MultiKeyDecryptor {
126 fn cipher_match(&self, m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
127 let mut by_algorithm = false;
128
129 for decryptor in &self.decryptors {
130 match decryptor.cipher_match(m) {
131 Some(KeyMatchStrength::ByKeyId) => return Some(KeyMatchStrength::ByKeyId),
132 Some(KeyMatchStrength::ByAlgorithm) => by_algorithm = true,
133 None => {}
134 }
135 }
136
137 by_algorithm.then_some(KeyMatchStrength::ByAlgorithm)
138 }
139
140 fn decrypt<'a>(
141 &'a self,
142 cipher_match: Option<&'a CipherMatch<'a>>,
143 nonce: &'a [u8],
144 ciphertext: &'a [u8],
145 tag: &'a [u8],
146 aad: &'a [u8],
147 ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
148 Box::pin(async move {
149 if let Some(m) = cipher_match {
150 return match self.select(m) {
151 SelectedDecryptor::ByKeyId(decryptor) => {
152 decryptor
153 .decrypt(cipher_match, nonce, ciphertext, tag, aad)
154 .await
155 }
156 SelectedDecryptor::ByAlgorithm(decryptors) => {
157 Self::try_decrypt(
158 decryptors.into_iter(),
159 cipher_match,
160 nonce,
161 ciphertext,
162 tag,
163 aad,
164 )
165 .await
166 }
167 SelectedDecryptor::None => Err(DecryptError::NoMatchingKey),
168 };
169 }
170
171 Self::try_decrypt(self.decryptors.iter(), None, nonce, ciphertext, tag, aad).await
173 })
174 }
175
176 fn try_refresh(&self) -> MaybeSendBoxFuture<'_, bool> {
177 Box::pin(async move {
178 join_all(self.decryptors.iter().map(AeadDecryptor::try_refresh))
179 .await
180 .into_iter()
181 .any(|b| b)
182 })
183 }
184}
185
186#[derive(Debug)]
192pub struct MultiKeyCipher<E> {
193 encryptor: E,
194 decryptor: MultiKeyDecryptor,
195}
196
197impl<E> MultiKeyCipher<E> {
198 pub fn new(encryptor: E, decryptor: MultiKeyDecryptor) -> Self {
200 Self {
201 encryptor,
202 decryptor,
203 }
204 }
205}
206
207impl<E: AeadEncryptor> AeadEncryptor for MultiKeyCipher<E> {
208 fn enc_algorithm(&self) -> Cow<'_, str> {
209 self.encryptor.enc_algorithm()
210 }
211
212 fn key_id(&self) -> Option<Cow<'_, str>> {
213 self.encryptor.key_id()
214 }
215
216 fn encrypt<'a>(
217 &'a self,
218 plaintext: &'a [u8],
219 aad: &'a [u8],
220 ) -> MaybeSendBoxFuture<'a, Result<AeadOutput, Error>> {
221 self.encryptor.encrypt(plaintext, aad)
222 }
223}
224
225impl<E: AeadEncryptor> AeadDecryptor for MultiKeyCipher<E> {
226 fn cipher_match(&self, m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
227 self.decryptor.cipher_match(m)
228 }
229
230 fn decrypt<'a>(
231 &'a self,
232 cipher_match: Option<&'a CipherMatch<'a>>,
233 nonce: &'a [u8],
234 ciphertext: &'a [u8],
235 tag: &'a [u8],
236 aad: &'a [u8],
237 ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
238 self.decryptor
239 .decrypt(cipher_match, nonce, ciphertext, tag, aad)
240 }
241
242 fn try_refresh(&self) -> MaybeSendBoxFuture<'_, bool> {
243 self.decryptor.try_refresh()
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use rstest::rstest;
250
251 use super::*;
252 use crate::{
253 crypto::{
254 KeyMatchStrength::*,
255 cipher::{
256 AeadSealer, AeadSealerSelector, AeadUnsealer, AeadV1Cipher, StaticAeadCipher,
257 },
258 },
259 error::ErrorKind,
260 };
261
262 #[derive(Clone, Copy, Debug)]
264 enum Outcome {
265 Ok(&'static [u8]),
266 NoMatchingKey,
267 Retryable,
268 NonRetryable,
269 }
270
271 #[derive(Debug)]
272 struct FakeDecryptor {
273 strength: Option<KeyMatchStrength>,
274 outcome: Outcome,
275 refresh: bool,
276 }
277
278 impl AeadDecryptor for FakeDecryptor {
279 fn cipher_match(&self, _m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
280 self.strength
281 }
282
283 fn decrypt<'a>(
284 &'a self,
285 _cipher_match: Option<&'a CipherMatch<'a>>,
286 _nonce: &'a [u8],
287 _ciphertext: &'a [u8],
288 _tag: &'a [u8],
289 _aad: &'a [u8],
290 ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
291 let outcome = self.outcome;
292 Box::pin(async move {
293 match outcome {
294 Outcome::Ok(b) => Ok(b.to_vec()),
295 Outcome::NoMatchingKey => Err(DecryptError::NoMatchingKey),
296 Outcome::Retryable => {
297 Err(Error::from(ErrorKind::Transport { retryable: true }).into())
298 }
299 Outcome::NonRetryable => Err(Error::from(ErrorKind::Crypto).into()),
300 }
301 })
302 }
303
304 fn try_refresh(&self) -> MaybeSendBoxFuture<'_, bool> {
305 let refresh = self.refresh;
306 Box::pin(async move { refresh })
307 }
308 }
309
310 fn dec(strength: Option<KeyMatchStrength>, outcome: Outcome) -> Arc<dyn AeadDecryptor> {
311 Arc::new(FakeDecryptor {
312 strength,
313 outcome,
314 refresh: false,
315 })
316 }
317
318 fn cm() -> CipherMatch<'static> {
319 CipherMatch {
320 enc: Some("A256GCM"),
321 kid: Some("k1"),
322 }
323 }
324
325 async fn decrypt(
326 d: &MultiKeyDecryptor,
327 m: Option<&CipherMatch<'_>>,
328 ) -> Result<Vec<u8>, DecryptError> {
329 d.decrypt(m, b"nonce", b"ciphertext", b"tag", b"aad").await
330 }
331
332 #[tokio::test]
335 async fn by_key_id_match_is_used_exclusively() {
336 let d = MultiKeyDecryptor::new(vec![
338 dec(Some(ByAlgorithm), Outcome::Ok(b"algo")),
339 dec(Some(ByKeyId), Outcome::Ok(b"by-kid")),
340 dec(Some(ByAlgorithm), Outcome::Ok(b"algo2")),
341 ]);
342 assert_eq!(decrypt(&d, Some(&cm())).await.unwrap(), b"by-kid");
343 }
344
345 #[tokio::test]
346 async fn algorithm_matches_are_tried_in_order() {
347 let d = MultiKeyDecryptor::new(vec![
348 dec(Some(ByAlgorithm), Outcome::Ok(b"first")),
349 dec(Some(ByAlgorithm), Outcome::Ok(b"second")),
350 ]);
351 assert_eq!(decrypt(&d, Some(&cm())).await.unwrap(), b"first");
352 }
353
354 #[tokio::test]
355 async fn algorithm_match_skips_a_failing_key() {
356 let d = MultiKeyDecryptor::new(vec![
357 dec(Some(ByAlgorithm), Outcome::NonRetryable),
358 dec(Some(ByAlgorithm), Outcome::Ok(b"second")),
359 ]);
360 assert_eq!(decrypt(&d, Some(&cm())).await.unwrap(), b"second");
361 }
362
363 #[tokio::test]
364 async fn no_candidate_match_does_not_attempt_decryption() {
365 let d = MultiKeyDecryptor::new(vec![dec(None, Outcome::Ok(b"never"))]);
368 let err = decrypt(&d, Some(&cm())).await.unwrap_err();
369 assert!(matches!(err, DecryptError::NoMatchingKey));
370 }
371
372 #[tokio::test]
375 async fn without_cipher_match_tries_all_keys_in_order() {
376 let d = MultiKeyDecryptor::new(vec![
377 dec(None, Outcome::Ok(b"a")),
378 dec(None, Outcome::Ok(b"b")),
379 ]);
380 assert_eq!(decrypt(&d, None).await.unwrap(), b"a");
381 }
382
383 #[tokio::test]
384 async fn without_cipher_match_falls_through_no_matching_key() {
385 let d = MultiKeyDecryptor::new(vec![
386 dec(None, Outcome::NoMatchingKey),
387 dec(None, Outcome::Ok(b"b")),
388 ]);
389 assert_eq!(decrypt(&d, None).await.unwrap(), b"b");
390 }
391
392 #[tokio::test]
395 async fn non_retryable_failure_is_preferred_over_retryable() {
396 let d = MultiKeyDecryptor::new(vec![
397 dec(Some(ByAlgorithm), Outcome::Retryable),
398 dec(Some(ByAlgorithm), Outcome::NonRetryable),
399 ]);
400 let err = decrypt(&d, Some(&cm())).await.unwrap_err();
401 assert!(!err.is_retryable(), "the non-retryable failure should win");
402 }
403
404 #[tokio::test]
405 async fn retryable_failure_surfaces_when_it_is_the_only_real_error() {
406 let d = MultiKeyDecryptor::new(vec![dec(Some(ByAlgorithm), Outcome::Retryable)]);
407 let err = decrypt(&d, Some(&cm())).await.unwrap_err();
408 assert!(err.is_retryable());
409 }
410
411 #[tokio::test]
412 async fn no_matching_key_is_not_recorded_as_a_real_failure() {
413 let d = MultiKeyDecryptor::new(vec![
415 dec(Some(ByAlgorithm), Outcome::NoMatchingKey),
416 dec(Some(ByAlgorithm), Outcome::Retryable),
417 ]);
418 let err = decrypt(&d, Some(&cm())).await.unwrap_err();
419 assert!(
420 err.is_retryable(),
421 "the retryable error, not NoMatchingKey, wins"
422 );
423 }
424
425 #[rstest]
428 #[case::by_kid_wins(&[Some(ByAlgorithm), Some(ByKeyId)], Some(KeyMatchStrength::ByKeyId))]
429 #[case::algorithm_when_no_kid(&[None, Some(ByAlgorithm)], Some(KeyMatchStrength::ByAlgorithm))]
430 #[case::none_when_nothing_matches(&[None, None], None)]
431 fn cipher_match_aggregates_strength(
432 #[case] matches: &[Option<KeyMatchStrength>],
433 #[case] expected: Option<KeyMatchStrength>,
434 ) {
435 let decryptors = matches
436 .iter()
437 .map(|m| dec(*m, Outcome::NoMatchingKey))
438 .collect();
439 assert_eq!(
440 MultiKeyDecryptor::new(decryptors).cipher_match(&cm()),
441 expected
442 );
443 }
444
445 #[rstest]
448 #[case::one_refreshes(&[false, true], true)]
449 #[case::none_refresh(&[false, false], false)]
450 #[case::empty(&[], false)]
451 #[tokio::test]
452 async fn try_refresh_is_true_if_any_inner_refreshes(
453 #[case] flags: &[bool],
454 #[case] expected: bool,
455 ) {
456 let decryptors = flags
457 .iter()
458 .map(|&refresh| {
459 Arc::new(FakeDecryptor {
460 strength: None,
461 outcome: Outcome::NoMatchingKey,
462 refresh,
463 }) as Arc<dyn AeadDecryptor>
464 })
465 .collect();
466 let d = MultiKeyDecryptor::new(decryptors);
467 assert_eq!(d.try_refresh().await, expected);
468 }
469
470 #[derive(Debug)]
473 struct FakeEncryptor;
474
475 impl AeadEncryptor for FakeEncryptor {
476 fn enc_algorithm(&self) -> Cow<'_, str> {
477 "A256GCM".into()
478 }
479 fn key_id(&self) -> Option<Cow<'_, str>> {
480 Some(Cow::Borrowed("enc-kid"))
481 }
482 fn encrypt<'a>(
483 &'a self,
484 plaintext: &'a [u8],
485 _aad: &'a [u8],
486 ) -> MaybeSendBoxFuture<'a, Result<AeadOutput, Error>> {
487 let ciphertext = plaintext.to_vec();
488 Box::pin(async move {
489 Ok(AeadOutput {
490 nonce: Vec::new(),
491 ciphertext,
492 tag: Vec::new(),
493 })
494 })
495 }
496 }
497
498 #[tokio::test]
499 async fn multi_key_cipher_delegates_each_direction() {
500 let cipher = MultiKeyCipher::new(
501 FakeEncryptor,
502 MultiKeyDecryptor::new(vec![dec(None, Outcome::Ok(b"plaintext"))]),
503 );
504
505 assert_eq!(cipher.enc_algorithm().as_ref(), "A256GCM");
507 assert_eq!(cipher.key_id().as_deref(), Some("enc-kid"));
508 let out = cipher.encrypt(b"plaintext", b"aad").await.unwrap();
509 assert_eq!(out.ciphertext, b"plaintext");
510
511 let recovered = cipher
513 .decrypt(None, &out.nonce, &out.ciphertext, &out.tag, b"aad")
514 .await
515 .unwrap();
516 assert_eq!(recovered, b"plaintext");
517 }
518
519 #[derive(Debug)]
526 struct KeyedCookieCipher {
527 kid: &'static str,
528 }
529
530 impl KeyedCookieCipher {
531 fn new(kid: &'static str) -> Self {
532 Self { kid }
533 }
534 }
535
536 impl AeadEncryptor for KeyedCookieCipher {
537 fn enc_algorithm(&self) -> Cow<'_, str> {
538 "A256GCM".into()
539 }
540 fn key_id(&self) -> Option<Cow<'_, str>> {
541 Some(Cow::Borrowed(self.kid))
542 }
543 fn encrypt<'a>(
544 &'a self,
545 plaintext: &'a [u8],
546 _aad: &'a [u8],
547 ) -> MaybeSendBoxFuture<'a, Result<AeadOutput, Error>> {
548 let ciphertext = plaintext.to_vec();
549 let tag = self.kid.as_bytes().to_vec();
550 Box::pin(async move {
551 Ok(AeadOutput {
552 nonce: Vec::new(),
553 ciphertext,
554 tag,
555 })
556 })
557 }
558 }
559
560 impl AeadDecryptor for KeyedCookieCipher {
561 fn cipher_match(&self, m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
562 m.strength_for("A256GCM", Some(self.kid))
563 }
564 fn decrypt<'a>(
565 &'a self,
566 _cipher_match: Option<&'a CipherMatch<'a>>,
567 _nonce: &'a [u8],
568 ciphertext: &'a [u8],
569 tag: &'a [u8],
570 _aad: &'a [u8],
571 ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
572 let opens = tag == self.kid.as_bytes();
573 let plaintext = ciphertext.to_vec();
574 Box::pin(async move {
575 if opens {
576 Ok(plaintext)
577 } else {
578 Err(Error::from(ErrorKind::Crypto).into())
579 }
580 })
581 }
582 }
583
584 #[tokio::test]
591 async fn rotated_cookie_keys_seal_new_and_unseal_old() {
592 let old_v1 = AeadV1Cipher::new(KeyedCookieCipher::new("v1"))
595 .seal(b"session", b"aad")
596 .await
597 .unwrap();
598 let retired_v0 = AeadV1Cipher::new(KeyedCookieCipher::new("v0"))
599 .seal(b"session", b"aad")
600 .await
601 .unwrap();
602
603 let cookies = StaticAeadCipher::new(MultiKeyCipher::new(
605 KeyedCookieCipher::new("v2"),
606 MultiKeyDecryptor::new(vec![
607 Arc::new(KeyedCookieCipher::new("v2")) as Arc<dyn AeadDecryptor>,
608 Arc::new(KeyedCookieCipher::new("v1")) as Arc<dyn AeadDecryptor>,
609 ]),
610 ));
611
612 let new_cookie = cookies
614 .select_sealer()
615 .await
616 .seal(b"session", b"aad")
617 .await
618 .unwrap();
619 assert_eq!(
620 &new_cookie[new_cookie.len() - 2..],
621 b"v2",
622 "new cookies are sealed with the primary key"
623 );
624
625 assert_eq!(
629 cookies.unseal(None, &new_cookie, b"aad").await.unwrap(),
630 b"session"
631 );
632 assert_eq!(
633 cookies.unseal(None, &old_v1, b"aad").await.unwrap(),
634 b"session"
635 );
636
637 cookies
639 .unseal(None, &retired_v0, b"aad")
640 .await
641 .expect_err("a key dropped from the set can no longer unseal");
642 }
643}