A service encrypts every record with AEAD cipher AES-GCM under one fixed IV — what breaks?
A service encrypts database fields with AES-256-GCM. Below is an excerpt of its configuration together with three lines from the audit log.
Constraints: the cipher choice must stay AEAD; the fix must survive process restarts and multiple concurrent writers; stored ciphertext must remain decryptable after remediation is planned.
crypto.algorithm = aes-256-gcm
crypto.key = ${DATA_KEY} # one key for the whole environment
crypto.iv = 000102030405060708090a0b # constant, read from config
crypto.aad =
audit encrypt rec=1041 nonce=000102030405060708090a0b tag=9f3c1a...
audit encrypt rec=1042 nonce=000102030405060708090a0b tag=4b17e0...
audit encrypt rec=1043 nonce=000102030405060708090a0b tag=c2d95b...
Explain which security property collapses and fix the configuration.
Repeating a GCM nonce under one key is catastrophic, not merely weak. The keystream repeats, so relationships between plaintexts leak, and the authentication subkey becomes recoverable, so integrity is lost for every message under that key. Fix with a per-message nonce and a new key.
- ✗Treating a repeated nonce as a minor entropy loss rather than a key-wide failure
- ✗Assuming a longer key compensates for a reused nonce
- ✗Believing the nonce affects only the authentication tag and not the keystream
- →Which nonce-generation strategies stay safe across restarts and multiple writers?
- →What does a nonce-misuse-resistant mode change about this failure?
What collapses
GCM is counter mode plus an authenticator. The keystream is derived from the key and the nonce, so the same nonce under the same key produces the same keystream: the records encrypted with it become related to one another and confidentiality no longer holds for that group. Worse, nonce reuse makes the GCM authentication subkey recoverable, which forfeits integrity for every message under that key — the tags can no longer be trusted.
The blast radius is the key, not one document. The remediation therefore has two parts: a unique nonce and a replacement key.
The fix
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# The key comes from a KMS or secret store, not from config. The old key is treated as
# compromised and kept only to decrypt existing data during the migration window.
def encrypt_record(key: bytes, record_id: str, plaintext: bytes) -> bytes:
nonce = os.urandom(12) # unique nonce per record
aad = record_id.encode() # binds ciphertext to its record
ciphertext = AESGCM(key).encrypt(nonce, plaintext, aad)
return nonce + ciphertext # nonce stored beside the ciphertext
Key points: crypto.iv is removed from configuration entirely — a nonce is never configured, it is generated per message and stored with the ciphertext; a 96-bit random nonce is safe across restarts and concurrent writers; data is re-encrypted under a new key and the old one is retired.