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:

  1. Is the use of filename + timestamp as info for HKDF appropriate here?
  2. 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)?
  3. 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)?
  4. 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)
    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)
    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:

def main():
    # Ask user.
    mode = int(input('Encrypt (1) or decrypt (2)? '))

    # Read salt (or create + save one if it doesn't exist yet).
        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__':
  • $\begingroup$ needs to memoize a - hopefully strong - password: suggestion use KeePass, 1password like password manager. There is one issue that the rollback attack, to countermeasure you need an approach like Merkle-tree. $\endgroup$
    – kelalaka
    Feb 4 at 19:55

Your Answer

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

Browse other questions tagged or ask your own question.