Skip to main content

stenoxide_core/crypto/
aead.rs

1//! XChaCha20-Poly1305 authenticated encryption of the payload.
2//!
3//! The payload is compressed before it is encrypted, never the other way round:
4//! ciphertext is indistinguishable from random and therefore incompressible, so
5//! compressing afterwards would cost time and save nothing. Compressing first
6//! also shrinks what has to be embedded, which directly lowers the bits per
7//! pixel the embedding layer needs — the single most important factor in
8//! staying invisible to steganalysis.
9
10use std::fmt;
11use std::io::Read;
12
13use chacha20poly1305::aead::{Aead, KeyInit, Payload};
14use chacha20poly1305::XChaCha20Poly1305;
15use zeroize::Zeroizing;
16
17/// Associated data bound into every tag produced by this crate.
18///
19/// It is not secret and not transmitted: both sides recompute it. Its purpose
20/// is to make a ciphertext produced by `stenoxide` fail authentication if it is
21/// ever fed to a different XChaCha20-Poly1305 construction, and vice versa.
22///
23/// Readable inside the crate because [`crate::generate`] seals a buffer that is
24/// not simply `zstd(message)` and therefore cannot go through
25/// [`compress_and_encrypt`] — but must be bound to the same construction, so
26/// that one associated string covers everything this crate encrypts.
27pub(crate) const STENOXIDE_AAD: &[u8] = b"STENOXIDE-v1";
28
29/// Associated data bound into the tag of a passphrase-protected private key
30/// file.
31///
32/// A second string rather than [`STENOXIDE_AAD`] because the two protect
33/// different things under keys derived the same way: one is a payload hidden in
34/// a container, the other is a key file sitting on the owner's disk. Binding
35/// them apart means a container's ciphertext handed to the key-file reader — or
36/// the reverse — fails authentication instead of being decrypted into
37/// something that has to be judged afterwards.
38#[cfg(feature = "pqc")]
39pub(crate) const STENOXIDE_IDENTITY_AAD: &[u8] = b"STENOXIDE-identity-v1";
40
41/// Zstandard compression level. The maximum non-ultra level: the payload is
42/// small and compressed exactly once, so spending time here is free compared
43/// with the embedding capacity it buys back.
44const ZSTD_LEVEL: i32 = 19;
45
46/// Largest plaintext one authenticated payload is allowed to expand to.
47///
48/// # Why a ceiling exists at all
49///
50/// Zstandard is a compression format, not a container format: a frame says how
51/// much it expands to and the decoder obliges. A few kilobytes of ciphertext
52/// can therefore ask for terabytes of memory, and an unbounded
53/// [`decompress`] would hand that request straight to the allocator.
54///
55/// Authentication runs first, so a stranger never reaches this code — but the
56/// threat is not a stranger. The sender is whoever holds the key, and holding
57/// the key is not the same as being a friend. A correspondent can build a frame
58/// that fits inside a container the receiver accepts and expands to more memory
59/// than the receiver has. With the embedding path the damage is bounded by a
60/// capacity of a few kilobytes; with [`crate::generate`], where the default
61/// container carries 1.45 MB and every sample is free, a valid container can
62/// ask for tens of gigabytes. This constant is what turns that request into an
63/// error instead.
64///
65/// # Where the number comes from
66///
67/// The bound is a property of the system rather than a guess. The largest
68/// payload this crate can *produce* is limited by the container that carries
69/// it: the generative mode fills every sample, so at the `MAX_PIXELS` ceiling
70/// of layer 1 — 128 Mi pixels over three channels, one bit each — the largest
71/// ciphertext that can exist is 48 MiB, the default 2000x2000 container carries
72/// 1.45 MB, and the embedding path carries some 7 KB. Half a gibibyte is
73/// therefore a tenfold expansion of the largest container this system will draw
74/// and roughly three hundredfold of the ordinary one, which is far past any
75/// compression ratio a payload worth hiding reaches.
76///
77/// From the other side it stays well under what a machine can spare: layer 1
78/// already commits to a peak working set of about two gibibytes when it accepts
79/// a maximum-size container, so the ceiling does not raise the memory profile
80/// the program already has. The rounding to a power of two is arbitrary and
81/// admitted as such; the order of magnitude is not.
82const MAX_DECOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
83
84/// Failures of the authenticated encryption primitive.
85#[derive(Debug)]
86pub enum AEADError {
87    /// The ciphertext did not authenticate.
88    ///
89    /// A wrong key, a wrong nonce, a modified tag and a truncated ciphertext
90    /// all collapse into this one variant on purpose; see
91    /// [`AEADCipher::decrypt`].
92    AuthenticationFailed,
93    /// The cipher failed while encrypting.
94    CipherError(String),
95}
96
97impl fmt::Display for AEADError {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        match self {
100            AEADError::AuthenticationFailed => {
101                write!(f, "authentication failed: wrong password or corrupted data")
102            }
103            AEADError::CipherError(message) => write!(f, "cipher error: {message}"),
104        }
105    }
106}
107
108impl std::error::Error for AEADError {}
109
110/// Failures of the combined compression and encryption stages.
111#[derive(Debug)]
112pub enum CryptoError {
113    /// Zstandard could not compress the plaintext.
114    CompressionError(String),
115    /// Zstandard could not decompress the authenticated plaintext.
116    DecompressionError(String),
117    /// The authenticated encryption layer failed.
118    AEADError(AEADError),
119}
120
121impl fmt::Display for CryptoError {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            CryptoError::CompressionError(message) => {
125                write!(f, "failed to compress the payload: {message}")
126            }
127            CryptoError::DecompressionError(message) => {
128                write!(f, "failed to decompress the payload: {message}")
129            }
130            CryptoError::AEADError(err) => write!(f, "{err}"),
131        }
132    }
133}
134
135impl std::error::Error for CryptoError {
136    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
137        match self {
138            CryptoError::AEADError(err) => Some(err),
139            _ => None,
140        }
141    }
142}
143
144impl From<AEADError> for CryptoError {
145    fn from(err: AEADError) -> Self {
146        CryptoError::AEADError(err)
147    }
148}
149
150/// Authenticated encryption with associated data.
151///
152/// Abstracted behind a trait so the pipeline depends on the operation and not
153/// on the concrete cipher. `Send + Sync` because a single cipher value is
154/// shared by reference across the pipeline's worker threads.
155pub trait AEADCipher: Send + Sync {
156    /// Encrypts `plaintext` under `key` and `nonce`, binding `aad` to the tag.
157    ///
158    /// The returned buffer is the ciphertext with the 16-byte Poly1305 tag
159    /// appended, and it is wiped when dropped.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`AEADError::CipherError`] if the underlying cipher fails.
164    fn encrypt(
165        &self,
166        key: &[u8; 32],
167        nonce: &[u8; 24],
168        plaintext: &[u8],
169        aad: &[u8],
170    ) -> Result<Zeroizing<Vec<u8>>, AEADError>;
171
172    /// Decrypts and authenticates `ciphertext`, which must carry its trailing
173    /// tag and must have been produced with the same `aad`.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`AEADError::AuthenticationFailed`], and nothing else. Every
178    /// internal cause — invalid tag, wrong key, truncated input — is collapsed
179    /// into that single variant, because distinguishing them would hand an
180    /// attacker an oracle that tells them *why* their guess was rejected.
181    fn decrypt(
182        &self,
183        key: &[u8; 32],
184        nonce: &[u8; 24],
185        ciphertext: &[u8],
186        aad: &[u8],
187    ) -> Result<Zeroizing<Vec<u8>>, AEADError>;
188}
189
190/// The production cipher: XChaCha20-Poly1305.
191///
192/// The 192-bit extended nonce is what makes the derived — rather than random —
193/// nonce of [`crate::crypto::expand`] safe: the space is far too large for the
194/// birthday bound to matter.
195#[derive(Debug, Default, Clone, Copy)]
196pub struct XChaCha20Poly1305Cipher;
197
198impl XChaCha20Poly1305Cipher {
199    /// Builds the cipher. It is stateless; the key arrives per call.
200    pub fn new() -> Self {
201        Self
202    }
203}
204
205impl AEADCipher for XChaCha20Poly1305Cipher {
206    fn encrypt(
207        &self,
208        key: &[u8; 32],
209        nonce: &[u8; 24],
210        plaintext: &[u8],
211        aad: &[u8],
212    ) -> Result<Zeroizing<Vec<u8>>, AEADError> {
213        let cipher = XChaCha20Poly1305::new(key.into());
214        // The crate appends the 16-byte Poly1305 tag to the ciphertext itself,
215        // so there is no tag to carry or splice by hand on either side.
216        let ciphertext = cipher
217            .encrypt(
218                nonce.into(),
219                Payload {
220                    msg: plaintext,
221                    aad,
222                },
223            )
224            .map_err(|err| AEADError::CipherError(err.to_string()))?;
225
226        Ok(Zeroizing::new(ciphertext))
227    }
228
229    fn decrypt(
230        &self,
231        key: &[u8; 32],
232        nonce: &[u8; 24],
233        ciphertext: &[u8],
234        aad: &[u8],
235    ) -> Result<Zeroizing<Vec<u8>>, AEADError> {
236        let cipher = XChaCha20Poly1305::new(key.into());
237        let plaintext = cipher
238            .decrypt(
239                nonce.into(),
240                Payload {
241                    msg: ciphertext,
242                    aad,
243                },
244            )
245            // The inner error is discarded deliberately: it is the only place
246            // where the failure reason could leak out of this layer.
247            .map_err(|_| AEADError::AuthenticationFailed)?;
248
249        Ok(Zeroizing::new(plaintext))
250    }
251}
252
253/// Compresses `plaintext` with Zstandard, at the one level this crate uses.
254///
255/// Split out of [`compress_and_encrypt`] because the generative container mode
256/// compresses at the same level and then seals the result inside a larger
257/// buffer of its own; the compression level is a property of the format both
258/// share, and there is one definition of it.
259///
260/// # Errors
261///
262/// Returns [`CryptoError::CompressionError`] if Zstandard fails.
263pub(crate) fn compress(plaintext: &[u8]) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
264    zstd::encode_all(plaintext, ZSTD_LEVEL)
265        .map(Zeroizing::new)
266        .map_err(|err| CryptoError::CompressionError(err.to_string()))
267}
268
269/// Decompresses an authenticated Zstandard frame, up to a fixed ceiling.
270///
271/// The inverse of [`compress`]. Nothing must reach this that the Poly1305 tag
272/// has not already vouched for; both callers arrange that.
273///
274/// The output is bounded by [`MAX_DECOMPRESSED_BYTES`], and it is bounded while
275/// the frame is being read rather than checked afterwards: the decoder is a
276/// stream and the read stops one byte past the ceiling, so a frame that expands
277/// without limit never gets to allocate without limit. Reading that one extra
278/// byte is what separates a payload that ends exactly at the ceiling, which is
279/// allowed, from one that does not, which is not.
280///
281/// # Why the failure is not its own error variant
282///
283/// A payload over the ceiling is reported as [`CryptoError::DecompressionError`],
284/// the same variant a payload that is not a Zstandard frame at all comes back
285/// as. Only the sentence inside it differs, which means no caller can branch on
286/// "compression bomb" against "damaged payload" without matching on a string.
287///
288/// The oracle argument that forces one single sentence on the extraction
289/// surface does not really apply here — decompression happens after the tag has
290/// verified, so anyone who can observe this failure already holds the key and
291/// has nothing left to learn from it. The variant is shared anyway, because
292/// nothing is bought by splitting it: the two failures call for the same action
293/// from a receiver, and one fewer public variant is one fewer distinction a
294/// future caller can accidentally come to depend on.
295///
296/// # Errors
297///
298/// Returns [`CryptoError::DecompressionError`] if the input is not a valid
299/// Zstandard stream, or if it expands past [`MAX_DECOMPRESSED_BYTES`].
300pub(crate) fn decompress(compressed: &[u8]) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
301    decompress_within(compressed, MAX_DECOMPRESSED_BYTES)
302}
303
304/// [`decompress`], against a ceiling given rather than assumed.
305///
306/// The ceiling is a parameter for one reason: a test that proves the bound is
307/// enforced has to build a frame that crosses it, and building one that crosses
308/// half a gibibyte means half a gibibyte of memory in a suite that runs on every
309/// commit. Against a ceiling of a few kilobytes the very same code path is
310/// exercised by a bomb small enough to be free. Production has one ceiling, and
311/// [`decompress`] is the only caller that chooses it.
312fn decompress_within(compressed: &[u8], ceiling: u64) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
313    let decoder = zstd::stream::read::Decoder::new(compressed)
314        .map_err(|err| CryptoError::DecompressionError(err.to_string()))?;
315
316    let mut plaintext = Zeroizing::new(Vec::new());
317    decoder
318        .take(ceiling + 1)
319        .read_to_end(&mut plaintext)
320        .map_err(|err| CryptoError::DecompressionError(err.to_string()))?;
321
322    if plaintext.len() as u64 > ceiling {
323        return Err(CryptoError::DecompressionError(format!(
324            "the payload expands past the {ceiling}-byte ceiling"
325        )));
326    }
327
328    Ok(plaintext)
329}
330
331/// Compresses `plaintext` with Zstandard and then encrypts the result.
332///
333/// The order is mandatory. Compression must happen first, while the data still
334/// has structure to exploit; afterwards it never would.
335///
336/// The intermediate compressed buffer is held in a [`Zeroizing`] and dropped —
337/// and therefore wiped — before this function returns.
338///
339/// # Errors
340///
341/// Returns [`CryptoError::CompressionError`] if Zstandard fails, or
342/// [`CryptoError::AEADError`] if encryption fails.
343pub fn compress_and_encrypt(
344    plaintext: &[u8],
345    enc_key: &[u8; 32],
346    nonce: &[u8; 24],
347    cipher: &dyn AEADCipher,
348) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
349    let compressed = compress(plaintext)?;
350
351    let ciphertext = cipher.encrypt(enc_key, nonce, &compressed, STENOXIDE_AAD)?;
352
353    drop(compressed);
354    Ok(ciphertext)
355}
356
357/// Decrypts `ciphertext` and decompresses the authenticated result.
358///
359/// The exact inverse of [`compress_and_encrypt`]: nothing is decompressed until
360/// the tag has been verified, so malformed input never reaches the Zstandard
361/// decoder unless it was produced with the right key.
362///
363/// # Errors
364///
365/// Returns [`CryptoError::AEADError`] with
366/// [`AEADError::AuthenticationFailed`] if the ciphertext does not authenticate,
367/// or [`CryptoError::DecompressionError`] if the authenticated plaintext is not
368/// a valid Zstandard stream.
369pub fn decrypt_and_decompress(
370    ciphertext: &[u8],
371    enc_key: &[u8; 32],
372    nonce: &[u8; 24],
373    cipher: &dyn AEADCipher,
374) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
375    let compressed = cipher.decrypt(enc_key, nonce, ciphertext, STENOXIDE_AAD)?;
376
377    let plaintext = decompress(compressed.as_slice())?;
378
379    drop(compressed);
380    Ok(plaintext)
381}
382
383#[cfg(test)]
384mod tests {
385    // The crate-wide bans on panicking helpers reach into `cfg(test)` code as
386    // well. A test that cannot panic cannot fail, so they are lifted here and
387    // only here.
388    #![allow(clippy::expect_used)]
389    #![allow(clippy::panic)]
390
391    use super::*;
392
393    /// The key the tests encrypt under.
394    const KEY: [u8; 32] = [0x2Bu8; 32];
395
396    /// The nonce the tests encrypt under.
397    const NONCE: [u8; 24] = [0x7Fu8; 24];
398
399    /// A payload with enough structure for compression to have work to do.
400    fn plaintext() -> Vec<u8> {
401        b"the same sentence, over and over. ".repeat(32)
402    }
403
404    /// The primitive on its own: what goes in comes out, tag included.
405    #[test]
406    fn the_cipher_round_trips_its_own_output() {
407        let cipher = XChaCha20Poly1305Cipher::new();
408        let message = b"a message";
409
410        let sealed = cipher
411            .encrypt(&KEY, &NONCE, message, b"aad")
412            .expect("encryption must succeed");
413
414        // The 16-byte Poly1305 tag rides at the end of the ciphertext, so the
415        // sealed form is exactly that much longer than the message.
416        assert_eq!(sealed.len(), message.len() + 16);
417
418        let opened = cipher
419            .decrypt(&KEY, &NONCE, &sealed, b"aad")
420            .expect("decryption must succeed");
421
422        assert_eq!(opened.as_slice(), message.as_slice());
423    }
424
425    /// A wrong key, a wrong nonce, wrong associated data and a damaged tag all
426    /// produce the same answer.
427    ///
428    /// Collapsing them is the point: a caller that could tell them apart would
429    /// hold an oracle saying *why* a guess was rejected.
430    #[test]
431    fn every_way_of_being_wrong_looks_the_same() {
432        let cipher = XChaCha20Poly1305Cipher::new();
433        let sealed = cipher
434            .encrypt(&KEY, &NONCE, b"a message", STENOXIDE_AAD)
435            .expect("encryption must succeed");
436
437        let mut damaged = sealed.to_vec();
438        damaged[0] ^= 0x40;
439
440        let attempts = [
441            cipher.decrypt(&[0u8; 32], &NONCE, &sealed, STENOXIDE_AAD),
442            cipher.decrypt(&KEY, &[0u8; 24], &sealed, STENOXIDE_AAD),
443            cipher.decrypt(&KEY, &NONCE, &sealed, b"other-construction"),
444            cipher.decrypt(&KEY, &NONCE, &damaged, STENOXIDE_AAD),
445            cipher.decrypt(&KEY, &NONCE, &sealed[..4], STENOXIDE_AAD),
446        ];
447
448        for attempt in attempts {
449            match attempt.map(|_| ()) {
450                Err(AEADError::AuthenticationFailed) => {}
451                Err(other) => panic!("expected an authentication failure, got: {other:?}"),
452                Ok(()) => panic!("a wrong input must not authenticate"),
453            }
454        }
455    }
456
457    /// Compression happens first, which is the only order that saves anything.
458    #[test]
459    fn the_payload_is_compressed_before_it_is_encrypted() {
460        let cipher = XChaCha20Poly1305Cipher::new();
461        let plaintext = plaintext();
462
463        let ciphertext = compress_and_encrypt(&plaintext, &KEY, &NONCE, &cipher)
464            .expect("compression and encryption must succeed");
465
466        assert!(
467            ciphertext.len() < plaintext.len(),
468            "a repetitive payload must shrink: {} against {}",
469            ciphertext.len(),
470            plaintext.len()
471        );
472
473        let recovered = decrypt_and_decompress(&ciphertext, &KEY, &NONCE, &cipher)
474            .expect("decryption and decompression must succeed");
475
476        assert_eq!(recovered.as_slice(), plaintext.as_slice());
477    }
478
479    /// Nothing reaches the Zstandard decoder that the tag has not vouched for.
480    #[test]
481    fn authentication_runs_before_decompression() {
482        let cipher = XChaCha20Poly1305Cipher::new();
483        let ciphertext = compress_and_encrypt(&plaintext(), &KEY, &NONCE, &cipher)
484            .expect("compression and encryption must succeed");
485
486        let error = decrypt_and_decompress(&ciphertext, &[9u8; 32], &NONCE, &cipher)
487            .map(|_| ())
488            .expect_err("a wrong key must not authenticate");
489
490        assert!(
491            matches!(
492                error,
493                CryptoError::AEADError(AEADError::AuthenticationFailed)
494            ),
495            "got: {error:?}"
496        );
497    }
498
499    /// A payload that authenticates but is not a Zstandard frame is a genuinely
500    /// broken payload, and is reported as one.
501    ///
502    /// The one failure the extraction path must *not* retry under another salt:
503    /// the tag has already said the key was right.
504    #[test]
505    fn a_verified_payload_that_will_not_decompress_is_a_decompression_failure() {
506        let cipher = XChaCha20Poly1305Cipher::new();
507        let sealed = cipher
508            .encrypt(&KEY, &NONCE, b"not a zstandard frame", STENOXIDE_AAD)
509            .expect("encryption must succeed");
510
511        let error = decrypt_and_decompress(&sealed, &KEY, &NONCE, &cipher)
512            .map(|_| ())
513            .expect_err("authenticated nonsense must not decompress");
514
515        assert!(
516            matches!(error, CryptoError::DecompressionError(_)),
517            "got: {error:?}"
518        );
519    }
520
521    /// A Zstandard frame that expands to `plaintext_bytes` of zeros.
522    ///
523    /// Compressed at level 1 rather than at [`ZSTD_LEVEL`]: the level is a
524    /// property of the encoder and not of the frame, a decoder cannot tell which
525    /// one produced what it is reading, and level 19 over a run this long would
526    /// dominate the runtime of the whole suite for no gain.
527    ///
528    /// Written through a streaming encoder in chunks, so building a bomb never
529    /// costs the memory the bomb is meant to demand.
530    fn bomb(plaintext_bytes: u64) -> Vec<u8> {
531        use std::io::Write;
532
533        const CHUNK: usize = 64 * 1024;
534
535        let zeros = [0u8; CHUNK];
536        let mut encoder =
537            zstd::stream::write::Encoder::new(Vec::new(), 1).expect("the encoder must start");
538
539        let mut written = 0u64;
540        while written < plaintext_bytes {
541            let step = CHUNK.min((plaintext_bytes - written) as usize);
542            encoder.write_all(&zeros[..step]).expect("the sink is memory");
543            written += step as u64;
544        }
545
546        encoder.finish().expect("the frame must close")
547    }
548
549    /// A payload that stops exactly at the ceiling is a payload, not a bomb.
550    ///
551    /// The boundary is worth pinning in both directions: an off-by-one here
552    /// would silently refuse the largest legitimate payload the system admits.
553    #[test]
554    fn a_payload_that_ends_at_the_ceiling_is_returned() {
555        const CEILING: u64 = 64 * 1024;
556
557        let frame = bomb(CEILING);
558        let plaintext = decompress_within(&frame, CEILING)
559            .expect("a payload that ends at the ceiling must decompress");
560
561        assert_eq!(plaintext.len() as u64, CEILING);
562    }
563
564    /// One byte past the ceiling is refused, and refused while it is being read.
565    ///
566    /// This is the regression test for the compression bomb: a frame of a few
567    /// dozen bytes that expands far past what it is allowed to. It runs against
568    /// a small ceiling so that the refusal costs nothing to prove; see
569    /// [`decompress_within`] for why the ceiling is a parameter.
570    #[test]
571    fn a_payload_that_expands_past_the_ceiling_is_refused() {
572        const CEILING: u64 = 64 * 1024;
573
574        // A thousandfold expansion, from a frame small enough to fit in any
575        // container this crate produces: about two kilobytes, an expansion of
576        // some thirty thousand to one. A bomb that were not far smaller than
577        // the ceiling it defeats would prove nothing.
578        let frame = bomb(CEILING * 1_000);
579        assert!(
580            (frame.len() as u64) < CEILING / 10,
581            "the bomb must be far smaller than the ceiling: {} bytes",
582            frame.len()
583        );
584
585        let error = decompress_within(&frame, CEILING)
586            .map(|_| ())
587            .expect_err("a frame past the ceiling must be refused");
588
589        match error {
590            CryptoError::DecompressionError(message) => {
591                assert!(message.contains("ceiling"), "got: {message}");
592            }
593            other => panic!("expected a decompression failure, got: {other:?}"),
594        }
595    }
596
597    /// A bomb and a payload that is not a Zstandard frame come back as the same
598    /// error variant.
599    ///
600    /// Deliberate. Only the sentence differs, so nothing downstream can branch
601    /// on which of the two happened; see [`decompress`] for the reasoning.
602    #[test]
603    fn a_bomb_and_a_damaged_payload_are_the_same_kind_of_failure() {
604        const CEILING: u64 = 64 * 1024;
605
606        let from_bomb = decompress_within(&bomb(CEILING * 1_000), CEILING).map(|_| ());
607        let from_garbage = decompress_within(b"not a zstandard frame", CEILING).map(|_| ());
608
609        for outcome in [from_bomb, from_garbage] {
610            assert!(
611                matches!(outcome, Err(CryptoError::DecompressionError(_))),
612                "got: {outcome:?}"
613            );
614        }
615    }
616
617    /// The production ceiling clears the largest payload the system can carry.
618    ///
619    /// The bound is not a number picked in isolation: the generative mode fills
620    /// every sample of a container, so the largest ciphertext that can exist is
621    /// fixed by the pixel ceiling of layer 1. The ceiling has to stay above it
622    /// by a wide margin, or a legitimate payload would be refused for being
623    /// large rather than for being a bomb.
624    #[test]
625    fn the_ceiling_clears_the_largest_container_by_an_order_of_magnitude() {
626        // Three channels of one bit per sample, which is what the generative
627        // container carries.
628        let largest_ciphertext = crate::image_io::validate::MAX_PIXELS * 3 / 8;
629
630        assert!(
631            MAX_DECOMPRESSED_BYTES > largest_ciphertext * 10,
632            "{MAX_DECOMPRESSED_BYTES} against {largest_ciphertext}"
633        );
634    }
635
636    /// The ceiling is invisible to every payload the crate actually produces.
637    #[test]
638    fn an_ordinary_payload_is_untouched_by_the_ceiling() {
639        let cipher = XChaCha20Poly1305Cipher::new();
640        let plaintext = plaintext();
641
642        let ciphertext = compress_and_encrypt(&plaintext, &KEY, &NONCE, &cipher)
643            .expect("compression and encryption must succeed");
644        let recovered = decrypt_and_decompress(&ciphertext, &KEY, &NONCE, &cipher)
645            .expect("an ordinary payload must survive the ceiling");
646
647        assert_eq!(recovered.as_slice(), plaintext.as_slice());
648    }
649
650    /// Every failure explains itself, and the chain of causes is wired.
651    #[test]
652    fn every_failure_explains_itself() {
653        assert!(AEADError::AuthenticationFailed
654            .to_string()
655            .contains("corrupted"));
656        assert!(AEADError::CipherError("no key".to_owned())
657            .to_string()
658            .contains("no key"));
659
660        assert!(CryptoError::CompressionError("level".to_owned())
661            .to_string()
662            .contains("level"));
663        assert!(CryptoError::DecompressionError("truncated".to_owned())
664            .to_string()
665            .contains("truncated"));
666
667        // The AEAD variant delegates rather than prefixing, so the sentence the
668        // user sees is the one the primitive wrote.
669        let wrapped = CryptoError::from(AEADError::AuthenticationFailed);
670        assert_eq!(
671            wrapped.to_string(),
672            AEADError::AuthenticationFailed.to_string()
673        );
674
675        assert!(std::error::Error::source(&wrapped).is_some());
676        assert!(
677            std::error::Error::source(&CryptoError::CompressionError("x".to_owned())).is_none()
678        );
679    }
680}