Skip to content

Commit

Permalink
Add support for ChaCha20 with BoringSSL
Browse files Browse the repository at this point in the history
  • Loading branch information
facutuesca committed Oct 24, 2023
1 parent a9bac40 commit fcb2f07
Show file tree
Hide file tree
Showing 7 changed files with 303 additions and 6 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions src/_cffi_src/build_openssl.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"asn1",
"bignum",
"bio",
"chacha",
"cmac",
"crypto",
"dh",
Expand Down
78 changes: 78 additions & 0 deletions src/_cffi_src/openssl/chacha.py
Original file line number Diff line number Diff line change
@@ -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 <openssl/chacha.h>
#include <stdint.h>
#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
"""
16 changes: 13 additions & 3 deletions src/cryptography/hazmat/backends/openssl/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
171 changes: 170 additions & 1 deletion src/cryptography/hazmat/backends/openssl/ciphers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from __future__ import annotations

import abc
import typing

from cryptography.exceptions import InvalidTag, UnsupportedAlgorithm, _Reasons
Expand All @@ -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
Expand Down
24 changes: 23 additions & 1 deletion tests/hazmat/backends/test_openssl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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")
17 changes: 17 additions & 0 deletions tests/hazmat/primitives/test_ciphers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
AES,
ARC4,
Camellia,
ChaCha20,
TripleDES,
_BlowfishInternal,
_CAST5Internal,
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit fcb2f07

Please sign in to comment.