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.
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}