1use std::fmt;
11
12use chacha20poly1305::aead::{Aead, KeyInit, Payload};
13use chacha20poly1305::XChaCha20Poly1305;
14use zeroize::Zeroizing;
15
16const STENOXIDE_AAD: &[u8] = b"STENOXIDE-v1";
22
23const ZSTD_LEVEL: i32 = 19;
27
28#[derive(Debug)]
30pub enum AEADError {
31 AuthenticationFailed,
37 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#[derive(Debug)]
56pub enum CryptoError {
57 CompressionError(String),
59 DecompressionError(String),
61 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
94pub trait AEADCipher: Send + Sync {
100 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 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#[derive(Debug, Default, Clone, Copy)]
140pub struct XChaCha20Poly1305Cipher;
141
142impl XChaCha20Poly1305Cipher {
143 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 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 .map_err(|_| AEADError::AuthenticationFailed)?;
192
193 Ok(Zeroizing::new(plaintext))
194 }
195}
196
197pub 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
226pub 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 #![allow(clippy::expect_used)]
261 #![allow(clippy::panic)]
262
263 use super::*;
264
265 const KEY: [u8; 32] = [0x2Bu8; 32];
267
268 const NONCE: [u8; 24] = [0x7Fu8; 24];
270
271 fn plaintext() -> Vec<u8> {
273 b"the same sentence, over and over. ".repeat(32)
274 }
275
276 #[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 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 #[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 #[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 #[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 #[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 #[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 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}