Skip to content

Commit

Permalink
Add support for ChaCha20 with LibreSSL
Browse files Browse the repository at this point in the history
  • Loading branch information
facutuesca committed Aug 31, 2023
1 parent 5ef77db commit 3ad3d35
Show file tree
Hide file tree
Showing 7 changed files with 284 additions and 5 deletions.
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
42 changes: 42 additions & 0 deletions src/_cffi_src/openssl/chacha.py
Original file line number Diff line number Diff line change
@@ -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 <openssl/chacha.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_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
"""
13 changes: 10 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,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.
Expand Down Expand Up @@ -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)
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 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
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 @@ -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")
21 changes: 21 additions & 0 deletions tests/hazmat/primitives/test_chacha20.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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_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)
Expand Down

0 comments on commit 3ad3d35

Please sign in to comment.