5
\$\begingroup\$

I have tried to write a more user-friendly AES CTR wrapper with PyCrypto, but I'm not sure if it's safe enough. I mean, by default there is IV=1 for CTR and in documentation it's said that IV is ignored anyway, so I'm not sure if I should use it or not (or if it's even necessary rather than overkill).

This code should be for files on a local disk, nothing to do with networking. The code saves initial part of a Counter i.e. secret.secret to a separate file together with the encrypted one (two files). Counter (or rather parts of it) in the script is incremented always by 1 and if it comes to 255 (which seems to throw some error), then it resets to 0 and starts again, so for each block for file stream there should be a unique counter (go print it in the while loop), therefore each block should be safe enough if only user knows the key and the counter incrementing isn't something obvious as +1, but rather something more complex such as working with % and remainders.

  • Is my code safe to use in public? Of course this is only an example, counter incrementing is much more complex than +1.
  • Is my code still safe if I reveal how Counter is incrementing and someone has access to .ctr file if he/she doesn't know the key(32)?

Note: the key(32) is generated with user's input combined with a machine's constant + salt and other stuff. User has to input password for each usage of the code and user's input isn't stored anywhere.

 

import os
import array
from Crypto.Cipher import AES


class Secret(object):
    def __init__(self, secret=None):
        if secret is None:
            secret = os.urandom(16)
        self.secret = secret
        self.reset()

    def counter(self):
        for i, c in enumerate(self.current):
            if c + 1 == 255:
                self.current[i] = 0
            else:
                self.current[i] = c + 1
        return self.current.tostring()

    def reset(self):
        self.current = array.array('B', self.secret)


class Vial(object):
    def __init__(self, key):
        self.key = key

    def encrypt(self, text, counter_path):
        secret = Secret()
        with open(counter_path, 'wb') as f:
            f.write(secret.secret)
        crypto = AES.new(self.key, AES.MODE_CTR, counter=secret.counter)
        encrypted = crypto.encrypt(text)
        return encrypted

    def decrypt(self, text, counter_path):
        with open(counter_path, 'rb') as f:
            load_secret = f.read()
        secret = Secret(load_secret)
        crypto = AES.new(self.key, AES.MODE_CTR, counter=secret.counter)
        decrypted = crypto.decrypt(text)
        return decrypted

    def encrypt_stream(self, input, output):
        secret = Secret()
        counter_path = os.path.splitext(output.name)[0] + '.ctr'
        with open(counter_path, 'wb') as f:
            f.write(secret.secret)
        crypto = AES.new(self.key, AES.MODE_CTR, counter=secret.counter)
        while True:
            data = input.read(4096)
            if not data:
                break
            data = crypto.encrypt(data)
            output.write(data)

    def decrypt_stream(self, input, output):
        counter_path = os.path.splitext(input.name)[0] + '.ctr'
        with open(counter_path, 'rb') as f:
            counter_read = f.read()
        secret = Secret(counter_read)
        crypto = AES.new(self.key, AES.MODE_CTR, counter=secret.counter)
        while True:
            data = input.read(4096)
            if not data:
                break
            data = crypto.decrypt(data)
            output.write(data)


if __name__ == '__main__':
    key32 = '0123456789' * 3 + 'QW'
    vial = Vial(key32)
    path = os.path.abspath(os.path.dirname(__file__))+'/vial_test.ctr'
    enc = vial.encrypt(16 * 'a', path)
    print 'enc: ', enc, len(enc)

    vial = Vial(key32)
    path = os.path.abspath(os.path.dirname(__file__))+'/vial_test.ctr'
    dec = vial.decrypt(enc, path)
    print 'dec: ', dec, len(dec)

    vial = Vial(key32)
    finput = open('encrypt_me.png', 'rb')
    foutput = open('im_encrypted.png', 'wb')
    vial.encrypt_stream(finput, foutput)
    finput.close()
    foutput.close()

    vial = Vial(key32)
    finput = open('im_encrypted.png', 'rb')
    foutput = open('im_decrypted.png', 'wb')
    vial.decrypt_stream(finput, foutput)
    finput.close()
    foutput.close()
\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

This is buggy and insecure due to the implementation of the Secret class.

1. Bug

If by chance one of the bytes returned by os.urandom should be 255, then the counter method will fail:

>>> [Secret().counter() for _ in range(100)]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "cr131248.py", line 18, in counter
    self.current[i] = c + 1
OverflowError: unsigned byte integer is greater than maximum

This will happen for about 6% of Secret instances.

It looks as if you are confused about what's going on here. A byte array consists of bytes, each of which consists of exactly 8 bits. So a byte can only take values between 0 and 255 (inclusive). That's why you get an error if you try to assign a value outside that range.

2. Insecure

Quoting from Dworkin's "Recommendation for Block Cipher Modes of Operation":

The Counter (CTR) mode is a confidentiality mode that features the application of the forward cipher to a set of input blocks, called counters, to produce a sequence of output blocks that are exclusive-ORed with the plaintext to produce the ciphertext, and vice versa. The sequence of counters must have the property that each block in the sequence is different from every other block.

But the counter blocks generated by the Secret class repeat every 255 blocks:

>>> secret = Secret()
>>> ctr = [secret.counter() for _ in range(400)]
>>> ctr[255] == ctr[0]
True
>>> ctr[256] == ctr[1]
True

That's because the counter method adds 1 to every byte in the array, wrapping around every 255 additions. So the array only gets 255 different values before it starts to repeat.

This is disastrous for the security of the message because if the message is long enough (more than 255 blocks or 4080 bytes) then it will have been built using repeated counter blocks. So if an eavesdropper XORs blocks 0 and 255 of the ciphertext, the duplicate encrypted counter blocks will cancel, leaving the XOR of blocks 0 and 255 of the plaintext, and from the XOR of two parts of the plaintext it is possible to recover the plaintext (see this answer on crypto.stackexchange.com).

If you are really determined to implement your own counter blocks, then Appendix B.1 of "Recommendation for Block Cipher Modes of Operation" explains how to do it correctly.

But it would be much better to use Crypto.Util.Counter.

3. How to count

The problem with Secret.counter is that it increments every byte of the counter. So if counter.current starts like this:

[253, 208, 169, 133, 161, 177, 54, 116, 192, 104, 69, 248, 65, 60, 111, 115]

then after one call to counter it becomes:

[254, 209, 170, 134, 162, 178, 55, 117, 193, 105, 70, 249, 66, 61, 112, 116]

and this is what causes it to repeat after only 255 calls.

What you need to do instead is to increment only the first byte, but if that rolls around from 255 to 0, increment the second byte, and if that rolls around rom 255 to 0, increment the third byte, and so on. This procedure is just just like counting in decimal, where you increment the units digits, but if that rolls around from 9 to 0 then you increment the tens digit, and so on.

So in the above example the array should be incremented to:

[254, 208, 169, 133, 161, 177, 54, 116, 192, 104, 69, 248, 65, 60, 111, 115]

and then:

[255, 208, 169, 133, 161, 177, 54, 116, 192, 104, 69, 248, 65, 60, 111, 115]

and then 255 rolls over to 0, so you increment the second byte:

[0, 209, 169, 133, 161, 177, 54, 116, 192, 104, 69, 248, 65, 60, 111, 115]

and then:

[1, 209, 169, 133, 161, 177, 54, 116, 192, 104, 69, 248, 65, 60, 111, 115]

and so on.

If you look at what Crypto.Util.Counter does, you'll see that it works in much the same way:

>>> c = Counter.new(128, initial_value=int.from_bytes(os.urandom(16), 'big'), allow_wraparound=True)
>>> array.array('B', c())
array('B', [170, 36, 8, 107, 115, 112, 224, 212, 58, 251, 145, 1, 68, 57, 75, 254])
>>> array.array('B', c())
array('B', [170, 36, 8, 107, 115, 112, 224, 212, 58, 251, 145, 1, 68, 57, 75, 255])
>>> array.array('B', c())
array('B', [170, 36, 8, 107, 115, 112, 224, 212, 58, 251, 145, 1, 68, 57, 76, 0])
>>> array.array('B', c())
array('B', [170, 36, 8, 107, 115, 112, 224, 212, 58, 251, 145, 1, 68, 57, 76, 1])

except that it increments the last byte in the array and works backwards.

Notice that I passed a random initial_value argument to Counter.new. That's because the counter block need to be unique across all messages using the same key (otherwise an eavesdropper could find two messages that have coincident counter blocks and XOR them to recover the plaintext, just as if the two blocks had occurred in the same message). I also specified allow_wraparound=True so that there's no possibility of getting an OverflowError if we are very unlucky with the result of os.urandom.

\$\endgroup\$
6
  • \$\begingroup\$ Yes, I know about the limitation of Secret that's why I reset it to zero(forgot to correct it to >=255), however isn't it the same as allow_wraparound for the Crypto.Util.Counter? From this I can see that +1 seems to be safer than something complicated because of lower repeating count. I see that the repeating is the real problem. So if I remove the custom counter (or basically whole Secret) and replace it with Crypto.Util.Counter.new(128), will it be secure even for large(1GB+) files i.e. won't it repeat? If not, how should I use the default Counter? \$\endgroup\$ Jun 10, 2016 at 12:04
  • \$\begingroup\$ @KeyWeeUsr: I added a section explaining how to count. \$\endgroup\$ Jun 10, 2016 at 12:20
  • \$\begingroup\$ Ooooh, that yes yes! I found an example where only first byte was incremented, so I made it to increment all... missed the final step as I see. If I make it increment this way, the first repeat would occur at ~256^256 + 1 if I'd start from [0 for i in range(16)]. After implementing this counting will the code be secure enough for large files or is something like IV(which by the docs is ignored and ==1) or anything special necessary, or will this be enough? \$\endgroup\$ Jun 10, 2016 at 12:30
  • \$\begingroup\$ Also, XORing... let's go extreme and say I have 100TB data that consists of 500kB files each and counter is at the beginning always urandom(16). How high is the chance for getting my key? Should the key be different for each file or the Counter does the job? \$\endgroup\$ Jun 10, 2016 at 12:31
  • 1
    \$\begingroup\$ 100 terabytes is about \$6 × 10^{12}\$ blocks. But there are \$2^{128} \approx 3 × 10^{38}\$ possible counter values. So it is very unlikely that there will be any counter collisions. \$\endgroup\$ Jun 10, 2016 at 12:42

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.