Skip to main content

huskarl_core/crypto/cipher/
retrying.rs

1use crate::{
2    crypto::{
3        KeyMatchStrength,
4        cipher::{AeadDecryptor, CipherMatch, DecryptError},
5    },
6    platform::MaybeSendBoxFuture,
7};
8
9/// An [`AeadDecryptor`] that retries once after a
10/// [`NoMatchingKey`](DecryptError::NoMatchingKey) error by calling
11/// [`try_refresh`](AeadDecryptor::try_refresh) on the inner decryptor.
12///
13/// It covers cross-server key propagation during rotation (one server seals with a
14/// freshly rotated key another has not loaded yet) and is the cipher analogue of
15/// [`RetryingVerifier`](crate::crypto::verifier::RetryingVerifier). Wrap the root
16/// decryptor with it so any composition underneath — a
17/// [`ScheduledRefreshCipher`](super::ScheduledRefreshCipher), a
18/// [`MultiKeyDecryptor`](super::MultiKeyDecryptor), or arbitrary nesting — gets one
19/// retry without implementing it. The retry's refresh is gated by the inner layer's
20/// policy, so a burst of unknown-`kid` bundles cannot force a burst of upstream
21/// fetches.
22///
23/// The retry fires only on `NoMatchingKey`, so callers must pass a [`CipherMatch`]
24/// with a `kid` **and** register kids on all keys in the set. A key with no
25/// registered kid can never be ruled out: it falls back to a
26/// [`ByAlgorithm`](crate::crypto::KeyMatchStrength::ByAlgorithm) match, is
27/// attempted, and its authentication failure masks the miss as
28/// [`Other`](DecryptError::Other) — no retry fires. Likewise, with no
29/// [`CipherMatch`] at all, a stale key set surfaces as an authentication failure
30/// from try-all dispatch, indistinguishable from tampering and deliberately not
31/// retried.
32///
33/// See [composing crypto strategies](crate::_docs::explanation::crypto_strategies)
34/// for how this layer (additions) pairs with the scheduled layer (removals).
35#[derive(Debug, Clone)]
36pub struct RetryingDecryptor<D> {
37    inner: D,
38}
39
40impl<D: AeadDecryptor> RetryingDecryptor<D> {
41    /// Wraps a decryptor so that a `NoMatchingKey` result triggers a refresh and one retry.
42    pub fn new(inner: D) -> Self {
43        Self { inner }
44    }
45}
46
47impl<D: AeadDecryptor> AeadDecryptor for RetryingDecryptor<D> {
48    fn cipher_match(&self, m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
49        self.inner.cipher_match(m)
50    }
51
52    fn decrypt<'a>(
53        &'a self,
54        cipher_match: Option<&'a CipherMatch<'a>>,
55        nonce: &'a [u8],
56        ciphertext: &'a [u8],
57        tag: &'a [u8],
58        aad: &'a [u8],
59    ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
60        Box::pin(async move {
61            match self
62                .inner
63                .decrypt(cipher_match, nonce, ciphertext, tag, aad)
64                .await
65            {
66                Err(DecryptError::NoMatchingKey) => {
67                    if self.inner.try_refresh().await {
68                        self.inner
69                            .decrypt(cipher_match, nonce, ciphertext, tag, aad)
70                            .await
71                    } else {
72                        Err(DecryptError::NoMatchingKey)
73                    }
74                }
75                other => other,
76            }
77        })
78    }
79
80    fn try_refresh(&self) -> MaybeSendBoxFuture<'_, bool> {
81        self.inner.try_refresh()
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use std::{
88        pin::Pin,
89        sync::{
90            Arc,
91            atomic::{AtomicU32, Ordering},
92        },
93    };
94
95    use super::*;
96    use crate::{
97        crypto::cipher::{MultiKeyDecryptor, RefreshableCipher},
98        error::Error,
99        platform::MaybeSendFuture,
100    };
101
102    /// A decryptor whose key identity is its kid; decryption succeeds only
103    /// when the bundle's tag carries the same kid.
104    #[derive(Debug)]
105    struct KeyedDecryptor {
106        kid: String,
107    }
108
109    impl AeadDecryptor for KeyedDecryptor {
110        fn cipher_match(&self, m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
111            m.strength_for("mock", Some(&self.kid))
112        }
113
114        fn decrypt<'a>(
115            &'a self,
116            _cipher_match: Option<&'a CipherMatch<'a>>,
117            _nonce: &'a [u8],
118            ciphertext: &'a [u8],
119            tag: &'a [u8],
120            _aad: &'a [u8],
121        ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
122            Box::pin(async move {
123                if tag == self.kid.as_bytes() {
124                    Ok(ciphertext.to_vec())
125                } else {
126                    Err(Error::from(crate::error::ErrorKind::Crypto).into())
127                }
128            })
129        }
130    }
131
132    /// Each refresh widens the key set by one version: refresh n yields
133    /// keys v0..=vn, simulating another server's rotation propagating.
134    async fn rotating_decryptor() -> RefreshableCipher<MultiKeyDecryptor> {
135        let generation = Arc::new(AtomicU32::new(0));
136        let factory =
137            move || -> Pin<Box<dyn MaybeSendFuture<Output = Result<MultiKeyDecryptor, Error>>>> {
138                let generation = Arc::clone(&generation);
139                Box::pin(async move {
140                    let n = generation.fetch_add(1, Ordering::SeqCst);
141                    let keys = (0..=n)
142                        .map(|v| {
143                            Arc::new(KeyedDecryptor {
144                                kid: format!("v{v}"),
145                            }) as Arc<dyn AeadDecryptor>
146                        })
147                        .collect();
148                    Ok(MultiKeyDecryptor::new(keys))
149                })
150            };
151        RefreshableCipher::builder()
152            .factory(factory)
153            .build()
154            .await
155            .unwrap()
156    }
157
158    #[tokio::test]
159    async fn retries_after_refresh_on_no_matching_key() {
160        let decryptor = RetryingDecryptor::new(rotating_decryptor().await);
161
162        // Sealed elsewhere with v1, which this decryptor (holding only v0)
163        // has not loaded yet.
164        let m = CipherMatch::builder().enc("mock").kid("v1").build();
165        let plaintext = decryptor
166            .decrypt(Some(&m), b"", b"hello", b"v1", b"")
167            .await
168            .unwrap();
169        assert_eq!(plaintext, b"hello");
170    }
171
172    #[tokio::test]
173    async fn retries_only_once() {
174        let decryptor = RetryingDecryptor::new(rotating_decryptor().await);
175
176        // v9 is never loaded: one refresh happens (v0 -> v0..=v1), the retry
177        // still misses, and the miss is returned without further refreshes.
178        let m = CipherMatch::builder().enc("mock").kid("v9").build();
179        let err = decryptor
180            .decrypt(Some(&m), b"", b"hello", b"v9", b"")
181            .await
182            .unwrap_err();
183        assert!(matches!(err, DecryptError::NoMatchingKey));
184        let m = CipherMatch::builder().kid("v1").build();
185        assert_eq!(decryptor.cipher_match(&m), Some(KeyMatchStrength::ByKeyId));
186    }
187
188    #[tokio::test]
189    async fn real_failures_are_not_retried() {
190        let decryptor = RetryingDecryptor::new(rotating_decryptor().await);
191
192        // Tag mismatch on a key that exists: authentication failure, no retry.
193        let m = CipherMatch::builder().enc("mock").kid("v0").build();
194        let err = decryptor
195            .decrypt(Some(&m), b"", b"hello", b"tampered", b"")
196            .await
197            .unwrap_err();
198        assert!(matches!(err, DecryptError::Other { .. }));
199        // No refresh happened: v1 is still unknown.
200        let m = CipherMatch::builder().kid("v1").build();
201        assert_eq!(decryptor.cipher_match(&m), None);
202    }
203}