2

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)

Michal Charemza
  • 25,940
  • 14
  • 98
  • 165

2 Answers2

5

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.

Topaco
  • 40,594
  • 4
  • 35
  • 62
  • 1
    Thanks! Based on this, I think I have figured out a way to use Cryptography. Have shared in [another answer](https://stackoverflow.com/a/69145945/1319998) – Michal Charemza Sep 11 '21 at 19:05
  • NIST doesn't set the initial value of the counter in the CTR mode so they are free. NIST only provide some recommendation to generate unique counters. So they are free. – kelalaka Sep 11 '21 at 20:11
2

(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)
Michal Charemza
  • 25,940
  • 14
  • 98
  • 165
  • 1
    Michal, this is a nice implementation. But since you are converting 1 CTR with n blocks to n CTRs with 1 block to have control over all counters, there may be a performance issue. Also, this is pretty close to a reimplementation of CTR mode, so beware of side-channel attacks (as always with implementations in the crypto field). Other than that, this is a pretty clever workaround. – Topaco Sep 12 '21 at 08:09
  • @Topaco Oh yeah I pretty much assumed performance would not be good. Can I ask though, what sort of side channel attacks? – Michal Charemza Sep 12 '21 at 08:11
  • 1
    I'm not thinking of a specific attack (maybe there isn't one in your implementation). I just wanted to point out the need in crypto implementations, especially when implementing cryptographic primitives/algorithms, to pay special attention to security in addition to functionality. – Topaco Sep 12 '21 at 09:29