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 (or one of two alternatives), 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#
Authenticated encryption hides the data and detects any modification. Flip a single bit in the file and decryption fails loudly instead of returning garbage. All three ciphers below take a 256-bit key and a 96-bit nonce and append a 16-byte authentication tag, so they fit the same file layout:
| Cipher | Strength | Pick it when |
|---|---|---|
| AES-256-GCM | Hardware-accelerated on almost every modern CPU | The default for desktops and servers |
| ChaCha20-Poly1305 | Fast and constant-time in pure software | Targets without AES instructions, such as older or low-power devices |
| AES-256-GCM-SIV | Survives a repeated nonce, leaking only whether two messages were identical | Nonces may come from a weak or shared source |
With plain GCM or ChaCha20-Poly1305 the nonce must never repeat for the same key. GCM-SIV relaxes that rule at the cost of a second pass over the data, which is irrelevant for a small secrets file.
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.
Choosing the Argon2 cost#
Argon2 has three knobs: the memory it fills (the weight), the number of passes over that memory (the time cost) and the number of parallel lanes. Memory is what hurts an attacker most, because every guess needs its own copy.
Published profiles are written for servers that check thousands of logins a minute and have to stay cheap per request. A secrets file is different: it's opened once, when the application starts, so it can afford far more. Measure on the slowest machine you support and aim for somewhere between a quarter of a second and one second:
import os
import time
from argon2.low_level import Type, hash_secret_raw
for memory_mib in (128, 256, 512, 1024):
start = time.perf_counter()
hash_secret_raw(
b"benchmark",
os.urandom(16),
time_cost=4,
memory_cost=memory_mib * 1024,
parallelism=4,
hash_len=32,
type=Type.ID,
)
print(f"{memory_mib:>5} MiB: {time.perf_counter() - start:.2f} s")
On a current laptop, 4 passes over 256 MiB on 4 lanes takes about a third of a second, which is what the code below uses. An older office machine might need a full second for the same work, still acceptable for a one-off unlock.
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 |
| cipher | 1 byte | Which authenticated cipher encrypted the data |
| time cost | 4 bytes | Argon2 passes |
| 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 the 16-byte authentication tag on the end.
Storing the cipher and the Argon2 parameters in the header means you can change them next year while old files still decrypt with the settings 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, AESGCMSIV, ChaCha20Poly1305
MAGIC = b"CSEC"
VERSION = 1
HEADER = struct.Struct(">4sBBIIB16s12s") # magic, version, cipher, t, m, p, salt, nonce
# Cipher ids are part of the file format: add new ones, never renumber
AES_GCM, CHACHA20_POLY1305, AES_GCM_SIV = 1, 2, 3
CIPHERS = {AES_GCM: AESGCM, CHACHA20_POLY1305: ChaCha20Poly1305, AES_GCM_SIV: AESGCMSIV}
# 4 passes over 256 MiB on 4 lanes, tuned with the benchmark above
TIME_COST, MEMORY_COST, PARALLELISM = 4, 256 * 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, cipher: int = AES_GCM) -> bytes:
salt, nonce = os.urandom(16), os.urandom(12)
header = HEADER.pack(MAGIC, VERSION, cipher, 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 + CIPHERS[cipher](key).encrypt(nonce, plaintext, header)
def decrypt(blob: bytes, passphrase: str) -> bytes:
if len(blob) < HEADER.size:
raise ValueError("file too short")
header, ciphertext = blob[: HEADER.size], blob[HEADER.size :]
magic, version, cipher, 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 cipher not in CIPHERS:
raise ValueError(f"unknown cipher {cipher}")
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 CIPHERS[cipher](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, cipher=CHACHA20_POLY1305)
with open("secrets.bin", "wb") as f:
f.write(blob)
secrets = json.loads(decrypt(open("secrets.bin", "rb").read(), passphrase))
decrypt takes no cipher argument: it reads the choice from the header, so one reader handles every file.
Other key derivation functions#
Argon2id is the current recommendation, but two older functions show up often and both ship with Python's standard library:
import hashlib
import os
salt = os.urandom(16)
passphrase = b"correct horse battery staple"
# scrypt is memory-hard too: n=2**17 with r=8 fills 128 MiB
scrypt_key = hashlib.scrypt(passphrase, salt=salt, n=2**17, r=8, p=1, maxmem=256 * 1024 * 1024, dklen=32)
# PBKDF2 is not memory-hard, so it relies on a high iteration count alone
pbkdf2_key = hashlib.pbkdf2_hmac("sha256", passphrase, salt, 600_000, dklen=32)
| Function | Memory-hard | Use it when |
|---|---|---|
| Argon2id | Yes | You can add a dependency: the first choice |
| scrypt | Yes | You need a strong option with no third-party packages |
| PBKDF2-HMAC-SHA256 | No | A standard such as FIPS 140 requires it; OWASP suggests at least 600,000 iterations |
Supporting one of them in the format above means a new version (its parameters don't map onto Argon2's fields), which is exactly what the version byte is for.
Why the details matter#
The header is authenticated. Passing it as associated data means an attacker can't quietly lower the Argon2 cost, swap the salt or switch the cipher. 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.
