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:
ageandgpgimplement 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.