From 3ad3d356463d273056abbbbcf3146b2c45c89846 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 LibreSSL --- src/_cffi_src/build_openssl.py | 1 + src/_cffi_src/openssl/chacha.py | 42 +++++ .../hazmat/backends/openssl/backend.py | 13 +- .../hazmat/backends/openssl/ciphers.py | 171 +++++++++++++++++- tests/hazmat/backends/test_openssl.py | 24 ++- tests/hazmat/primitives/test_chacha20.py | 21 +++ tests/hazmat/primitives/test_ciphers.py | 17 ++ 7 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 src/_cffi_src/openssl/chacha.py diff --git a/src/_cffi_src/build_openssl.py b/src/_cffi_src/build_openssl.py index 361473679ece..73bf02111090 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 000000000000..7dff166fd08b --- /dev/null +++ b/src/_cffi_src/openssl/chacha.py @@ -0,0 +1,42 @@ +# 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_LIBRESSL +#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_LIBRESSL +static const long Cryptography_HAS_CHACHA20_API = 1; +#else +static const long Cryptography_HAS_CHACHA20_API = 0; +#endif + +#if CRYPTOGRAPHY_IS_LIBRESSL +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) { + CRYPTO_chacha_20(out, in, in_len, key, 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 3797d1df83e3..cbe68570ba58 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,10 @@ 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 LibreSSL by a different API than OpenSSL, + # so checking for the corresponding EVP_CIPHER is not useful + if self._lib.CRYPTOGRAPHY_IS_LIBRESSL and isinstance(cipher, ChaCha20): + return True if self._fips_enabled: # FIPS mode requires AES. TripleDES is disallowed/deprecated in # FIPS 140-3. @@ -312,12 +319,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 a34dcbe6ce1a..e6568767b166 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 LibreSSL + """ + + _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 LibreSSL and BoringSSL's ChaCha20 API is + # stateless, as opposed to OpenSSL'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 68f3d1a5fb24..8b2b9d0b0975 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 ( @@ -406,3 +406,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_LIBRESSL, + 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_chacha20.py b/tests/hazmat/primitives/test_chacha20.py index 314b0aa60666..7c52ad598d3c 100644 --- a/tests/hazmat/primitives/test_chacha20.py +++ b/tests/hazmat/primitives/test_chacha20.py @@ -69,3 +69,24 @@ def test_invalid_nonce(self): def test_invalid_key_type(self): with pytest.raises(TypeError, match="key must be bytes"): algorithms.ChaCha20("0" * 32, b"0" * 16) # type:ignore[arg-type] + + def test_partial_blocks(self, backend): + # Test that partial blocks and counter increments are handled + # correctly. Successive calls to update should return the same + # as if the entire input was passed in a single call: + # update(pt[0:n]) + update(pt[n:m]) + update(pt[m:]) == update(pt) + key = bytearray(os.urandom(32)) + nonce = bytearray(os.urandom(16)) + cipher = Cipher(algorithms.ChaCha20(key, nonce), None, backend) + pt = bytearray(os.urandom(96 * 3)) + + enc_full = cipher.encryptor() + ct_full = enc_full.update(pt) + + enc_partial = cipher.encryptor() + len_partial = len(pt) // 3 + ct_partial_1 = enc_partial.update(pt[:len_partial]) + ct_partial_2 = enc_partial.update(pt[len_partial : len_partial * 2]) + ct_partial_3 = enc_partial.update(pt[len_partial * 2 :]) + + assert ct_full == ct_partial_1 + ct_partial_2 + ct_partial_3 diff --git a/tests/hazmat/primitives/test_ciphers.py b/tests/hazmat/primitives/test_ciphers.py index bf3b047dec25..9310550f5c45 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_LIBRESSL, + 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)