Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for ChaCha20 with LibreSSL #9209

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
alex marked this conversation as resolved.
Show resolved Hide resolved
#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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is a new interface required? This seems to match our existing public cipher context ABC.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's an interface for the backend cipher context, which needs a couple of methods not present in the existing public CipherContext ABC:

    @abc.abstractmethod
    def authenticate_additional_data(self, data: bytes) -> None:
        ...

    @abc.abstractmethod
    def finalize_with_tag(self, tag: bytes) -> bytes:
        ...

I think it also makes sense to have separate interfaces for public vs backend ctx, since the public context wraps the backend one:

    def _wrap_ctx(
        self, ctx: _BackendCipherContext, encrypt: bool
    ) -> typing.Union[
        AEADEncryptionContext, AEADDecryptionContext, CipherContext
    ]:
        if isinstance(self.mode, modes.ModeWithAuthenticationTag):
            if encrypt:
                return _AEADEncryptionContext(ctx)
            else:
                return _AEADDecryptionContext(ctx)
        else:
            return _CipherContext(ctx)

_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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, shouldn't counter be advancing across multiple calls?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I missed that completely. I have rewritten the update_into() function as per our discussion

)

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