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}