Encrypting secrets at rest in Python with AES-GCM and Argon2
Sooner or later, every on-prem tool needs to remember a secret: a database password, an FTPS credential, an API token. Cloud apps have managed secret stores. A desktop application or a service in a locked-down intranet often doesn't.
This is the approach I use when secrets have to live in a local file: authenticated encryption with AES-256-GCM, a key derived from a passphrase with Argon2id and (the part most examples skip) a versioned format so the design can change later without stranding existing files.
Don't invent cryptography
Everything below uses well-reviewed primitives from the cryptography and argon2-cffi packages. The only thing
we design is the file layout. Never implement the algorithms yourself.
pip install cryptography argon2-cffi
The two building blocks#
AES-GCM is authenticated encryption: besides hiding the data, it detects any modification. Flip a single bit in the file and decryption fails loudly instead of returning garbage. It needs a 256-bit key and a 96-bit nonce that must never repeat for the same key.
Argon2id turns a human passphrase into that 256-bit key. It's deliberately slow and memory-hungry, so an attacker who steals the file can't try billions of guesses per second on a GPU. It needs a random salt per file so identical passphrases produce different keys.
A versioned file format#
Everything needed to decrypt, except the passphrase, goes into a small header:
| Field | Size | Purpose |
|---|---|---|
| magic | 4 bytes | Identifies the file type (CSEC) |
| version | 1 byte | Lets the format evolve |
| time cost | 4 bytes | Argon2 iterations |
| memory cost | 4 bytes | Argon2 memory, in KiB |
| parallelism | 1 byte | Argon2 lanes |
| salt | 16 bytes | Random, per file |
| nonce | 12 bytes | Random, per encryption |
Then the ciphertext, with GCM's 16-byte authentication tag on the end.
Storing the Argon2 parameters in the header means you can make them stronger next year while old files still decrypt with the parameters they were written with.
The code#
import os
import struct
from argon2.low_level import Type, hash_secret_raw
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
MAGIC = b"CSEC"
VERSION = 1
HEADER = struct.Struct(">4sBIIB16s12s") # magic, version, t, m, p, salt, nonce
# RFC 9106's second recommended profile: 3 passes, 64 MiB, 4 lanes
TIME_COST, MEMORY_COST, PARALLELISM = 3, 64 * 1024, 4
# What we're willing to run when *reading* a file (see "Why the details matter")
MAX_TIME_COST, MAX_MEMORY_COST, MAX_PARALLELISM = 10, 1024 * 1024, 16
def _derive_key(passphrase: str, salt: bytes, t: int, m: int, p: int) -> bytes:
return hash_secret_raw(
passphrase.encode(), salt, time_cost=t, memory_cost=m, parallelism=p, hash_len=32, type=Type.ID
)
def encrypt(plaintext: bytes, passphrase: str) -> bytes:
salt, nonce = os.urandom(16), os.urandom(12)
header = HEADER.pack(MAGIC, VERSION, TIME_COST, MEMORY_COST, PARALLELISM, salt, nonce)
key = _derive_key(passphrase, salt, TIME_COST, MEMORY_COST, PARALLELISM)
# The header is passed as "associated data": not encrypted, but authenticated
return header + AESGCM(key).encrypt(nonce, plaintext, header)
def decrypt(blob: bytes, passphrase: str) -> bytes:
header, ciphertext = blob[: HEADER.size], blob[HEADER.size :]
magic, version, t, m, p, salt, nonce = HEADER.unpack(header)
if magic != MAGIC:
raise ValueError("not a codestinger secrets file")
if version != 1:
raise ValueError(f"unsupported format version {version}")
if not (1 <= t <= MAX_TIME_COST and 8 * p <= m <= MAX_MEMORY_COST and 1 <= p <= MAX_PARALLELISM):
raise ValueError("key-derivation parameters out of range")
key = _derive_key(passphrase, salt, t, m, p)
return AESGCM(key).decrypt(nonce, ciphertext, header) # raises InvalidTag on tampering or wrong passphrase
Using it:
import json
blob = encrypt(json.dumps({"ftps_password": "hunter2"}).encode(), passphrase)
with open("secrets.bin", "wb") as f:
f.write(blob)
secrets = json.loads(decrypt(open("secrets.bin", "rb").read(), passphrase))
Why the details matter#
The header is authenticated. Passing it as associated data means an attacker can't quietly lower the Argon2 cost or swap the salt. Any change to the header makes decryption fail.
Parameters are checked before they're used. The header is only authenticated after the key has been derived, so a malicious file could ask for a billion Argon2 passes or terabytes of memory and freeze the application before the tag check ever runs. Bounding the parameters first turns that denial-of-service into an instant error.
A fresh salt and nonce every time. Each encryption derives a new key from a new salt and uses a new random nonce. Nonce reuse is the one mistake that truly breaks GCM and this design makes it practically impossible.
Wrong passphrase and tampering look the same. Both raise InvalidTag. That's intentional: don't give an attacker
a signal about which one happened.
Versioning is your escape hatch. When the format needs to change (a new KDF, a key file instead of a passphrase or per-entry encryption), write version 2, keep reading version 1 and migrate files the next time they're saved.
What this doesn't solve#
Encryption at rest protects a stolen file. It doesn't protect a compromised machine: if malware can read your process memory or log your keystrokes, it gets the passphrase too. And the passphrase itself has to come from somewhere: a prompt, the operating system's keyring or a hardware-backed store. Choosing that source is the real security decision; the file format above just makes sure that decision isn't undone by a weak container.
Finally, remember where most secrets actually leak. It is rarely through broken cryptography. It is through people: a passphrase pasted into a chat, written on a note beside the screen or read out to a convincing caller. Strong encryption has to be paired with simple procedures, sensible access rules and people who know why they matter.
