Let's assume that a user wants to symmetrically encrypt the files in one of his folders (being able to decrypt them later).
For this, I have come up with the following procedure:
- A random 32 byte salt is generated and saved in a non-encrypted file.
- The user then needs to memoize a - hopefully strong - password, which is used to create a master key with argon2 (together with the salt; slow).
- For each file in the folder, the filename concatenated with the current timestamp (as 8 bytes) is supplied as info to HKDF-Expand together with the master key. Thereby, a (single-use) key is created.
- A 24 byte nonce is created randomly.
- The key and nonce are used for encryption with XChaCha20_Poly1305; additionally, the 8 byte timestamp from above is supplied as header/AAD.
- The data are then saved in the following format: timestamp (8 bytes) || nonce (24 bytes) || tag (16 bytes) || ciphertext.
- Decryption works "backwards" accordingly.
My reasoning was:
- By using HKDF-Expand, each file gets its own key for encryption.
- If a file is decrypted and re-encrypted under the same name, inclusion of the timestamp in HKDF ensures that the encryption key should most probably be different from the last one.
- And even if two keys would happen to be the identical, a randomly generated 192 bit nonce should still be safe to use (as I have read).
My questions are:
- Is the use of filename + timestamp as info for HKDF appropriate here?
- I've read that it is not necessary to include the nonce in the header/AAD as it is used in the tag calculation. So, would it even be necessary to include the timestamp in the header here (as it affects the key derivation and therefore, also the tag calculation)?
- Is it a security risk to include the plain 8 byte timestamp in the encrypted file (assuming that the encryption time itself is not confidential)?
- Are there any other flaws in the procedure above that I have overlooked (please bear with me, I'm new to all of this)?
PS: For reference, a condensed version of the code is as follows:
import getpass import os import struct import time import argon2 from Cryptodome.Cipher import ChaCha20_Poly1305 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.hashes import SHA256 from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand SALTNAME = 'salt' # Filename for salt. SUFFIX = '.enc' # To mark files as already encrypted. def get_key(info, master_key): # Get single-use key from master key (using info). return HKDFExpand(SHA256(), 32, info, default_backend()).derive(master_key) def encrypt_file(master_key, filename): # Read original file. with open(filename, 'rb') as f: plaintext = f.read() # Prepare key and nonce for XChaCha20_Poly1305. new_filename = filename + SUFFIX timestamp = struct.pack('>d', time.time()) key = get_key(new_filename.encode() + timestamp, master_key) nonce = os.urandom(24) # Encrypt (with timestamp as header/AAD). cipher = ChaCha20_Poly1305.new(key=key, nonce=nonce) cipher.update(timestamp) ciphertext, tag = cipher.encrypt_and_digest(plaintext) # Rename file and write encrypted data. os.rename(filename, new_filename) with open(new_filename, 'wb') as f: f.write(timestamp + nonce + tag + ciphertext) def decrypt_file(master_key, filename): # Read encrypted file. with open(filename, 'rb') as f: timestamp, nonce, tag = f.read(8), f.read(24), f.read(16) ciphertext = f.read() # Get key and decrypt. key = get_key(filename.encode() + timestamp, master_key) cipher = ChaCha20_Poly1305.new(key=key, nonce=nonce) cipher.update(timestamp) plaintext = cipher.decrypt_and_verify(ciphertext, tag) # Rename file and write decrypted data. os.rename(filename, new_filename := filename[:-len(SUFFIX)]) with open(new_filename, 'wb') as f: f.write(plaintext) def main(): # Ask user. mode = int(input('Encrypt (1) or decrypt (2)? ')) # Read salt (or create + save one if it doesn't exist yet). try: with open(SALTNAME, 'rb') as f: salt = f.read() except FileNotFoundError: with open(SALTNAME, 'wb') as f: f.write(salt := os.urandom(32)) # Ask for a password and generate the master key with argon2 (low # settings for testing here, should be appropriately increased). master_key = argon2.low_level.hash_secret_raw( getpass.getpass().encode(), salt, time_cost=1, memory_cost=1024, parallelism=1, hash_len=32, type=argon2.low_level.Type.ID) # Encrypt/decrypt all files except for this script and the salt. excluded = os.path.basename(__file__), SALTNAME for filename in os.listdir('.'): if filename not in excluded: if mode == 1 and not filename.endswith(SUFFIX): encrypt_file(master_key, filename) elif mode == 2 and filename.endswith(SUFFIX): decrypt_file(master_key, filename) if __name__ == '__main__': main()