From 87066eeca08ee853b47413da057de554dbba9137 Mon Sep 17 00:00:00 2001 From: Paul Kehrer Date: Sun, 7 Jun 2026 09:42:00 -0700 Subject: [PATCH 1/2] Track ChaCha20 32-bit counter and error on overflow ChaCha20 now extracts the 32-bit little-endian block counter from the first 4 bytes of the 128-bit nonce and tracks the number of bytes processed. Encrypting or decrypting more than (2**32 - counter) * 64 bytes would overflow that counter, at which point the underlying implementation silently diverges from RFC 7539. We now raise ValueError instead. Setting the counter portion of the nonce to zero allows encrypting up to 256 GiB with a given nonce. The docs are updated accordingly and no longer suggest randomizing the full value. The counter-overflow.txt vectors (and their generator/verifier docs) captured the old carry behavior, which can no longer be reproduced, so they are removed in favor of explicit overflow tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.rst | 7 ++ docs/development/custom-vectors/chacha20.rst | 29 ------ .../chacha20/generate_chacha20_overflow.py | 47 --------- .../chacha20/verify_chacha20_overflow.py | 67 ------------- docs/development/test-vectors.rst | 4 +- .../primitives/symmetric-encryption.rst | 38 ++++---- src/rust/src/backend/ciphers.rs | 97 +++++++++++++++++-- tests/hazmat/primitives/test_chacha20.py | 64 +++++++++++- .../ciphers/ChaCha20/counter-overflow.txt | 70 ------------- .../ciphers/ChaCha20/rfc7539.txt | 6 +- 10 files changed, 179 insertions(+), 250 deletions(-) delete mode 100644 docs/development/custom-vectors/chacha20.rst delete mode 100644 docs/development/custom-vectors/chacha20/generate_chacha20_overflow.py delete mode 100644 docs/development/custom-vectors/chacha20/verify_chacha20_overflow.py delete mode 100644 vectors/cryptography_vectors/ciphers/ChaCha20/counter-overflow.txt diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4e87d9b1b2f6..d8e228431d90 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,6 +12,13 @@ Changelog We now only publish ``arm64`` wheels for macOS. * **BACKWARDS INCOMPATIBLE:** Support for 32-bit Windows has been removed. Users should move to a 64-bit Python installation. +* **BACKWARDS INCOMPATIBLE:** :class:`~cryptography.hazmat.primitives.ciphers.algorithms.ChaCha20` + now treats the first 4 bytes of the ``nonce`` as a 32-bit little-endian block + counter (as defined in :rfc:`7539`) and tracks the number of bytes processed. + Attempting to encrypt or decrypt more data than the counter allows before it + would overflow now raises a :class:`ValueError` rather than silently diverging + from RFC 7539. Setting the counter portion of the ``nonce`` to zero allows + encrypting up to 256 GiB with a given nonce. * Fixed cross-compilation of the CFFI bindings when ``PYO3_CROSS_LIB_DIR`` is set. The build now derives the Python include directory from ``PYO3_CROSS_LIB_DIR`` instead of querying the host interpreter, which diff --git a/docs/development/custom-vectors/chacha20.rst b/docs/development/custom-vectors/chacha20.rst deleted file mode 100644 index 5fee0c360e35..000000000000 --- a/docs/development/custom-vectors/chacha20.rst +++ /dev/null @@ -1,29 +0,0 @@ -ChaCha20 vector creation -======================== - -This page documents the code that was used to generate the vectors -to test the counter overflow behavior in ChaCha20 as well as code -used to verify them against another implementation. - -Creation --------- - -The following Python script was run to generate the vector files. - -.. literalinclude:: /development/custom-vectors/chacha20/generate_chacha20_overflow.py - -Download link: :download:`generate_chacha20_overflow.py -` - - -Verification ------------- - -The following Python script was used to verify the vectors. The -counter overflow is handled manually to avoid relying on the same -code that generated the vectors. - -.. literalinclude:: /development/custom-vectors/chacha20/verify_chacha20_overflow.py - -Download link: :download:`verify_chacha20_overflow.py -` diff --git a/docs/development/custom-vectors/chacha20/generate_chacha20_overflow.py b/docs/development/custom-vectors/chacha20/generate_chacha20_overflow.py deleted file mode 100644 index c8ed339f4074..000000000000 --- a/docs/development/custom-vectors/chacha20/generate_chacha20_overflow.py +++ /dev/null @@ -1,47 +0,0 @@ -# This file is dual licensed under the terms of the Apache License, Version -# 2.0, and the BSD License. See the LICENSE file in the root of this repository -# for complete details. - -import binascii -import struct - -from cryptography.hazmat.primitives import ciphers -from cryptography.hazmat.primitives.ciphers import algorithms - -_N_BLOCKS = [1, 1.5, 2, 2.5, 3] -_INITIAL_COUNTERS = [2**32 - 1, 2**64 - 1] - - -def _build_vectors(): - count = 0 - output = [] - key = "0" * 64 - nonce = "0" * 16 - for blocks in _N_BLOCKS: - plaintext = binascii.unhexlify("0" * int(128 * blocks)) - for counter in _INITIAL_COUNTERS: - full_nonce = struct.pack(" bytes: - full_nonce = struct.pack(">> import struct, os + >>> import os >>> from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes >>> key = os.urandom(32) - >>> nonce = os.urandom(8) - >>> counter = 0 - >>> full_nonce = struct.pack(">> algorithm = algorithms.ChaCha20(key, full_nonce) + >>> counter = b"\x00\x00\x00\x00" + >>> nonce = counter + os.urandom(12) + >>> algorithm = algorithms.ChaCha20(key, nonce) >>> cipher = Cipher(algorithm, mode=None) >>> encryptor = cipher.encryptor() >>> ct = encryptor.update(b"a secret message") @@ -862,7 +859,6 @@ Exceptions .. _`Communications Security Establishment`: https://www.cse-cst.gc.ca .. _`encrypt`: https://ssd.eff.org/en/module/what-should-i-know-about-encryption .. _`CRYPTREC`: https://www.cryptrec.go.jp/en/ -.. _`original version`: https://en.wikipedia.org/wiki/Salsa20#ChaCha_variant .. _`significant patterns in the output`: https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Electronic_codebook_(ECB) .. _`International Data Encryption Algorithm`: https://en.wikipedia.org/wiki/International_Data_Encryption_Algorithm .. _`OpenPGP`: https://www.openpgp.org/ diff --git a/src/rust/src/backend/ciphers.rs b/src/rust/src/backend/ciphers.rs index c22e12dec883..a67c94f8393d 100644 --- a/src/rust/src/backend/ciphers.rs +++ b/src/rust/src/backend/ciphers.rs @@ -228,12 +228,67 @@ impl CipherContext { } } +// ChaCha20 generates 64 bytes of keystream per 32-bit block counter value. +const CHACHA20_BLOCK_SIZE: u64 = 64; + +// The 16-byte ChaCha20 nonce begins with a 32-bit little-endian block counter. +// Encrypting more than `(2**32 - counter) * 64` bytes would overflow that +// counter, at which point the underlying implementation silently diverges from +// RFC 7539 (the counter carries into the rest of the nonce), so we instead +// refuse to encrypt past that point. +fn chacha20_byte_limit(counter: u32) -> u64 { + ((1u64 << 32) - u64::from(counter)) * CHACHA20_BLOCK_SIZE +} + +// Returns the maximum number of bytes that may be processed before the +// ChaCha20 block counter would overflow, or `None` if `algorithm` is not +// ChaCha20. +fn chacha20_initial_byte_limit( + py: pyo3::Python<'_>, + algorithm: &pyo3::Bound<'_, pyo3::PyAny>, +) -> CryptographyResult> { + if algorithm.is_instance(&types::CHACHA20.get(py)?)? { + let nonce = algorithm + .getattr(pyo3::intern!(py, "nonce"))? + .extract::>()?; + // The nonce is guaranteed to be 16 bytes by the ChaCha20 constructor. + let counter = u32::from_le_bytes(nonce.as_bytes()[..4].try_into().unwrap()); + Ok(Some(chacha20_byte_limit(counter))) + } else { + Ok(None) + } +} + #[pyo3::pyclass( module = "cryptography.hazmat.bindings._rust.openssl.ciphers", name = "CipherContext" )] struct PyCipherContext { ctx: Option, + // For ChaCha20 this tracks how many more bytes may be processed before the + // 32-bit block counter would overflow. `None` for all other ciphers. + bytes_remaining: Option, +} + +impl PyCipherContext { + fn decrement_bytes_remaining(&mut self, n: usize) -> CryptographyResult<()> { + if let Some(remaining) = self.bytes_remaining { + self.bytes_remaining = Some( + remaining + .checked_sub(u64::try_from(n).unwrap()) + .ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err( + "Exceeded the maximum number of bytes that can be \ + encrypted with ChaCha20 for this nonce. The 32-bit \ + counter portion of the nonce would overflow; set the \ + counter portion of the nonce to zero to allow \ + encrypting up to 256 GiB.", + ) + })?, + ); + } + Ok(()) + } } #[pyo3::pyclass( @@ -270,11 +325,25 @@ impl PyCipherContext { py: pyo3::Python<'p>, data: CffiBuf<'_>, ) -> CryptographyResult> { - get_mut_ctx(self.ctx.as_mut())?.update(py, data.as_bytes()) + let data = data.as_bytes(); + self.decrement_bytes_remaining(data.len())?; + get_mut_ctx(self.ctx.as_mut())?.update(py, data) } fn reset_nonce(&mut self, py: pyo3::Python<'_>, nonce: CffiBuf<'_>) -> CryptographyResult<()> { - get_mut_ctx(self.ctx.as_mut())?.reset_nonce(py, nonce) + // Capture the counter before the buffer is moved into the inner reset, + // but only apply the new limit once that reset (which validates the + // nonce length) succeeds. + let counter_bytes: Option<[u8; 4]> = if self.bytes_remaining.is_some() { + nonce.as_bytes().get(..4).map(|b| b.try_into().unwrap()) + } else { + None + }; + get_mut_ctx(self.ctx.as_mut())?.reset_nonce(py, nonce)?; + if let Some(counter_bytes) = counter_bytes { + self.bytes_remaining = Some(chacha20_byte_limit(u32::from_le_bytes(counter_bytes))); + } + Ok(()) } fn update_into( @@ -283,7 +352,9 @@ impl PyCipherContext { data: CffiBuf<'_>, mut buf: CffiMutBuf<'_>, ) -> CryptographyResult { - get_mut_ctx(self.ctx.as_mut())?.update_into(py, data.as_bytes(), buf.as_mut_bytes()) + let data = data.as_bytes(); + self.decrement_bytes_remaining(data.len())?; + get_mut_ctx(self.ctx.as_mut())?.update_into(py, data, buf.as_mut_bytes()) } fn finalize<'p>( @@ -523,6 +594,7 @@ fn create_encryption_ctx<'p>( algorithm: pyo3::Bound<'_, pyo3::PyAny>, mode: pyo3::Bound<'_, pyo3::PyAny>, ) -> CryptographyResult> { + let bytes_remaining = chacha20_initial_byte_limit(py, &algorithm)?; let ctx = CipherContext::new(py, algorithm, mode.clone(), openssl::symm::Mode::Encrypt)?; if mode.is_instance(&types::MODE_WITH_AUTHENTICATION_TAG.get(py)?)? { @@ -540,9 +612,12 @@ fn create_encryption_ctx<'p>( .into_pyobject(py)? .into_any()) } else { - Ok(PyCipherContext { ctx: Some(ctx) } - .into_pyobject(py)? - .into_any()) + Ok(PyCipherContext { + ctx: Some(ctx), + bytes_remaining, + } + .into_pyobject(py)? + .into_any()) } } @@ -552,6 +627,7 @@ fn create_decryption_ctx<'p>( algorithm: pyo3::Bound<'_, pyo3::PyAny>, mode: pyo3::Bound<'_, pyo3::PyAny>, ) -> CryptographyResult> { + let bytes_remaining = chacha20_initial_byte_limit(py, &algorithm)?; let mut ctx = CipherContext::new(py, algorithm, mode.clone(), openssl::symm::Mode::Decrypt)?; if mode.is_instance(&types::MODE_WITH_AUTHENTICATION_TAG.get(py)?)? { @@ -575,9 +651,12 @@ fn create_decryption_ctx<'p>( .into_pyobject(py)? .into_any()) } else { - Ok(PyCipherContext { ctx: Some(ctx) } - .into_pyobject(py)? - .into_any()) + Ok(PyCipherContext { + ctx: Some(ctx), + bytes_remaining, + } + .into_pyobject(py)? + .into_any()) } } diff --git a/tests/hazmat/primitives/test_chacha20.py b/tests/hazmat/primitives/test_chacha20.py index 3ade8b9e2eb1..6596e65d93c7 100644 --- a/tests/hazmat/primitives/test_chacha20.py +++ b/tests/hazmat/primitives/test_chacha20.py @@ -27,7 +27,7 @@ class TestChaCha20: "vector", _load_all_params( os.path.join("ciphers", "ChaCha20"), - ["counter-overflow.txt", "rfc7539.txt"], + ["rfc7539.txt"], load_nist_vectors, ), ) @@ -42,6 +42,68 @@ def test_vectors(self, vector, backend): computed_ct = encryptor.update(pt) + encryptor.finalize() assert binascii.hexlify(computed_ct) == vector["ciphertext"] + def _nonce_with_counter(self, counter: int) -> bytes: + # The 16-byte ChaCha20 nonce is a 4-byte little-endian block counter + # followed by a 12-byte nonce. + return struct.pack(" Date: Sun, 7 Jun 2026 10:28:35 -0700 Subject: [PATCH 2/2] look spellchecker, GiB is real even if we wish it wasn't --- docs/spelling_wordlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index e7fcf529e5e0..d26db8150758 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -69,6 +69,7 @@ Fernet fernet FIPS Gaynor +GiB Google Graviola hazmat