From fcb2f075f6ca875ec22eb2117d1f8e5d3fdb8ca3 Mon Sep 17 00:00:00 2001 From: Facundo Tuesca Date: Fri, 7 Jul 2023 18:28:25 +0200 Subject: [PATCH] Add support for ChaCha20 with BoringSSL --- CHANGELOG.rst | 2 +- src/_cffi_src/build_openssl.py | 1 + src/_cffi_src/openssl/chacha.py | 78 ++++++++ .../hazmat/backends/openssl/backend.py | 16 +- .../hazmat/backends/openssl/ciphers.py | 171 +++++++++++++++++- tests/hazmat/backends/test_openssl.py | 24 ++- tests/hazmat/primitives/test_ciphers.py | 17 ++ 7 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 src/_cffi_src/openssl/chacha.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index be5ba20702992..45f93e033f4a0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -40,7 +40,7 @@ Changelog in favor of the new timezone-aware variants mentioned above. * Added support for :class:`~cryptography.hazmat.primitives.ciphers.algorithms.ChaCha20` - on LibreSSL. + on LibreSSL and BoringSSL. .. _v41-0-4: diff --git a/src/_cffi_src/build_openssl.py b/src/_cffi_src/build_openssl.py index ae8b821fe644d..74ef71877037b 100644 --- a/src/_cffi_src/build_openssl.py +++ b/src/_cffi_src/build_openssl.py @@ -26,6 +26,7 @@ "asn1", "bignum", "bio", + "chacha", "cmac", "crypto", "dh", diff --git a/src/_cffi_src/openssl/chacha.py b/src/_cffi_src/openssl/chacha.py new file mode 100644 index 0000000000000..19b6a4023c805 --- /dev/null +++ b/src/_cffi_src/openssl/chacha.py @@ -0,0 +1,78 @@ +# 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. + +from __future__ import annotations + +INCLUDES = """ +#if CRYPTOGRAPHY_IS_BORINGSSL +#include +#include +#endif""" + +TYPES = """ +static const long Cryptography_HAS_CHACHA20_API; +""" + +FUNCTIONS = """ +/* Signature is different between LibreSSL and BoringSSL, so expose via + different symbol name */ +void Cryptography_CRYPTO_chacha_20(uint8_t *, const uint8_t *, size_t, + const uint8_t[32], const uint8_t[8], + uint64_t); +""" + +CUSTOMIZATIONS = """ +#if CRYPTOGRAPHY_IS_BORINGSSL +static const long Cryptography_HAS_CHACHA20_API = 1; +#else +static const long Cryptography_HAS_CHACHA20_API = 0; +#endif + +#if CRYPTOGRAPHY_IS_BORINGSSL +void Cryptography_CRYPTO_chacha_20(uint8_t *out, const uint8_t *in, + size_t in_len, const uint8_t key[32], + const uint8_t nonce[8], uint64_t counter) { + /* BoringSSL uses a 32 bit counter, leaving the other 32 bits as part of + the nonce. Here we adapt the 64 bit counter so that the first 32 bits + are used as a counter and the other 32 bits as the beginning of the + nonce */ + uint32_t new_counter = (uint32_t) counter; + uint8_t new_nonce[12]; + memcpy(new_nonce, ((uint8_t*) &counter) + 4, 4); + memcpy(new_nonce + 4, nonce, 8); + + /* The maximum amount of bytes that can be encrypted using a 32-bit + counter */ + uint64_t max_bytes = ((uint64_t) UINT32_MAX + 1) * 64; + + /* Since BoringSSL uses a smaller 32 bit counter, it behaves differently + from OpenSSL/LibreSSL during counter overflow. In order to have + consistent implementations, we split the input so that no call to the + API results in counter overflow, and we manually increase the counter + as if it was 64 bits. */ + uint64_t bytes_before_overflow = max_bytes - (uint64_t) new_counter * 64; + uint64_t bytes_remaining = in_len; + uint64_t bytes_processed = 0; + while (bytes_remaining > 0) { + uint64_t next_batch = bytes_remaining < bytes_before_overflow ? + bytes_remaining : bytes_before_overflow; + CRYPTO_chacha_20(out + bytes_processed, in + bytes_processed, + next_batch, key, new_nonce, new_counter); + bytes_before_overflow = max_bytes; + bytes_remaining -= next_batch; + bytes_processed += next_batch; + /* Since each batch (except the last one) saturates the 32 bit + counter, we increase it by treating it and the first 32 bits + of the nonce as a 64 bit counter, matching Libre and OpenSSL */ + new_counter = 0; + uint32_t* nonce_counter = (uint32_t*) new_nonce; + (*nonce_counter)++; + } +} +#else +void (*Cryptography_CRYPTO_chacha_20)(uint8_t *, const uint8_t *, size_t, + const uint8_t[32], const uint8_t[8], + uint64_t) = NULL; +#endif +""" diff --git a/src/cryptography/hazmat/backends/openssl/backend.py b/src/cryptography/hazmat/backends/openssl/backend.py index 733c31d47296c..223f3b9a02004 100644 --- a/src/cryptography/hazmat/backends/openssl/backend.py +++ b/src/cryptography/hazmat/backends/openssl/backend.py @@ -12,7 +12,10 @@ from cryptography import utils, x509 from cryptography.exceptions import UnsupportedAlgorithm, _Reasons from cryptography.hazmat.backends.openssl import aead -from cryptography.hazmat.backends.openssl.ciphers import _CipherContext +from cryptography.hazmat.backends.openssl.ciphers import ( + _CipherContext, + create_cipher_context, +) from cryptography.hazmat.backends.openssl.cmac import _CMACContext from cryptography.hazmat.bindings._rust import openssl as rust_openssl from cryptography.hazmat.bindings.openssl import binding @@ -221,6 +224,13 @@ def hmac_supported(self, algorithm: hashes.HashAlgorithm) -> bool: return self.hash_supported(algorithm) def cipher_supported(self, cipher: CipherAlgorithm, mode: Mode) -> bool: + # ChaCha20 is supported in BoringSSL by a different API than OpenSSL + # and LibreSSL, so checking for the corresponding EVP_CIPHER is not + # useful + if self._lib.CRYPTOGRAPHY_IS_BORINGSSL and isinstance( + cipher, ChaCha20 + ): + return True if self._fips_enabled: # FIPS mode requires AES. TripleDES is disallowed/deprecated in # FIPS 140-3. @@ -318,12 +328,12 @@ def _register_default_ciphers(self) -> None: def create_symmetric_encryption_ctx( self, cipher: CipherAlgorithm, mode: Mode ) -> _CipherContext: - return _CipherContext(self, cipher, mode, _CipherContext._ENCRYPT) + return create_cipher_context(self, cipher, mode, encrypt=True) def create_symmetric_decryption_ctx( self, cipher: CipherAlgorithm, mode: Mode ) -> _CipherContext: - return _CipherContext(self, cipher, mode, _CipherContext._DECRYPT) + return create_cipher_context(self, cipher, mode, encrypt=False) def pbkdf2_hmac_supported(self, algorithm: hashes.HashAlgorithm) -> bool: return self.hmac_supported(algorithm) diff --git a/src/cryptography/hazmat/backends/openssl/ciphers.py b/src/cryptography/hazmat/backends/openssl/ciphers.py index a34dcbe6ce1a2..69e13fcc0cce8 100644 --- a/src/cryptography/hazmat/backends/openssl/ciphers.py +++ b/src/cryptography/hazmat/backends/openssl/ciphers.py @@ -4,6 +4,7 @@ from __future__ import annotations +import abc import typing from cryptography.exceptions import InvalidTag, UnsupportedAlgorithm, _Reasons @@ -14,7 +15,175 @@ from cryptography.hazmat.backends.openssl.backend import Backend -class _CipherContext: +class _CipherContext(metaclass=abc.ABCMeta): + _mode: typing.Any + tag: typing.Any + + @abc.abstractmethod + def update(self, data: bytes) -> bytes: + """ + Processes the provided bytes through the cipher and returns the results + as bytes. + """ + + @abc.abstractmethod + def update_into(self, data: bytes, buf: bytes) -> int: + """ + Processes the provided bytes and writes the resulting data into the + provided buffer. Returns the number of bytes written. + """ + + @abc.abstractmethod + def finalize(self) -> bytes: + """ + Returns the results of processing the final block as bytes. + """ + + @abc.abstractmethod + def authenticate_additional_data(self, data: bytes) -> None: + ... + + @abc.abstractmethod + def finalize_with_tag(self, tag: bytes) -> bytes: + ... + + +def create_cipher_context( + backend: Backend, cipher, mode: modes.Mode, encrypt: bool +) -> _CipherContext: + if ( + isinstance(cipher, algorithms.ChaCha20) + and backend._lib.Cryptography_HAS_CHACHA20_API + ): + return _CipherContextChaCha(backend, cipher) + else: + operation = ( + _CipherContextEVP._ENCRYPT + if encrypt + else _CipherContextEVP._DECRYPT + ) + return _CipherContextEVP(backend, cipher, mode, operation) + + +class _CipherContextChaCha(_CipherContext): + """ + Cipher context specific to ChaCha20 under BoringSSL + """ + + _BLOCK_SIZE_BYTES = 64 + _MAX_COUNTER_VALUE = 2**64 - 1 + + def __init__(self, backend: Backend, cipher) -> None: + assert isinstance(cipher, algorithms.ChaCha20) + assert backend._lib.Cryptography_HAS_CHACHA20_API + self._backend = backend + self._cipher = cipher + + # The ChaCha20 stream cipher. The key length is 256 bits, the IV is + # 128 bits long. The first 64 bits consists of a counter in + # little-endian order followed by a 64 bit nonce. + self._counter = int.from_bytes(cipher.nonce[:8], byteorder="little") + self._iv_nonce = cipher.nonce[8:] + + # We store the cleartext of the last partial block encrypted. For + # example, if `update()` is called with 96 bytes of data (1.5 blocks), + # it will return all 96 bytes of ciphertext, but the last 32 bytes + # (0.5 blocks) will also be stored in `_leftover_data`. + # See `update_into()` for more details. + self._leftover_data = bytearray() + + def update(self, data: bytes) -> bytes: + buf = bytearray(len(data)) + n = self.update_into(data, buf) + return bytes(buf[:n]) + + def update_into(self, data: bytes, buf: bytes) -> int: + data_len = len(data) + if len(buf) < data_len: + raise ValueError( + f"buffer must be at least {data_len} bytes for this payload" + ) + + previous_leftover_len = len(self._leftover_data) + if previous_leftover_len > 0: + # We prepend the last partial block from previous `update_into()` + # calls so that the resulting ciphertext is the same as if the + # data had been passed as a full block. + # This is needed because BoringSSL's ChaCha20 API is + # stateless, as opposed to OpenSSL's and LibreSSL's. + data_with_leftover = b"".join((self._leftover_data, data)) + buffer_with_leftover: bytes | bytearray = bytearray( + len(data_with_leftover) + ) + else: + data_with_leftover = data + buffer_with_leftover = buf + + baseoutbuf = self._backend._ffi.from_buffer( + buffer_with_leftover, require_writable=True + ) + baseinbuf = self._backend._ffi.from_buffer(data_with_leftover) + + self._backend._lib.Cryptography_CRYPTO_chacha_20( + baseoutbuf, + baseinbuf, + len(data_with_leftover), + self._backend._ffi.from_buffer(self._cipher.key), + self._backend._ffi.from_buffer(self._iv_nonce), + self._counter, + ) + + if previous_leftover_len > 0: + # Since we had to use a new buffer different that `buf` to fit + # the ciphertext, now we need to copy the ciphertext to `buf`. + # We copy the ciphertext but skipping the bytes corresponding + # to `_leftover_buf`, since those have already been returned by a + # previous call. + self._backend._ffi.memmove( + buf, + buffer_with_leftover[previous_leftover_len:], + data_len, + ) + + complete_blocks_written, leftover_len = divmod( + len(data_with_leftover), self._BLOCK_SIZE_BYTES + ) + if leftover_len > 0: + # Store the last partial block of data to use in the next call + self._leftover_data = bytearray( + data_with_leftover[ + complete_blocks_written * self._BLOCK_SIZE_BYTES : + ] + ) + assert len(self._leftover_data) < 64 + else: + self._leftover_data = bytearray() + + # Our implementation of ChaCha20 uses a 64-bit counter which wraps + # around on overflow + self._counter += complete_blocks_written + if self._counter > self._MAX_COUNTER_VALUE: + self._counter -= self._MAX_COUNTER_VALUE + 1 + + return data_len + + def finalize(self) -> bytes: + self._counter = 0 + self._leftover_data = bytearray() + return b"" + + def authenticate_additional_data(self, data: bytes) -> None: + raise NotImplementedError( + "ChaCha20 context cannot be used as AEAD context" + ) + + def finalize_with_tag(self, tag: bytes) -> bytes: + raise NotImplementedError( + "ChaCha20 context cannot be used as AEAD context" + ) + + +class _CipherContextEVP(_CipherContext): _ENCRYPT = 1 _DECRYPT = 0 _MAX_CHUNK_SIZE = 2**30 - 1 diff --git a/tests/hazmat/backends/test_openssl.py b/tests/hazmat/backends/test_openssl.py index a47470b9a2439..f8481f0d6463a 100644 --- a/tests/hazmat/backends/test_openssl.py +++ b/tests/hazmat/backends/test_openssl.py @@ -14,7 +14,7 @@ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives.ciphers import Cipher -from cryptography.hazmat.primitives.ciphers.algorithms import AES +from cryptography.hazmat.primitives.ciphers.algorithms import AES, ChaCha20 from cryptography.hazmat.primitives.ciphers.modes import CBC from ...doubles import ( @@ -375,3 +375,25 @@ def test_public_load_dhx_unsupported(self, key_path, loader_func, backend): ) with pytest.raises(ValueError): loader_func(key_bytes, backend) + + +@pytest.mark.supported( + only_if=lambda backend: backend.cipher_supported( + ChaCha20(b"\x00" * 32, b"\x00" * 16), None + ) + and backend._lib.CRYPTOGRAPHY_IS_BORINGSSL, + skip_message="Does not support non-EVP ChaCha20 cipher", +) +class TestChaCha20CipherContext: + def test_unsupported_api(self): + from cryptography.hazmat.backends.openssl.ciphers import ( + _CipherContextChaCha, + ) + + ctx = _CipherContextChaCha( + backend, ChaCha20(b"\x00" * 32, b"\x00" * 16) + ) + with pytest.raises(NotImplementedError): + ctx.authenticate_additional_data(b"data") + with pytest.raises(NotImplementedError): + ctx.finalize_with_tag(b"tag") diff --git a/tests/hazmat/primitives/test_ciphers.py b/tests/hazmat/primitives/test_ciphers.py index bf3b047dec254..a4c0255e6c852 100644 --- a/tests/hazmat/primitives/test_ciphers.py +++ b/tests/hazmat/primitives/test_ciphers.py @@ -15,6 +15,7 @@ AES, ARC4, Camellia, + ChaCha20, TripleDES, _BlowfishInternal, _CAST5Internal, @@ -340,6 +341,22 @@ def test_update_into_buffer_too_small_gcm(self, backend): with pytest.raises(ValueError): encryptor.update_into(b"testing", buf) + @pytest.mark.supported( + only_if=lambda backend: backend.cipher_supported( + ChaCha20(b"\x00" * 32, b"\x00" * 16), None + ) + and backend._lib.CRYPTOGRAPHY_IS_BORINGSSL, + skip_message="Does not support non-EVP ChaCha20 cipher", + ) + def test_update_into_buffer_too_small_chacha20(self, backend): + key = b"\x00" * 32 + nonce = b"\x00" * 16 + c = ciphers.Cipher(ChaCha20(key, nonce), None) + encryptor = c.encryptor() + buf = bytearray(5) + with pytest.raises(ValueError): + encryptor.update_into(b"testing", buf) + def test_update_into_auto_chunking(self, backend, monkeypatch): key = b"\x00" * 16 c = ciphers.Cipher(AES(key), modes.ECB(), backend)