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;
11
12use chacha20poly1305::aead::{Aead, KeyInit, Payload};
13use chacha20poly1305::XChaCha20Poly1305;
14use zeroize::Zeroizing;
15
16/// Associated data bound into every tag produced by this crate.
17///
18/// It is not secret and not transmitted: both sides recompute it. Its purpose
19/// is to make a ciphertext produced by `stenoxide` fail authentication if it is
20/// ever fed to a different XChaCha20-Poly1305 construction, and vice versa.
21///
22/// Readable inside the crate because [`crate::generate`] seals a buffer that is
23/// not simply `zstd(message)` and therefore cannot go through
24/// [`compress_and_encrypt`] — but must be bound to the same construction, so
25/// that one associated string covers everything this crate encrypts.
26pub(crate) const STENOXIDE_AAD: &[u8] = b"STENOXIDE-v1";
27
28/// Zstandard compression level. The maximum non-ultra level: the payload is
29/// small and compressed exactly once, so spending time here is free compared
30/// with the embedding capacity it buys back.
31const ZSTD_LEVEL: i32 = 19;
32
33/// Failures of the authenticated encryption primitive.
34#[derive(Debug)]
35pub enum AEADError {
36 /// The ciphertext did not authenticate.
37 ///
38 /// A wrong key, a wrong nonce, a modified tag and a truncated ciphertext
39 /// all collapse into this one variant on purpose; see
40 /// [`AEADCipher::decrypt`].
41 AuthenticationFailed,
42 /// The cipher failed while encrypting.
43 CipherError(String),
44}
45
46impl fmt::Display for AEADError {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 match self {
49 AEADError::AuthenticationFailed => {
50 write!(f, "authentication failed: wrong password or corrupted data")
51 }
52 AEADError::CipherError(message) => write!(f, "cipher error: {message}"),
53 }
54 }
55}
56
57impl std::error::Error for AEADError {}
58
59/// Failures of the combined compression and encryption stages.
60#[derive(Debug)]
61pub enum CryptoError {
62 /// Zstandard could not compress the plaintext.
63 CompressionError(String),
64 /// Zstandard could not decompress the authenticated plaintext.
65 DecompressionError(String),
66 /// The authenticated encryption layer failed.
67 AEADError(AEADError),
68}
69
70impl fmt::Display for CryptoError {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 match self {
73 CryptoError::CompressionError(message) => {
74 write!(f, "failed to compress the payload: {message}")
75 }
76 CryptoError::DecompressionError(message) => {
77 write!(f, "failed to decompress the payload: {message}")
78 }
79 CryptoError::AEADError(err) => write!(f, "{err}"),
80 }
81 }
82}
83
84impl std::error::Error for CryptoError {
85 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
86 match self {
87 CryptoError::AEADError(err) => Some(err),
88 _ => None,
89 }
90 }
91}
92
93impl From<AEADError> for CryptoError {
94 fn from(err: AEADError) -> Self {
95 CryptoError::AEADError(err)
96 }
97}
98
99/// Authenticated encryption with associated data.
100///
101/// Abstracted behind a trait so the pipeline depends on the operation and not
102/// on the concrete cipher. `Send + Sync` because a single cipher value is
103/// shared by reference across the pipeline's worker threads.
104pub trait AEADCipher: Send + Sync {
105 /// Encrypts `plaintext` under `key` and `nonce`, binding `aad` to the tag.
106 ///
107 /// The returned buffer is the ciphertext with the 16-byte Poly1305 tag
108 /// appended, and it is wiped when dropped.
109 ///
110 /// # Errors
111 ///
112 /// Returns [`AEADError::CipherError`] if the underlying cipher fails.
113 fn encrypt(
114 &self,
115 key: &[u8; 32],
116 nonce: &[u8; 24],
117 plaintext: &[u8],
118 aad: &[u8],
119 ) -> Result<Zeroizing<Vec<u8>>, AEADError>;
120
121 /// Decrypts and authenticates `ciphertext`, which must carry its trailing
122 /// tag and must have been produced with the same `aad`.
123 ///
124 /// # Errors
125 ///
126 /// Returns [`AEADError::AuthenticationFailed`], and nothing else. Every
127 /// internal cause — invalid tag, wrong key, truncated input — is collapsed
128 /// into that single variant, because distinguishing them would hand an
129 /// attacker an oracle that tells them *why* their guess was rejected.
130 fn decrypt(
131 &self,
132 key: &[u8; 32],
133 nonce: &[u8; 24],
134 ciphertext: &[u8],
135 aad: &[u8],
136 ) -> Result<Zeroizing<Vec<u8>>, AEADError>;
137}
138
139/// The production cipher: XChaCha20-Poly1305.
140///
141/// The 192-bit extended nonce is what makes the derived — rather than random —
142/// nonce of [`crate::crypto::expand`] safe: the space is far too large for the
143/// birthday bound to matter.
144#[derive(Debug, Default, Clone, Copy)]
145pub struct XChaCha20Poly1305Cipher;
146
147impl XChaCha20Poly1305Cipher {
148 /// Builds the cipher. It is stateless; the key arrives per call.
149 pub fn new() -> Self {
150 Self
151 }
152}
153
154impl AEADCipher for XChaCha20Poly1305Cipher {
155 fn encrypt(
156 &self,
157 key: &[u8; 32],
158 nonce: &[u8; 24],
159 plaintext: &[u8],
160 aad: &[u8],
161 ) -> Result<Zeroizing<Vec<u8>>, AEADError> {
162 let cipher = XChaCha20Poly1305::new(key.into());
163 // The crate appends the 16-byte Poly1305 tag to the ciphertext itself,
164 // so there is no tag to carry or splice by hand on either side.
165 let ciphertext = cipher
166 .encrypt(
167 nonce.into(),
168 Payload {
169 msg: plaintext,
170 aad,
171 },
172 )
173 .map_err(|err| AEADError::CipherError(err.to_string()))?;
174
175 Ok(Zeroizing::new(ciphertext))
176 }
177
178 fn decrypt(
179 &self,
180 key: &[u8; 32],
181 nonce: &[u8; 24],
182 ciphertext: &[u8],
183 aad: &[u8],
184 ) -> Result<Zeroizing<Vec<u8>>, AEADError> {
185 let cipher = XChaCha20Poly1305::new(key.into());
186 let plaintext = cipher
187 .decrypt(
188 nonce.into(),
189 Payload {
190 msg: ciphertext,
191 aad,
192 },
193 )
194 // The inner error is discarded deliberately: it is the only place
195 // where the failure reason could leak out of this layer.
196 .map_err(|_| AEADError::AuthenticationFailed)?;
197
198 Ok(Zeroizing::new(plaintext))
199 }
200}
201
202/// Compresses `plaintext` with Zstandard, at the one level this crate uses.
203///
204/// Split out of [`compress_and_encrypt`] because the generative container mode
205/// compresses at the same level and then seals the result inside a larger
206/// buffer of its own; the compression level is a property of the format both
207/// share, and there is one definition of it.
208///
209/// # Errors
210///
211/// Returns [`CryptoError::CompressionError`] if Zstandard fails.
212pub(crate) fn compress(plaintext: &[u8]) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
213 zstd::encode_all(plaintext, ZSTD_LEVEL)
214 .map(Zeroizing::new)
215 .map_err(|err| CryptoError::CompressionError(err.to_string()))
216}
217
218/// Decompresses an authenticated Zstandard frame.
219///
220/// The inverse of [`compress`]. Nothing must reach this that the Poly1305 tag
221/// has not already vouched for; both callers arrange that.
222///
223/// # Errors
224///
225/// Returns [`CryptoError::DecompressionError`] if the input is not a valid
226/// Zstandard stream.
227pub(crate) fn decompress(compressed: &[u8]) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
228 zstd::decode_all(compressed)
229 .map(Zeroizing::new)
230 .map_err(|err| CryptoError::DecompressionError(err.to_string()))
231}
232
233/// Compresses `plaintext` with Zstandard and then encrypts the result.
234///
235/// The order is mandatory. Compression must happen first, while the data still
236/// has structure to exploit; afterwards it never would.
237///
238/// The intermediate compressed buffer is held in a [`Zeroizing`] and dropped —
239/// and therefore wiped — before this function returns.
240///
241/// # Errors
242///
243/// Returns [`CryptoError::CompressionError`] if Zstandard fails, or
244/// [`CryptoError::AEADError`] if encryption fails.
245pub fn compress_and_encrypt(
246 plaintext: &[u8],
247 enc_key: &[u8; 32],
248 nonce: &[u8; 24],
249 cipher: &dyn AEADCipher,
250) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
251 let compressed = compress(plaintext)?;
252
253 let ciphertext = cipher.encrypt(enc_key, nonce, &compressed, STENOXIDE_AAD)?;
254
255 drop(compressed);
256 Ok(ciphertext)
257}
258
259/// Decrypts `ciphertext` and decompresses the authenticated result.
260///
261/// The exact inverse of [`compress_and_encrypt`]: nothing is decompressed until
262/// the tag has been verified, so malformed input never reaches the Zstandard
263/// decoder unless it was produced with the right key.
264///
265/// # Errors
266///
267/// Returns [`CryptoError::AEADError`] with
268/// [`AEADError::AuthenticationFailed`] if the ciphertext does not authenticate,
269/// or [`CryptoError::DecompressionError`] if the authenticated plaintext is not
270/// a valid Zstandard stream.
271pub fn decrypt_and_decompress(
272 ciphertext: &[u8],
273 enc_key: &[u8; 32],
274 nonce: &[u8; 24],
275 cipher: &dyn AEADCipher,
276) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
277 let compressed = cipher.decrypt(enc_key, nonce, ciphertext, STENOXIDE_AAD)?;
278
279 let plaintext = decompress(compressed.as_slice())?;
280
281 drop(compressed);
282 Ok(plaintext)
283}
284
285#[cfg(test)]
286mod tests {
287 // The crate-wide bans on panicking helpers reach into `cfg(test)` code as
288 // well. A test that cannot panic cannot fail, so they are lifted here and
289 // only here.
290 #![allow(clippy::expect_used)]
291 #![allow(clippy::panic)]
292
293 use super::*;
294
295 /// The key the tests encrypt under.
296 const KEY: [u8; 32] = [0x2Bu8; 32];
297
298 /// The nonce the tests encrypt under.
299 const NONCE: [u8; 24] = [0x7Fu8; 24];
300
301 /// A payload with enough structure for compression to have work to do.
302 fn plaintext() -> Vec<u8> {
303 b"the same sentence, over and over. ".repeat(32)
304 }
305
306 /// The primitive on its own: what goes in comes out, tag included.
307 #[test]
308 fn the_cipher_round_trips_its_own_output() {
309 let cipher = XChaCha20Poly1305Cipher::new();
310 let message = b"a message";
311
312 let sealed = cipher
313 .encrypt(&KEY, &NONCE, message, b"aad")
314 .expect("encryption must succeed");
315
316 // The 16-byte Poly1305 tag rides at the end of the ciphertext, so the
317 // sealed form is exactly that much longer than the message.
318 assert_eq!(sealed.len(), message.len() + 16);
319
320 let opened = cipher
321 .decrypt(&KEY, &NONCE, &sealed, b"aad")
322 .expect("decryption must succeed");
323
324 assert_eq!(opened.as_slice(), message.as_slice());
325 }
326
327 /// A wrong key, a wrong nonce, wrong associated data and a damaged tag all
328 /// produce the same answer.
329 ///
330 /// Collapsing them is the point: a caller that could tell them apart would
331 /// hold an oracle saying *why* a guess was rejected.
332 #[test]
333 fn every_way_of_being_wrong_looks_the_same() {
334 let cipher = XChaCha20Poly1305Cipher::new();
335 let sealed = cipher
336 .encrypt(&KEY, &NONCE, b"a message", STENOXIDE_AAD)
337 .expect("encryption must succeed");
338
339 let mut damaged = sealed.to_vec();
340 damaged[0] ^= 0x40;
341
342 let attempts = [
343 cipher.decrypt(&[0u8; 32], &NONCE, &sealed, STENOXIDE_AAD),
344 cipher.decrypt(&KEY, &[0u8; 24], &sealed, STENOXIDE_AAD),
345 cipher.decrypt(&KEY, &NONCE, &sealed, b"other-construction"),
346 cipher.decrypt(&KEY, &NONCE, &damaged, STENOXIDE_AAD),
347 cipher.decrypt(&KEY, &NONCE, &sealed[..4], STENOXIDE_AAD),
348 ];
349
350 for attempt in attempts {
351 match attempt.map(|_| ()) {
352 Err(AEADError::AuthenticationFailed) => {}
353 Err(other) => panic!("expected an authentication failure, got: {other:?}"),
354 Ok(()) => panic!("a wrong input must not authenticate"),
355 }
356 }
357 }
358
359 /// Compression happens first, which is the only order that saves anything.
360 #[test]
361 fn the_payload_is_compressed_before_it_is_encrypted() {
362 let cipher = XChaCha20Poly1305Cipher::new();
363 let plaintext = plaintext();
364
365 let ciphertext = compress_and_encrypt(&plaintext, &KEY, &NONCE, &cipher)
366 .expect("compression and encryption must succeed");
367
368 assert!(
369 ciphertext.len() < plaintext.len(),
370 "a repetitive payload must shrink: {} against {}",
371 ciphertext.len(),
372 plaintext.len()
373 );
374
375 let recovered = decrypt_and_decompress(&ciphertext, &KEY, &NONCE, &cipher)
376 .expect("decryption and decompression must succeed");
377
378 assert_eq!(recovered.as_slice(), plaintext.as_slice());
379 }
380
381 /// Nothing reaches the Zstandard decoder that the tag has not vouched for.
382 #[test]
383 fn authentication_runs_before_decompression() {
384 let cipher = XChaCha20Poly1305Cipher::new();
385 let ciphertext = compress_and_encrypt(&plaintext(), &KEY, &NONCE, &cipher)
386 .expect("compression and encryption must succeed");
387
388 let error = decrypt_and_decompress(&ciphertext, &[9u8; 32], &NONCE, &cipher)
389 .map(|_| ())
390 .expect_err("a wrong key must not authenticate");
391
392 assert!(
393 matches!(
394 error,
395 CryptoError::AEADError(AEADError::AuthenticationFailed)
396 ),
397 "got: {error:?}"
398 );
399 }
400
401 /// A payload that authenticates but is not a Zstandard frame is a genuinely
402 /// broken payload, and is reported as one.
403 ///
404 /// The one failure the extraction path must *not* retry under another salt:
405 /// the tag has already said the key was right.
406 #[test]
407 fn a_verified_payload_that_will_not_decompress_is_a_decompression_failure() {
408 let cipher = XChaCha20Poly1305Cipher::new();
409 let sealed = cipher
410 .encrypt(&KEY, &NONCE, b"not a zstandard frame", STENOXIDE_AAD)
411 .expect("encryption must succeed");
412
413 let error = decrypt_and_decompress(&sealed, &KEY, &NONCE, &cipher)
414 .map(|_| ())
415 .expect_err("authenticated nonsense must not decompress");
416
417 assert!(
418 matches!(error, CryptoError::DecompressionError(_)),
419 "got: {error:?}"
420 );
421 }
422
423 /// Every failure explains itself, and the chain of causes is wired.
424 #[test]
425 fn every_failure_explains_itself() {
426 assert!(AEADError::AuthenticationFailed
427 .to_string()
428 .contains("corrupted"));
429 assert!(AEADError::CipherError("no key".to_owned())
430 .to_string()
431 .contains("no key"));
432
433 assert!(CryptoError::CompressionError("level".to_owned())
434 .to_string()
435 .contains("level"));
436 assert!(CryptoError::DecompressionError("truncated".to_owned())
437 .to_string()
438 .contains("truncated"));
439
440 // The AEAD variant delegates rather than prefixing, so the sentence the
441 // user sees is the one the primitive wrote.
442 let wrapped = CryptoError::from(AEADError::AuthenticationFailed);
443 assert_eq!(
444 wrapped.to_string(),
445 AEADError::AuthenticationFailed.to_string()
446 );
447
448 assert!(std::error::Error::source(&wrapped).is_some());
449 assert!(
450 std::error::Error::source(&CryptoError::CompressionError("x".to_owned())).is_none()
451 );
452 }
453}