Skip to main content

dryoc/classic/
crypto_aead_xchacha20poly1305_ietf.rs

1//! # XChaCha20-Poly1305-IETF authenticated encryption
2//!
3//! Implements libsodium's `crypto_aead_xchacha20poly1305_ietf_*` functions.
4//! This construction authenticates optional additional data, appends the
5//! authentication tag in combined mode, and uses 192-bit public nonces.
6//!
7//! ## Compatibility note
8//!
9//! This module follows libsodium's XChaCha20-Poly1305-IETF API and message
10//! size limit. The `_ietf` suffix refers to the RFC 8439 AEAD layout and
11//! Poly1305 input format; libsodium's XChaCha implementation uses an
12//! extended-counter XChaCha20 stream so it can support larger individual
13//! messages than plain ChaCha20-Poly1305-IETF.
14//!
15//! ## Behavior on failure
16//!
17//! Every decrypt function checks the buffer lengths and verifies the tag
18//! before it writes anything, so any error, a length error or
19//! [`Error::AuthenticationFailed`](crate::Error::AuthenticationFailed), leaves
20//! the output (or, in place, `data`) exactly as it found it. The tag case is
21//! the one deliberate departure from libsodium, whose
22//! `crypto_aead_xchacha20poly1305_ietf_decrypt*` zero the output buffer on a
23//! failed tag check, destroying the ciphertext when decrypting in place.
24//!
25//! ## Classic API example
26//!
27//! ```
28//! use dryoc::classic::crypto_aead_xchacha20poly1305_ietf::*;
29//! use dryoc::constants::CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES;
30//! use dryoc::types::*;
31//!
32//! let key = crypto_aead_xchacha20poly1305_ietf_keygen();
33//! let nonce = Nonce::generate();
34//! let message = b"hello";
35//! let aad = b"metadata";
36//!
37//! let mut ciphertext = vec![0u8; message.len() + CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES];
38//! crypto_aead_xchacha20poly1305_ietf_encrypt(&mut ciphertext, message, Some(aad), &nonce, &key)
39//!     .expect("encrypt failed");
40//!
41//! let mut decrypted = vec![0u8; message.len()];
42//! crypto_aead_xchacha20poly1305_ietf_decrypt(
43//!     &mut decrypted,
44//!     &ciphertext,
45//!     Some(aad),
46//!     &nonce,
47//!     &key,
48//! )
49//! .expect("decrypt failed");
50//!
51//! assert_eq!(message, decrypted.as_slice());
52//! ```
53
54use zeroize::Zeroize;
55
56use crate::chacha20::ChaCha20;
57use crate::classic::crypto_aead_chacha20poly1305_impl::impl_chacha20poly1305_aead;
58use crate::classic::crypto_core::{HChaCha20Key, crypto_core_hchacha20};
59use crate::constants::{
60    CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES, CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES,
61    CRYPTO_AEAD_XCHACHA20POLY1305_IETF_MESSAGEBYTES_MAX,
62    CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, CRYPTO_CORE_HCHACHA20_INPUTBYTES,
63};
64use crate::types::*;
65
66/// Authentication tag for XChaCha20-Poly1305-IETF AEAD.
67pub type Mac = [u8; CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES];
68/// Public nonce for XChaCha20-Poly1305-IETF AEAD.
69pub type Nonce = [u8; CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES];
70/// Secret key for XChaCha20-Poly1305-IETF AEAD.
71pub type Key = [u8; CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES];
72
73/// libsodium's `chacha20_ietf_ext` stream for `nonce` and `key`, positioned
74/// at block `counter`.
75fn xchacha20_stream(nonce: &Nonce, key: &Key, counter: u64) -> ChaCha20 {
76    let mut subkey = HChaCha20Key::default();
77    crypto_core_hchacha20(
78        &mut subkey,
79        nonce
80            .first_chunk::<CRYPTO_CORE_HCHACHA20_INPUTBYTES>()
81            .expect("XChaCha20 nonce holds the HChaCha20 input"),
82        key,
83        None,
84    );
85
86    // libsodium's `chacha20_ietf_ext` starts with IETF layout but allows the
87    // 32-bit block counter to overflow into the leading zero nonce word. With
88    // XChaCha's `0 || nonce_tail` derived nonce, that is equivalent to the
89    // original 64-bit-counter ChaCha20 layout with `nonce_tail`.
90    let nonce_tail = nonce
91        .last_chunk()
92        .expect("XChaCha20 nonce ends with the ChaCha20 nonce");
93    let cipher = ChaCha20::legacy(&subkey, nonce_tail, counter);
94    subkey.zeroize();
95    cipher
96}
97
98impl_chacha20poly1305_aead! {
99    abytes: CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES,
100    // libsodium's XChaCha bound: the extended-counter stream never wraps
101    // within an addressable message.
102    messagebytes_max: CRYPTO_AEAD_XCHACHA20POLY1305_IETF_MESSAGEBYTES_MAX,
103    stream: |nonce: &Nonce, key: &Key| xchacha20_stream(nonce, key, 0),
104    key: Key,
105    nonce: Nonce,
106    mac: Mac,
107
108    /// In-place variant of [`crypto_aead_xchacha20poly1305_ietf_keygen`].
109    keygen_inplace: crypto_aead_xchacha20poly1305_ietf_keygen_inplace,
110
111    /// Generates a random key using [`copy_randombytes`](crate::rng::copy_randombytes).
112    keygen: crypto_aead_xchacha20poly1305_ietf_keygen,
113
114    /// Detached version of [`crypto_aead_xchacha20poly1305_ietf_encrypt`].
115    ///
116    /// Compatible with libsodium's
117    /// `crypto_aead_xchacha20poly1305_ietf_encrypt_detached`.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if `message` exceeds the maximum supported length or
122    /// `ciphertext.len()` does not equal `message.len()`.
123    encrypt_detached: crypto_aead_xchacha20poly1305_ietf_encrypt_detached,
124
125    /// In-place detached variant of
126    /// [`crypto_aead_xchacha20poly1305_ietf_encrypt_detached`].
127    ///
128    /// # Errors
129    ///
130    /// Returns an error if `data` exceeds the maximum supported message length.
131    encrypt_detached_inplace: crypto_aead_xchacha20poly1305_ietf_encrypt_detached_inplace,
132
133    /// Detached version of [`crypto_aead_xchacha20poly1305_ietf_decrypt`].
134    ///
135    /// Compatible with libsodium's
136    /// `crypto_aead_xchacha20poly1305_ietf_decrypt_detached`, except that a
137    /// failed tag check leaves `message` untouched (see [Behavior on
138    /// failure](self#behavior-on-failure)).
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if `ciphertext` is too long, `message.len()` does not equal
143    /// `ciphertext.len()`, or authentication fails.
144    decrypt_detached: crypto_aead_xchacha20poly1305_ietf_decrypt_detached,
145
146    /// In-place detached variant of
147    /// [`crypto_aead_xchacha20poly1305_ietf_decrypt_detached`]. On a failed tag
148    /// check `data` is left unchanged, so the ciphertext survives (libsodium
149    /// zeroes it; see [Behavior on failure](self#behavior-on-failure)).
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if `data` exceeds the maximum supported message length or
154    /// authentication fails.
155    decrypt_detached_inplace: crypto_aead_xchacha20poly1305_ietf_decrypt_detached_inplace,
156
157    /// Encrypts `message` with `nonce`, `key`, and optional associated data.
158    ///
159    /// Compatible with libsodium's `crypto_aead_xchacha20poly1305_ietf_encrypt`.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if `message` exceeds the maximum supported length or
164    /// `ciphertext` is not exactly one authentication tag longer than `message`.
165    encrypt: crypto_aead_xchacha20poly1305_ietf_encrypt,
166
167    /// Decrypts `ciphertext` with `nonce`, `key`, and optional associated data.
168    ///
169    /// Compatible with libsodium's `crypto_aead_xchacha20poly1305_ietf_decrypt`,
170    /// except that a failed tag check leaves `message` untouched (see
171    /// [Behavior on failure](self#behavior-on-failure)).
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if `ciphertext` is shorter than an authentication tag,
176    /// `message` has the wrong length, or authentication fails.
177    decrypt: crypto_aead_xchacha20poly1305_ietf_decrypt,
178
179    /// Encrypts `data` in place and appends the authentication tag.
180    ///
181    /// The last [`CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES`] bytes are reserved
182    /// for the tag and are ignored as plaintext input.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if `data` is shorter than an authentication tag or its
187    /// plaintext portion exceeds the maximum supported message length.
188    encrypt_inplace: crypto_aead_xchacha20poly1305_ietf_encrypt_inplace,
189
190    /// Decrypts `data` in place after verifying the appended authentication tag.
191    ///
192    /// After success, the first `data.len() -
193    /// CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES` bytes contain the plaintext.
194    /// On a failed tag check `data` is left unchanged, so the ciphertext
195    /// survives (libsodium zeroes it; see [Behavior on
196    /// failure](self#behavior-on-failure)).
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if `data` is shorter than an authentication tag or
201    /// authentication fails.
202    decrypt_inplace: crypto_aead_xchacha20poly1305_ietf_decrypt_inplace,
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    #[cfg(dryoc_native_tests)]
209    use crate::classic::crypto_aead_chacha20poly1305_impl::test_util::check_matches_libsodium;
210    use crate::classic::crypto_aead_chacha20poly1305_impl::test_util::{
211        Aead, check_failures_leave_outputs_untouched,
212    };
213    use crate::error::{Error, LengthConstraint};
214
215    #[test]
216    fn test_message_len_bound_is_xchacha_max() {
217        const MAX: usize = CRYPTO_AEAD_XCHACHA20POLY1305_IETF_MESSAGEBYTES_MAX;
218        const ABYTES: usize = CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES;
219
220        // libsodium's bound leaves exactly one tag below the address space, so
221        // every combined length at or above `ABYTES` is a valid message length.
222        // The MESSAGEBYTES_MAX check itself is unreachable here: a combined
223        // length of `MAX + ABYTES + 1` does not fit in `usize`.
224        assert!(matches!(
225            message_len_from_combined_len(MAX + ABYTES, crate::ErrorContext::Ciphertext),
226            Ok(len) if len == MAX
227        ));
228        assert!(matches!(
229            message_len_from_combined_len(ABYTES - 1, crate::ErrorContext::Ciphertext),
230            Err(Error::InvalidLength {
231                context: crate::ErrorContext::Ciphertext,
232                constraint: LengthConstraint::AtLeast(ABYTES),
233                ..
234            })
235        ));
236    }
237
238    const MESSAGE: &[u8] =
239        b"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it.";
240    const AD: &[u8] = &[
241        0x50, 0x51, 0x52, 0x53, 0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7,
242    ];
243    const KEY: Key = [
244        0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e,
245        0x8f, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d,
246        0x9e, 0x9f,
247    ];
248    const NONCE: Nonce = [
249        0xf2, 0x8a, 0x50, 0xa7, 0x8a, 0x7e, 0x23, 0xc9, 0xcb, 0xa6, 0x78, 0x34, 0x66, 0xf8, 0x03,
250        0x59, 0x0f, 0x04, 0xe9, 0x22, 0x31, 0xa3, 0x2d, 0x5d,
251    ];
252    const EXPECTED: &[u8] = &[
253        0x20, 0xf1, 0xae, 0x75, 0xe1, 0xe5, 0xe0, 0x00, 0x40, 0x29, 0x4f, 0x0f, 0xb1, 0x0e, 0xbb,
254        0x08, 0x10, 0xc5, 0x93, 0xc7, 0xdb, 0xa4, 0xec, 0x10, 0x4c, 0x1e, 0x5e, 0xf9, 0x50, 0x7f,
255        0xae, 0xef, 0x58, 0xfc, 0x28, 0x98, 0xbb, 0xd0, 0xe4, 0x7b, 0x2f, 0x53, 0x31, 0xfb, 0xc3,
256        0x67, 0xd3, 0xc2, 0x78, 0x4e, 0x36, 0x48, 0xce, 0x1e, 0xaa, 0x77, 0x87, 0xad, 0x18, 0x6d,
257        0xb2, 0x68, 0x5e, 0xe8, 0x9a, 0xe4, 0xd3, 0x44, 0x1f, 0x6e, 0xa0, 0xb2, 0x22, 0x4c, 0xd5,
258        0xa1, 0x34, 0x16, 0x1b, 0x55, 0x4d, 0x8b, 0x48, 0x35, 0x0b, 0x4a, 0xd4, 0x01, 0x15, 0xdb,
259        0x81, 0xea, 0x82, 0x09, 0x68, 0xe9, 0x43, 0x89, 0x2f, 0x2b, 0x80, 0x51, 0xcb, 0x5f, 0x7a,
260        0x86, 0x66, 0xe7, 0xe7, 0xef, 0x7f, 0x84, 0xc0, 0xa2, 0xf8, 0x0a, 0x12, 0xd0, 0x66, 0x80,
261        0xc8, 0xee, 0xbb, 0xd9, 0x30, 0x04, 0x10, 0x9d, 0xe8, 0x42,
262    ];
263
264    #[test]
265    fn test_known_answer() {
266        let mut ciphertext = vec![0u8; MESSAGE.len() + CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES];
267        crypto_aead_xchacha20poly1305_ietf_encrypt(
268            &mut ciphertext,
269            MESSAGE,
270            Some(AD),
271            &NONCE,
272            &KEY,
273        )
274        .expect("encrypt");
275        assert_eq!(ciphertext, EXPECTED);
276
277        let mut decrypted = vec![0u8; MESSAGE.len()];
278        crypto_aead_xchacha20poly1305_ietf_decrypt(
279            &mut decrypted,
280            &ciphertext,
281            Some(AD),
282            &NONCE,
283            &KEY,
284        )
285        .expect("decrypt");
286        assert_eq!(decrypted, MESSAGE);
287    }
288
289    #[test]
290    fn test_detached_matches_combined() {
291        let mut combined = vec![0u8; MESSAGE.len() + CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES];
292        crypto_aead_xchacha20poly1305_ietf_encrypt(&mut combined, MESSAGE, Some(AD), &NONCE, &KEY)
293            .expect("encrypt");
294
295        let mut detached = vec![0u8; MESSAGE.len()];
296        let mut mac = Mac::default();
297        crypto_aead_xchacha20poly1305_ietf_encrypt_detached(
298            &mut detached,
299            &mut mac,
300            MESSAGE,
301            Some(AD),
302            &NONCE,
303            &KEY,
304        )
305        .expect("detached encrypt");
306
307        assert_eq!(detached, combined[..MESSAGE.len()]);
308        assert_eq!(mac.as_slice(), &combined[MESSAGE.len()..]);
309    }
310
311    #[test]
312    fn test_empty_message_and_no_aad() {
313        let mut ciphertext = vec![0u8; CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES];
314        crypto_aead_xchacha20poly1305_ietf_encrypt(&mut ciphertext, &[], None, &NONCE, &KEY)
315            .expect("encrypt");
316
317        let mut decrypted = vec![];
318        crypto_aead_xchacha20poly1305_ietf_decrypt(&mut decrypted, &ciphertext, None, &NONCE, &KEY)
319            .expect("decrypt");
320        assert!(decrypted.is_empty());
321    }
322
323    #[test]
324    fn test_inplace_roundtrip() {
325        let mut data = MESSAGE.to_vec();
326        data.resize(MESSAGE.len() + CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES, 0);
327        crypto_aead_xchacha20poly1305_ietf_encrypt_inplace(&mut data, Some(AD), &NONCE, &KEY)
328            .expect("inplace encrypt");
329        assert_eq!(data, EXPECTED);
330
331        crypto_aead_xchacha20poly1305_ietf_decrypt_inplace(&mut data, Some(AD), &NONCE, &KEY)
332            .expect("inplace decrypt");
333        assert_eq!(&data[..MESSAGE.len()], MESSAGE);
334    }
335
336    /// The XChaCha20 stream's block function input: the original
337    /// 64-bit-counter ChaCha20 layout keyed by HChaCha20 of the first 16
338    /// nonce bytes, with the last 8 nonce bytes as its nonce (words 14 and
339    /// 15) and the counter supplied per block.
340    fn xchacha20_state(nonce: &Nonce, key: &Key) -> [u32; 16] {
341        let mut subkey = HChaCha20Key::default();
342        crypto_core_hchacha20(
343            &mut subkey,
344            nonce.first_chunk::<16>().expect("16-byte prefix"),
345            key,
346            None,
347        );
348        let mut state = [0u32; 16];
349        state[..4].copy_from_slice(&crate::utils::SIGMA);
350        for (word, bytes) in state[4..12].iter_mut().zip(subkey.as_chunks::<4>().0) {
351            *word = u32::from_le_bytes(*bytes);
352        }
353        for (word, bytes) in state[14..].iter_mut().zip(nonce[16..].as_chunks::<4>().0) {
354            *word = u32::from_le_bytes(*bytes);
355        }
356        state
357    }
358
359    /// The extended stream across the IETF 32-bit counter boundary and at
360    /// the end of the 64-bit counter: 128 bytes from `u32::MAX` are blocks
361    /// `u32::MAX` and `2^32` (word 13 becomes 1), and from `u64::MAX` block
362    /// `u64::MAX` followed by block 0.
363    #[test]
364    fn test_xietf_ext_stream_crosses_counter_boundaries() {
365        let state = xchacha20_state(&NONCE, &KEY);
366        for start in [u64::from(u32::MAX), u64::MAX] {
367            let mut expected = [0u8; 128];
368            let (first, second) = expected.split_at_mut(64);
369            crate::chacha20::scalar_block(&state, start, first.try_into().unwrap());
370            crate::chacha20::scalar_block(
371                &state,
372                start.wrapping_add(1),
373                second.try_into().unwrap(),
374            );
375            assert_ne!(first, second);
376
377            let mut stream = [0u8; 128];
378            xchacha20_stream(&NONCE, &KEY, start).apply_keystream(&mut stream);
379            assert_eq!(stream, expected, "from {start:#x}");
380        }
381    }
382
383    fn aead() -> Aead<Nonce> {
384        Aead {
385            encrypt_detached: crypto_aead_xchacha20poly1305_ietf_encrypt_detached,
386            encrypt_detached_inplace: crypto_aead_xchacha20poly1305_ietf_encrypt_detached_inplace,
387            decrypt_detached: crypto_aead_xchacha20poly1305_ietf_decrypt_detached,
388            decrypt_detached_inplace: crypto_aead_xchacha20poly1305_ietf_decrypt_detached_inplace,
389            encrypt: crypto_aead_xchacha20poly1305_ietf_encrypt,
390            decrypt: crypto_aead_xchacha20poly1305_ietf_decrypt,
391            encrypt_inplace: crypto_aead_xchacha20poly1305_ietf_encrypt_inplace,
392            decrypt_inplace: crypto_aead_xchacha20poly1305_ietf_decrypt_inplace,
393        }
394    }
395
396    #[test]
397    fn test_failures_leave_outputs_untouched() {
398        check_failures_leave_outputs_untouched(&aead(), &KEY, &NONCE);
399    }
400
401    #[cfg(dryoc_native_tests)]
402    mod native_tests {
403        use super::*;
404
405        #[test]
406        fn test_libsodium_interop() {
407            use crate::native_test_util::{
408                crypto_aead_xchacha20poly1305_ietf_decrypt as open,
409                crypto_aead_xchacha20poly1305_ietf_encrypt as seal,
410            };
411
412            let mut ciphertext =
413                vec![0u8; MESSAGE.len() + CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES];
414            crypto_aead_xchacha20poly1305_ietf_encrypt(
415                &mut ciphertext,
416                MESSAGE,
417                Some(AD),
418                &NONCE,
419                &KEY,
420            )
421            .expect("encrypt");
422            let so_plaintext = open(&ciphertext, Some(AD), &NONCE, &KEY).expect("libsodium open");
423            assert_eq!(so_plaintext, MESSAGE);
424
425            let so_ciphertext = seal(MESSAGE, Some(AD), &NONCE, &KEY);
426            let mut plaintext = vec![0u8; MESSAGE.len()];
427            crypto_aead_xchacha20poly1305_ietf_decrypt(
428                &mut plaintext,
429                &so_ciphertext,
430                Some(AD),
431                &NONCE,
432                &KEY,
433            )
434            .expect("decrypt");
435            assert_eq!(plaintext, MESSAGE);
436        }
437
438        /// The extended stream at the IETF 32-bit counter boundary and at the
439        /// end of the 64-bit counter, two blocks from each, against
440        /// libsodium's `crypto_stream_xchacha20_xor_ic`.
441        #[test]
442        fn test_counter_boundaries_match_libsodium_xchacha_stream() {
443            use libsodium_sys::crypto_stream_xchacha20_xor_ic;
444
445            crate::native_test_util::init();
446
447            for start in [u64::from(u32::MAX), u64::MAX] {
448                let input = [0u8; 128];
449                let mut expected = [0u8; 128];
450                // SAFETY: All pointers are derived from initialized fixed-size
451                // buffers with lengths matching the arguments passed to
452                // libsodium. The key and nonce are exact-size test vectors.
453                unsafe {
454                    assert_eq!(
455                        crypto_stream_xchacha20_xor_ic(
456                            expected.as_mut_ptr(),
457                            input.as_ptr(),
458                            input.len() as u64,
459                            NONCE.as_ptr(),
460                            start,
461                            KEY.as_ptr(),
462                        ),
463                        0
464                    );
465                }
466
467                let mut actual = [0u8; 128];
468                xchacha20_stream(&NONCE, &KEY, start).apply_keystream(&mut actual);
469                assert_eq!(actual, expected, "from {start:#x}");
470            }
471        }
472
473        #[test]
474        fn test_matches_libsodium_detached_and_combined() {
475            crate::native_test_util::init();
476            check_matches_libsodium(
477                &aead(),
478                libsodium_sys::crypto_aead_xchacha20poly1305_ietf_encrypt_detached,
479                &KEY,
480                &NONCE,
481            );
482        }
483    }
484}