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/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 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("