Skip to main content

huskarl_core/crypto/cipher/
multi.rs

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/// An [`AeadDecryptor`] that holds multiple keys and applies [`CipherMatch`] /
15/// [`KeyMatchStrength`] selection semantics.
16///
17/// This is the cipher analogue of
18/// [`MultiKeyVerifier`](crate::crypto::verifier::MultiKeyVerifier).
19///
20/// # Key selection
21///
22/// [`cipher_match`](AeadDecryptor::cipher_match) follows [`KeyMatchStrength`] priority:
23/// - A [`ByKeyId`](KeyMatchStrength::ByKeyId) match (algorithm + kid) is definitive.
24/// - A single [`ByAlgorithm`](KeyMatchStrength::ByAlgorithm) match is used directly.
25///
26/// [`decrypt`](AeadDecryptor::decrypt) uses the optional [`CipherMatch`] to select
27/// the correct key when available. When no `CipherMatch` is provided, keys are
28/// tried in order.
29///
30/// Unlike [`MultiKeyVerifier`](crate::crypto::verifier::MultiKeyVerifier), which
31/// fails closed on ambiguity, trying every candidate is safe here: an AEAD tag
32/// self-authenticates, so a wrong key fails decryption rather than risking
33/// wrong-key acceptance. Try-all is standard practice for rotated symmetric
34/// keys (e.g. cookie-key rotation).
35///
36/// # Errors
37///
38/// When no candidate matches the criteria, decryption fails with
39/// [`DecryptError::NoMatchingKey`] without attempting decryption — this is
40/// what lets [`RetryingDecryptor`](crate::crypto::cipher::RetryingDecryptor)
41/// trigger a key refresh. When candidates were attempted and all failed, the
42/// last real failure is returned (non-retryable preferred over retryable),
43/// matching the verifier's dispatch discipline.
44#[derive(Debug)]
45pub struct MultiKeyDecryptor {
46    decryptors: Vec<Arc<dyn AeadDecryptor>>,
47}
48
49impl MultiKeyDecryptor {
50    /// Creates a new `MultiKeyDecryptor` from the given decryptors.
51    #[must_use]
52    pub fn new(decryptors: Vec<Arc<dyn AeadDecryptor>>) -> Self {
53        Self { decryptors }
54    }
55}
56
57enum SelectedDecryptor<'a> {
58    /// A single key matched definitively by key ID.
59    ByKeyId(&'a Arc<dyn AeadDecryptor>),
60    /// One or more keys matched by algorithm only.
61    ByAlgorithm(Vec<&'a Arc<dyn AeadDecryptor>>),
62    /// No keys matched.
63    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                // NoMatchingKey means the decryptor didn't attempt decryption —
107                // it is the implicit fallback, not a result to prefer over others.
108                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            // No cipher_match — try all keys in order.
172            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/// An [`AeadEncryptor`] + [`AeadDecryptor`] that encrypts with a single key
187/// and decrypts with a [`MultiKeyDecryptor`].
188///
189/// This allows a single value to be passed where both encryption and decryption
190/// capabilities are needed (e.g. encrypted cookies with key rotation).
191#[derive(Debug)]
192pub struct MultiKeyCipher<E> {
193    encryptor: E,
194    decryptor: MultiKeyDecryptor,
195}
196
197impl<E> MultiKeyCipher<E> {
198    /// Creates a new `MultiKeyCipher`.
199    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    /// What a fake decryptor's `decrypt` returns.
263    #[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    // ── Selection with a CipherMatch ──────────────────────────────────────
333
334    #[tokio::test]
335    async fn by_key_id_match_is_used_exclusively() {
336        // The kid match wins even though algorithm matches surround it.
337        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        // The only key would succeed if asked, but it doesn't match the criteria,
366        // so decryption is never attempted and NoMatchingKey is returned.
367        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    // ── Selection without a CipherMatch (try-all) ─────────────────────────
373
374    #[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    // ── Error preference when every attempted key fails ───────────────────
393
394    #[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        // A real failure (retryable) is preferred over a NoMatchingKey skip.
414        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    // ── Aggregate cipher_match strength ───────────────────────────────────
426
427    #[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    // ── try_refresh is the OR of inner refreshes ──────────────────────────
446
447    #[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    // ── MultiKeyCipher delegation ─────────────────────────────────────────
471
472    #[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        // Encryptor side → delegates to the inner encryptor.
506        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        // Decryptor side → delegates to the MultiKeyDecryptor.
512        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    // ── Rotated cookie keys (seal/unseal roundtrip) ───────────────────────
520
521    /// A keyed AEAD cipher for the rotated-cookie-keys scenario: it stamps its
522    /// `kid` into the authentication tag on the way out, and only opens a bundle
523    /// whose tag is its own `kid`. So which key sealed a cookie is observable, and
524    /// a cookie can only be unsealed by the key that sealed it.
525    #[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    /// The rotated-cookie-keys shape: one `StaticAeadCipher<MultiKeyCipher>` seals
585    /// every new cookie with the current primary key while still unsealing cookies
586    /// sealed by any key left in the decryptor set — and a key dropped from the set
587    /// can no longer open its old cookies. This is the composition the sealing docs
588    /// name; it exercises `select_sealer` (encrypt with one) and `unseal` (decrypt
589    /// against many) on the same value.
590    #[tokio::test]
591    async fn rotated_cookie_keys_seal_new_and_unseal_old() {
592        // A cookie minted with v1 before the rotation, and one minted with a
593        // since-retired key v0 that is no longer carried.
594        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        // After rotation: seal with v2, still decrypt v2 and v1 (v0 dropped).
604        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        // New cookies are sealed with the current primary (v2)...
613        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        // ...and every key still in the set opens the cookies it sealed, with no
626        // out-of-band kid — the bundle carries none, so the decryptor tries keys
627        // in turn until one authenticates.
628        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        // The retired key's cookie no longer opens once its key leaves the set.
638        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}