Symmetric Encryption and Block Ciphers Questions
How symmetric-key primitives are constructed and why they work: block-cipher internals (Feistel networks vs substitution-permutation networks, the AES round structure and S-box design, key schedules and why a weak one degrades security), stream ciphers (ChaCha20 and CTR-mode keystream generation), and the internal mechanics of modes of operation (ECB, CBC, CTR, XTS, GCM), including why some are parallelizable, why ECB leaks structure, and why some require a unique nonce. Covers authenticated encryption construction internals (how GHASH and Poly1305 work, why nonce reuse breaks their security algebraically, formal security notions like IND-CPA and INT-CTXT), padding schemes and the mechanics of padding-oracle attacks, and cryptanalysis of block ciphers (differential and linear cryptanalysis, reduced-round attacks). This is the design and internals layer: how these primitives are built and proven secure, distinct from choosing which algorithm or mode to deploy, managing key lifecycle and rotation, or architecting data protection for a system, which belong to the applied cryptography layer.
You observe microsecond-level timing differences in an otherwise uniform decryption API that correlate with padding validation steps, effectively providing a timing-based padding oracle even though error messages are unified. Describe how an attacker could exploit this channel, how you would measure and confirm the oracle in noisy networks, and practical countermeasures both at the API boundary and inside the crypto library.
Sample Answer
Direct answer
A unified error message removes the classic "bad MAC" (message authentication code, a keyed checksum that proves data was not altered) vs "bad padding" text signal, but it does not remove the timing signal: if the code path taken for valid padding does different work (or the same work in a different order) than the path for invalid padding, the two paths finish in different amounts of wall-clock time. An attacker who can send many chosen ciphertexts and measure response latency can distinguish "padding valid" from "padding invalid" from timing alone, then run the ordinary byte-at-a-time padding-oracle decryption exactly as if the error text had leaked it. Unifying the message text is necessary but not sufficient; the fix has to remove the timing difference itself.
Structured elaboration
How the attacker exploits it (CBC-style padding oracle, timing variant): for a cipher block chaining (CBC) ciphertext, the attacker takes the block immediately before the target block, flips bits in it, and resubmits. If the flipped bits happen to produce a syntactically valid PKCS#7 padding after decryption, the server's code typically goes on to run a MAC or authentication check; if the padding is invalid, many implementations short-circuit and return immediately without ever reaching that check. That asymmetry in how much work happens is exactly what leaks through timing, one padding byte at a time, recovering the plaintext block by block without ever seeing distinguishable error text.
Measuring and confirming the oracle in a noisy network: a real network path adds jitter that is usually far larger than a microsecond-scale code-path difference, so a single request pair proves nothing. The standard approach is to sample many repeated requests for each of the two classes (known-valid-padding ciphertext vs known-invalid-padding ciphertext) and run a two-sample statistical test (a Welch t-test, which compares the average round-trip time of the two groups while allowing each group to have its own amount of noise, is the simplest choice) on the round-trip-time distributions. The number of samples needed per class scales roughly as:
n≈16(δσ)2where σ is the network jitter's standard deviation and δ is the true timing difference you are trying to detect (this is the standard two-sample-mean sample-size approximation for a two-sided test at the usual 5% significance level, meaning you accept at most a 5% chance of concluding a timing difference exists when it actually does not, and 80% power, meaning an 80% chance of actually detecting the difference when it really is there). The intuition: doubling the noise relative to the signal quadruples the number of samples you need to be confident the difference is real and not noise.
Countermeasures at the API boundary: rate limit decryption attempts per credential/connection, since the attacker's required query budget is exactly the n above per byte guessed, and a hard cap on requests-per-minute directly caps their statistical power. Log and alert on repeated authentication failures against the same ciphertext prefix, since a real client essentially never triggers this pattern. These are useful defense-in-depth but do not remove the underlying channel.
Countermeasures inside the crypto library (the actual fix): make the decrypt-then-verify code path do the same amount of work regardless of whether the padding turns out to be valid, never branching or returning early on a secret-dependent condition, and use a constant-time byte comparison for any MAC/tag check (XOR-and-accumulate over every byte, never an early return False on first mismatch). Better still, migrate off "decrypt, then check padding, then check MAC" designs entirely and use an authenticated-encryption-with-associated-data (AEAD) construction such as AES-GCM or ChaCha20-Poly1305, where the authentication tag is verified as a single step before any padding or plaintext is touched at all; a rejected ciphertext never reaches padding logic, so the timing side channel this question describes has no code path left to exist in.
Worked example
Sample-size table from the formula above, plus a fully pinned synthetic simulation of the statistical test (these are simulated timings with a fixed random seed used only to demonstrate the method, not measurements of any real system):
import random, statistics, math
def required_n(sigma, delta):
return math.ceil(16 * (sigma / delta) ** 2)
for jitter_us, leak_us in [(50, 2), (200, 2), (50, 10)]:
print(f"jitter={jitter_us}us leak={leak_us}us -> n={required_n(jitter_us, leak_us):,}/class")
rng = random.Random(20260901)
sigma, delta, n = 50.0, 2.0, required_n(50, 2)
fast = [rng.gauss(100.0, sigma) for _ in range(n)] # invalid-padding path
slow = [rng.gauss(100.0 + delta, sigma) for _ in range(n)] # valid-padding path
def welch_t(a, b):
ma, mb = statistics.mean(a), statistics.mean(b)
va, vb = statistics.variance(a), statistics.variance(b)
se = math.sqrt(va/len(a) + vb/len(b))
return (mb - ma) / se
print(f"n={n:,}: Welch t = {welch_t(fast, slow):.2f}")
Output:
jitter=50us leak=2us -> n=10,000/class
jitter=200us leak=2us -> n=160,000/class
jitter=50us leak=10us -> n=400/class
n=10,000: Welch t = 3.82
A |t| well above roughly 2 at that sample size is the statistical signal confirming a real timing channel exists; note how a 4x noisier network (200us vs 50us jitter) costs a 16x larger sample, which is exactly why "just add network jitter" is not a real defense: it raises the attacker's cost polynomially, not infinitely.
The comparison-function fix, checked functionally:
def constant_time_compare(a: bytes, b: bytes) -> bool:
if len(a) != len(b):
return False
diff = 0
for x, y in zip(a, b):
diff |= x ^ y # visits every byte, no branch on the comparison outcome
return diff == 0
This returns the correct boolean for equal and unequal inputs (verified against Python's own hmac.compare_digest), but unlike a naive for loop with an early return False on first mismatch, it always performs exactly the same number of operations regardless of where the first differing byte is.
Trade-offs and pitfalls
Constant-time code at the source level is not automatically constant-time in practice: an optimizing compiler can reintroduce a branch, and table-lookup-based S-box or padding-check implementations can still leak through CPU cache timing even with zero explicit branches, because the memory address accessed depends on secret data. Treating "unify the error text" as the whole fix is the most common wrong turn, since the question's own premise shows text alone does not close the channel. Adding artificial random delay is also a weak, non-robust fix: it raises the noise floor but an attacker with enough queries drives their required sample size up only quadratically, not indefinitely, and it typically also degrades legitimate latency for everyone. The durable fix is architectural: verify-then-decrypt with a single constant-time tag check, ideally via a real AEAD mode, so there is no secret-dependent code path left for timing to leak from.
Explain the role of Associated Data (AAD) in AEAD constructions. Provide two concrete examples where AAD is necessary (for example: network headers in TLS/QUIC, sequence numbers or frame metadata) and describe what can go wrong if AAD is omitted or handled incorrectly.
Sample Answer
Direct answer
Associated Data (AAD, sometimes written "additional authenticated data") in an AEAD (Authenticated Encryption with Associated Data) scheme is cleartext metadata that travels alongside the ciphertext, unencrypted but cryptographically bound to it: the authentication tag is computed over the AAD together with the ciphertext, so any change to the AAD, even though it was never confidential, causes tag verification to fail. AAD exists for exactly the case where some data legitimately needs to stay readable (a router needs to see it, a receiver needs it before decryption even starts) but still must not be tamperable.
Structured elaboration
Why AAD is needed at all. Encryption alone protects confidentiality; a MAC (message authentication code), which AEAD tags effectively are, protects integrity. But some data can never be encrypted in the first place because something downstream needs to read it in the clear (a network router forwarding based on a header, a protocol parser deciding how to interpret a frame before any decryption occurs). AAD lets that cleartext data still be tamper-evident, without requiring it to also be confidential.
Two concrete examples.
- TLS/QUIC packet headers: fields like packet type, protocol version, and the sequence number are sent in the clear (routers and middleboxes, or the receiver's own parsing logic, need to see them immediately) but are included as AAD so an attacker cannot flip a version bit to force a downgrade to a weaker cipher, or alter a sequence number to enable a replay.
- Frame or record metadata: a messaging protocol's sender ID, stream ID, or frame-type byte, sent unencrypted for routing/dispatch purposes, but authenticated via AAD so an attacker cannot reroute or relabel a message by editing only the cleartext header while leaving the ciphertext untouched.
What goes wrong if AAD is omitted or mishandled. If genuinely security-relevant cleartext data is NOT included in AAD, an attacker who can modify data in transit can rewrite it freely: the AEAD tag still verifies (since it never covered that data), and the receiver has no way to detect the tampering. If AAD encoding is ambiguous (variable-length fields concatenated without length-prefixing or delimiters), two DIFFERENT sets of AAD values can hash to the same authenticated input, letting an attacker substitute one set of metadata for another while the tag still checks out.
Worked example
AAD binding demonstrated directly: the SAME ciphertext and tag, decrypted once with the correct AAD and once with a single-character-different AAD:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
key = bytes.fromhex("000102030405060708090a0b0c0d0e0f")
aesgcm = AESGCM(key)
iv = bytes(12)
plaintext = b"apply patch v1"
real_aad = b"seq=104" # a cleartext sequence-number header, authenticated via AAD
sealed = aesgcm.encrypt(iv, plaintext, real_aad)
print("decrypt with correct AAD:", aesgcm.decrypt(iv, sealed, real_aad))
tampered_aad = b"seq=999" # attacker flips the sequence number in the cleartext header
try:
aesgcm.decrypt(iv, sealed, tampered_aad)
print("decrypt with tampered AAD: SUCCEEDED (would be a real vulnerability)")
except InvalidTag:
print("decrypt with tampered AAD: InvalidTag (header tamper detected)")
Output:
decrypt with correct AAD: b'apply patch v1'
decrypt with tampered AAD: InvalidTag (header tamper detected)
The ciphertext bytes never changed between the two decrypt calls; only the AAD did. The tag still fails, which is exactly the property that makes AAD useful for protecting cleartext metadata: the receiver can detect the header was altered even though the header itself was always readable.
Trade-offs and pitfalls
- Variable-length AAD fields must be encoded UNAMBIGUOUSLY (length-prefixed, or fixed-width, or delimited in a way that cannot itself be forged) before being handed to the AEAD; concatenating raw strings without such structure can let an attacker shift a boundary between two fields while still passing authentication.
- Deciding what goes in AAD versus what goes in the plaintext is a real design choice: put it in AAD if a downstream component needs to read it before or without decrypting; put it in the plaintext if it should stay confidential; anything security-relevant that is NEITHER should not exist as unauthenticated cleartext at all.
- AAD is authenticated but adds no confidentiality of its own; it is not a substitute for encrypting data that should not be readable.
List and explain five common implementation pitfalls when using AEAD in real systems (for example: nonce reuse in GCM, verifying tag after parsing plaintext, truncated tags, improper AAD handling, non-constant-time comparisons). For each pitfall describe the practical security consequence and a mitigation.
Sample Answer
Direct answer
The five most common AEAD (Authenticated Encryption with Associated Data) implementation pitfalls all share the same shape: a place where the implementation quietly trusts something it should be verifying, or verifies it in a way that leaks information about WHY it failed. Nonce reuse, using plaintext before tag verification completes, truncated tags, improper AAD (associated authenticated data) handling, and non-constant-time comparisons each turn a sound cryptographic construction into a practically breakable one.
Structured elaboration
| Pitfall | Consequence | Mitigation |
|---|---|---|
| Nonce reuse (e.g. AES-GCM, Galois/Counter Mode) | Keystream and authentication mask both repeat; plaintext recoverable via XOR, tags forgeable via linear algebra over the field | Deterministic, monotonic per-key counter, or a misuse-resistant construction (AES-GCM-SIV) where reuse cannot be operationally guaranteed against |
| Acting on plaintext before tag verification completes | Unauthenticated data reaches application logic (parsing, command execution, state changes) before it's known to be genuine | Always fully verify the tag first; use an API where verification and plaintext release are the same atomic call |
| Truncated authentication tags | A shorter tag means a smaller forgery search space; an attacker's per-attempt success probability rises directly | Use full-length tags (128 bits for AES-GCM); if truncation is unavoidable, compensate with strict rate-limiting and rekeying |
| Improper AAD handling | Mismatched or ambiguously-encoded AAD lets an attacker substitute or reorder authenticated metadata while the tag still verifies | Canonicalize AAD encoding (length-prefixed or fixed-width fields), and cross-check the exact same AAD construction on both sender and receiver |
| Non-constant-time tag comparison | An early-exit comparison leaks, via timing, WHERE a guessed tag first diverges from the real one, enabling incremental brute force | Use a comparison routine that always inspects every byte regardless of where a mismatch occurs (e.g. hmac.compare_digest) |
Quantifying the truncated-tag risk. For a tag of t bits, an attacker submitting a single forged (ciphertext, tag) pair succeeds with probability
Dropping from a full 128-bit tag to, say, a 32-bit tag does not just make forgery "a bit easier," it makes it 296 times more likely per attempt, turning an astronomically infeasible attack into one that becomes practical with enough submission attempts against a system that does not rate-limit failed verifications.
Worked example
The comparison-count pitfall made concrete: a naive early-exit comparator's NUMBER OF BYTE COMPARISONS depends on where a guess first diverges, exactly the signal a timing attacker exploits; a constant-time comparator's does not.
import hmac
def naive_compare(a, b):
if len(a) != len(b):
return False, 0
count = 0
for x, y in zip(a, b):
count += 1
if x != y:
return False, count
return True, count
real_tag = bytes.fromhex("a1b2c3d4e5f60718293a4b5c6d7e8f90")[:16]
guess_wrong_at_0 = bytes([0x00]) + real_tag[1:]
guess_wrong_at_15 = real_tag[:15] + bytes([real_tag[15] ^ 0xff])
for label, guess in [("wrong at byte 0", guess_wrong_at_0), ("wrong at byte 15", guess_wrong_at_15)]:
eq, comparisons = naive_compare(real_tag, guess)
print(f"{label}: naive_compare comparisons={comparisons}/16, hmac.compare_digest equal={hmac.compare_digest(real_tag, guess)}")
Output:
wrong at byte 0: naive_compare comparisons=1/16, hmac.compare_digest equal=False
wrong at byte 15: naive_compare comparisons=16/16, hmac.compare_digest equal=False
Both guesses are correctly rejected by hmac.compare_digest, but the naive comparator's WORK performed (1 comparison vs. 16) depends entirely on where the mismatch is, exactly the kind of data-dependent behavior a timing side-channel can turn into a byte-by-byte oracle.
Trade-offs and pitfalls
- All five pitfalls are individually well-known, but they compound: a system with non-constant-time comparison AND a truncated tag is far weaker than either issue alone would suggest, since a smaller search space combined with a timing oracle makes brute force dramatically faster.
- Reaching for a well-reviewed, high-level AEAD library API (rather than composing cipher and MAC primitives by hand) closes most of these by construction: atomic verify-then-decrypt, standard tag lengths, and constant-time comparisons are usually already built in.
- Rate-limiting failed tag verifications is a useful DEFENSE-IN-DEPTH layer against several of these at once (truncated tags, residual timing signals), but it should never be treated as a substitute for fixing the underlying implementation issue.
Describe in detail the internal structure of AES-GCM: explain the role of CTR mode for confidentiality, GHASH for authentication (polynomial evaluation in GF(2^128)), how the IV/nonce is processed (96-bit optimized case vs general case), and how the authentication tag is computed and verified.
Sample Answer
Direct answer
AES-GCM (Galois/Counter Mode) is an AEAD (Authenticated Encryption with Associated Data) construction built from two pieces sharing one key: Counter (CTR) mode does the actual encryption (a stream of AES-encrypted counter blocks XORed with the plaintext), and GHASH, a hash function built from multiplication in the finite field GF(2^128), authenticates both the ciphertext and any associated data (AAD, cleartext metadata that must be tamper-evident but not confidential) by folding them into a single 128-bit value that gets combined with one more AES block into the tag.
Structured elaboration
CTR mode (confidentiality). A 128-bit counter block J is built from the nonce (IV, a value used only once per key) and an internal counter, then encrypted with AES and XORed with plaintext: Ci=Pi⊕AESK(Ji).
GHASH (authentication). First, H = AES_K(0^128) (AES applied to an all-zero block) is derived once per key; it is the "hash subkey." GHASH then evaluates a polynomial over GF(2^128) (a finite field with 2^128 elements, arithmetic done modulo an irreducible polynomial rather than with normal carries) by folding 128-bit blocks of data through repeated multiplication by H:
with Y_0 = 0. Fed with the AAD blocks, then the ciphertext blocks, then a final block encoding the bit-lengths of both, GHASH's output S is combined with one more AES output to produce the tag:
IV processing: 96-bit vs. general. GCM defines a special fast path for the common case of a 96-bit (12-byte) nonce: J0 = IV || 0x00000001, a simple concatenation, no GHASH involved. For any OTHER IV length, J0 itself must be derived by running GHASH over the IV (padded to a block boundary) followed by a length block, which is both slower (an extra GHASH pass before encryption can even start) and puts the IV through the same field arithmetic as the message, which is why virtually every real-world GCM deployment uses 96-bit nonces.
Tag verification. The receiver recomputes T the same way from the received ciphertext, AAD, and key, then compares it against the received tag using a constant-time comparison (one that takes the same time regardless of where a mismatch occurs, so a timing side-channel cannot leak tag bytes one at a time). Any mismatch, in the ciphertext, the AAD, or the tag itself, must cause the WHOLE message to be rejected with no plaintext exposed.
Worked example
A from-scratch GHASH + CTR implementation, cross-checked against the cryptography library's own AES-GCM output for the SAME key, AAD, and plaintext, in both the 96-bit and the general (non-96-bit) IV cases:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
R = 0xE1 << 120 # reduction constant, top byte 0xE1 then 120 zero bits
def gf128_mul(x: int, y: int) -> int:
z, v = 0, y
for i in range(128):
if (x >> (127 - i)) & 1:
z ^= v
v = (v >> 1) ^ R if v & 1 else v >> 1
return z
def to_int(b): return int.from_bytes(b, "big")
def to_bytes16(x): return (x & ((1 << 128) - 1)).to_bytes(16, "big")
def ghash(h_int, data):
y = 0
for i in range(0, len(data), 16):
y = gf128_mul(y ^ to_int(data[i:i+16]), h_int)
return to_bytes16(y)
def pad16(b): return b if len(b) % 16 == 0 else b + b"\x00" * (16 - len(b) % 16)
def aes_ecb_block(key, block16):
enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
return enc.update(block16) + enc.finalize()
def inc32(block16):
c = (int.from_bytes(block16[12:16], "big") + 1) % (1 << 32)
return block16[:12] + c.to_bytes(4, "big")
def gctr(key, icb, data):
out, j = bytearray(), icb
for i in range(0, len(data), 16):
j = icb if i == 0 else inc32(j)
ks = aes_ecb_block(key, j)
out += bytes(a ^ b for a, b in zip(data[i:i+16], ks))
return bytes(out)
def compute_j0(key, h_int, iv):
if len(iv) == 12:
return iv + b"\x00\x00\x00\x01"
len_block = (0).to_bytes(8, "big") + (len(iv) * 8).to_bytes(8, "big")
return ghash(h_int, pad16(iv) + len_block)
def gcm_encrypt(key, iv, aad, plaintext):
h_int = to_int(aes_ecb_block(key, b"\x00" * 16))
j0 = compute_j0(key, h_int, iv)
ciphertext = gctr(key, inc32(j0), plaintext)
ghash_input = pad16(aad) + pad16(ciphertext) + (len(aad)*8).to_bytes(8,"big") + (len(ciphertext)*8).to_bytes(8,"big")
s = ghash(h_int, ghash_input)
tag = bytes(a ^ b for a, b in zip(s, aes_ecb_block(key, j0)))
return ciphertext, tag
key = bytes.fromhex("00112233445566778899aabbccddeeff"[:32])
plaintext = b"The quick brown fox jumps over the lazy dog! 01234567"
aad = b"header:seq=42;ver=3"
aesgcm = AESGCM(key)
iv96 = bytes.fromhex("101112131415161718191a1b") # 12 bytes = 96 bits
ct, tag = gcm_encrypt(key, iv96, aad, plaintext)
ref = aesgcm.encrypt(iv96, plaintext, aad)
print("Case 1 (96-bit IV) MATCH:", (ct + tag) == ref)
iv_general = bytes.fromhex("cafebabefacedbad") # 8 bytes = 64 bits, general path
ct2, tag2 = gcm_encrypt(key, iv_general, aad, plaintext)
ref2 = aesgcm.encrypt(iv_general, plaintext, aad)
print("Case 2 (64-bit IV, via GHASH(IV)) MATCH:", (ct2 + tag2) == ref2)
Output:
Case 1 (96-bit IV) MATCH: True
Case 2 (64-bit IV, via GHASH(IV)) MATCH: True
Both paths, hand-implemented from the GHASH/CTR definitions above with no library help for the cryptographic core, produce byte-identical ciphertext and tag to the authoritative library implementation.
Trade-offs and pitfalls
- Nonce uniqueness is the entire security foundation here: reusing
(key, IV)reuses both the CTR keystream and the GHASH maskE_K(J_0), which breaks confidentiality directly and, with basic linear algebra overGF(2^128), lets an attacker recoverHand forge tags for arbitrary messages. - GHASH's per-block multiplication in
GF(2^128)is why hardware carry-less multiply instructions (CLMUL) exist on modern CPUs; without them, GHASH is one of the more expensive parts of AES-GCM in pure software. - Always set the IV length explicitly in library APIs (OpenSSL, for example, defaults to expecting 12 bytes but supports others); silently accepting a non-96-bit IV without realizing GHASH is now on the IV's critical path is a common source of subtle bugs and performance regressions.
A messaging protocol encrypts payloads with AEAD but leaves the protocol version and sender ID in plaintext headers (not included in AAD). Describe concrete attacks that become possible due to this omission, such as downgrade, replay, or cross-user message mixup attacks. Propose a redesign of the message format and AAD usage to mitigate these vulnerabilities.
Sample Answer
Direct answer
Leaving the protocol version and sender ID as unauthenticated plaintext headers means an on-path attacker can rewrite either one while the AEAD (Authenticated Encryption with Associated Data) tag still verifies, because the tag never covered them; this enables a version-downgrade attack (force the receiver to process the message under older, weaker semantics), a replay/reflection attack (resend an old ciphertext whose headers were never bound to session freshness), and a cross-user message mixup (relabel WHO a genuine message appears to be from). The fix is to move version, sender ID, and any other field the receiver's logic depends on into AAD (associated authenticated data), so tampering with any of them breaks tag verification.
Structured elaboration
Downgrade. If version is a plaintext header never covered by the tag, an attacker rewrites it to an older value in transit; the receiver, trusting the header at face value, may switch to older, weaker cryptographic handling for that message, even though the payload itself decrypts and verifies fine under the CURRENT protocol version's key and construction.
Replay/reflection. Without an authenticated sequence number or timestamp binding a specific ciphertext to a specific point in the session, an attacker can capture and resend an old, still-valid ciphertext; if headers carrying freshness information are not authenticated, the receiver has no authenticated basis to reject the replay.
Cross-user mixup. If sender_id sits outside AAD, an attacker can swap it between two intercepted messages (or attach a stolen ciphertext to a different sender's plaintext header) and forward the result; the payload still authenticates fine (the tag never covered WHO sent it), so the receiver misattributes the message, which can escalate into privilege confusion if downstream logic trusts the sender field for authorization decisions.
The fix: redesign what AAD covers. Every field the receiver's processing logic actually DEPENDS ON, not just the ciphertext payload, needs to be part of the authenticated input: AAD = version || sender_id || session_id || sequence_number || message_type. Any attacker edit to ANY of these now breaks tag verification, closing all three attack classes at once, without needing to encrypt fields that are legitimately fine to leave readable.
Worked example
The vulnerable design (headers outside AAD) and the fixed design (headers inside AAD), using the SAME payload and key, showing the exact behavior difference:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
key = bytes.fromhex("202122232425262728292a2b2c2d2e2f")
aesgcm = AESGCM(key)
iv = bytes(12)
payload = b"transfer $500"
# vulnerable: version + sender_id are plaintext headers, AAD is empty
sealed = aesgcm.encrypt(iv, payload, b"")
header_version, header_sender = b"v=3", b"sender=alice"
forged_version, forged_sender = b"v=1", b"sender=bob" # attacker rewrites headers only
recovered = aesgcm.decrypt(iv, sealed, b"") # receiver never checked the headers at all
print("vulnerable: payload still verifies after header rewrite:", recovered == payload)
# fixed: version + sender_id are folded into AAD
aad = header_version + b"|" + header_sender
sealed_fixed = aesgcm.encrypt(iv, payload, aad)
try:
aesgcm.decrypt(iv, sealed_fixed, forged_version + b"|" + forged_sender)
print("fixed: forged headers accepted (still vulnerable)")
except InvalidTag:
print("fixed: forged headers rejected (InvalidTag)")
Output:
vulnerable: payload still verifies after header rewrite: True
fixed: forged headers rejected (InvalidTag)
In the vulnerable design, the SAME ciphertext decrypts successfully no matter what the plaintext headers claim, because nothing ever checked them. In the fixed design, the identical attacker rewrite now breaks tag verification immediately, because the headers are part of what the tag actually covers.
Trade-offs and pitfalls
- AAD does not need to be exhaustive of every byte on the wire, only the fields that actually influence receiver-side logic; over-including AAD (routing metadata a downstream proxy legitimately needs to modify in transit) can break legitimate protocol operation.
- This redesign requires BOTH sides to construct the exact same AAD encoding deterministically; a version skew where one side includes a field the other omits will cause universal tag-verification failures, not a security improvement.
- Moving a field into AAD authenticates it but does not encrypt it; if version or sender ID also needs to stay confidential (not just tamper-evident), it belongs in the encrypted payload instead, a separate design decision from the one this fix addresses.
Unlock Full Question Bank
Get access to all 21 Symmetric Encryption and Block Ciphers interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.