Skip to content

Commit

Permalink
add support for update_into on CipherContext
Browse files Browse the repository at this point in the history
This allows you to provide your own buffer (like recv_into) to improve
performance when repeatedly calling encrypt/decrypt on large payloads.
  • Loading branch information
reaperhulk committed Nov 12, 2016
1 parent c3b8ff6 commit 40077be
Show file tree
Hide file tree
Showing 6 changed files with 263 additions and 5 deletions.
41 changes: 41 additions & 0 deletions docs/hazmat/primitives/symmetric-encryption.rst
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,47 @@ Interfaces
return bytes immediately, however in other modes it will return chunks
whose size is determined by the cipher's block size.

.. method:: update_into(data, buf)

.. versionadded:: 1.6

.. warning::

This method allows you to avoid a memory copy by passing a writable
buffer and reading the resulting data. You are responsible for
correctly sizing the buffer and properly handling the data. Failure
to do so correctly can result in crashes. This method should only
be used when extremely high performance is a requirement and
you will be making many small calls to ``update_into``.

:param bytes data: The data you wish to pass into the context.
:param buf: A writable Python buffer that the data will be written
into. This buffer should be ``n - 1`` bytes bigger than the size of
``data`` where ``n`` is the block size (in bytes) of the cipher
and mode combination.
:return int: Number of bytes written.
:raises NotImplementedError: This is raised if the version of ``cffi``
used is too old (this can happen on older PyPy releases).
:raises ValueError: This is raised if the supplied buffer is too small.

.. doctest::

>>> import os
>>> from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
>>> from cryptography.hazmat.backends import default_backend
>>> backend = default_backend()
>>> key = os.urandom(32)
>>> iv = os.urandom(16)
>>> cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=backend)
>>> encryptor = cipher.encryptor()
>>> buf = bytearray(31)
>>> len_encrypted = encryptor.update_into(b"a secret message", buf)
>>> ct = bytes(buf[:len_encrypted]) + encryptor.finalize()
>>> decryptor = cipher.decryptor()
>>> len_decrypted = decryptor.update_into(ct, buf)
>>> bytes(buf[:len_decrypted]) + decryptor.finalize()
'a secret message'

.. method:: finalize()

:return bytes: Returns the remainder of the data.
Expand Down
56 changes: 56 additions & 0 deletions src/cryptography/hazmat/backends/commoncrypto/ciphers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from __future__ import absolute_import, division, print_function

import cffi

from cryptography import utils
from cryptography.exceptions import (
InvalidTag, UnsupportedAlgorithm, _Reasons
Expand Down Expand Up @@ -86,6 +88,33 @@ def update(self, data):
self._backend._check_cipher_response(res)
return self._backend._ffi.buffer(buf)[:outlen[0]]

# cffi 1.7 supports from_buffer on bytearray, which is required. We can
# remove this check in the future when we raise our minimum PyPy version.
if utils._version_check(cffi.__version__, "1.7"):
def update_into(self, data, buf):
if len(buf) < (len(data) + self._byte_block_size - 1):
raise ValueError(
"buffer must be at least {0} bytes for this "
"payload".format(len(data) + self._byte_block_size - 1)
)
# Count bytes processed to handle block alignment.
self._bytes_processed += len(data)
outlen = self._backend._ffi.new("size_t *")
buf = self._backend._ffi.cast(
"unsigned char *", self._backend._ffi.from_buffer(buf)
)
res = self._backend._lib.CCCryptorUpdate(
self._ctx[0], data, len(data), buf,
len(data) + self._byte_block_size - 1, outlen)
self._backend._check_cipher_response(res)
return outlen[0]
else:
def update_into(self, data, buf):
raise NotImplementedError(
"update_into requires cffi 1.7+. To use this method please "
"update cffi."
)

def finalize(self):
# Raise error if block alignment is wrong.
if self._bytes_processed % self._byte_block_size:
Expand Down Expand Up @@ -161,6 +190,33 @@ def update(self, data):
self._backend._check_cipher_response(res)
return self._backend._ffi.buffer(buf)[:]

# cffi 1.7 supports from_buffer on bytearray, which is required. We can
# remove this check in the future when we raise our minimum PyPy version.
if utils._version_check(cffi.__version__, "1.7"):
def update_into(self, data, buf):
if len(buf) < len(data):
raise ValueError(
"buffer must be at least {0} bytes".format(len(data))
)

buf = self._backend._ffi.cast(
"unsigned char *", self._backend._ffi.from_buffer(buf)
)
args = (self._ctx[0], data, len(data), buf)
if self._operation == self._backend._lib.kCCEncrypt:
res = self._backend._lib.CCCryptorGCMEncrypt(*args)
else:
res = self._backend._lib.CCCryptorGCMDecrypt(*args)

self._backend._check_cipher_response(res)
return len(data)
else:
def update_into(self, data, buf):
raise NotImplementedError(
"update_into requires cffi 1.7+. To use this method please "
"update cffi."
)

def finalize(self):
# CommonCrypto has a yet another bug where you must make at least one
# call to update. If you pass just AAD and call finalize without a call
Expand Down
32 changes: 32 additions & 0 deletions src/cryptography/hazmat/backends/openssl/ciphers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from __future__ import absolute_import, division, print_function

import cffi

from cryptography import utils
from cryptography.exceptions import InvalidTag, UnsupportedAlgorithm, _Reasons
from cryptography.hazmat.primitives import ciphers
Expand Down Expand Up @@ -109,6 +111,31 @@ def update(self, data):
self._backend.openssl_assert(res != 0)
return self._backend._ffi.buffer(buf)[:outlen[0]]

# cffi 1.7 supports from_buffer on bytearray, which is required. We can
# remove this check in the future when we raise our minimum PyPy version.
if utils._version_check(cffi.__version__, "1.7"):
def update_into(self, data, buf):
if len(buf) < (len(data) + self._block_size_bytes - 1):
raise ValueError(
"buffer must be at least {0} bytes for this "
"payload".format(len(data) + self._block_size_bytes - 1)
)

buf = self._backend._ffi.cast(
"unsigned char *", self._backend._ffi.from_buffer(buf)
)
outlen = self._backend._ffi.new("int *")
res = self._backend._lib.EVP_CipherUpdate(self._ctx, buf, outlen,
data, len(data))
self._backend.openssl_assert(res != 0)
return outlen[0]
else:
def update_into(self, data, buf):
raise NotImplementedError(
"update_into requires cffi 1.7+. To use this method please "
"update cffi."
)

def finalize(self):
# OpenSSL 1.0.1 on Ubuntu 12.04 (and possibly other distributions)
# appears to have a bug where you must make at least one call to update
Expand Down Expand Up @@ -196,6 +223,11 @@ def update(self, data):
)
return self._backend._ffi.buffer(buf)[:]

def update_into(self, data, buf):
raise NotImplementedError(
"update_into is not implemented for AES CTR in OpenSSL 1.0.0"
)

def finalize(self):
self._key = None
self._ecount = None
Expand Down
22 changes: 20 additions & 2 deletions src/cryptography/hazmat/primitives/ciphers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ def update(self, data):
as bytes.
"""

@abc.abstractmethod
def update_into(self, data, buf):
"""
Processes the provided bytes and writes the resulting data into the
provided buffer. Returns the number of bytes written.
"""

@abc.abstractmethod
def finalize(self):
"""
Expand Down Expand Up @@ -136,6 +143,11 @@ def update(self, data):
raise AlreadyFinalized("Context was already finalized.")
return self._ctx.update(data)

def update_into(self, data, buf):
if self._ctx is None:
raise AlreadyFinalized("Context was already finalized.")
return self._ctx.update_into(data, buf)

def finalize(self):
if self._ctx is None:
raise AlreadyFinalized("Context was already finalized.")
Expand All @@ -154,20 +166,26 @@ def __init__(self, ctx):
self._tag = None
self._updated = False

def update(self, data):
def _check_limit(self, data_size):
if self._ctx is None:
raise AlreadyFinalized("Context was already finalized.")
self._updated = True
self._bytes_processed += len(data)
self._bytes_processed += data_size
if self._bytes_processed > self._ctx._mode._MAX_ENCRYPTED_BYTES:
raise ValueError(
"{0} has a maximum encrypted byte limit of {1}".format(
self._ctx._mode.name, self._ctx._mode._MAX_ENCRYPTED_BYTES
)
)

def update(self, data):
self._check_limit(len(data))
return self._ctx.update(data)

def update_into(self, data, buf):
self._check_limit(len(data))
return self._ctx.update_into(data, buf)

def finalize(self):
if self._ctx is None:
raise AlreadyFinalized("Context was already finalized.")
Expand Down
7 changes: 7 additions & 0 deletions src/cryptography/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import sys
import warnings

from pkg_resources import parse_version


# the functions deprecated in 1.0 and 1.4 are on an arbitrarily extended
# deprecation cycle and should not be removed until we agree on when that cycle
Expand Down Expand Up @@ -109,6 +111,11 @@ def bit_length(x):
return len(bin(x)) - (2 + (x <= 0))


def _version_check(version, required_version):
# This is used to check if we support update_into on CipherContext.
return parse_version(version) >= parse_version(required_version)


class _DeprecatedValue(object):
def __init__(self, value, message, warning_class):
self.value = value
Expand Down
110 changes: 107 additions & 3 deletions tests/hazmat/primitives/test_ciphers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,25 @@
from __future__ import absolute_import, division, print_function

import binascii
import os

import cffi

from pkg_resources import parse_version

import pytest

from cryptography.exceptions import _Reasons
from cryptography.hazmat.backends.interfaces import CipherBackend
from cryptography.hazmat.primitives import ciphers
from cryptography.hazmat.primitives.ciphers import modes
from cryptography.hazmat.primitives.ciphers.algorithms import (
AES, ARC4, Blowfish, CAST5, Camellia, IDEA, SEED, TripleDES
)
from cryptography.hazmat.primitives.ciphers.modes import ECB

from ...utils import raises_unsupported_algorithm
from ...utils import (
load_nist_vectors, load_vectors_from_file, raises_unsupported_algorithm
)


class TestAES(object):
Expand Down Expand Up @@ -132,4 +140,100 @@ def test_invalid_backend():
pretend_backend = object()

with raises_unsupported_algorithm(_Reasons.BACKEND_MISSING_INTERFACE):
ciphers.Cipher(AES(b"AAAAAAAAAAAAAAAA"), ECB, pretend_backend)
ciphers.Cipher(AES(b"AAAAAAAAAAAAAAAA"), modes.ECB, pretend_backend)


@pytest.mark.skipif(
parse_version(cffi.__version__) < parse_version('1.7'),
reason="cffi version too old"
)
@pytest.mark.supported(
only_if=lambda backend: backend.cipher_supported(
AES(b"\x00" * 16), modes.ECB()
),
skip_message="Does not support AES ECB",
)
@pytest.mark.requires_backend_interface(interface=CipherBackend)
class TestCipherUpdateInto(object):
@pytest.mark.parametrize(
"params",
load_vectors_from_file(
os.path.join("ciphers", "AES", "ECB", "ECBGFSbox128.rsp"),
load_nist_vectors
)
)
def test_update_into(self, params, backend):
key = binascii.unhexlify(params["key"])
pt = binascii.unhexlify(params["plaintext"])
ct = binascii.unhexlify(params["ciphertext"])
c = ciphers.Cipher(AES(key), modes.ECB(), backend)
encryptor = c.encryptor()
buf = bytearray(len(pt) + 15)
res = encryptor.update_into(pt, buf)
assert res == len(pt)
assert bytes(buf)[:res] == ct

def test_update_into_gcm(self, backend):
key = binascii.unhexlify(b"e98b72a9881a84ca6b76e0f43e68647a")
iv = binascii.unhexlify(b"8b23299fde174053f3d652ba")
ct = binascii.unhexlify(b"5a3c1cf1985dbb8bed818036fdd5ab42")
pt = binascii.unhexlify(b"28286a321293253c3e0aa2704a278032")
c = ciphers.Cipher(AES(key), modes.GCM(iv), backend)
encryptor = c.encryptor()
buf = bytearray(len(pt) + 15)
res = encryptor.update_into(pt, buf)
assert res == len(pt)
assert bytes(buf)[:res] == ct

@pytest.mark.parametrize(
"params",
load_vectors_from_file(
os.path.join("ciphers", "AES", "ECB", "ECBGFSbox128.rsp"),
load_nist_vectors
)
)
def test_update_into_multiple_calls(self, params, backend):
key = binascii.unhexlify(params["key"])
pt = binascii.unhexlify(params["plaintext"])
ct = binascii.unhexlify(params["ciphertext"])
c = ciphers.Cipher(AES(key), modes.ECB(), backend)
encryptor = c.encryptor()
buf = bytearray(len(pt) + 15)
res = encryptor.update_into(pt[:3], buf)
assert res == 0
res = encryptor.update_into(pt[3:], buf)
assert res == len(pt)
assert bytes(buf)[:res] == ct

def test_update_into_buffer_too_small(self, backend):
key = b"\x00" * 16
c = ciphers.Cipher(AES(key), modes.ECB(), backend)
encryptor = c.encryptor()
buf = bytearray(16)
with pytest.raises(ValueError):
encryptor.update_into(b"testing", buf)


@pytest.mark.skipif(
parse_version(cffi.__version__) >= parse_version('1.7'),
reason="cffi version too new"
)
@pytest.mark.supported(
only_if=lambda backend: backend.cipher_supported(
AES(b"\x00" * 16), modes.ECB()
),
skip_message="Does not support AES ECB",
)
@pytest.mark.requires_backend_interface(interface=CipherBackend)
class TestCipherUpdateIntoUnsupported(object):
@pytest.mark.parametrize(
"mode",
[modes.ECB(), modes.CTR(b"0" * 16), modes.GCM(b"0" * 12)]
)
def test_cffi_too_old(self, mode, backend):
key = b"\x00" * 16
c = ciphers.Cipher(AES(key), mode, backend)
encryptor = c.encryptor()
buf = bytearray(32)
with pytest.raises(NotImplementedError):
encryptor.update_into(b"\x00" * 16, buf)

0 comments on commit 40077be

Please sign in to comment.