AES CTR decryption: Cryptography and Cryptodome give different results

Viewed 495

The below code snippet that AES-decrypts some bytes using Cryptodome works as I expect:

from Crypto.Cipher import AES
from Crypto.Util import Counter

key = b'\x12' * 32
decryptor = AES.new(key, AES.MODE_CTR,counter=Counter.new(nbits=128, little_endian=True))
print(decryptor.decrypt(b'Something encrypted'))

The below uses Python Cryptography, and gives different results:

from cryptography.hazmat.primitives.ciphers import Cipher, modes, algorithms

key = b'\x12' * 32
decryptor = Cipher(algorithms.AES(key), modes.CTR(b'\0' * 16)).decryptor()
print(decryptor.update(b'Something encrypted'))

Why? And how can I change the Python Cryptography version to output the same results as Cryptodome?

(Context is writing AES decrypt code for unzipping files, and am considering using Python Cryptography)

2 Answers

In PyCryptodome the counter is started at 1 by default. Also, the counter for little endian counts as follows: 0x0100...0000, 0x0200...0000, 0x0300...0000 etc.

Since in Cryptography the endianess of the counter cannot be configured and big endian is used, this count cannot be implemented. Although the start value can be explicitly set to 0x0100...00 , but the counter would then count: 0x0100...0000, 0x0100...0001, 0x0100...0002 etc.

This can be verified with the following code:

from Crypto.Cipher import AES
from Crypto.Util import Counter
key = b'\x12' * 32
decryptor = AES.new(key, AES.MODE_CTR,counter=Counter.new(nbits=128, little_endian=True, initial_value=1))
print(decryptor.decrypt(b'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx').hex())

from cryptography.hazmat.primitives.ciphers import Cipher, modes, algorithms
key = b'\x12' * 32
decryptor = Cipher(algorithms.AES(key), modes.CTR(b'\x01' + b'\0' * 15)).decryptor()
print(decryptor.update(b'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx').hex())

with the output:

faebe2ab213094fcd5c9ec3dae32372b 13b3b971b7694faa5e15f5387ac5a67f bc5dbc82ce54cf1bbe2719488b322078
faebe2ab213094fcd5c9ec3dae32372b 6ddda72218780c287bc74956395bf7db 0603820b26889ec64e7f7964a14518c5

Because the counter value for the first block matches, the first block is identical. However, because of the different values for the following blocks the following blocks are different.

I currently don't see any way how the Cryptography library could be used to generate the PyCryptodome library result when PyCryptodome is configured for little endian.

(Very much inspired by @Topaco's answer)

There are 2 issues

  • The value passed to CTR is the initial value of the counter, and has to be b'\x01' + b'\0' * 15

  • This then should be incremented as a little endian integer for each block of 16 encrypted bytes, but Python Cryptography only increments it as a big endian integer.

So it is to possible get Python Cryptography to do this, but you have to do the incrementing of the counter outside of it and re-create a decryption context for each block of 16 bytes:

from cryptography.hazmat.primitives.ciphers import Cipher, modes, algorithms

key = b'\x12' * 32

def chunks(original):
    for i in range(0, len(original), 16):
        yield original[i:i+16]

def decrypt(chunks):
    for j, chunk in enumerate(chunks):
        yield Cipher(algorithms.AES(key), modes.CTR((j+1).to_bytes(16, byteorder='little'))).decryptor().update(chunk)

print(b''.join(decrypt(chunks(b'Something encrypted'))))

It looks like Cryptography is/aims to be a thin layer over OpenSSL. So, you can decrypt AES in CTR mode with a little endian counter directly with multiple libcrypto (OpenSSL) contexts, as can be seen in this gist and below

from contextlib import contextmanager
from ctypes import POINTER, cdll, c_char_p, c_void_p, c_int, create_string_buffer, byref
from sys import platform

def decrypt_aes_256_ctr_little_endian(
    key, ciphertext_chunks,
    get_libcrypto=lambda: cdll.LoadLibrary({'linux': 'libcrypto.so', 'darwin': 'libcrypto.dylib'}[platform])
):
    def non_null(result, func, args):
        if result == 0:
            raise Exception('Null value returned')
        return result

    def ensure_1(result, func, args):
        if result != 1:
            raise Exception(f'Result {result}')
        return result

    libcrypto = get_libcrypto()
    libcrypto.EVP_CIPHER_CTX_new.restype = c_void_p
    libcrypto.EVP_CIPHER_CTX_new.errcheck = non_null
    libcrypto.EVP_CIPHER_CTX_free.argtypes = (c_void_p,)

    libcrypto.EVP_DecryptInit_ex.argtypes = (c_void_p, c_void_p, c_void_p, c_char_p, c_char_p)
    libcrypto.EVP_DecryptInit_ex.errcheck = ensure_1
    libcrypto.EVP_DecryptUpdate.argtypes = (c_void_p, c_char_p, POINTER(c_int), c_char_p, c_int)
    libcrypto.EVP_DecryptUpdate.errcheck = ensure_1

    libcrypto.EVP_aes_256_ctr.restype = c_void_p

    @contextmanager
    def cipher_context():
        ctx = libcrypto.EVP_CIPHER_CTX_new()
        try:
            yield ctx
        finally:
            libcrypto.EVP_CIPHER_CTX_free(ctx)

    def in_fixed_size_chunks(chunks, size):
        chunk = b''
        offset = 0
        it = iter(chunks)

        def get(size):
            nonlocal chunk, offset

            while size:
                if not chunk:
                    try:
                        chunk = next(it)
                    except StopIteration:
                        return
                to_yield = min(size, len(chunk) - offset)
                yield chunk[offset:offset + to_yield]
                offset = (offset + to_yield) % len(chunk)
                chunk = chunk if offset else b''
                size -= to_yield

        while True:
            fixed_size_chunk = b''.join(get(size))
            if fixed_size_chunk:
                yield fixed_size_chunk
            else:
                break

    def decrypted_chunks(fixed_size_chunks):
        for j, chunk in enumerate(fixed_size_chunks):
            with cipher_context() as ctx:
                plaintext = create_string_buffer(16)
                plaintext_len = c_int()
                libcrypto.EVP_DecryptInit_ex(ctx, libcrypto.EVP_aes_256_ctr(), None, key, (j + 1).to_bytes(16, byteorder='little'))
                libcrypto.EVP_DecryptUpdate(ctx, plaintext, byref(plaintext_len), chunk, len(chunk))
                yield plaintext.raw[:plaintext_len.value]

    fixed_size_chunks = in_fixed_size_chunks(ciphertext_chunks, 16)
    decrypted_chunks = decrypted_chunks(fixed_size_chunks)
    yield from decrypted_chunks

The above code also has the benefit of working with an iterable of bytes where it's not known how big each chunk is, which is my particular use case:

ciphertext_chunks = [b'Something encrypted', b'even more encrypted']
key = b'\x12' * 32
for plaintext in decrypt_aes_256_ctr_little_endian(key, ciphertext_chunks):
   print(plaintext)
Related