AES-GCM Done Right: Nonces, Tags and Key Derivation
Security lecture 5: ECB leaking a picture, a reused GCM nonce exposing a message, a 1-byte tag PHP accepted, openssl enc traps and KDF costs, all run locally.
Series: Security Basics lecture 5/6 (advanced). This one is for developers who have to store something encrypted (API keys of customers, tokens, a column of personal data, a backup file) and want to know which calls are safe, not just which ones run. By the end you will have broken ECB, a reused nonce and unauthenticated CBC/CTR on your own machine, and you will have a small password → key → AES-GCM pattern that Python and PHP can both open.
Encryption is the part of security where code that "works" is most often wrong. Every broken example below decrypts correctly when you test it with your own data. The failures only show up when somebody else edits a byte, sees two ciphertexts, or tries a short tag. In OWASP Top 10 terms this is A02 Cryptographic Failures: not "no encryption", but encryption used in a way that does not protect anything.
Everything was run on an Apple Silicon Mac (M1 Pro) with PHP 8.5.6 (linked to OpenSSL 3.6.2, libsodium 1.0.22), the OpenSSL 4.1.0 CLI, the macOS system Python 3.9.6 and Java 26. All keys, messages and "attacks" are local demo data. Paths were shortened to ~/demo/.
If you skipped the earlier lectures: lecture 1 separates hashing, MACs and encryption, and lecture 4 uses a MAC to sign tokens. Here we need both halves: secrecy (nobody can read it) and integrity (nobody can change it unnoticed).
What your toolchain actually gives you
Before writing any code, check what exists. This is the table we ended up with after testing each line.
| Tool (version here) | AES-GCM | Other AEAD | Password KDF |
|---|---|---|---|
| Python 3.9.6 stdlib | none (no AES at all) | none | hashlib.pbkdf2_hmac only; hashlib.scrypt missing |
Python + cryptography 48.0.0 (third-party) |
AESGCM |
ChaCha20-Poly1305 | Scrypt, PBKDF2 |
PHP 8.5.6 openssl_* |
aes-256-gcm |
aes-256-gcm-siv listed |
hash_pbkdf2 |
PHP 8.5.6 sodium_* |
sodium_crypto_aead_aes256gcm_* (available: true) |
XChaCha20-Poly1305 | Argon2id, scrypt |
Java 26 javax.crypto |
AES/GCM/NoPadding |
ChaCha20-Poly1305 | PBKDF2 |
OpenSSL 4.1.0 CLI enc |
refused | refused | legacy by default, -pbkdf2 optional |
Two of those lines surprised us. The macOS system Python is linked to LibreSSL 2.8.3, and on it hashlib.scrypt simply does not exist:
$ python3 -c "import hashlib; hashlib.scrypt(b'pw', salt=b's'*16, n=2**14, r=8, p=1)"
AttributeError: module 'hashlib' has no attribute 'scrypt'
A python.org or Homebrew build linked to OpenSSL has it, so "works on my laptop" code can fail on a colleague's machine. The other surprise is the CLI, covered below. The Python AES code in this post uses the cryptography package, which happened to be installed on this machine already. It is not part of the standard library; in a project you add it to your requirements.
ECB: the mode that draws your data
ECB (Electronic Codebook) encrypts each 16-byte block on its own with the same key. Equal plaintext blocks give equal ciphertext blocks. To make that visible, we built an 8×8 "icon" where each cell is one 16-byte block of 0xFF (dark) or 0x00 (light), and counted distinct ciphertext blocks.
<?php
// ~/demo/ecb.php — ECB vs CBC vs GCM on data with repeated blocks
$key = hex2bin('000102030405060708090a0b0c0d0e0f'); // fixed demo key only
$icon = ["..####..", ".#....#.", "#.#..#.#", "#......#",
"#.#..#.#", "#..##..#", ".#....#.", "..####.."];
$pt = '';
foreach ($icon as $row) foreach (str_split($row) as $c)
$pt .= str_repeat($c === '#' ? "\xFF" : "\x00", 16);
function show(string $name, string $ct): void {
$blocks = str_split(substr($ct, 0, 1024), 16);
$uniq = array_values(array_unique($blocks));
echo "$name: unique ciphertext blocks = ", count($uniq), " / 64\n";
if (count($uniq) <= 4) foreach (array_chunk($blocks, 8) as $row) {
echo " ";
foreach ($row as $b) echo chr(65 + array_search($b, $uniq));
echo "\n";
}
}
$raw = OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING;
show('AES-128-ECB', openssl_encrypt($pt, 'aes-128-ecb', $key, $raw));
show('AES-128-CBC', openssl_encrypt($pt, 'aes-128-cbc', $key, $raw, random_bytes(16)));
show('AES-128-GCM', openssl_encrypt($pt, 'aes-128-gcm', $key, OPENSSL_RAW_DATA, random_bytes(12), $tag));
AES-128-ECB: unique ciphertext blocks = 2 / 64
AABBBBAA
ABAAAABA
BABAABAB
BAAAAAAB
BABAABAB
BAABBAAB
ABAAAABA
AABBBBAA
AES-128-CBC: unique ciphertext blocks = 64 / 64
AES-128-GCM: unique ciphertext blocks = 64 / 64
The smiley comes back from the ECB ciphertext without the key: there are only two distinct blocks, and their positions are the picture. Real data has the same shape. A table of 16-byte "status" fields, fixed-width records or padded IDs encrypted with ECB reveals which rows are equal and how often each value occurs.
ECB is also what you get by accident. In PHP, openssl_encrypt($data, 'aes-256-cbc', $key) without an IV prints Warning: openssl_encrypt(): Using an empty Initialization Vector (iv) is potentially insecure and not recommended and then uses an all-zero IV. For a one-block message we got exactly the same ciphertext as ECB: sHQyEV+uqAy8hauXYqiQGw== both times.
Nonce reuse: two ciphertexts, one XOR away
GCM is CTR mode plus an authentication tag. CTR turns the key and nonce into a keystream and XORs it with the plaintext. If the same key and nonce are used twice, the keystream is the same, and it cancels out:
c1 ⊕ c2 = (m1 ⊕ k) ⊕ (m2 ⊕ k) = m1 ⊕ m2
The attacker does not need the key, only one plaintext they can guess. Here the first message is a public maintenance notice, which anyone can read on a status page.
<?php
// ~/demo/nonce.php — what reusing a GCM nonce gives away
$m1 = 'Notice: maintenance starts at 22:00 UTC'; // public, known to everyone
$m2 = 'Employee 1234 review: rating A, raise 5%'; // secret
$key = random_bytes(32);
$nonce = random_bytes(12); // BUG: the same nonce is used twice below
$c1 = openssl_encrypt($m1, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $t1);
$c2 = openssl_encrypt($m2, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $t2);
$n = min(strlen($c1), strlen($c2));
$xc = substr($c1, 0, $n) ^ substr($c2, 0, $n);
echo "c1 xor c2 == m1 xor m2 ? ", var_export($xc === (substr($m1, 0, $n) ^ substr($m2, 0, $n)), true), "\n";
echo "recovered m2 prefix: ", $xc ^ substr($m1, 0, $n), "\n"; // no key used
c1 xor c2 == m1 xor m2 ? true
recovered m2 prefix: Employee 1234 review: rating A, raise 5
We recovered 39 of the 40 secret bytes (the notice is 39 bytes long), limited only by the length of the known message. The same script with Japanese and Korean messages recovered the whole secret, because the known notice was longer; the XOR works on UTF-8 bytes, not characters (how text becomes bytes). There is a second, worse consequence that we describe but did not run: with two messages under one nonce, an attacker can solve for GCM's internal authentication key and then forge valid tags for messages of their choice.
How do nonces get reused in real code? A constant nonce "for now", a counter stored in memory that resets on restart, two servers counting from 1, or a nonce derived from a timestamp. Java guards one of these cases:
first: 19 bytes
java.security.InvalidAlgorithmParameterException: Cannot reuse iv for GCM encryption
new Cipher, same key+iv: accepted, 19 bytes
The same Cipher object refused to encrypt again with the same IV, but a fresh Cipher object accepted it. The library cannot protect you across objects, processes or servers. The rule: generate a random 12-byte nonce for every message and store it next to the ciphertext. With random 96-bit nonces, keep the number of messages per key well below 2^32 (the NIST guidance for GCM). If you encrypt more than that, or cannot guarantee good randomness, use XChaCha20-Poly1305 with its 24-byte nonce.
Encryption without authentication: 100 becomes 900
Encryption hides data. It does not stop anyone from changing it. In CTR mode, flipping a bit in the ciphertext flips the same bit in the plaintext, and the attacker knows exactly where amount=100 sits because the format is fixed.
<?php
// ~/demo/tamper.php — the same one-byte edit against CTR and GCM
$m = 'to=alice&amount=100&cur=USD';
$key = random_bytes(32);
$pos = strpos($m, 'amount=') + 7; // the '1' of 100
$flip = ord('1') ^ ord('9');
$iv = random_bytes(16);
$c = openssl_encrypt($m, 'aes-256-ctr', $key, OPENSSL_RAW_DATA, $iv);
$c[$pos] = chr(ord($c[$pos]) ^ $flip); // attacker edits one byte, no key
echo "CTR decrypts to: ", openssl_decrypt($c, 'aes-256-ctr', $key, OPENSSL_RAW_DATA, $iv), "\n";
$n = random_bytes(12);
$c = openssl_encrypt($m, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag);
$c[$pos] = chr(ord($c[$pos]) ^ $flip);
echo "GCM decrypt: "; var_dump(openssl_decrypt($c, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag));
CTR decrypts to: to=alice&amount=900&cur=USD
GCM decrypt: bool(false)
CTR returned a perfectly valid-looking transfer of 900. GCM refused, because the 16-byte tag no longer matched. That is what "authenticated encryption" means: decryption either returns the original plaintext or fails.
CBC is not safer. We encrypted the 33-byte file memo: thanks!!!!to=alice;pay=100 with the OpenSSL CLI, flipped one byte in the first ciphertext block, and decrypted:
$ openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -pass pass:demo -in t2.bin | xxd
00000000: 7fbf 389b 36ff 0355 7316 78ee 6aaf d17d ..8.6..Us.x.j..}
00000010: 746f 3d61 6c69 6365 3b70 6179 3d39 3030 to=alice;pay=900
00000020: 0a .
exit=0
The first block turned into garbage, the second block now says pay=900, and the exit code is 0. A program that only checks the exit code accepts it. Padding does not help either: we tried 2,000 wrong passwords against a CBC file and 8 of them "decrypted" without a bad decrypt error (expected about 2000/256 ≈ 7.8), producing garbage with valid-looking padding.
The 1-byte tag PHP accepted
GCM's tag is normally 16 bytes. While testing the tamper demo we tried shorter tags on decrypt, and PHP 8.5.6 accepted every length down to 1 byte:
tag len 16: ACCEPTED
tag len 12: ACCEPTED
tag len 8: ACCEPTED
tag len 4: ACCEPTED
tag len 1: ACCEPTED
If your code takes the tag from the request or the stored blob and passes it straight to openssl_decrypt, an attacker can send a 1-byte tag. Then forging a message needs at most 256 tries:
<?php
// ~/demo/shorttag.php — forge a GCM message against a receiver that trusts the tag length
$m = 'to=alice&amount=100&cur=USD';
$key = random_bytes(32); $n = random_bytes(12);
$c = openssl_encrypt($m, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag);
$pos = strpos($m, 'amount=') + 7;
$c[$pos] = chr(ord($c[$pos]) ^ (ord('1') ^ ord('9'))); // tampered, real tag unknown
for ($b = 0; $b < 256; $b++) {
$r = openssl_decrypt($c, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, chr($b));
if ($r !== false) { echo "forged after ", $b + 1, " tries: $r\n"; break; }
}
function gcm_open(string $c, string $key, string $n, string $tag): string|false {
if (strlen($key) !== 32 || strlen($n) !== 12 || strlen($tag) !== 16) return false; // FIX
return openssl_decrypt($c, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag);
}
Four runs forged the message after 125, 254, 204 and 51 tries (the key and nonce are random each run, so the number changes; the average is about 128). With the length check, the same 1-byte tag returns false. An empty tag was rejected even without the check. Java is stricter: new GCMParameterSpec(32, iv) failed with Unsupported TLen value. Must be one of {128, 120, 112, 104, 96}. libsodium has no tag length parameter at all.
PHP's key handling has the same kind of silence. openssl_encrypt with aes-256-cbc and the key "k" gave the same ciphertext as the key "k" followed by 31 NUL bytes, and a 40-byte key gave the same result as its first 32 bytes. No warning in either case. So openssl_encrypt($data, 'aes-256-cbc', 'password123', …) uses your password, padded with zeros, as a raw AES key. There is no key derivation at all.
A complete pattern: password → key → AES-GCM
When the key comes from a password, three things go into the stored value besides the ciphertext: the random salt for the KDF, the random nonce, and a version prefix so you can change parameters later. We also pass the version as associated data (AAD), which is authenticated but not encrypted.
# ~/demo/seal.py — PBKDF2 from the stdlib, AES-256-GCM from the "cryptography" package
import base64, hashlib, os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
def seal(password: str, plaintext: str, aad: bytes = b"v1") -> str:
salt, nonce = os.urandom(16), os.urandom(12)
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600_000, dklen=32)
ct = AESGCM(key).encrypt(nonce, plaintext.encode(), aad) # ciphertext || 16-byte tag
return base64.b64encode(b"v1" + salt + nonce + ct).decode()
def open_(password: str, token: str, aad: bytes = b"v1") -> str:
raw = base64.b64decode(token)
if raw[:2] != b"v1" or len(raw) < 2 + 16 + 12 + 16:
raise ValueError("bad format")
salt, nonce, ct = raw[2:18], raw[18:30], raw[30:]
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600_000, dklen=32)
return AESGCM(key).decrypt(nonce, ct, aad).decode()
tok = seal("correct horse", "API key for staging: demo-123")
print(len(tok), tok[:24] + "...")
print(open_("correct horse", tok))
try:
open_("wrong horse", tok)
except InvalidTag:
print("wrong password -> InvalidTag")
100 djGdCNO22uBzjbOIeagX4qDc...
API key for staging: demo-123
wrong password -> InvalidTag
A wrong password raises InvalidTag every time, unlike CBC's 8-in-2,000 false successes. The format is portable. PHP opened a value sealed by Python with nothing but hash_pbkdf2 and openssl_decrypt, splitting the tag off the end:
<?php
$raw = base64_decode(trim(file_get_contents($argv[1])), true);
$salt = substr($raw, 2, 16); $nonce = substr($raw, 18, 12);
$tag = substr($raw, -16); $ct = substr($raw, 30, -16);
$key = hash_pbkdf2('sha256', 'correct horse', $salt, 600000, 32, true);
var_dump(openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, 'v1'));
// string(12) "interop test"
How expensive should the KDF be? Its job is the same as in password hashing (lecture 2): make each password guess cost real time. We measured the median of 5 runs for each setting with a fixed password and salt.
| KDF and setting | Median time |
|---|---|
| PBKDF2-SHA256, 10,000 iterations (Python) | 3.0 ms |
| PBKDF2-SHA256, 100,000 iterations (Python) | 30.5 ms |
| PBKDF2-SHA256, 600,000 iterations (Python) | 181.6 ms |
PBKDF2-SHA256, 600,000 iterations (PHP hash_pbkdf2) |
793.6 ms |
| scrypt, libsodium "interactive" (16 MiB) | 33.9 ms |
scrypt N=2^17, r=8 (OpenSSL CLI kdf, includes process start) |
0.30 s |
| Argon2id, libsodium "interactive" (2 passes, 64 MiB) | 62.9 ms |
| Argon2id, libsodium "moderate" (3 passes, 256 MiB) | 425.7 ms |
Python and PHP produced the same PBKDF2 key (96a5904c2e08c8da…), but PHP took about 4.4 times as long for it. That matters when you choose an iteration count on one stack and verify on the other. Argon2id and scrypt are better choices than PBKDF2 when you have them, because they also cost memory, which makes GPU guessing much more expensive. PBKDF2 with 600,000 iterations is the OWASP number for SHA-256 when it is the only option, as it is in the Python stdlib on this Mac.
If you do not need a password at all (the usual case for server-side data), skip the KDF: generate a 32-byte key with random_bytes(32) or secrets.token_bytes(32) once, keep it outside the database (environment, secret manager), and use it directly. Random keys need no stretching.
openssl enc on the command line
The OpenSSL CLI is where people encrypt backups, so it is worth knowing what 4.1.0 actually does.
$ openssl enc -aes-256-gcm -pass pass:demo -in msg.txt -out /dev/null
enc: AEAD ciphers not supported
enc: Use -help for summary.
exit=1
$ openssl enc -aes-256-cbc -pass pass:demo -in msg.txt -out c1.bin
*** WARNING : deprecated key derivation used.
Using -iter or -pbkdf2 would be better.
exit=0
encrefuses GCM. So the CLI can only give you unauthenticated modes, and the tampering above applies to every file it produces.- Without
-pbkdf2or-iter, the default is still the old one-pass key derivation (EVP_BytesToKey). It only warns, and the exit code is 0. We checked this on 4.1.0 because we were not sure it was still the default; it is. - With
-pbkdf2,openssl enc -helpsays the iteration count defaults to 10000. That took 3.0 ms in our PBKDF2 measurement. Pass-iter 600000or more. - A file encrypted with
-pbkdf2and decrypted without it fails withbad decrypt. The options are not stored in the file; onlySalted__and the 8-byte salt are. Record them next to the backup. -pass pass:…puts the password in your shell history and in the process list. Use-pass file:or-pass env:.
For encrypted backups, a tool built on authenticated encryption is the better choice (age and gpg are common; we did not run them here). If you must use openssl enc, add a separate HMAC over the file and check it before decrypting.
Common mistakes and their fixes
| Mistake | What actually happened in our runs | Fix |
|---|---|---|
| AES-ECB, or CBC with no IV | The icon survived; 2 distinct blocks out of 64 | AEAD mode with a random nonce |
| Fixed or counter nonce reused | 39 of 40 secret bytes recovered from a known message | Random 12-byte nonce per message, or XChaCha20-Poly1305 |
| CTR/CBC without a MAC | amount=900 and pay=900 accepted, exit 0 |
GCM or ChaCha20-Poly1305; check the result of decrypt |
| Tag length taken from input | 1-byte tag accepted, forged in ≤256 tries | Require a 16-byte tag and a 12-byte nonce |
| Password used as the key | PHP padded "k" with NUL bytes silently |
KDF with salt, or a random 32-byte key |
openssl enc defaults |
Legacy KDF, 10,000 PBKDF2 iterations, no integrity | -pbkdf2 -iter 600000 plus a MAC, or an AEAD tool |
Ignoring a false from decrypt |
Code continues with false or an empty string |
Treat failure as an error; never "fall back" to the raw data |
Choosing a construction
| Situation | Use | Why |
|---|---|---|
| PHP with libsodium | sodium_crypto_aead_xchacha20poly1305_ietf_* |
Fixed sizes, 24-byte random nonce, no mode choices |
| Must be AES (policy, interop) | AES-256-GCM, random 12-byte nonce, 16-byte tag checked | Standard AEAD, hardware-accelerated |
| Very many messages per key, or a weak RNG | XChaCha20-Poly1305 or AES-GCM-SIV | Random nonces safe, or misuse-resistant |
| Key from a human password | Argon2id → key, then AEAD | Each guess costs time and memory |
| Server-side secret key | random_bytes(32) stored outside the DB |
No KDF needed |
| Encrypted file from the CLI | An AEAD tool, or enc plus a separate HMAC |
enc cannot do AEAD |
With libsodium the whole PHP side is two calls, and associated data works the same way as in GCM:
<?php
$key = sodium_crypto_aead_xchacha20poly1305_ietf_keygen(); // 32 random bytes
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); // 24
$c = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt('to=alice&amount=100&cur=USD', 'order:42', $nonce, $key);
var_dump(sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($c, 'order:43', $nonce, $key)); // bool(false)
The sample message to=alice&amount=100&cur=USD became 43 bytes (27 + 16-byte tag), and decrypting with order:43 instead of order:42 as AAD returned false.
Honest limits and version notes
- Versions: PHP 8.5.6 (OpenSSL 3.6.2, libsodium 1.0.22), OpenSSL CLI 4.1.0, Python 3.9.6 (LibreSSL 2.8.3) with
cryptography48.0.0, Java 26. The 1-byte tag result is what PHP 8.5.6 did here; we did not test other PHP versions. Check yours with the loop above. - The GCM authentication-key recovery after nonce reuse is described, not run. The XOR recovery was run.
- KDF timings are medians of 5 runs on one M1 Pro; the CLI scrypt number includes process start. Your server will differ, so measure there and aim for a cost your login path can afford.
sodium_crypto_aead_aes256gcm_is_available()returnedtrueon this Mac. On some CPUs it isfalse; XChaCha20-Poly1305 has no such dependency.- Swift CryptoKit and .NET
AesGcmwere not run in this lecture. - Nothing here is a full key-management design: rotation, backups of keys and access control are separate work.
Exercises
1. A colleague stores customer notes with openssl_encrypt($note, 'aes-256-ctr', $key, 0, $iv), where $iv is a 16-byte constant from the config file. Name the two problems and what an attacker who can read and write the database can do with each.
2. You receive values from the Python seal() above in PHP. Write the checks you would do before calling openssl_decrypt, in order.
Answers
1. (a) The IV is constant, so every note is encrypted with the same keystream. Anyone who reads two notes gets their XOR, and one known or guessable note (a template like "Customer called about…") reveals the others, as in the nonce demo. (b) CTR has no integrity. An attacker with write access can flip bytes at known positions, like 100 → 900, and decryption succeeds. Fix: AES-256-GCM or XChaCha20-Poly1305 with a fresh random nonce per note stored with it, and treat a false from decrypt as an error.
2. Base64-decode in strict mode and fail on false; check the v1 prefix; check the total length is at least 2 + 16 + 12 + 16; split salt (16), nonce (12), tag (last 16) and ciphertext; confirm the tag is exactly 16 bytes and the nonce 12; derive the key with the same KDF and parameters; pass v1 as AAD; and only use the plaintext if the result is not false.
Next
- Previous: Security Basics lecture 4/6: HS256 JWT signing pitfalls
- Next: Security Basics lecture 6/6: timing attacks and constant-time compare
- Earlier in the series: hashing vs encryption vs encoding, password hashing with bcrypt and Argon2, secure random tokens and secrets
- Encryption in transit rather than at rest: HTTPS, TLS and certificates
- The other half of web security in PHP: XSS, CSRF and sessions in PHP
- All lecture series: programming learning roadmap
FAQ
Can I reuse a nonce with AES-GCM if the key is secret?
No. With the same key and nonce, GCM produces the same keystream, so XOR of the two ciphertexts equals XOR of the two plaintexts. In our run, knowing one public notice was enough to read the second message without the key. Reuse also lets an attacker forge tags. Use a fresh random 12-byte nonce per message, or XChaCha20-Poly1305 with a 24-byte nonce.
Why did openssl_decrypt accept a forged AES-GCM message?
PHP 8.5.6 accepted any tag length on decrypt, including 1 byte. We changed amount=100 to amount=900 and tried all 256 one-byte tags; one passed after 51 to 254 tries. Always check that the tag is exactly 16 bytes and the nonce exactly 12 bytes before calling openssl_decrypt, or use libsodium, which has fixed sizes.
Is openssl enc -aes-256-cbc with a password safe for files?
Not by default. Without -pbkdf2, OpenSSL 4.1.0 still uses the legacy key derivation and prints a deprecation warning, and with -pbkdf2 the default is only 10000 iterations. CBC has no integrity: we changed pay=100 to pay=900 in an encrypted file and decryption exited 0. enc refuses AEAD modes such as GCM, so use a tool with authenticated encryption.