8
\$\begingroup\$

I'm writing a little script that encrypts a file using the user's SSH identity.

  • I read the RSA private key from ~/.ssh/id_rsa (default)
  • Use the private key to encrypt a random 32 byte symmetric key (PKCS#1 OAEP)
  • Encrypt a stream of data with AES-256 in counter mode (no extra padding)
  • Write all of this to the output stream in 65535 byte chunks
  • Append an HMAC-SHA256 of the ciphertext

The idea is that data is encrypted on a user's computer before being pushed to the cloud. I'm using the SSH identity so that there's no passwords to worry about. If the user's ssh private key is compromised, the data being encrypted is compromised as well.

If the user wants to use a password, they'll just encrypt their private key.

Here's the code:

from Crypto import Random
from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA
from Crypto.Cipher import AES
from Crypto.Util import Counter
from Crypto.Hash import HMAC, SHA256


def encrypt_stream_v1(identity, stdin, stdout):
    rng = Random.new()
    iv = rng.read(8)  # counter mode prefix
    key = rng.read(32)  # random AES-256 key
    # AES-256 in counter mode
    aes = AES.new(key, mode=AES.MODE_CTR, counter=Counter.new(64, iv))
    mac = HMAC.new(key, digestmod=SHA256)  # HMAC-SHA256 for ciphertext
    with open(identity) as f:
        # the AES-256 key is encrypte with the users rsa private key
        key = PKCS1_OAEP.new(RSA.importKey(f.read())).encrypt(key)
    # the output stream begins with the 8 byte iv, the length of the
    # encrypted AES-256 key in two bytes and the encrypted key itself
    stdout.write(iv + len(key).to_bytes(2, "big") + key)
    while True:
        # encrypt in chunks of 65535 bytes (a two byte integer)
        chunk = aes.encrypt(stdin.read(0xFFFF))
        if chunk:
            # write the length of the encrypted chunk and the chunk itself
            stdout.write(len(chunk).to_bytes(2, "big") + chunk)
            # update the hmac with the ciphertext
            mac.update(chunk)
        if len(chunk) < 0xFFFF:
            break
    # the two null bytes represent an empty chunk, i.e. the end of the
    # ciphertext, the last 32 bytes of the output stream are the
    # HMAC-SHA256 digest
    stdout.write(b"\x00\x00" + mac.digest())

So the file format is as follows:

7a 70 79 01              # magic bytes and version number (01)
2c 81 6a 25 97 9c 8b 68  # counter iv
01 00                    # length of...
46 3e ........ 47 55 7e  # symmetric key encrypted with public key
00 08                    # length of...
22 12 11 01 2d 66 2e 66  # first encrypted block (repeated)
00 00                    # zero-length block signifies end of stream
18 9a ........ 0f 5a 0e  # hmac of the ciphetext

The reason the IV is 8 bytes is the counter. In order to get a 128 bit counter (the block size of AES), we use an incrementing 64 bit integer (via Crypto.Util.Counter) and prepend the random 64 bit iv.

Relevant links/documentation:

Is there anything wrong with how I'm using RSA/OAEP or AES-256-CTR? Is there anything I can optimize with the file format? Any mistakes I'm not seeing?

Changes so far

  • I'm including the entire file header (magic bytes, encrypted key length, encrypted key) in the HMAC-SHA256 calculation
  • I'm using a random 128 bit number as the initial value for the counter instead of effectively 64 bits with zeros appended.

Update: The project is now on GitHub

\$\endgroup\$
  • \$\begingroup\$ I'm not a crypto expert either, but I'd imagine you can treat the counter as a single 128-bit number - initializing it with a 128-bit random IV, then incrementing it for every block - instead of separate "IV" and "counter" parts. \$\endgroup\$ – immibis Sep 6 '15 at 8:40
  • \$\begingroup\$ That's a good call, why not. (more discussion on this at crypto.se) \$\endgroup\$ – Stefano Palazzo Sep 6 '15 at 16:11
5
\$\begingroup\$

Is there anything wrong with how I'm using RSA/OAEP or AES-256-CTR?

I wish I could answer that but I don't know enough crypto. I hope somebody else can address this point for you.

Is there anything I can optimize with the file format?

Nothing pops to mind. Same note as previous point.

Any mistakes I'm not seeing?

Not really "mistakes", but I see a few minor things that could be improved.

Magic numbers

The chuck size 0xFFFF appears twice. It would be better to put that in a variable, perhaps CHUNK_SIZE at the package level. That way if you ever change the value, you only have to change in one place.

Duplicated logic

You do len(something).to_bytes(2, "big") twice. I suppose both uses need to have consistent logic, just like with magic numbers, the best way to ensure consistency is to define things in one place, rather than duplicating at two or more, which risks inconsistent parallel edits in the future.

The approach

Once again, I'm no expert on this, but anyway...

I'm using the SSH identity so that there's no passwords to worry about.

Makes perfect sense to me. That is, the default private key should be protected by a passphrase, normally managed by a key-chain manager, and you can piggy-back on that.

If the user's ssh private key is compromised, the data being encrypted is compromised as well.

That seems acceptable, because it's inevitable.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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