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