Skip to main content

huskarl_core/crypto/cipher/
mod.rs

1//! Cipher implementations for encryption and decryption.
2
3mod error;
4#[cfg(feature = "metrics")]
5mod metrics_decryptor;
6mod multi;
7mod refreshable;
8mod retrying;
9mod scheduled;
10
11use std::{borrow::Cow, sync::Arc};
12
13use bon::Builder;
14pub use error::{DecryptError, UnsealError};
15#[cfg(feature = "metrics")]
16pub use metrics_decryptor::MetricsAeadDecryptor;
17pub use multi::{MultiKeyCipher, MultiKeyDecryptor};
18pub use refreshable::RefreshableCipher;
19pub use retrying::RetryingDecryptor;
20pub use scheduled::ScheduledRefreshCipher;
21
22use crate::{
23    crypto::KeyMatchStrength,
24    error::{Error, ErrorKind},
25    platform::{MaybeSendBoxFuture, MaybeSendSync},
26};
27
28/// The output from [`AeadEncryptor::encrypt`]
29pub struct AeadOutput {
30    /// The nonce (IV) used to encrypt.
31    pub nonce: Vec<u8>,
32    /// The ciphertext resulting from the encryption.
33    pub ciphertext: Vec<u8>,
34    /// The authentication tag resulting from the encryption.
35    pub tag: Vec<u8>,
36}
37
38/// Selection criteria used to choose a content decryption key.
39///
40/// Both fields are optional. When `enc` is `None`, algorithm matching is
41/// skipped and the key is considered algorithm-compatible. When `kid` is
42/// `None`, key ID matching is skipped.
43#[non_exhaustive]
44#[derive(Debug, Clone, Copy, Builder)]
45pub struct CipherMatch<'a> {
46    /// The content encryption algorithm (e.g. from the JWE `enc` header).
47    /// When `None`, the algorithm is not used for matching.
48    pub enc: Option<&'a str>,
49    /// The key ID (e.g. from the JWE `kid` header or an out-of-band source).
50    pub kid: Option<&'a str>,
51}
52
53impl CipherMatch<'_> {
54    /// Computes the match strength for a single key, applying the standard
55    /// rules documented on [`AeadDecryptor::cipher_match`].
56    ///
57    /// `enc_algorithm` is the content encryption algorithm of the key;
58    /// `registered_kid` is the key ID registered for the key, if any.
59    ///
60    /// Single-key [`AeadDecryptor`] implementations should delegate to this
61    /// from [`cipher_match`](AeadDecryptor::cipher_match) rather than
62    /// re-implementing the rules — in particular the requirement that a
63    /// `kid` mismatch returns `None` rather than `Some(ByAlgorithm)`.
64    #[must_use]
65    pub fn strength_for(
66        &self,
67        enc_algorithm: &str,
68        registered_kid: Option<&str>,
69    ) -> Option<KeyMatchStrength> {
70        if let Some(enc) = self.enc
71            && enc != enc_algorithm
72        {
73            return None;
74        }
75
76        crate::crypto::kid_match_strength(self.kid, registered_kid)
77    }
78}
79
80/// Trait for AEAD encryption.
81///
82/// This trait is dyn-capable: consumers store it as `Arc<dyn AeadEncryptor>`.
83/// Write the `encrypt` body as `Box::pin(async move { ... })`; failures
84/// classify as [`ErrorKind::Crypto`].
85pub trait AeadEncryptor: std::fmt::Debug + MaybeSendSync {
86    /// Returns the content encryption algorithm identifier (e.g. `A256GCM`).
87    fn enc_algorithm(&self) -> Cow<'_, str>;
88
89    /// Returns the key ID for this encryptor, if any.
90    fn key_id(&self) -> Option<Cow<'_, str>>;
91
92    /// Asynchronously encrypts the given plaintext with the associated data.
93    ///
94    /// Implementations that draw a random nonce per call (e.g. AES-GCM with
95    /// its 96-bit nonce) carry a per-key encryption bound — NIST SP 800-38D
96    /// §8.3 caps random-nonce GCM at 2^32 invocations per key. See the
97    /// implementation's documentation, and size key rotation accordingly.
98    ///
99    /// # Errors
100    ///
101    /// Returns [`ErrorKind::Crypto`] if the encryption operation fails.
102    fn encrypt<'a>(
103        &'a self,
104        plaintext: &'a [u8],
105        aad: &'a [u8],
106    ) -> MaybeSendBoxFuture<'a, Result<AeadOutput, Error>>;
107}
108
109/// Trait for AEAD decryption.
110///
111/// Exposes key selection via [`cipher_match`](Self::cipher_match) so that
112/// multi-key types can dispatch to the correct decryptor.
113///
114/// This trait is dyn-capable: consumers store it as `Arc<dyn AeadDecryptor>`.
115pub trait AeadDecryptor: std::fmt::Debug + MaybeSendSync {
116    /// Returns how well this decryptor matches the given selection criteria.
117    ///
118    /// Implementations must return:
119    ///
120    /// - `Some(ByKeyId)` — the algorithm is compatible (matches or was not specified)
121    ///   **and** both the criteria and this decryptor have a `kid`, and they are equal.
122    /// - `Some(ByAlgorithm)` — the algorithm is compatible, but the `kid` could not be
123    ///   used for matching: either the criteria has no `kid`, or this decryptor has no
124    ///   `kid` registered.
125    /// - `None` — the algorithm is unsupported by this decryptor, **or** both the
126    ///   criteria and this decryptor have a `kid` but they differ.
127    ///
128    /// Single-key implementations should delegate to
129    /// [`CipherMatch::strength_for`], which implements these rules.
130    fn cipher_match(&self, m: &CipherMatch<'_>) -> Option<KeyMatchStrength>;
131
132    /// Asynchronously decrypts the given ciphertext with the provided nonce, tag, and associated data.
133    ///
134    /// `cipher_match` carries the selection criteria (algorithm and key ID) from the
135    /// caller, when available. Multi-key implementations use this to dispatch to
136    /// the correct key without trying every candidate. Single-key implementations
137    /// may ignore it.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`DecryptError::NoMatchingKey`] if no key matched the selection
142    /// criteria — decryption was not attempted, and
143    /// [`RetryingDecryptor`] treats this as grounds for a refresh and one
144    /// retry. All other failures — including authentication failure — classify
145    /// as [`ErrorKind::Crypto`] via [`DecryptError::Other`].
146    fn decrypt<'a>(
147        &'a self,
148        cipher_match: Option<&'a CipherMatch<'a>>,
149        nonce: &'a [u8],
150        ciphertext: &'a [u8],
151        tag: &'a [u8],
152        aad: &'a [u8],
153    ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>>;
154
155    /// Requests a best-effort refresh of the decryptor's key material — the cipher
156    /// analogue of
157    /// [`JwsVerifier::try_refresh`](crate::crypto::verifier::JwsVerifier::try_refresh).
158    ///
159    /// Called automatically by [`RetryingDecryptor`] when no key
160    /// matches, and may also be called manually. A wrapping policy layer may
161    /// rate-limit or decline the request, so it can return `false` without
162    /// reloading; for a guaranteed reload, use a refreshable wrapper's inherent
163    /// `refresh`.
164    ///
165    /// Returns `true` if new key material was loaded (or was concurrently loaded by another
166    /// task). Returns `false` if no refresh was needed, attempted, or successful. The default
167    /// implementation always returns `false`.
168    fn try_refresh(&self) -> MaybeSendBoxFuture<'_, bool> {
169        Box::pin(async { false })
170    }
171}
172
173/// Combined trait for types that can both encrypt and decrypt.
174///
175/// Automatically implemented for any type with both capabilities. Store as
176/// `Arc<dyn AeadCipher>` when both directions must come from the same source
177/// (e.g. a symmetric AEAD key).
178pub trait AeadCipher: AeadEncryptor + AeadDecryptor {}
179
180impl<T: AeadEncryptor + AeadDecryptor + ?Sized> AeadCipher for T {}
181
182/// A selector for an AEAD encryptor.
183///
184/// Returns an encryptor that is a frozen snapshot of the current key; hold it
185/// briefly (for one encryption) and drop it. Selection is **async** so a
186/// refreshing implementation (e.g. `ScheduledRefreshCipher`) can reload a stale
187/// key before handing it back. See [composing crypto
188/// strategies](crate::_docs::explanation::crypto_strategies) for why outbound
189/// operations select rather than hot-swap.
190///
191/// This trait is dyn-capable: consumers store it as
192/// `Arc<dyn AeadCipherSelector>`.
193pub trait AeadCipherSelector: std::fmt::Debug + MaybeSendSync {
194    /// Selects the current encryptor to use for encryption, refreshing stale key
195    /// material first where the implementation supports it.
196    fn select_cipher(&self) -> MaybeSendBoxFuture<'_, Arc<dyn AeadEncryptor>>;
197}
198
199/// An encryptor that produces self-contained, versioned bundles.
200///
201/// Where [`AeadEncryptor::encrypt`] returns the nonce, ciphertext, and tag as
202/// separate fields, a sealer packs them into one opaque byte string so the value
203/// can travel on its own — an encrypted cookie, a stateless token — and be
204/// re-opened later ([`AeadUnsealer`]) with just the bundle and the key.
205/// [`AeadV1Cipher`] is the standard implementation; see it for a worked example.
206pub trait AeadSealer: AeadEncryptor {
207    /// Encrypts `plaintext` and returns a versioned bundle:
208    /// `[0x01 || nonce_len:u8 || tag_len:u8 || nonce || ciphertext || tag]`.
209    fn seal<'a>(
210        &'a self,
211        plaintext: &'a [u8],
212        aad: &'a [u8],
213    ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, Error>>;
214}
215
216/// A decryptor that consumes self-contained bundles produced by [`AeadSealer`].
217pub trait AeadUnsealer: AeadDecryptor {
218    /// Decrypts a versioned bundle produced by [`AeadSealer::seal`].
219    ///
220    /// `cipher_match` carries optional key selection criteria from an out-of-band
221    /// source (e.g. a cookie attribute or database column). Multi-key decryptors
222    /// use this to select the correct key without trying all candidates.
223    ///
224    /// # Errors
225    ///
226    /// Returns [`DecryptError::NoMatchingKey`] if no key matched the selection
227    /// criteria; all other failures — malformed bundles, authentication
228    /// failure — classify as [`ErrorKind::Crypto`] via [`DecryptError::Other`].
229    fn unseal<'a>(
230        &'a self,
231        cipher_match: Option<&'a CipherMatch<'a>>,
232        bundle: &'a [u8],
233        aad: &'a [u8],
234    ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>>;
235}
236
237/// Combined trait for types that can both seal and unseal bundles.
238///
239/// Automatically implemented for any type with both capabilities — the
240/// bundle-level analogue of [`AeadCipher`]. Store as
241/// `Arc<dyn SealedAeadCipher>` when both directions must come from the same
242/// source (e.g. [`AeadV1Cipher`] over a symmetric or KMS-backed key).
243pub trait SealedAeadCipher: AeadSealer + AeadUnsealer {}
244
245impl<T: AeadSealer + AeadUnsealer + ?Sized> SealedAeadCipher for T {}
246
247/// A selector for an [`AeadSealer`] — the bundle-level analogue of
248/// [`AeadCipherSelector`].
249///
250/// [`select_sealer`](Self::select_sealer) hands back an [`AeadSealer`] that is a
251/// **frozen snapshot** of the current key, so reading its
252/// [`key_id`](AeadEncryptor::key_id)/[`enc_algorithm`](AeadEncryptor::enc_algorithm)
253/// and then [`seal`](AeadSealer::seal)ing against it describe and use the *same*
254/// key even if a rotation lands between the two calls. That is what lets a caller
255/// emit a `kid` alongside a bundle for a later [`unseal`](AeadUnsealer::unseal) to
256/// select on. Selection is **async** so a refreshing implementation (e.g.
257/// [`ScheduledRefreshCipher`]) reloads a stale key first; hold the returned sealer
258/// briefly (for one seal) and drop it. See [the sealing section of composing
259/// crypto strategies](crate::_docs::explanation::crypto_strategies) for the
260/// rotation hazard this prevents.
261///
262/// This trait is dyn-capable: consumers store it as
263/// `Arc<dyn AeadSealerSelector>`.
264pub trait AeadSealerSelector: std::fmt::Debug + MaybeSendSync {
265    /// Selects the current sealer — a frozen key snapshot — refreshing stale key
266    /// material first where the implementation supports it.
267    fn select_sealer(&self) -> MaybeSendBoxFuture<'_, Arc<dyn AeadSealer>>;
268}
269
270/// Combined trait for a sealed-bundle cipher driven through the selector API: it
271/// selects a frozen [`AeadSealer`] on the outbound side and
272/// [`unseal`](AeadUnsealer::unseal)s on the inbound one.
273///
274/// The selector-side analogue of [`SealedAeadCipher`] — where that is one object
275/// that both seals and unseals a fixed key, this selects a *fresh* sealer each
276/// time (so rotation stays safe) while still unsealing directly. Implemented by
277/// the composed refresh stack ([`ScheduledRefreshCipher`], [`RefreshableCipher`])
278/// and by the fixed [`StaticAeadCipher`] base case.
279///
280/// Automatically implemented for any type with both capabilities. Store as
281/// `Arc<dyn SealedAeadCipherSelector>` to erase the concrete key source while
282/// keeping a single value that covers both directions.
283pub trait SealedAeadCipherSelector: AeadSealerSelector + AeadUnsealer {}
284
285impl<T: AeadSealerSelector + AeadUnsealer + ?Sized> SealedAeadCipherSelector for T {}
286
287macro_rules! forward_aead_encryptor {
288    ($wrapper:ty) => {
289        impl<T: AeadEncryptor + ?Sized> AeadEncryptor for $wrapper {
290            fn enc_algorithm(&self) -> Cow<'_, str> {
291                (**self).enc_algorithm()
292            }
293
294            fn key_id(&self) -> Option<Cow<'_, str>> {
295                (**self).key_id()
296            }
297
298            fn encrypt<'a>(
299                &'a self,
300                plaintext: &'a [u8],
301                aad: &'a [u8],
302            ) -> MaybeSendBoxFuture<'a, Result<AeadOutput, Error>> {
303                (**self).encrypt(plaintext, aad)
304            }
305        }
306    };
307}
308
309forward_aead_encryptor!(&T);
310forward_aead_encryptor!(Box<T>);
311forward_aead_encryptor!(Arc<T>);
312
313macro_rules! forward_aead_decryptor {
314    ($wrapper:ty) => {
315        impl<T: AeadDecryptor + ?Sized> AeadDecryptor for $wrapper {
316            fn cipher_match(&self, m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
317                (**self).cipher_match(m)
318            }
319
320            fn decrypt<'a>(
321                &'a self,
322                cipher_match: Option<&'a CipherMatch<'a>>,
323                nonce: &'a [u8],
324                ciphertext: &'a [u8],
325                tag: &'a [u8],
326                aad: &'a [u8],
327            ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
328                (**self).decrypt(cipher_match, nonce, ciphertext, tag, aad)
329            }
330
331            fn try_refresh(&self) -> MaybeSendBoxFuture<'_, bool> {
332                (**self).try_refresh()
333            }
334        }
335    };
336}
337
338forward_aead_decryptor!(&T);
339forward_aead_decryptor!(Box<T>);
340forward_aead_decryptor!(Arc<T>);
341
342macro_rules! forward_aead_cipher_selector {
343    ($wrapper:ty) => {
344        impl<T: AeadCipherSelector + ?Sized> AeadCipherSelector for $wrapper {
345            fn select_cipher(&self) -> MaybeSendBoxFuture<'_, Arc<dyn AeadEncryptor>> {
346                (**self).select_cipher()
347            }
348        }
349    };
350}
351
352forward_aead_cipher_selector!(&T);
353forward_aead_cipher_selector!(Box<T>);
354forward_aead_cipher_selector!(Arc<T>);
355
356macro_rules! forward_aead_sealer {
357    ($wrapper:ty) => {
358        impl<T: AeadSealer + ?Sized> AeadSealer for $wrapper {
359            fn seal<'a>(
360                &'a self,
361                plaintext: &'a [u8],
362                aad: &'a [u8],
363            ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, Error>> {
364                (**self).seal(plaintext, aad)
365            }
366        }
367    };
368}
369
370forward_aead_sealer!(&T);
371forward_aead_sealer!(Box<T>);
372forward_aead_sealer!(Arc<T>);
373
374macro_rules! forward_aead_unsealer {
375    ($wrapper:ty) => {
376        impl<T: AeadUnsealer + ?Sized> AeadUnsealer for $wrapper {
377            fn unseal<'a>(
378                &'a self,
379                cipher_match: Option<&'a CipherMatch<'a>>,
380                bundle: &'a [u8],
381                aad: &'a [u8],
382            ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
383                (**self).unseal(cipher_match, bundle, aad)
384            }
385        }
386    };
387}
388
389forward_aead_unsealer!(&T);
390forward_aead_unsealer!(Box<T>);
391forward_aead_unsealer!(Arc<T>);
392
393macro_rules! forward_aead_sealer_selector {
394    ($wrapper:ty) => {
395        impl<T: AeadSealerSelector + ?Sized> AeadSealerSelector for $wrapper {
396            fn select_sealer(&self) -> MaybeSendBoxFuture<'_, Arc<dyn AeadSealer>> {
397                (**self).select_sealer()
398            }
399        }
400    };
401}
402
403forward_aead_sealer_selector!(&T);
404forward_aead_sealer_selector!(Box<T>);
405forward_aead_sealer_selector!(Arc<T>);
406
407/// A bundle wrapper over an AEAD cipher using the v1 format:
408/// `[0x01 || nonce_len:u8 || tag_len:u8 || nonce || ciphertext || tag]`.
409///
410/// The implementations are conditional on the inner type's capabilities:
411/// wrapping an [`AeadCipher`] (e.g. a symmetric key, or a KMS-backed cipher
412/// that handles both directions as one consistent object) yields a
413/// [`SealedAeadCipher`]; wrapping an encrypt-only [`AeadEncryptor`] yields
414/// only an [`AeadSealer`], and a decrypt-only [`AeadDecryptor`] (e.g. a
415/// retired rotation key) only an [`AeadUnsealer`].
416///
417/// # Example
418///
419/// ```
420/// # use std::borrow::Cow;
421/// # use huskarl_core::crypto::KeyMatchStrength;
422/// # use huskarl_core::crypto::cipher::{
423/// #     AeadDecryptor, AeadEncryptor, AeadOutput, AeadSealer, AeadUnsealer, AeadV1Cipher,
424/// #     CipherMatch, DecryptError,
425/// # };
426/// # use huskarl_core::error::Error;
427/// # use huskarl_core::platform::MaybeSendBoxFuture;
428/// # // Stand-in for the AEAD cipher a crypto backend (native / WebCrypto) provides.
429/// # #[derive(Debug)]
430/// # struct BackendCipher;
431/// # impl AeadEncryptor for BackendCipher {
432/// #     fn enc_algorithm(&self) -> Cow<'_, str> { "A256GCM".into() }
433/// #     fn key_id(&self) -> Option<Cow<'_, str>> { None }
434/// #     fn encrypt<'a>(&'a self, plaintext: &'a [u8], _aad: &'a [u8])
435/// #         -> MaybeSendBoxFuture<'a, Result<AeadOutput, Error>> {
436/// #         let ciphertext = plaintext.to_vec();
437/// #         Box::pin(async move { Ok(AeadOutput { nonce: vec![7], ciphertext, tag: vec![9] }) })
438/// #     }
439/// # }
440/// # impl AeadDecryptor for BackendCipher {
441/// #     fn cipher_match(&self, _m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
442/// #         Some(KeyMatchStrength::ByAlgorithm)
443/// #     }
444/// #     fn decrypt<'a>(&'a self, _cm: Option<&'a CipherMatch<'a>>, _nonce: &'a [u8],
445/// #         ciphertext: &'a [u8], _tag: &'a [u8], _aad: &'a [u8])
446/// #         -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
447/// #         let plaintext = ciphertext.to_vec();
448/// #         Box::pin(async move { Ok(plaintext) })
449/// #     }
450/// # }
451/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
452/// let cipher = AeadV1Cipher::new(BackendCipher);
453///
454/// // `seal` packs the version byte, nonce, tag and ciphertext into one bundle...
455/// let bundle = cipher.seal(b"session-state", b"aad").await?;
456/// // ...that `unseal` re-opens with only the bundle and the same aad.
457/// let recovered = cipher.unseal(None, &bundle, b"aad").await?;
458/// assert_eq!(recovered, b"session-state");
459/// # Ok(())
460/// # }
461/// ```
462#[derive(Debug)]
463pub struct AeadV1Cipher<C>(C);
464
465impl<C> AeadV1Cipher<C> {
466    /// Wraps a cipher in the v1 bundle format.
467    pub fn new(cipher: C) -> Self {
468        Self(cipher)
469    }
470}
471
472impl<C: AeadEncryptor> AeadEncryptor for AeadV1Cipher<C> {
473    fn enc_algorithm(&self) -> Cow<'_, str> {
474        self.0.enc_algorithm()
475    }
476
477    fn key_id(&self) -> Option<Cow<'_, str>> {
478        self.0.key_id()
479    }
480
481    fn encrypt<'a>(
482        &'a self,
483        plaintext: &'a [u8],
484        aad: &'a [u8],
485    ) -> MaybeSendBoxFuture<'a, Result<AeadOutput, Error>> {
486        self.0.encrypt(plaintext, aad)
487    }
488}
489
490impl<C: AeadEncryptor> AeadSealer for AeadV1Cipher<C> {
491    fn seal<'a>(
492        &'a self,
493        plaintext: &'a [u8],
494        aad: &'a [u8],
495    ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, Error>> {
496        Box::pin(async move {
497            let output = self.encrypt(plaintext, aad).await?;
498
499            let nonce_len: u8 = output.nonce.len().try_into().map_err(|_| {
500                Error::new(crate::ErrorKind::Crypto, "nonce length exceeds u8::MAX")
501            })?;
502            let tag_len: u8 =
503                output.tag.len().try_into().map_err(|_| {
504                    Error::new(crate::ErrorKind::Crypto, "tag length exceeds u8::MAX")
505                })?;
506
507            let mut bundle = Vec::with_capacity(
508                3 + output.nonce.len() + output.ciphertext.len() + output.tag.len(),
509            );
510            bundle.push(0x01);
511            bundle.push(nonce_len);
512            bundle.push(tag_len);
513            bundle.extend_from_slice(&output.nonce);
514            bundle.extend_from_slice(&output.ciphertext);
515            bundle.extend_from_slice(&output.tag);
516
517            Ok(bundle)
518        })
519    }
520}
521
522fn invalid_bundle() -> Error {
523    Error::new(ErrorKind::Crypto, UnsealError::InvalidBundle)
524}
525
526impl<C: AeadDecryptor> AeadDecryptor for AeadV1Cipher<C> {
527    fn cipher_match(&self, m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
528        self.0.cipher_match(m)
529    }
530
531    fn decrypt<'a>(
532        &'a self,
533        cipher_match: Option<&'a CipherMatch<'a>>,
534        nonce: &'a [u8],
535        ciphertext: &'a [u8],
536        tag: &'a [u8],
537        aad: &'a [u8],
538    ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
539        self.0.decrypt(cipher_match, nonce, ciphertext, tag, aad)
540    }
541
542    fn try_refresh(&self) -> MaybeSendBoxFuture<'_, bool> {
543        self.0.try_refresh()
544    }
545}
546
547impl<C: AeadDecryptor> AeadUnsealer for AeadV1Cipher<C> {
548    fn unseal<'a>(
549        &'a self,
550        cipher_match: Option<&'a CipherMatch<'a>>,
551        bundle: &'a [u8],
552        aad: &'a [u8],
553    ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
554        Box::pin(async move {
555            if bundle.len() < 3 || bundle[0] != 0x01 {
556                return Err(invalid_bundle().into());
557            }
558
559            let nonce_len = bundle[1] as usize;
560            let tag_len = bundle[2] as usize;
561
562            if bundle.len() < 3 + nonce_len + tag_len {
563                return Err(invalid_bundle().into());
564            }
565
566            let nonce = &bundle[3..3 + nonce_len];
567            let tag = &bundle[bundle.len() - tag_len..];
568            let ciphertext = &bundle[3 + nonce_len..bundle.len() - tag_len];
569
570            self.decrypt(cipher_match, nonce, ciphertext, tag, aad)
571                .await
572        })
573    }
574}
575
576/// A fixed AEAD cipher presented through the selection API.
577///
578/// The non-rotating base case for the outbound selector traits: it holds one key
579/// and hands that same key back from
580/// [`select_cipher`](AeadCipherSelector::select_cipher) and
581/// [`select_sealer`](AeadSealerSelector::select_sealer). Reach for it where a
582/// consumer wants a selector (for example a resource server's sealed
583/// `DPoP`-nonce checker, which takes an [`AeadSealerSelector`]) but the key never
584/// rotates — the cipher analogue of a
585/// signing key that is its own
586/// [`JwsSignerSelector`](crate::crypto::signer::JwsSignerSelector). For a rotating
587/// key, use [`RefreshableCipher`]/[`ScheduledRefreshCipher`] instead; they
588/// implement the same traits.
589///
590/// It also implements [`AeadDecryptor`] and [`AeadUnsealer`] by delegating to the
591/// held key, so one value covers both directions of a sealed round-trip.
592#[derive(Debug)]
593pub struct StaticAeadCipher<C> {
594    cipher: Arc<C>,
595}
596
597impl<C> StaticAeadCipher<C> {
598    /// Wraps a fixed AEAD cipher so it can be used through the selector API.
599    pub fn new(cipher: C) -> Self {
600        Self {
601            cipher: Arc::new(cipher),
602        }
603    }
604}
605
606impl<C: AeadEncryptor + 'static> AeadCipherSelector for StaticAeadCipher<C> {
607    fn select_cipher(&self) -> MaybeSendBoxFuture<'_, Arc<dyn AeadEncryptor>> {
608        let encryptor: Arc<dyn AeadEncryptor> = self.cipher.clone();
609        Box::pin(async move { encryptor })
610    }
611}
612
613impl<C: AeadEncryptor + 'static> AeadSealerSelector for StaticAeadCipher<C> {
614    fn select_sealer(&self) -> MaybeSendBoxFuture<'_, Arc<dyn AeadSealer>> {
615        let sealer: Arc<dyn AeadSealer> = Arc::new(AeadV1Cipher::new(Arc::clone(&self.cipher)));
616        Box::pin(async move { sealer })
617    }
618}
619
620impl<C: AeadDecryptor + 'static> AeadDecryptor for StaticAeadCipher<C> {
621    fn cipher_match(&self, m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
622        self.cipher.cipher_match(m)
623    }
624
625    fn decrypt<'a>(
626        &'a self,
627        cipher_match: Option<&'a CipherMatch<'a>>,
628        nonce: &'a [u8],
629        ciphertext: &'a [u8],
630        tag: &'a [u8],
631        aad: &'a [u8],
632    ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
633        self.cipher
634            .decrypt(cipher_match, nonce, ciphertext, tag, aad)
635    }
636
637    fn try_refresh(&self) -> MaybeSendBoxFuture<'_, bool> {
638        self.cipher.try_refresh()
639    }
640}
641
642impl<C: AeadDecryptor + 'static> AeadUnsealer for StaticAeadCipher<C> {
643    fn unseal<'a>(
644        &'a self,
645        cipher_match: Option<&'a CipherMatch<'a>>,
646        bundle: &'a [u8],
647        aad: &'a [u8],
648    ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
649        Box::pin(async move {
650            AeadV1Cipher::new(Arc::clone(&self.cipher))
651                .unseal(cipher_match, bundle, aad)
652                .await
653        })
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    #[derive(Debug)]
662    struct MockEncryptor;
663
664    impl AeadEncryptor for MockEncryptor {
665        fn enc_algorithm(&self) -> Cow<'_, str> {
666            "mock".into()
667        }
668
669        fn key_id(&self) -> Option<Cow<'_, str>> {
670            None
671        }
672
673        fn encrypt<'a>(
674            &'a self,
675            plaintext: &'a [u8],
676            _aad: &'a [u8],
677        ) -> MaybeSendBoxFuture<'a, Result<AeadOutput, Error>> {
678            Box::pin(async move {
679                Ok(AeadOutput {
680                    nonce: vec![1, 2, 3],
681                    ciphertext: plaintext.iter().map(|b| b ^ 0xFF).collect(),
682                    tag: vec![4, 5, 6, 7],
683                })
684            })
685        }
686    }
687
688    #[derive(Debug)]
689    struct MockDecryptor;
690
691    impl AeadDecryptor for MockDecryptor {
692        fn cipher_match(&self, _m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
693            Some(KeyMatchStrength::ByAlgorithm)
694        }
695
696        fn decrypt<'a>(
697            &'a self,
698            _cipher_match: Option<&'a CipherMatch<'a>>,
699            _nonce: &'a [u8],
700            ciphertext: &'a [u8],
701            _tag: &'a [u8],
702            _aad: &'a [u8],
703        ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
704            // Reverse the XOR
705            Box::pin(async move { Ok(ciphertext.iter().map(|b| b ^ 0xFF).collect()) })
706        }
707    }
708
709    /// One object with both capabilities (the symmetric-key / KMS shape).
710    #[derive(Debug)]
711    struct MockCipher;
712
713    impl AeadEncryptor for MockCipher {
714        fn enc_algorithm(&self) -> Cow<'_, str> {
715            MockEncryptor.enc_algorithm()
716        }
717
718        fn key_id(&self) -> Option<Cow<'_, str>> {
719            MockEncryptor.key_id()
720        }
721
722        fn encrypt<'a>(
723            &'a self,
724            plaintext: &'a [u8],
725            aad: &'a [u8],
726        ) -> MaybeSendBoxFuture<'a, Result<AeadOutput, Error>> {
727            Box::pin(async move { MockEncryptor.encrypt(plaintext, aad).await })
728        }
729    }
730
731    impl AeadDecryptor for MockCipher {
732        fn cipher_match(&self, m: &CipherMatch<'_>) -> Option<KeyMatchStrength> {
733            MockDecryptor.cipher_match(m)
734        }
735
736        fn decrypt<'a>(
737            &'a self,
738            cipher_match: Option<&'a CipherMatch<'a>>,
739            nonce: &'a [u8],
740            ciphertext: &'a [u8],
741            tag: &'a [u8],
742            aad: &'a [u8],
743        ) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, DecryptError>> {
744            Box::pin(async move {
745                MockDecryptor
746                    .decrypt(cipher_match, nonce, ciphertext, tag, aad)
747                    .await
748            })
749        }
750    }
751
752    fn assert_invalid_bundle(err: &DecryptError) {
753        let DecryptError::Other { source } = err else {
754            unreachable!("expected DecryptError::Other, got {err:?}");
755        };
756        assert_eq!(source.kind(), ErrorKind::Crypto);
757        assert_eq!(source.to_string(), "cryptographic operation failed");
758        assert_eq!(
759            std::error::Error::source(source)
760                .expect("source")
761                .to_string(),
762            "invalid bundle"
763        );
764    }
765
766    #[tokio::test]
767    async fn seal_roundtrip() {
768        let plaintext = b"hello world";
769        let aad = b"associated";
770
771        let sealer = AeadV1Cipher::new(MockEncryptor);
772        let bundle = sealer.seal(plaintext, aad).await.unwrap();
773
774        let unsealer = AeadV1Cipher::new(MockDecryptor);
775        let recovered = unsealer.unseal(None, &bundle, aad).await.unwrap();
776        assert_eq!(recovered, plaintext);
777    }
778
779    #[tokio::test]
780    async fn erased_cipher_roundtrip() {
781        let sealer: Arc<dyn AeadSealer> = Arc::new(AeadV1Cipher::new(MockEncryptor));
782        let bundle = sealer.seal(b"hello", b"").await.unwrap();
783
784        let unsealer: Arc<dyn AeadUnsealer> = Arc::new(AeadV1Cipher::new(MockDecryptor));
785        let recovered = unsealer.unseal(None, &bundle, b"").await.unwrap();
786        assert_eq!(recovered, b"hello");
787    }
788
789    /// One object covering both directions (the symmetric-key / KMS shape):
790    /// `AeadV1Cipher<C: AeadCipher>` satisfies `SealedAeadCipher`, both
791    /// concretely and erased, including through generic bounds.
792    #[tokio::test]
793    async fn one_object_sealed_cipher_roundtrip() {
794        async fn roundtrip(cipher: impl AeadSealer + AeadUnsealer) {
795            let bundle = cipher.seal(b"hello", b"aad").await.unwrap();
796            let recovered = cipher.unseal(None, &bundle, b"aad").await.unwrap();
797            assert_eq!(recovered, b"hello");
798        }
799
800        roundtrip(AeadV1Cipher::new(MockCipher)).await;
801
802        let erased: Arc<dyn SealedAeadCipher> = Arc::new(AeadV1Cipher::new(MockCipher));
803        roundtrip(erased).await;
804    }
805
806    #[tokio::test]
807    async fn bundle_format() {
808        let plaintext = b"AB";
809        let sealer = AeadV1Cipher::new(MockEncryptor);
810        let bundle = sealer.seal(plaintext, b"").await.unwrap();
811
812        // [0x01, nonce_len=3, tag_len=4, nonce=[1,2,3], ciphertext=[0xBE, 0xBD], tag=[4,5,6,7]]
813        assert_eq!(bundle[0], 0x01);
814        assert_eq!(bundle[1], 3); // nonce_len
815        assert_eq!(bundle[2], 4); // tag_len
816        assert_eq!(&bundle[3..6], &[1, 2, 3]); // nonce
817        assert_eq!(&bundle[6..8], &[0x41 ^ 0xFF, 0x42 ^ 0xFF]); // ciphertext
818        assert_eq!(&bundle[8..12], &[4, 5, 6, 7]); // tag
819    }
820
821    #[tokio::test]
822    async fn unseal_wrong_version() {
823        let unsealer = AeadV1Cipher::new(MockDecryptor);
824        let err = unsealer.unseal(None, &[0x02, 0, 0], b"").await.unwrap_err();
825        assert_invalid_bundle(&err);
826    }
827
828    #[tokio::test]
829    async fn unseal_too_short() {
830        let unsealer = AeadV1Cipher::new(MockDecryptor);
831        let err = unsealer.unseal(None, &[0x01], b"").await.unwrap_err();
832        assert_invalid_bundle(&err);
833    }
834
835    #[tokio::test]
836    async fn unseal_truncated() {
837        let unsealer = AeadV1Cipher::new(MockDecryptor);
838        // nonce_len=10, tag_len=10, but only 1 byte of data after header
839        let err = unsealer
840            .unseal(None, &[0x01, 10, 10, 0x00], b"")
841            .await
842            .unwrap_err();
843        assert_invalid_bundle(&err);
844    }
845
846    #[tokio::test]
847    async fn unseal_empty() {
848        let unsealer = AeadV1Cipher::new(MockDecryptor);
849        let err = unsealer.unseal(None, &[], b"").await.unwrap_err();
850        assert_invalid_bundle(&err);
851    }
852
853    fn cipher_match<'a>(enc: Option<&'a str>, kid: Option<&'a str>) -> CipherMatch<'a> {
854        CipherMatch { enc, kid }
855    }
856
857    #[test]
858    fn strength_for_enc_mismatch() {
859        let m = cipher_match(Some("A128GCM"), Some("kid-1"));
860        assert_eq!(m.strength_for("A256GCM", Some("kid-1")), None);
861    }
862
863    #[test]
864    fn strength_for_no_enc_is_compatible() {
865        let m = cipher_match(None, None);
866        assert_eq!(
867            m.strength_for("A256GCM", None),
868            Some(KeyMatchStrength::ByAlgorithm)
869        );
870    }
871
872    #[test]
873    fn strength_for_matching_kid() {
874        let m = cipher_match(Some("A256GCM"), Some("kid-1"));
875        assert_eq!(
876            m.strength_for("A256GCM", Some("kid-1")),
877            Some(KeyMatchStrength::ByKeyId)
878        );
879    }
880
881    #[test]
882    fn strength_for_kid_mismatch_is_none_not_by_algorithm() {
883        let m = cipher_match(None, Some("kid-1"));
884        assert_eq!(m.strength_for("A256GCM", Some("kid-2")), None);
885    }
886
887    #[test]
888    fn strength_for_missing_kid_falls_back_to_algorithm() {
889        let m = cipher_match(Some("A256GCM"), None);
890        assert_eq!(
891            m.strength_for("A256GCM", Some("kid-1")),
892            Some(KeyMatchStrength::ByAlgorithm)
893        );
894        let m = cipher_match(Some("A256GCM"), Some("kid-1"));
895        assert_eq!(
896            m.strength_for("A256GCM", None),
897            Some(KeyMatchStrength::ByAlgorithm)
898        );
899    }
900}