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