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}