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

Flow diagram from left to right. A password and a random 16-byte salt go into PBKDF2 or Argon2id, which outputs a 32-byte key. The key, a random 12-byte nonce and the version string v1 as associated data go into AES-256-GCM encryption. The stored value is v1, salt, nonce, ciphertext and the 16-byte tag, encoded in Base64.
Password-based encryption as one stored value: a random salt feeds the KDF, a random nonce feeds AES-GCM, and both are stored next to the ciphertext and tag.

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.

Bar chart of key derivation times in milliseconds: PBKDF2-SHA256 with 10,000 iterations in Python 3.0, with 600,000 iterations in Python 181.6, with 600,000 iterations in PHP 793.6; scrypt interactive in libsodium 33.9; scrypt N=2^17 via the OpenSSL CLI 300; Argon2id interactive 62.9; Argon2id moderate 425.7.
Time to derive one 32-byte key (median of 5 runs, Apple Silicon Mac M1 Pro). The CLI scrypt bar includes process start-up.
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
  • enc refuses GCM. So the CLI can only give you unauthenticated modes, and the tampering above applies to every file it produces.
  • Without -pbkdf2 or -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 -help says the iteration count defaults to 10000. That took 3.0 ms in our PBKDF2 measurement. Pass -iter 600000 or more.
  • A file encrypted with -pbkdf2 and decrypted without it fails with bad decrypt. The options are not stored in the file; only Salted__ 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

Decision diagram. Start: you need to encrypt data. If libsodium is available, use XChaCha20-Poly1305 with a random 24-byte nonce. If AES is required, use AES-256-GCM with a random 12-byte nonce and check that the tag is exactly 16 bytes. A dashed arrow from the start leads to a grey box: ECB, CBC or CTR without a MAC, and openssl enc, which should be avoided.
Which construction to use. Pick an AEAD; avoid ECB and unauthenticated CBC/CTR, which includes everything openssl enc produces.
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 cryptography 48.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() returned true on this Mac. On some CPUs it is false; XChaCha20-Poly1305 has no such dependency.
  • Swift CryptoKit and .NET AesGcm were 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

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.

  • #security
  • #advanced
  • #encryption
  • #AES-GCM
  • #PHP
  • #Python
  • #OpenSSL

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑