information-security/

Encrypting secrets at rest in Python with AES-GCM and Argon2

5 min read codestinger

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.