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}