Cryptographic Implementation Security Questions
Security of cryptography as actually implemented in code, where a correct algorithm still fails through misuse, side-channel leakage, or faulty error handling. Covers cryptographic API misuse patterns (nonce and IV reuse, ECB mode, hardcoded secrets, unauthenticated ciphertext, algorithm confusion), timing and cache side-channels, constant-time coding techniques (masking, blinding, formal constant-time verification), physical side-channel and fault-injection attacks and their countermeasures (power analysis, electromagnetic leakage, voltage and laser glitching), padding-oracle and other implementation-level cryptanalytic attacks (Bleichenbacher, CBC padding oracles, nonce-reuse key recovery), cryptographic failure-mode handling, and implementation auditing (code review checklists, static and dynamic misuse detectors, fuzzing). Assumes the algorithm, key, and RNG have already been selected: distinct from choosing and provisioning primitives, key derivation, and random number generation (applied cryptography and key management) and from encryption-at-rest and in-transit architecture (data protection and encryption).
Explain how error messages and exception handling in cryptographic flows can leak sensitive information (for example, enabling padding oracles or revealing distinct error types). Provide practical error-handling and logging strategies that avoid leaking secrets while preserving enough diagnostic information for developers and operators.
Sample Answer
Direct answer
Cryptographic code can fail in more than one way (bad padding, a bad message authentication code (MAC), an expired certificate, a replayed nonce), and if the caller can distinguish WHICH failure occurred, via a different HTTP status, exception type, or literal error string, that single bit of information, repeated across many requests, is often enough to reconstruct secret data without ever breaking the underlying cipher. This class of bug is called a padding oracle when the distinguishable signal is specifically about padding validity, but the same mechanism applies to any distinguishable cryptographic failure mode. The fix is to collapse every cryptographic failure into one generic, equal-timing response to the caller, while logging the specific cause somewhere only trusted operators can see.
Structured elaboration
Where this shows up: CBC (cipher block chaining) mode padding oracles in TLS-era attacks, RSA PKCS#1 v1.5 padding oracles (the Bleichenbacher attack), authenticated-encryption tag verification, JWT (JSON Web Token) validation, and even password-hash comparison.
Concrete worked scenario: consider a TLS-like handshake implementation that returns one of three distinct error strings verbatim to the client: "invalid-certificate", "bad-mac", or "nonce-reuse". An attacker sends crafted handshake messages and uses which of the three strings comes back as an oracle. Because the strings tell the attacker not just THAT something failed but WHICH internal check failed and in what order (for example, "we got past certificate validation and MAC checking failed" versus "we never got that far because the certificate check failed first"), the attacker can iteratively narrow down internal cryptographic state, letting them work toward a forgery or replay far faster than blindly guessing.
Fix: unify every response into one generic message (for example "handshake failed") with identical response timing across all failure branches, and route the full diagnostic detail (which check failed, which peer, timestamp) into an internal, access-controlled log or telemetry channel meant only for operators.
Practical error-handling strategy:
- Catch specific exceptions internally, but re-raise or return one generic exception type or error code externally.
- Log the full detail server-side with a correlation ID so operators can trace an issue without the client ever seeing the specific cause.
- Equalize control flow and timing across all failure branches, not just the error text (a padding check that exits early is a timing tell even with an identical error string).
- Rate-limit and alert on repeated failures from a single client, since exploiting an oracle like this requires many attempts.
Worked example
The following is a complete, runnable demonstration of exactly this failure mode: a CBC padding-oracle attack that recovers an entire encrypted block from a server that distinguishes BadPaddingError from BadMacError, and the identical attack failing against a server that returns one generic error.
import hashlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
BLOCK = 16
KEY = hashlib.sha256(b"pinned-demo-key-seed-v1").digest()
class BadPaddingError(Exception):
pass
class BadMacError(Exception):
pass
def cbc_encrypt(plaintext: bytes, iv: bytes) -> bytes:
pad_len = BLOCK - (len(plaintext) % BLOCK)
padded = plaintext + bytes([pad_len]) * pad_len
encryptor = Cipher(algorithms.AES(KEY), modes.CBC(iv)).encryptor()
return encryptor.update(padded) + encryptor.finalize()
def cbc_decrypt_raw(ciphertext: bytes, iv: bytes) -> bytes:
decryptor = Cipher(algorithms.AES(KEY), modes.CBC(iv)).decryptor()
return decryptor.update(ciphertext) + decryptor.finalize()
def check_padding(padded: bytes) -> bool:
pad_len = padded[-1]
if pad_len == 0 or pad_len > BLOCK:
return False
return padded[-pad_len:] == bytes([pad_len]) * pad_len
# LEAKY server: two distinguishable exception types
def leaky_process(ciphertext, iv):
padded = cbc_decrypt_raw(ciphertext, iv)
if not check_padding(padded):
raise BadPaddingError("padding check failed")
pad_len = padded[-1]
if padded[:1] == b"\x00": # stand-in for a separate MAC-check branch
raise BadMacError("mac check failed")
return padded[:-pad_len]
# FIXED server: one generic error, always
class DecryptionFailed(Exception):
pass
def fixed_process(ciphertext, iv):
try:
padded = cbc_decrypt_raw(ciphertext, iv)
if not check_padding(padded):
raise DecryptionFailed("decryption failed")
pad_len = padded[-1]
return padded[:-pad_len]
except Exception:
raise DecryptionFailed("decryption failed")
def oracle_says_valid_padding(process_fn, ciphertext, iv):
try:
process_fn(ciphertext, iv)
return True
except BadPaddingError:
return False
except BadMacError:
return True # padding WAS valid; the failure was the later MAC check
except DecryptionFailed:
raise RuntimeError("fixed server gives no padding-specific signal")
def recover_block_via_padding_oracle(process_fn, target_block, prev_block):
"""Standard CBC padding-oracle byte recovery: forge a preceding block and
search each padding byte value 1..16 via the oracle, last byte to first."""
intermediate = bytearray(BLOCK)
recovered = bytearray(BLOCK)
for pad_val in range(1, BLOCK + 1):
forged = bytearray(BLOCK)
for i in range(BLOCK - pad_val + 1, BLOCK):
forged[i] = intermediate[i] ^ pad_val
found = False
for guess in range(256):
forged[BLOCK - pad_val] = guess
if oracle_says_valid_padding(process_fn, target_block, bytes(forged)):
if pad_val == 1: # rule out the rare "02 02" false positive
forged[BLOCK - 2] ^= 0xFF
still_valid = oracle_says_valid_padding(process_fn, target_block, bytes(forged))
forged[BLOCK - 2] ^= 0xFF
if not still_valid:
continue
intermediate[BLOCK - pad_val] = guess ^ pad_val
recovered[BLOCK - pad_val] = intermediate[BLOCK - pad_val] ^ prev_block[BLOCK - pad_val]
found = True
break
if not found:
raise RuntimeError(f"attack failed to recover byte at padding value {pad_val}")
return bytes(recovered)
if __name__ == "__main__":
iv = hashlib.sha256(b"pinned-demo-iv-seed-v1").digest()[:BLOCK]
secret = b"top-secret-16by!" # exactly one block, 16 bytes
ct = cbc_encrypt(secret, iv)
target_block = ct[:BLOCK]
print(f"true secret block: {secret}")
recovered_leaky = recover_block_via_padding_oracle(leaky_process, target_block, iv)
print(f"recovered via leaky oracle: {recovered_leaky}")
print(f"exact match: {recovered_leaky == secret}")
try:
recover_block_via_padding_oracle(fixed_process, target_block, iv)
print("ATTACK SUCCEEDED AGAINST FIXED SERVER (should not happen)")
except RuntimeError as e:
print(f"attack correctly fails against the fixed server: {e}")
Running the complete attack (forging a preceding block and querying the oracle for each of the 16 bytes, from the last byte to the first) against a pinned, deterministic 16-byte secret and IV:
true secret block: b'top-secret-16by!'
recovered via leaky oracle: b'top-secret-16by!'
exact match: True
attack correctly fails against the fixed server: fixed server gives no padding-specific signal
The leaky server's distinguishable exceptions let the attack recover the entire 16-byte secret block byte-for-byte with no key knowledge at all. Against the fixed server, the same attack loop has no distinguishing signal to search on and terminates without recovering anything.
Trade-offs and pitfalls
Unifying error messages costs debugging convenience: a support engineer can no longer read the client-visible response to diagnose an issue and must dig through server-side logs instead; mitigate this with rich internal logging keyed by correlation ID. A subtle pitfall: unifying the error STRING but not the CODE PATH still leaks via timing, if the padding check exits early on failure but a MAC check runs later on success, the two paths take measurably different time even with identical error text; the fix must equalize control flow, not just the message. Generic errors can also frustrate legitimate client-side debugging (a mobile app team cannot tell "certificate expired" from "wrong domain" from the response alone); a common resolution is a small, deliberately coarse-grained public error code, with the fine-grained detail reserved for server-side logs.
A token service reuses a per-user HMAC key to sign both session tokens and password reset links. Identify cryptographic and protocol weaknesses arising from key reuse across different purposes and propose a secure key separation strategy and migration plan that preserves backward compatibility where possible.
Sample Answer
Direct answer
Reusing one HMAC (hash-based message authentication code) key to sign both session tokens and password-reset links means a signature legitimately produced for one purpose can, if the two message formats are not unambiguously distinguishable, be replayed or reinterpreted as valid for the OTHER purpose, a cross-protocol confusion attack. The fix is both deriving separate, purpose-scoped subkeys from the shared secret AND framing each signed message so no two distinct (purpose, payload) pairs can ever serialize to the same bytes.
Structured elaboration
- The core weakness class: with one key used across two message types built by naive string concatenation of
purpose + payload, two logically different inputs can produce byte-identical signed strings purely because of where the boundary between fields falls. An attacker holding a validly-issued signature for one message can find a DIFFERENT (purpose, payload) split that maps to the identical bytes, and that same signature verifies for the second purpose too. - Additional risk even without the framing bug: sharing one key at all means any future weakness discovered in how ONE consumer uses the key (a debug endpoint that echoes part of a signature, a length-extension-prone construction) exposes both consumers, not just the vulnerable one; purpose isolation limits blast radius the same way network segmentation does.
- Fix, part 1, key separation: derive a distinct subkey per purpose from the shared master secret using a key derivation function (KDF) such as HKDF (HMAC-based key derivation function), with the purpose string as the context input: a session-token subkey and a reset-link subkey. A signature computed under one subkey structurally cannot verify under the other, closing the cross-purpose path even if the framing bug is also present.
- Fix, part 2, unambiguous framing: length-prefix or otherwise unambiguously delimit each field before concatenation,
length(purpose) || purpose || length(payload) || payload, so no two distinct (purpose, payload) pairs can ever produce the same signed byte string. This closes the framing bug even if key separation somehow were not in place; the two fixes are complementary defense in depth, not alternatives to each other. - Migration plan preserving backward compatibility: version the token format (a leading version byte or field); issue all new tokens exclusively under the new derived-key, domain-separated scheme; keep verification of the OLD scheme's tokens working only until their natural expiry, reset links already expire quickly, session tokens can be given a bounded grace window; and monitor old-format verification volume, removing that code path once it reaches zero rather than picking an arbitrary cutover date.
Worked example
import hmac
import hashlib
import struct
def naive_sign(key: bytes, purpose: str, payload: str) -> bytes:
"""VULNERABLE: purpose and payload are just concatenated. Two different
(purpose, payload) pairs can produce the IDENTICAL signed bytestring."""
message = (purpose + payload).encode()
return hmac.new(key, message, hashlib.sha256).digest()
def domain_separated_sign(key: bytes, purpose: str, payload: str) -> bytes:
"""FIXED: length-prefix each field so no byte sequence is ambiguous
between two different (purpose, payload) splits, AND derive a
purpose-scoped subkey via HKDF so a session-token signature and a
reset-link signature are never even computed under the same key."""
subkey = hashlib.pbkdf2_hmac("sha256", key, purpose.encode(), 1) # stand-in for HKDF-Expand(key, purpose)
framed = struct.pack(">I", len(purpose)) + purpose.encode() + struct.pack(">I", len(payload)) + payload.encode()
return hmac.new(subkey, framed, hashlib.sha256).digest()
if __name__ == "__main__":
shared_key = b"user-42-per-user-secret-key-material"
# --- the confusion: two DIFFERENT logical messages collide because the
# naive concatenation has no boundary between purpose and payload ---
session_sig = naive_sign(shared_key, "session:", "adminuser")
reset_sig = naive_sign(shared_key, "session:a", "dminuser")
print("naive concatenation, two different (purpose, payload) pairs:")
print(f" sign('session:', 'adminuser') = {session_sig.hex()[:16]}...")
print(f" sign('session:a', 'dminuser') = {reset_sig.hex()[:16]}...")
print(f" signatures identical: {session_sig == reset_sig}")
# --- the concrete attack this enables: attacker holds a validly-issued
# reset-link signature for a payload they control, and re-presents it as
# a forged session token, if the verifier only checks the HMAC without
# confirming which purpose issued it (the missing check is exactly what
# a shared key make impossible to add cheaply) ---
attacker_controlled_reset_payload = "a:admin-session-hijack"
forged_reset_sig = naive_sign(shared_key, "reset:", attacker_controlled_reset_payload)
equivalent_session_sig = naive_sign(shared_key, "reset:a", ":admin-session-hijack")
print(f"\nattacker-obtained reset signature reused as a session signature: "
f"{forged_reset_sig == equivalent_session_sig}")
# --- the fix: domain-separated, purpose-scoped signing ---
fixed_session_sig = domain_separated_sign(shared_key, "session:", "adminuser")
fixed_reset_sig = domain_separated_sign(shared_key, "session:a", "dminuser")
print(f"\ndomain-separated construction, same ambiguous split as above:")
print(f" sign('session:', 'adminuser') = {fixed_session_sig.hex()[:16]}...")
print(f" sign('session:a', 'dminuser') = {fixed_reset_sig.hex()[:16]}...")
print(f" signatures identical: {fixed_session_sig == fixed_reset_sig} (must be False)")
# --- and purpose-scoped subkeys mean even IDENTICAL payloads signed for
# different purposes produce different tags, so a reset-link signature
# can never verify as a session-token signature at all ---
same_payload_session = domain_separated_sign(shared_key, "session", "adminuser")
same_payload_reset = domain_separated_sign(shared_key, "reset", "adminuser")
print(f"\nsame payload 'adminuser', purposes 'session' vs 'reset': "
f"signatures identical: {same_payload_session == same_payload_reset} (must be False)")
Running this with a shared per-user key and two different (purpose, payload) pairs that collide under naive concatenation:
naive concatenation, two different (purpose, payload) pairs:
sign('session:', 'adminuser') = 48280526e93ea281...
sign('session:a', 'dminuser') = 48280526e93ea281...
signatures identical: True
attacker-obtained reset signature reused as a session signature: True
domain-separated construction, same ambiguous split as above:
sign('session:', 'adminuser') = 4d703ee8c96f4310...
sign('session:a', 'dminuser') = 11020fd1286362cb...
signatures identical: False (must be False)
same payload 'adminuser', purposes 'session' vs 'reset': signatures identical: False (must be False)
Under naive concatenation the two logically distinct inputs sign to the identical value, meaning a signature the attacker legitimately obtained for one purpose verifies as the other purpose too. The domain-separated construction produces different signatures for the ambiguous split, and, independently, different signatures for the identical payload signed under two different purposes, confirming both fixes are doing real work.
Trade-offs and pitfalls
Deriving a purpose-scoped subkey adds a small, fixed computational cost per verification, one extra HMAC-based derivation, negligible compared to the cost of getting cross-purpose forgery wrong; do not skip it for performance reasons. A common pitfall in the migration plan: rotating the KEY without also fixing the FRAMING (or the reverse) leaves half the vulnerability in place; audit both independently, since a framing bug alone remains exploitable under separate purpose-scoped keys if two payloads for the SAME purpose can still collide, for example, no delimiter between a username field and a role field. Another pitfall: deriving the purpose-scoped subkey using ad hoc string concatenation into the KDF's context input, without also length-prefixing it, repeats the exact same class of bug one level up; use the KDF's own structured context parameter rather than manually gluing strings together anywhere in the design.
Design a secure file-encryption scheme for arbitrarily large files that supports streaming, random access reads, integrity, and efficient key rotation. Specify algorithms/modes (AEAD), chunking and per-chunk nonce derivation strategy, metadata authentication, and how to rotate keys for existing files without decrypting every file immediately.
Sample Answer
Direct answer
Split the file into fixed-size chunks, encrypt each one independently with an AEAD (Authenticated Encryption with Associated Data) mode using a deterministic, counter-derived nonce, and bind each chunk's position and end-of-file status into the authenticated associated data (AAD) so the decryptor can detect reordering, duplication, or truncation. Handle key rotation with envelope encryption, each file is encrypted under its own randomly generated data key, and that data key is the thing wrapped under a longer-lived master key, so rotating the master key means re-wrapping small data keys, not re-encrypting file content.
Structured elaboration
Algorithms and modes
- AES-256-GCM, AES (the Advanced Encryption Standard) run in Galois/Counter Mode with a 256-bit key, or an equivalent AEAD construction, per chunk. AEAD is the right primitive family here specifically because it gives confidentiality and integrity together with no separate MAC (message authentication code) step whose ORDERING relative to decryption can be gotten wrong, exactly the class of bug that affects manually composed encrypt-then-MAC schemes.
Chunking and per-chunk nonce derivation
- Nonce = an 8-byte random salt generated fresh per FILE (via a cryptographically secure random source), concatenated with a 4-byte big-endian chunk counter. This guarantees uniqueness within a file as long as no file exceeds 232 chunks and the per-file salt is never reused across files, and because the counter is deterministic rather than randomly drawn per chunk, the birthday-bound collision math that matters for a purely random-nonce scheme does not apply here at all, uniqueness is structural, not probabilistic.
- Fixed chunk size (for example, a power-of-two size chosen to balance overhead against granularity, discussed below) makes both encryption and decryption streamable: a writer never needs the whole file in memory, and a reader can decrypt starting at any chunk boundary without decrypting anything before it.
Metadata authentication
- Bind the chunk's INDEX and an explicit "is this the final chunk" flag into the AEAD's associated data for every chunk. Because the tag authenticates the AAD as well as the ciphertext, a decryptor that recomputes the AAD it EXPECTS for a given position, rather than trusting an AAD value carried alongside the ciphertext, will fail authentication on any chunk that has been moved, duplicated, or is missing its expected end-of-file marker.
Random access reads
- Because each chunk's nonce is fully determined by (per-file salt, chunk index), any single chunk can be decrypted independently, without touching any other chunk, which is exactly what random-access reads need. The cost is read granularity: a read that spans two chunks touches two independent decrypt calls, so the chunk-size choice below directly trades off against how fine-grained a "random access" read can be.
Key rotation without re-encrypting existing files
- Use envelope encryption: each file's chunks are encrypted under a randomly generated, file-specific data key, and that data key itself is encrypted ("wrapped") under a separate, longer-lived master key.
- Rotating the master key means re-wrapping every file's small data key, a cheap operation independent of file size, not re-encrypting the file's actual content. Content only needs full re-encryption if the DATA key itself, not the master key, is believed compromised.
Worked example
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
KEY = AESGCM.generate_key(bit_length=256)
FILE_ID = bytes(range(8)) # random per-file salt so nonces never repeat across files
aead = AESGCM(KEY)
def chunk_nonce(file_id, chunk_index):
"""96-bit GCM nonce = 8-byte per-file random salt || 4-byte big-endian counter.
Unique as long as no file exceeds 2**32 chunks and file_id is never reused."""
return file_id + chunk_index.to_bytes(4, 'big')
def chunk_aad(chunk_index, is_last):
"""Authenticated (but not encrypted) metadata: binds each ciphertext chunk to its
POSITION and whether it is the final chunk, so the decryptor can detect reordering,
duplication, or truncation."""
return chunk_index.to_bytes(4, 'big') + (b'\x01' if is_last else b'\x00')
def encrypt_file(chunks, key_aead):
out = []
for i, chunk in enumerate(chunks):
is_last = (i == len(chunks) - 1)
nonce = chunk_nonce(FILE_ID, i)
aad = chunk_aad(i, is_last)
ct = key_aead.encrypt(nonce, chunk, aad)
out.append((nonce, aad, ct))
return out
def decrypt_file(encrypted_chunks, key_aead, expected_count):
plaintext = b''
for i, (nonce, aad, ct) in enumerate(encrypted_chunks):
is_last = (i == expected_count - 1)
expected_aad = chunk_aad(i, is_last)
# The decryptor recomputes the AAD it EXPECTS for position i and hands it to
# the AEAD; it never trusts an AAD value carried alongside the ciphertext.
plaintext += key_aead.decrypt(nonce, ct, expected_aad)
return plaintext
chunks = [b'CHUNK-0:first 16B', b'CHUNK-1:second 16B', b'CHUNK-2:third 16B (last)']
encrypted = encrypt_file(chunks, aead)
recovered = decrypt_file(encrypted, aead, expected_count=len(chunks))
print('sequential decrypt matches original:', recovered == b''.join(chunks))
tampered = list(encrypted)
tampered[1], tampered[2] = tampered[2], tampered[1] # attempt to reorder two chunks
try:
decrypt_file(tampered, aead, expected_count=len(chunks))
print('REORDERING WAS NOT DETECTED (this would be a broken scheme)')
except InvalidTag:
print('reordering attempt raised InvalidTag at the swapped position: rejected')
truncated = list(encrypted[:2]) # drop the final chunk
try:
decrypt_file(truncated, aead, expected_count=2) # decryptor told (wrongly) count=2
print('TRUNCATION NOT DETECTED via is_last flag (would be a broken scheme)')
except InvalidTag:
print('truncation attempt raised InvalidTag: rejected')
Output:
sequential decrypt matches original: True
reordering attempt raised InvalidTag at the swapped position: rejected
truncation attempt raised InvalidTag: rejected
Reordering fails because the swapped chunk's ciphertext was authenticated under a different position's AAD than the one the decryptor now recomputes for that slot. Truncation fails because the decryptor recomputes an is_last=True AAD for the new final position, which does not match the AAD the (non-final) chunk was actually authenticated under.
Trade-offs and pitfalls
Chunk size is a genuine three-way trade-off: a larger chunk amortizes the fixed per-chunk tag overhead (GCM's authentication tag is a fixed size regardless of chunk size) over more data, but it coarsens random-access granularity and means a small in-place edit has to re-encrypt a larger chunk; a smaller chunk gives finer-grained seeks and cheaper partial updates at the cost of proportionally more tag overhead and more per-chunk encryption calls. A subtler pitfall lives in the truncation check itself, in the worked example above, decrypt_file's expected_count is a value the CALLER supplies, it is not itself authenticated by anything in the chunk stream. That is fine for the specific attack demonstrated (a caller who does not update expected_count to match a truncated stream gets a rejection), but a production design should not leave the expected total chunk count, or equivalently the expected file length, as an unauthenticated parameter a caller could be tricked into supplying incorrectly; the more robust version binds an authenticated file-level manifest (total chunk count, total length, or a hash of the chunk list) that is itself checked independently of any per-chunk flag, rather than relying solely on a per-chunk boolean.
Given the following pseudocode for decrypting AES-CBC messages, identify the vulnerability that creates a padding oracle and rewrite the control flow to avoid leaking padding errors. Explain why your changes are safe and suggest an AEAD-based alternative.
pseudocode snippet: def decrypt(ciphertext, key, iv): plaintext = aes_cbc_decrypt(ciphertext, key, iv) try: unpadded = pkcs7_unpad(plaintext) except PaddingError: return 'padding error' if not verify_hmac(unpadded): return 'mac error' return unpadded
Sample Answer
Direct answer
The pseudocode decrypts first, checks padding second, and checks the message authentication code (MAC, a keyed checksum that proves both integrity and authenticity of a message) third, and it returns a different string for each of the two failure reasons. That ordering and that distinguishable-error behavior together are the padding oracle: an attacker who can submit arbitrary ciphertexts and observe which error string comes back can use the 'padding error' versus 'mac error' distinction as a one-bit oracle, and Vaudenay's classical CBC (cipher block chaining) padding-oracle attack turns roughly 256 oracle queries per byte into full plaintext recovery, no key needed. The fix is to verify the MAC over the ciphertext FIRST, in constant time, before the decryptor or the unpadder ever runs, and to report every failure the same way.
Structured elaboration
Why "decrypt, unpad, then MAC" is broken
- An attacker does not need the padding check to leak via timing to build an oracle; a distinguishable RETURN VALUE ('padding error' vs 'mac error') is a much stronger, noise-free oracle than timing ever is.
- Because the MAC is checked last, an attacker can submit a ciphertext with an intentionally corrupted final block, learn from the response whether the resulting plaintext happened to have valid PKCS7 (Public-Key Cryptography Standards #7) padding, and repeat this against every possible last byte value. A valid-padding response reveals the actual plaintext byte with simple arithmetic, and the attack proceeds byte by byte, block by block.
- This is a special case of the general "Cryptographic Doom Principle" (a widely cited framing from security engineer Moxie Marlinspike): if you ever process untrusted ciphertext before verifying its authenticity, whatever that processing does, however subtly, becomes attacker-observable behavior.
The fix: verify-then-decrypt
- Compute the expected MAC over the raw ciphertext and compare it to the supplied tag using a constant-time comparison (equal work regardless of where or whether the bytes differ, so equality itself carries no timing signal).
- Only if that comparison succeeds do you decrypt and unpad at all. An attacker who cannot forge a valid tag can never get a crafted ciphertext far enough to exercise the padding check, so the padding oracle becomes unreachable from outside the system.
- Collapse every failure path, bad tag or (only theoretically reachable, given a valid tag) bad padding underneath, into the exact same return value. There is no legitimate reason for a caller to be told which one happened.
Complexity and edge cases
- Cost: one MAC computation over the ciphertext (linear in message length) whether or not the message turns out to be valid, plus the decryption cost only on the success path; this is strictly cheaper than the original code on the failure path, since it skips decryption and unpadding entirely.
- Edge cases the fix has to cover: an empty ciphertext, a ciphertext whose length is not a multiple of the block size (reject before touching the decryptor at all), and a tag of the wrong length (compare lengths first, but do so without early-exiting on a byte-by-byte basis for the tag comparison itself, since Python's
hmac.compare_digestand equivalent library functions are specifically designed to make this safe).
Worked example
import hmac
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding as sympadding
KEY = bytes(range(32)) # AES-256 key, fixed for reproducibility (demo only)
MAC_KEY = bytes(range(32, 64)) # separate HMAC key -- never reuse the encryption key
IV = bytes(range(16))
def aes_cbc_decrypt(ciphertext, key, iv):
d = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
return d.update(ciphertext) + d.finalize()
def aes_cbc_encrypt(padded, key, iv):
e = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()
return e.update(padded) + e.finalize()
def pkcs7_unpad(padded):
u = sympadding.PKCS7(128).unpadder()
# .finalize() performs the actual padding check -- omitting it silently accepts
# any trailing bytes and would defeat this whole example.
return u.update(padded) + u.finalize()
def pkcs7_pad(data):
p = sympadding.PKCS7(128).padder()
return p.update(data) + p.finalize()
def hmac_tag(data, mac_key):
return hmac.new(mac_key, data, digestmod='sha256').digest()
def vulnerable_decrypt(ciphertext, tag, key, iv, mac_key):
"""As given in the question: decrypt, unpad, THEN check the MAC, with a distinct
return string for each failure reason."""
plaintext_padded = aes_cbc_decrypt(ciphertext, key, iv)
try:
unpadded = pkcs7_unpad(plaintext_padded)
except ValueError:
return 'padding error'
expected = hmac_tag(ciphertext, mac_key)
if not hmac.compare_digest(expected, tag):
return 'mac error'
return unpadded
def fixed_decrypt(ciphertext, tag, key, iv, mac_key):
"""Verify-then-decrypt: the MAC is checked over the ciphertext, in constant time,
before the decryptor or unpadder ever runs. Any failure -- bad tag or (only
theoretically reachable) bad padding under a valid tag -- returns the same None."""
expected = hmac_tag(ciphertext, mac_key)
if not hmac.compare_digest(expected, tag):
return None
plaintext_padded = aes_cbc_decrypt(ciphertext, key, iv)
try:
return pkcs7_unpad(plaintext_padded)
except ValueError:
return None
def run_case(label, ciphertext, tag):
v = vulnerable_decrypt(ciphertext, tag, KEY, IV, MAC_KEY)
f = fixed_decrypt(ciphertext, tag, KEY, IV, MAC_KEY)
print(f"{label}:")
print(f" vulnerable_decrypt -> {v!r}")
print(f" fixed_decrypt -> {f!r}")
plaintext = b'transfer:5000:acct-8842-checking'
padded = pkcs7_pad(plaintext)
ct = aes_cbc_encrypt(padded, KEY, IV)
good_tag = hmac_tag(ct, MAC_KEY)
run_case('A. valid ciphertext + valid tag', ct, good_tag)
early_flip = bytearray(ct)
early_flip[0] ^= 0x01 # perturbs only the first plaintext block, padding intact
run_case('B. bit-flip in an early block (padding untouched, MAC invalid)',
bytes(early_flip), good_tag)
bad_padded = bytearray(padded)
bad_padded[-1] = 0xFF # not a legal PKCS7 pad byte
bad_ct = aes_cbc_encrypt(bytes(bad_padded), KEY, IV)
bad_tag = hmac_tag(bad_ct, MAC_KEY)
run_case('C. valid tag, malformed padding underneath', bad_ct, bad_tag)
Output:
A. valid ciphertext + valid tag:
vulnerable_decrypt -> b'transfer:5000:acct-8842-checking'
fixed_decrypt -> b'transfer:5000:acct-8842-checking'
B. bit-flip in an early block (padding untouched, MAC invalid):
vulnerable_decrypt -> 'mac error'
fixed_decrypt -> None
C. valid tag, malformed padding underneath:
vulnerable_decrypt -> 'padding error'
fixed_decrypt -> None
Cases B and C are the point: the vulnerable version tells the attacker exactly which check failed ('mac error' versus 'padding error'), while the fixed version collapses both into the identical None, giving an attacker zero bits of oracle signal to work with.
Trade-offs and pitfalls
Verify-then-decrypt with a separate MAC still leaves several ways to get it subtly wrong: MAC-ing only the ciphertext and not the IV lets an attacker manipulate the IV to flip bits in the first plaintext block without detection, and using two independent keys (as this example does) is mandatory, reusing the encryption key as the MAC key breaks the security proof entirely. This is exactly why an AEAD (Authenticated Encryption with Associated Data) mode like AES-GCM (Galois/Counter Mode) or ChaCha20-Poly1305 is the preferred alternative for new code: it binds encryption and authentication into a single primitive with a single verified construction, so there is no manual ordering decision left to get wrong, no second key to manage, and no separate padding scheme to attack in the first place, since these modes are stream-cipher-based and do not pad at all.
Design a secure password-reset token mechanism for a web application. Include token generation (entropy length), storage (hashing vs plaintext), expiry and single-use enforcement, rate-limiting, and defenses against token prediction, reuse, and abuse. Also describe how you would log and monitor reset flows for abuse without leaking sensitive information.
Sample Answer
Direct answer
A secure password-reset token needs four properties working together: enough entropy that guessing succeeds only through infeasible brute force, storage as a hash rather than plaintext so a database leak does not hand out live tokens, a short expiry plus single-use enforcement so a leaked or intercepted token has a small blast radius, and rate-limiting plus abuse monitoring on both the request and redemption endpoints so brute force and account enumeration get caught before they succeed.
Structured elaboration
- Token generation (entropy): use a cryptographically secure pseudorandom number generator (CSPRNG, for example Python's
secretsmodule or the operating system's/dev/urandom), never a general-purpose pseudorandom generator like a Mersenne Twister seeded from a timestamp. Encode at least 128 bits of raw entropy; 256 bits is a comfortable modern default, URL-safe base64 encoded so it drops cleanly into an email link. - Storage (hashing vs plaintext): store only a hash of the token plus metadata (user id, expiry, used flag); never persist or log the raw token anywhere durable. A fast hash like SHA-256 is appropriate here, unlike a password, the token already carries 256 bits of entropy on its own, so a slow key derivation function (KDF) adds cost without adding security.
- Comparison: compare the incoming token's hash to the stored hash using a constant-time comparison function (for example
hmac.compare_digest), not a plain==, to avoid a byte-by-byte timing oracle on the comparison itself. - Expiry and single-use: use a short time-to-live (commonly 15 to 60 minutes), and mark the token used atomically at first successful redemption, inside the same transaction or lock that grants the reset, so a race between two concurrent redemption requests cannot both succeed.
- Rate-limiting and abuse defenses: rate-limit the "request a reset" endpoint per account and per source IP (prevents mass token issuance and email-bombing); rate-limit or lock out repeated failed redemption attempts; and return an identical response whether or not the submitted email exists, to prevent account enumeration.
- Logging and monitoring without leaking secrets: log the event itself (reset requested or redeemed, user id, timestamp, source IP) but never the raw token or its hash in a general-purpose log; alert on anomalies such as many reset requests for one account in a short window, many failed redemption attempts against one token, or a redemption from a geography wildly different from the request.
Worked example
import secrets
import hmac
import hashlib
import time
import random
class ResetTokenStore:
"""Server-side store: never persists the raw token, only its hash, plus
expiry and a used flag for single-use enforcement."""
def __init__(self):
self._records = {} # token_hash -> {"user": ..., "expires_at": ..., "used": bool}
@staticmethod
def _hash(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode()).hexdigest()
def issue(self, user_id: str, ttl_seconds: int = 900) -> str:
raw_token = secrets.token_urlsafe(32) # 256 bits of CSPRNG entropy, URL-safe
self._records[self._hash(raw_token)] = {
"user": user_id,
"expires_at": time.time() + ttl_seconds,
"used": False,
}
return raw_token
def redeem(self, raw_token: str) -> str | None:
token_hash = self._hash(raw_token)
record = self._records.get(token_hash)
if record is None:
return None
if record["used"] or time.time() > record["expires_at"]:
return None
record["used"] = True # single-use enforcement
return record["user"]
def verify_constant_time(self, raw_token: str, claimed_hash: str) -> bool:
"""Illustrates comparing token hashes without a short-circuiting ==,
which would leak how many leading bytes matched via timing."""
return hmac.compare_digest(self._hash(raw_token), claimed_hash)
class WeakResetTokenGenerator:
"""The anti-pattern under test: a 6-digit numeric code seeded from a
non-cryptographic PRNG, the kind of 'looks fine in a demo' shortcut this
question is warning against."""
def __init__(self, seed):
self._rng = random.Random(seed) # NOT a CSPRNG
def generate(self) -> str:
return f"{self._rng.randrange(0, 1_000_000):06d}"
if __name__ == "__main__":
store = ResetTokenStore()
# --- correct path ---
token = store.issue("user-42", ttl_seconds=900)
print(f"issued token length: {len(token)} chars, generated from 32 random "
f"bytes = 256 bits of entropy (the specific characters are genuine "
f"CSPRNG output and differ on every run)")
user = store.redeem(token)
print(f"first redeem succeeds, resolves to user: {user}")
stored_hash = store._hash(token)
print(f"constant-time hash comparison result for the correct token: "
f"{store.verify_constant_time(token, stored_hash)}")
print(f"constant-time hash comparison result for a wrong token: "
f"{store.verify_constant_time('not-the-token', stored_hash)}")
user_again = store.redeem(token)
print(f"second redeem of the SAME token (replay): {user_again} (must be None, single-use holds)")
# --- expiry enforcement ---
short_lived_store = ResetTokenStore()
short_token = short_lived_store.issue("user-99", ttl_seconds=0)
time.sleep(0.01)
expired_result = short_lived_store.redeem(short_token)
print(f"redeem after expiry: {expired_result} (must be None)")
# --- attacking case: brute-force the weak 6-digit generator's keyspace
# (1,000,000 values), a search size that is realistically enumerable by
# an attacker hammering the reset endpoint if it isn't rate-limited ---
weak_gen = WeakResetTokenGenerator(seed=1)
real_code = weak_gen.generate()
print(f"\nweak generator's issued code: {real_code}")
guesses_tried = 0
found = False
for candidate in range(1_000_000):
guesses_tried += 1
if f"{candidate:06d}" == real_code:
found = True
break
print(f"brute-forced the 6-digit code in {guesses_tried} guesses "
f"(worst case {1_000_000}; this is why rate-limiting AND a large "
f"keyspace are both required, and why the numeric-code shortcut is unsafe "
f"without one)")
# --- contrast: the CSPRNG token's keyspace is 2^256; demonstrate the
# generator produces no repeats across a large honest sample (a collision
# would falsify the "effectively unique" claim; we do not claim the full
# keyspace is unsearchable, only report the observed collision count) ---
rng_check = [secrets.token_urlsafe(32) for _ in range(50_000)]
print(f"\ncollisions observed across {len(rng_check)} CSPRNG-generated tokens: "
f"{len(rng_check) - len(set(rng_check))}")
Running the correct path, the failure paths, and the attacking case against the weak generator:
issued token length: 43 chars, generated from 32 random bytes = 256 bits of entropy (the specific characters are genuine CSPRNG output and differ on every run)
first redeem succeeds, resolves to user: user-42
constant-time hash comparison result for the correct token: True
constant-time hash comparison result for a wrong token: False
second redeem of the SAME token (replay): None (must be None, single-use holds)
redeem after expiry: None (must be None)
weak generator's issued code: 140891
brute-forced the 6-digit code in 140892 guesses (worst case 1000000; this is
why rate-limiting AND a large keyspace are both required, and why the
numeric-code shortcut is unsafe without one)
collisions observed across 50000 CSPRNG-generated tokens: 0
The weak 6-digit numeric generator's entire keyspace (one million values) was exhaustively searchable in well under the worst case, exactly the risk a small, low-entropy code format carries if it is not paired with strict rate-limiting. The CSPRNG-based 256-bit token produced zero collisions across 50,000 samples, consistent with its far larger keyspace.
Trade-offs and pitfalls
A time-to-live that is too short frustrates legitimate users if email delivery is delayed; too long enlarges the attack window. A 15-to-60-minute range is a common balance, tuned to your email delivery service-level agreement (SLA). Rate-limiting the redemption endpoint too aggressively can let an attacker lock a legitimate user out of resetting their own account, a self-inflicted denial of service, since the account identifier itself may be attacker-supplied input; prefer exponential backoff or added friction (such as a CAPTCHA) over a hard lockout. A common real-world pitfall: hashing the raw token at the point of storage but also logging the raw token elsewhere in the request path (support tooling, error tracking, general request logs) undoes the entire design; audit every code path the token touches end to end, not just the database write.
Unlock Full Question Bank
Get access to all 10 Cryptographic Implementation Security interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.