Skillversitas

Encrypting Files

File encryption is the hybrid pattern at scale: derive (or wrap) a key, split the file into chunks, encrypt each chunk with an AEAD cipher and a fresh nonce, and package everything behind a small header. The result decrypts with just the key - and refuses to open if any byte changed. Fernet is fine for small files; for anything large, chunked AES-GCM keeps memory flat.

The Container Format

offset  size   field
0       4      magic b"ENC1"
4       16     salt (for the KDF)
20      ...    repeated per chunk:
            4      ciphertext length (big-endian)
            12     nonce
            +len   ciphertext + 16-byte GCM tag
EOF     0      end of file

The per-chunk length prefix keeps decryption a clean loop, and the salt lives once in the header because every chunk derives from the same key.

Encrypt

import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes

def _key(password: bytes, salt: bytes) -> bytes:
    return PBKDF2HMAC(hashes.SHA256(), 32, salt, 600_000).derive(password)

def encrypt_file(src: str, dst: str, password: bytes, chunk=1 << 20):
    salt = os.urandom(16)
    key = _key(password, salt)
    with open(src, "rb") as fin, open(dst, "wb") as fout:
        fout.write(b"ENC1" + salt)
        index = 0
        while block := fin.read(chunk):
            nonce = os.urandom(12)
            ct = AESGCM(key).encrypt(nonce, block, str(index).encode())
            fout.write(len(ct).to_bytes(4, "big") + nonce + ct)
            index += 1

The chunk index is passed as AAD (additional authenticated data), so a truncated or reordered file fails authentication instead of decrypting to partial garbage.

Decrypt

def decrypt_file(src: str, dst: str, password: bytes):
    with open(src, "rb") as fin:
        assert fin.read(4) == b"ENC1"
        salt = fin.read(16)
        key = _key(password, salt)
        with open(dst, "wb") as fout:
            index = 0
            while size_raw := fin.read(4):
                size = int.from_bytes(size_raw, "big")
                nonce = fin.read(12)
                ct = fin.read(size)
                fout.write(AESGCM(key).decrypt(nonce, ct, str(index).encode()))
                index += 1
# wrong password or one flipped byte -> InvalidTag at the first bad chunk

Alternatives and Notes

  • Small files and records: Fernet over the whole content - simpler, no container needed.
  • Recipient-based delivery: replace the PBKDF2 step with RSA-OAEP (or X25519 plus a KDF) wrapping, so the file travels to a public key instead of a password.
  • Shell workflows: age and gpg implement the same shape (recipient-wrapped key, chunked AEAD body) and are worth knowing.
  • Never reuse a nonce across chunks, files, or keys, and never encrypt with the password directly - always run it through the KDF with the stored salt.

Summary

Build file encryption as a small container: magic, salt, then length-prefixed chunks of AES-GCM with per-chunk nonces and the chunk index as AAD. Passwords go through PBKDF2, recipients go through key wrapping, and any tampering surfaces as an InvalidTag at decrypt time.