From 33d5a5b1442e480fb12a9ae349a2a5043a623906 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 12:29:38 +0000 Subject: [PATCH] AES-GCM and ChaCha20-Poly1305: out-of-place encryption from pieces AesGcm::encrypt and ChaCha20Poly1305::encrypt encrypt the concatenation of a list of slices into a separate output buffer, and AesGcmEncryptor::update_into encrypts the next piece of a stream from one buffer into another (#990). A TLS record layer is handed the plaintext in buffers it does not own, often in pieces, plus TLS 1.3's content-type byte, and must write the ciphertext to its own output: this is the API it needs, the one-call equivalent of aws-lc-rs's seal_out_of_place_scatter (which also takes several pieces). For now they gather the pieces into the output and encrypt it there with the in-place functions, on every architecture; the verified out-of-place functions (contracts in the Spec PR) replace that copy target by target without changing the API. Every length is checked before anything is written, and an output of the wrong length is InvalidOutputLength. Tests: every split of a message into pieces against in-place encryption, and the Wycheproof vectors in two pieces (and update_into). Benchmarks: out-of-place encryption of a TLS 1.3-shaped record (plaintext plus the content-type byte) against aws-lc-rs's seal_out_of_place_scatter and OpenSSL's EVP interface. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MmkzVkkRyGFLYBPdnXPeVb --- bench/benches/primitives/aes_gcm.rs | 54 ++++++++ bench/benches/primitives/chacha20poly1305.rs | 61 ++++++++- src/aes_gcm.rs | 124 ++++++++++++++++++- src/chacha20poly1305.rs | 69 +++++++++++ tests/wycheproof/aes_gcm.rs | 18 ++- tests/wycheproof/chacha20poly1305.rs | 6 + 6 files changed, 328 insertions(+), 4 deletions(-) diff --git a/bench/benches/primitives/aes_gcm.rs b/bench/benches/primitives/aes_gcm.rs index e54fd18075..ae095e90e3 100644 --- a/bench/benches/primitives/aes_gcm.rs +++ b/bench/benches/primitives/aes_gcm.rs @@ -91,6 +91,60 @@ pub fn bench(c: &mut Criterion) { }); g.finish(); + // Out of place, shaped as a TLS 1.3 record: the plaintext and the + // content-type byte after it encrypted into a separate buffer, as + // rustls's record layer asks of its provider (aws-lc-rs's + // `seal_out_of_place_scatter` with the byte as `extra_in`). + let typ = [0x17u8]; + let mut out = vec![0u8; size + 1]; + let mut extra = [0u8; 17]; + let mut g = c.benchmark_group("aes-128-gcm-encrypt-out-of-place"); + g.throughput(Throughput::Bytes(size as u64)); + g.bench_function(BenchmarkId::new(VG, size), |b| { + b.iter(|| { + let k = AesGcm::new(black_box(&key)).unwrap(); + k.encrypt( + black_box(&nonce), + black_box(&aad), + &[black_box(&data[..]), &typ], + black_box(&mut out), + ) + .unwrap() + }) + }); + let mut ossl_out = vec![0u8; size + 1 + cipher.block_size()]; + g.bench_function(BenchmarkId::new(OPENSSL, size), |b| { + b.iter(|| { + let mut s = Crypter::new( + cipher, + Mode::Encrypt, + black_box(&key), + Some(black_box(&nonce)), + ) + .unwrap(); + s.aad_update(black_box(&aad)).unwrap(); + let n = s.update(black_box(&data), &mut ossl_out).unwrap(); + let n = n + s.update(&typ, &mut ossl_out[n..]).unwrap(); + s.finalize(&mut ossl_out[n..]).unwrap(); + s.get_tag(&mut t).unwrap(); + }) + }); + g.bench_function(BenchmarkId::new(AWS_LC, size), |b| { + b.iter(|| { + aws_lc_key(black_box(&key)) + .seal_out_of_place_scatter( + Nonce::assume_unique_for_key(*black_box(&nonce)), + Aad::from(black_box(&aad)), + black_box(&data), + black_box(&mut out[..size]), + &typ, + &mut extra, + ) + .unwrap() + }) + }); + g.finish(); + let mut g = c.benchmark_group("aes-128-gcm-decrypt"); g.throughput(Throughput::Bytes(size as u64)); g.bench_function(BenchmarkId::new(VG, size), |b| { diff --git a/bench/benches/primitives/chacha20poly1305.rs b/bench/benches/primitives/chacha20poly1305.rs index f228a60156..3805543cd6 100644 --- a/bench/benches/primitives/chacha20poly1305.rs +++ b/bench/benches/primitives/chacha20poly1305.rs @@ -18,7 +18,7 @@ pub fn bench(c: &mut Criterion) { use aws_lc_rs::aead::{Aad, CHACHA20_POLY1305, LessSafeKey, Nonce, UnboundKey}; use criterion::{BenchmarkId, Throughput}; - use openssl::symm::{Cipher, decrypt_aead, encrypt_aead}; + use openssl::symm::{Cipher, Crypter, Mode, decrypt_aead, encrypt_aead}; use verified_garbage::chacha20poly1305::ChaCha20Poly1305; use crate::{AWS_LC, OPENSSL, SIZES, VG}; @@ -66,6 +66,65 @@ pub fn bench(c: &mut Criterion) { } g.finish(); + // Out of place, shaped as a TLS 1.3 record: the plaintext and the + // content-type byte after it encrypted into a separate buffer, as + // rustls's record layer asks of its provider (aws-lc-rs's + // `seal_out_of_place_scatter` with the byte as `extra_in`; OpenSSL's + // EVP interface always encrypts out of place). + let typ = [0x17u8]; + let mut g = c.benchmark_group("chacha20poly1305-out-of-place"); + for size in SIZES { + g.throughput(Throughput::Bytes(size as u64)); + let data = vec![0u8; size]; + let mut out = vec![0u8; size + 1]; + g.bench_function(BenchmarkId::new(VG, size), |b| { + b.iter(|| { + ChaCha20Poly1305::new(black_box(&key)) + .encrypt( + black_box(&nonce), + black_box(&aad), + &[black_box(&data[..]), &typ], + black_box(&mut out), + ) + .unwrap() + }) + }); + let mut ossl_out = vec![0u8; size + 1]; + let mut tag = [0u8; 16]; + g.bench_function(BenchmarkId::new(OPENSSL, size), |b| { + b.iter(|| { + let mut s = Crypter::new( + Cipher::chacha20_poly1305(), + Mode::Encrypt, + black_box(&key), + Some(black_box(&nonce)), + ) + .unwrap(); + s.aad_update(black_box(&aad)).unwrap(); + let n = s.update(black_box(&data), &mut ossl_out).unwrap(); + let n = n + s.update(&typ, &mut ossl_out[n..]).unwrap(); + s.finalize(&mut ossl_out[n..]).unwrap(); + s.get_tag(&mut tag).unwrap(); + }) + }); + let mut extra = [0u8; 17]; + g.bench_function(BenchmarkId::new(AWS_LC, size), |b| { + b.iter(|| { + aws_lc_key(black_box(&key)) + .seal_out_of_place_scatter( + Nonce::assume_unique_for_key(*black_box(&nonce)), + Aad::from(black_box(&aad)), + black_box(&data), + black_box(&mut out[..size]), + &typ, + &mut extra, + ) + .unwrap() + }) + }); + } + g.finish(); + let mut g = c.benchmark_group("chacha20poly1305-decrypt"); for size in SIZES { g.throughput(Throughput::Bytes(size as u64)); diff --git a/src/aes_gcm.rs b/src/aes_gcm.rs index cbbe77917e..f93d8c270e 100644 --- a/src/aes_gcm.rs +++ b/src/aes_gcm.rs @@ -18,6 +18,12 @@ //! fits in memory, [`AesGcm::decrypt_in_place`] checks the tag before it //! decrypts. //! +//! [`AesGcm::encrypt`] and [`AesGcmEncryptor::update_into`] encrypt out of +//! place, from plaintext in buffers of the caller's (in pieces, for +//! `encrypt`) into an output buffer, as a TLS record layer needs. They gather +//! the plaintext into the output and encrypt it there with the in-place +//! functions. +//! //! # Tags //! //! Encryption returns the full 16-byte tag; a protocol that sends a shorter @@ -490,6 +496,27 @@ pub enum Error { /// was called after `update`: GCM authenticates all of the additional /// data before the text. AadAfterText, + /// The output buffer of [`AesGcm::encrypt`] or + /// [`AesGcmEncryptor::update_into`] is not exactly as long as the input. + InvalidOutputLength, +} + +/// The total length of the pieces `chunks`, if it fits in a `usize`. +fn total_len(chunks: &[&[u8]]) -> Option { + chunks + .iter() + .try_fold(0usize, |n, c| n.checked_add(c.len())) +} + +/// Copies the pieces `chunks` one after the other into `out`, which is +/// exactly as long as they are in all. +fn gather(chunks: &[&[u8]], out: &mut [u8]) { + let mut rest = out; + for c in chunks { + let (head, tail) = rest.split_at_mut(c.len()); + head.copy_from_slice(c); + rest = tail; + } } /// `len + n`, if it is at most `max`. @@ -718,6 +745,35 @@ impl AesGcm { } } + /// GCM-AE (ยง7.1), out of place: encrypts the concatenation of the pieces + /// of `plaintext` under `nonce`, writes the ciphertext to `out`, and + /// returns the 16-byte tag authenticating it and `aad`. A caller holding + /// the plaintext in buffers it does not own, such as a TLS record layer + /// with a record in several pieces and its content type after them, + /// encrypts it without first gathering it into `out` itself. + /// + /// `out` must be exactly as long as the plaintext (the pieces' lengths + /// added up), or this returns [`Error::InvalidOutputLength`] and writes + /// nothing. The nonce is as for + /// [`encrypt_in_place`](Self::encrypt_in_place). + pub fn encrypt( + &self, + nonce: &[u8], + aad: &[u8], + plaintext: &[&[u8]], + out: &mut [u8], + ) -> Result { + // Check every length before writing anything. + if total_len(plaintext) != Some(out.len()) { + return Err(Error::InvalidOutputLength); + } + check_nonce(nonce)?; + add_len(0, out.len(), MAX_TEXT).map_err(|()| Error::InvalidTextLength)?; + add_len(0, aad.len(), MAX_AAD).map_err(|()| Error::InvalidAadLength)?; + gather(plaintext, out); + self.encrypt_in_place(nonce, aad, out) + } + /// Starts encrypting a message under this key and `nonce` (of any /// nonzero length; 12 bytes is the recommended one). A nonce must never /// be used twice with the same key. @@ -1071,6 +1127,19 @@ impl AesGcmEncryptor<'_> { self.stream.update(data) } + /// Encrypts the next `input.len()` bytes of the plaintext, from `input`, + /// into `output`, which must be exactly as long (or this returns + /// [`Error::InvalidOutputLength`] and writes nothing). + pub fn update_into(&mut self, input: &[u8], output: &mut [u8]) -> Result<(), Error> { + if input.len() != output.len() { + return Err(Error::InvalidOutputLength); + } + add_len(self.stream.text_len, input.len(), MAX_TEXT) + .map_err(|()| Error::InvalidTextLength)?; + output.copy_from_slice(input); + self.stream.update(output) + } + /// Finishes the message and returns its full 16-byte tag. A protocol /// that sends a shorter tag sends its first bytes. GCM leaves no /// buffered text to return. @@ -1139,7 +1208,7 @@ impl AesGcmDecryptor<'_> { #[cfg(test)] mod tests { - use super::{AesGcm, Backend, Error, MAX_AAD, MAX_TEXT, add_len, select}; + use super::{AesGcm, Backend, Error, MAX_AAD, MAX_TEXT, add_len, select, total_len}; use crate::cpu::{Features, detected}; /// The implementation chosen for each set of features: AES's and @@ -1815,6 +1884,59 @@ mod tests { truncated::<16>(); } + /// Out-of-place encryption matches in-place encryption for every split + /// of the plaintext into pieces, empty ones included, and its errors + /// write nothing. + #[test] + fn out_of_place() { + let k = AesGcm::new(&[3; 16]).unwrap(); + let nonce = [5u8; 12]; + let aad = [6u8; 20]; + let msg: [u8; 70] = core::array::from_fn(|i| (i as u8).wrapping_mul(29)); + let mut ct = msg; + let tag = k.encrypt_in_place(&nonce, &aad, &mut ct).unwrap(); + for a in 0..=msg.len() { + for b in a..=msg.len() { + let mut out = [0u8; 70]; + let pieces = [&msg[..a], &[][..], &msg[a..b], &msg[b..]]; + assert_eq!(k.encrypt(&nonce, &aad, &pieces, &mut out), Ok(tag)); + assert_eq!(out, ct); + } + } + // A short piece at a time, as `update_into`. + let mut e = k.encryptor(&nonce).unwrap(); + e.update_aad(&aad).unwrap(); + let mut out = [0u8; 70]; + for (x, y) in msg.chunks(3).zip(out.chunks_mut(3)) { + e.update_into(x, y).unwrap(); + } + assert_eq!(out, ct); + assert_eq!(e.finalize(), tag); + + let mut out = [9u8; 4]; + let three = [1u8; 3]; + assert_eq!( + k.encrypt(&nonce, &aad, &[&three[..]], &mut out), + Err(Error::InvalidOutputLength) + ); + assert_eq!( + k.encrypt(&[], &aad, &[&three[..], &[2][..]], &mut out), + Err(Error::InvalidNonceLength) + ); + let mut e = k.encryptor(&nonce).unwrap(); + assert_eq!( + e.update_into(&three, &mut out), + Err(Error::InvalidOutputLength) + ); + e.stream.text_len = MAX_TEXT; + assert_eq!( + e.update_into(&three, &mut out[..3]), + Err(Error::InvalidTextLength) + ); + assert_eq!(out, [9; 4]); + assert_eq!(total_len(&[&three[..], &three[..2]]), Some(5)); + } + #[test] fn errors() { assert_eq!(AesGcm::new(&[0; 15]).err(), Some(Error::InvalidKeyLength)); diff --git a/src/chacha20poly1305.rs b/src/chacha20poly1305.rs index ef2345d228..f0d811b9af 100644 --- a/src/chacha20poly1305.rs +++ b/src/chacha20poly1305.rs @@ -29,6 +29,11 @@ //! the keystream of short data (up to 960 bytes with AVX-512, 192 with AVX2), //! which then takes no second call; every other variant computes it with the //! scalar `vg_chacha20_block`. +//! +//! [`ChaCha20Poly1305::encrypt`] encrypts out of place, from plaintext in +//! buffers of the caller's (in pieces) into an output buffer, as a TLS record +//! layer needs: it gathers the plaintext into the output and encrypts it +//! there with `vg_chacha20_poly1305_seal`. #![cfg(any( target_arch = "x86_64", @@ -95,6 +100,9 @@ pub enum Error { /// The tag does not match: the ciphertext, the additional data or the /// nonce is not what was authenticated under this key. TagMismatch, + /// The output buffer of [`ChaCha20Poly1305::encrypt`] is not exactly as + /// long as the plaintext. + InvalidOutputLength, } /// The AEAD with a key. @@ -177,6 +185,40 @@ impl ChaCha20Poly1305 { Ok(tag) } + /// Encrypts the concatenation of the pieces of `plaintext`, with the + /// nonce `nonce` and the additional data `aad`, writes the ciphertext to + /// `out`, and returns the tag: encryption out of place, for a caller + /// holding the plaintext in buffers it does not own, such as a TLS record + /// layer with a record in several pieces and its content type after + /// them. + /// + /// `out` must be exactly as long as the plaintext (the pieces' lengths + /// added up), or this returns [`Error::InvalidOutputLength`] and writes + /// nothing. + pub fn encrypt( + &self, + nonce: &[u8; 12], + aad: &[u8], + plaintext: &[&[u8]], + out: &mut [u8], + ) -> Result<[u8; 16], Error> { + // Check the lengths before writing anything. + let total = plaintext + .iter() + .try_fold(0usize, |n, c| n.checked_add(c.len())); + if total != Some(out.len()) { + return Err(Error::InvalidOutputLength); + } + check_len(out.len())?; + let mut rest = &mut out[..]; + for c in plaintext { + let (head, tail) = rest.split_at_mut(c.len()); + head.copy_from_slice(c); + rest = tail; + } + self.encrypt_in_place(nonce, aad, out) + } + /// Decrypts `data` in place, with the nonce `nonce` and the additional /// data `aad`, if `tag` authenticates it; otherwise returns /// [`Error::TagMismatch`] and zeroes `data`. @@ -235,6 +277,33 @@ mod tests { use crate::cpu::Features; use crate::cpu::detected; + /// Out-of-place encryption matches in-place encryption for every split + /// of the plaintext into pieces, empty ones included, and its errors + /// write nothing. + #[test] + fn out_of_place() { + let aead = ChaCha20Poly1305::new(&[4; 32]); + let nonce = [8; 12]; + let aad = [2; 9]; + let msg: [u8; 80] = core::array::from_fn(|i| (i as u8).wrapping_mul(31)); + let mut ct = msg; + let tag = aead.encrypt_in_place(&nonce, &aad, &mut ct).unwrap(); + for a in 0..=msg.len() { + for b in (a..=msg.len()).step_by(7) { + let mut out = [0u8; 80]; + let pieces = [&msg[..a], &[][..], &msg[a..b], &msg[b..]]; + assert_eq!(aead.encrypt(&nonce, &aad, &pieces, &mut out), Ok(tag)); + assert_eq!(out, ct); + } + } + let mut out = [9u8; 4]; + assert_eq!( + aead.encrypt(&nonce, &aad, &[&[1, 2, 3][..]], &mut out), + Err(Error::InvalidOutputLength) + ); + assert_eq!(out, [9; 4]); + } + /// Decryption undoes encryption, and rejects any change to the data, the /// additional data, the nonce or the tag, zeroing the data. #[test] diff --git a/tests/wycheproof/aes_gcm.rs b/tests/wycheproof/aes_gcm.rs index c87911903c..b358334649 100644 --- a/tests/wycheproof/aes_gcm.rs +++ b/tests/wycheproof/aes_gcm.rs @@ -1,7 +1,7 @@ //! AES-GCM (`AeadTest` vectors, `aes_gcm_test.json`). //! -//! A valid vector must encrypt to exactly its ciphertext and tag, and -//! decrypt back, both at once and streaming. An invalid vector must be +//! A valid vector must encrypt to exactly its ciphertext and tag, in place +//! and out of place, and decrypt back, both at once and streaming. An invalid vector must be //! rejected, at once and streaming: by decryption (a modified tag, //! ciphertext or additional data), or already by the nonce check (an empty //! nonce). @@ -82,6 +82,20 @@ fn aes_gcm() { e.update(&mut buf).unwrap(); assert_eq!(buf, c.ct.0, "tcId {id}"); assert_eq!(e.finalize(), tag, "tcId {id}"); + + // Out of place, the plaintext in two pieces. + let (x, y) = c.msg.0.split_at(c.msg.0.len() / 2); + let mut out = vec![0; c.msg.0.len()]; + let sealed = key.encrypt(&c.iv.0, &c.aad.0, &[x, y], &mut out); + assert_eq!(sealed, Ok(tag), "tcId {id}"); + assert_eq!(out, c.ct.0, "tcId {id}"); + let mut e = key.encryptor(&c.iv.0).unwrap(); + e.update_aad(&c.aad.0).unwrap(); + let (o, p) = out.split_at_mut(x.len()); + e.update_into(x, o).unwrap(); + e.update_into(y, p).unwrap(); + assert_eq!(out, c.ct.0, "tcId {id}"); + assert_eq!(e.finalize(), tag, "tcId {id}"); assert_eq!(stream_decrypt(&key, c), Ok(c.msg.0.clone()), "tcId {id}"); valid.add(); } diff --git a/tests/wycheproof/chacha20poly1305.rs b/tests/wycheproof/chacha20poly1305.rs index bb95d011cb..f31b750028 100644 --- a/tests/wycheproof/chacha20poly1305.rs +++ b/tests/wycheproof/chacha20poly1305.rs @@ -57,6 +57,12 @@ fn chacha20_poly1305() { let sealed = aead.encrypt_in_place(&nonce, &c.aad.0, &mut data); assert_eq!(data, c.ct.0, "tcId {}", test.tc_id); assert_eq!(sealed, Ok(tag), "tcId {}", test.tc_id); + // Out of place, the plaintext in two pieces. + let (x, y) = c.msg.0.split_at(c.msg.0.len() / 2); + let mut out = vec![0; c.msg.0.len()]; + let sealed = aead.encrypt(&nonce, &c.aad.0, &[x, y], &mut out); + assert_eq!(out, c.ct.0, "tcId {}", test.tc_id); + assert_eq!(sealed, Ok(tag), "tcId {}", test.tc_id); } else { // There are no acceptable vectors. assert_eq!(test.result, Expectation::Invalid, "tcId {}", test.tc_id);