Cryptography Fundamentals Questions
Core concepts and vocabulary of cryptography: confidentiality, integrity, authentication, and non-repudiation; the difference between symmetric and asymmetric primitives; and how standard algorithms, libraries, and protocols fit together. Covers threat models, common standards, and applying primitives and cryptographic libraries correctly to real-world security problems. The entry point for the cryptography track.
Compare an encrypt-then-MAC construction (e.g. AES-CBC + HMAC) against a dedicated AEAD cipher like AES-GCM for protecting an HTTP API payload. Cover performance, hardware acceleration, streaming support, IV/nonce requirements, and where each approach is more likely to be implemented incorrectly.
Sample Answer
Direct answer
Both encrypt-then-MAC (AES-CBC plus a separately computed HMAC) and a dedicated AEAD cipher
like AES-GCM protect confidentiality and integrity together, but AEAD bundles them into one
call with one failure mode to get right, while encrypt-then-MAC is two separate primitives you
must sequence correctly yourself. For a new HTTP API payload, AES-GCM is the default choice
unless there's a specific reason to prefer the older combination.
Structured elaboration
- Performance and hardware acceleration: AES-GCM benefits from dedicated CPU instructions
(AES-NI plus carryless multiplication for the authentication step), making it very fast on
essentially all modern server and mobile hardware. Encrypt-then-MAC with AES-CBC plus HMAC
requires two separate passes over the data (one for the cipher, one for the MAC), roughly
doubling the work, though both individual pieces can still be hardware-accelerated. - Streaming support: HMAC can be updated incrementally as data arrives, since it just
needs the full message to compute a final digest, which suits streaming reasonably well.
AES-GCM also supports incremental processing but has an internal limit on how much data one
nonce can safely encrypt before the underlying counter risks exhaustion; for very large or
long-lived streams under one key, that limit needs to be tracked. - IV/nonce requirements: CBC needs an unpredictable IV per message (repetition leaks
structure but is not immediately catastrophic). GCM needs a nonce that is never reused
under the same key (repetition is catastrophic: see the nonce-reuse mechanics above, up to
full key-material exposure). - Where each is more likely to be implemented incorrectly: encrypt-then-MAC has several
places to get wrong: MAC-then-encrypt or encrypt-and-MAC (rather than encrypt-then-MAC)
order can reopen padding-oracle-style attacks; the MAC comparison must be constant-time to
avoid a timing oracle; padding itself must be handled carefully. AES-GCM collapses most of
that into a single library call, but concentrates all the risk into one rule: never reuse a
nonce. In distributed services, where many stateless instances encrypt independently under
a shared key, coordinating nonce uniqueness (a monotonic counter plus a per-instance ID, or
purely random 96-bit nonces) becomes the operational version of the same problem, and it is
what drives the key-rotation volume worked out below. A cipher with a larger nonce space,
like XChaCha20-Poly1305's 192-bit nonce, removes that
practical ceiling by making random-nonce collisions negligible even at very high message
volumes.
Worked example
Take NIST's rule of thumb that a random 96-bit nonce under one key should be retired once
roughly 2^32 messages have been encrypted. That 2^32 is NIST's deliberately conservative
invocation limit, chosen to keep the IV-collision probability down around 2^-32 to 2^-33; it
sits far below the true birthday bound of 2^48 (the square root of the 96-bit space, the point
where the chance of some collision reaches about 50 percent). 2^32 = 4,294,967,296. A service encrypting 10 million API payloads per day reaches
that count in 4,294,967,296 / 10,000,000 ≈ 429.5 days, a little over a year. That is a
concrete, calendar-relevant reason to build key rotation into the design up front rather than
treating AES-GCM as "safe forever" once it is wired up.
Trade-offs & pitfalls
- If you must support encrypt-then-MAC (say, for interoperability with an existing system),
the order matters: authenticate the ciphertext, not the plaintext, and use a
constant-time comparison when checking the tag. - "AEAD is simpler" does not mean "AEAD is foolproof"; it moves the single point of failure to
nonce management, which still requires real design attention in a distributed system.
Describe how you would perform a cryptographic library audit to find misuse (e.g., incorrect mode selection, improper padding, insecure defaults). What static and dynamic analysis tools or test vectors would you use, and how would you prioritize remediation across findings that vary in exploitability?
Sample Answer
Direct answer
Treat a cryptographic library-misuse audit as two separate lanes that feed each other: static analysis to find suspicious call sites at scale, and dynamic, test-vector-driven analysis to prove which of those sites are actually exploitable. Then triage findings by exploitability and blast radius, not by file order or how alarming a finding looks in isolation.
What "misuse" looks like
- Wrong mode selection: ECB mode used for anything beyond a single block. ECB encrypts identical plaintext blocks to identical ciphertext blocks, so repeated structure in the plaintext (a bitmap, a repeated field) leaks straight through the ciphertext.
- Improper padding: a custom padding-validation routine whose error path (message, timing, or response code) differs depending on whether the padding was well-formed, the classic padding-oracle shape that lets an attacker decrypt ciphertext byte by byte through repeated queries.
- Insecure defaults: a library or wrapper that ships a default IV of all zeros, a default weak cipher, or a key size that's fine for a demo but not for production, and a caller who never overrides the default inherits the weakness silently.
Static analysis
Pattern- and AST-based scanners (for example Semgrep with a crypto-focused ruleset, or a language's own security linter such as Bandit for Python) can flag known-bad call shapes directly: Cipher(..., modes.ECB()), a broken digest like MD5 used outside a non-security checksum context, or a byte-array key or IV that's a literal constant in source. Secret-scanning tools (gitleaks, truffleHog) catch hardcoded keys specifically. General static analysis (CodeQL and similar) adds language-wide security query packs beyond crypto-specific ones.
Dynamic and test-vector analysis
Run the code path that actually performs encryption/decryption against known-answer test vectors, Google's Project Wycheproof is built exactly for this: it contains adversarial test vectors (weak IVs, invalid curve points, edge-case moduli) specifically designed to catch the misuse classes above rather than just confirming a library works on "normal" input. Pair that with differential testing against a reference implementation on the same inputs, and fuzz the parsing and validation code paths that handle ciphertext or padding, since those are exactly where a padding-oracle-shaped bug tends to live.
Prioritizing remediation
Score every finding on two axes: exploitability (is the vulnerable path reachable by an unauthenticated network attacker, or only by someone who already has local code execution) and impact (does it break confidentiality of data at rest, or fail loudly and safely). Fix network-reachable, confidentiality-breaking findings first regardless of how small or old the code looks; track lower-exploitability findings on a remediation backlog with a deadline weighted by that same exploitability score, rather than by severity label alone.
Worked example
The ECB pattern leak, demonstrated directly:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
import os
key = os.urandom(32)
block = b"AAAAAAAAAAAAAAAA" # one AES block (16 bytes), repeated
plaintext = block * 4
encryptor = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
blocks = [ciphertext[i:i + 16] for i in range(0, len(ciphertext), 16)]
print("all four ciphertext blocks identical:", blocks[0] == blocks[1] == blocks[2] == blocks[3])
Output:
all four ciphertext blocks identical: True
Four identical plaintext blocks produce four identical ciphertext blocks under ECB, exactly the structural leak that static-analysis rules are built to flag before the code ever ships.
Trade-offs and pitfalls
Static analysis alone produces false positives (a "bad" call inside a well-reviewed wrapper that's actually fine) and false negatives (a config-gated insecure default that's only reached through a rarely-used flag), so treat its hits as leads, not verdicts. Test-vector and fuzzing analysis alone will miss anything the vectors and fuzz corpus don't exercise. And both together still miss pure logic-level misuse, the right function called with the wrong parameters, such as a correct AEAD (Authenticated Encryption with Associated Data) call with a caller-supplied fixed nonce, which usually needs a manual review pass on the highest-risk call sites even after tooling runs clean.
State recommended key length guidance for common algorithms: AES (symmetric), RSA (classical public-key), and elliptic curve algorithms (ECDSA/ECDH). Explain why key length matters and what operational considerations (performance, lifespan, algorithm migration) influence your choice of length.
Sample Answer
Direct answer
Roughly: AES-128 or AES-256 for symmetric keys, at least RSA-2048 with RSA-3072 preferred for
longer-lived data, and a 256-bit elliptic curve (like P-256) for ECDSA/ECDH. These are not
interchangeable "bit strengths": because the underlying hard problems differ, RSA needs a
much larger key than AES or ECC to reach the same practical security level.
Structured elaboration
Key length matters because it determines how expensive the best known attack is. For a
symmetric cipher, the best general attack is brute force over the key space, so security
scales directly and exponentially with key bits. RSA's security instead rests on integer
factorization, which has sub-exponential algorithms (the General Number Field Sieve), so RSA
needs a much larger key to match a given symmetric strength. Elliptic-curve cryptography
relies on the discrete logarithm problem over an elliptic curve, which has no known
sub-exponential attack, so it reaches strong security with much smaller keys than RSA.
Roughly matched security levels (NIST SP 800-57 style guidance):
| Symmetric-equivalent strength | AES | RSA | ECC (ECDSA/ECDH) |
|---|---|---|---|
| ~128-bit | AES-128 | RSA-3072 | P-256 |
| ~192-bit | AES-192 | RSA-7680 | P-384 |
| ~256-bit | AES-256 | RSA-15360 | P-521 |
Operational considerations that influence the choice:
- Performance: RSA key generation and private-key operations get noticeably slower as key
size grows; ECC stays comparatively cheap even at higher security levels, which is why TLS
handshakes favor ECDHE over large-RSA key exchange today. - Lifespan: a key protecting data that must stay confidential for decades (a root CA, an
archival encryption key) should sit at a higher security margin than a short-lived TLS
session key, since attacker capability only improves over time. - Algorithm migration headroom: teams often deliberately keep some headroom above the
current minimum recommendation, so that a future algorithm migration (moving to a larger
key size, or off an algorithm entirely) can happen on a planned schedule rather than as an
emergency re-key after a break is announced.
Worked example
A document-signing service needs signatures that remain verifiable for 20-year regulatory
retention. A TLS session key, by contrast, only needs to resist attack for the minutes the
connection is open. Both could technically use "128-bit class" security today, but the
signing service deliberately picks RSA-3072 over RSA-2048 (or a 384-bit curve over a 256-bit
one) precisely because of the lifespan consideration: a 20-year window gives attacker capability
two decades to improve, so the team spends a bit more compute per signature now to buy margin
that a short-lived session key does not need.
Trade-offs & pitfalls
- RSA-2048 is still widely deployed and considered acceptable for most near-term use, but
guidance is trending toward RSA-3072 for anything expected to remain secure for many years;
treat any bare "RSA-2048 is fine forever" claim with suspicion. - Bumping AES from 128 to 256 bits has essentially no practical performance cost on modern
hardware (AES-NI accelerates both equally), so the "just use AES-256 to be safe" instinct is
cheap here in a way it is not for RSA.
Your code needs N cryptographically secure random bytes for a key or nonce. Walk through how you'd get them correctly in Python and in C, and what specific mistakes in either language would quietly make the result insecure.
Sample Answer
Direct answer
A key or nonce needs bytes from a cryptographically secure pseudorandom number generator (CSPRNG), a random source specifically designed so its output cannot be predicted even by an attacker who has seen previous outputs, unlike an ordinary statistical random-number generator, which is built for speed and distribution quality, not unpredictability against an adversary. In Python, use the secrets module or os.urandom(). In C, use the operating system's CSPRNG interface directly, arc4random_buf() on Apple and BSD platforms, or the getrandom() system call (falling back to reading /dev/urandom) on Linux. The quiet, common mistake in both languages is reaching for the general-purpose random-number function instead, which looks identical in code but is not safe for this purpose.
Python: correct and incorrect
import os, secrets, random
# Correct: designed specifically for security-sensitive use.
key_a = secrets.token_bytes(32)
key_b = os.urandom(32)
# WRONG for keys or nonces: random.random() and friends use the Mersenne Twister,
# a fast, high-quality STATISTICAL generator whose internal state can be reconstructed
# from a few hundred of its outputs, making every future output predictable. It was
# never designed to resist an adversary, only to pass statistical randomness tests.
insecure_key = bytes(random.randint(0, 255) for _ in range(32))
print("secrets.token_bytes(32):", key_a.hex())
print("os.urandom(32): ", key_b.hex())
print("random module (INSECURE, do not use):", insecure_key.hex())
Running this prints three distinct 32-byte hex strings; the first two are safe to use as a key or nonce, the third looks identical in shape but must never be used for anything security-sensitive, because its generator is not designed to resist prediction.
C: correct and incorrect
#include <stdio.h>
#include <stdint.h>
#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__)
#include <stdlib.h>
static int get_random_bytes(uint8_t *buf, size_t n) {
arc4random_buf(buf, n); /* CSPRNG, cannot fail, needs no caller-supplied seed */
return 0;
}
#else
#include <sys/random.h>
#include <errno.h>
static int get_random_bytes(uint8_t *buf, size_t n) {
size_t got = 0;
while (got < n) {
ssize_t r = getrandom(buf + got, n - got, 0); /* Linux syscall, kernel CSPRNG */
if (r < 0) { if (errno == EINTR) continue; return -1; }
got += (size_t)r;
}
return 0;
}
#endif
int main(void) {
uint8_t key[32];
if (get_random_bytes(key, sizeof(key)) != 0) {
fprintf(stderr, "failed to obtain secure random bytes\n");
return 1;
}
printf("32-byte key: ");
for (size_t i = 0; i < sizeof(key); i++) printf("%02x", key[i]);
printf("\n");
return 0;
}
Compiling and running this twice prints two different 32-byte hex keys, confirming fresh, non-repeating output. The mistake this avoids: calling rand() (seeded with srand(time(NULL)) or similar) for key material. rand() is a general-purpose generator with a small internal state and, critically, seeding it from the current time means an attacker who has even a rough idea of when the program started only has to search a small number of candidate seeds to reproduce every "random" value it ever produces.
Specific mistakes that quietly make the result insecure
- Python: using
random.random(),random.randint(), or anything else from therandommodule for keys, nonces, tokens, or passwords, it is a statistical generator, not a security one, and Python's own documentation says so explicitly. - C: using
rand()/srand(), especially seeded fromtime(),getpid(), or another low-entropy, guessable value, or reading raw bytes from/dev/randomand blocking indefinitely under the outdated assumption that it's "more secure" than/dev/urandom, on modern systems both draw from the same underlying kernel CSPRNG once it has been seeded at boot. - Both languages: not checking the return value of a random-byte function for failure. A CSPRNG call that can fail (
getrandom()returning an error, for instance) and is silently ignored can leave a buffer partially unfilled, with the unfilled portion holding whatever was previously in memory, which may not be random at all.
Trade-offs and pitfalls
The dangerous part of this class of mistake is that insecure and secure code look almost identical: both produce a byte string the same length, and neither raises an obvious error. The only reliable defense is a policy, never call a general-purpose random function for anything security-sensitive, enforced by code review or, better, a linter rule that flags random.random/rand() calls near variables named key, nonce, token, or secret.
Why can't you build a secure MAC by simply computing hash(key || message)? Walk through the HMAC construction (the nested inner/outer padding), and explain specifically what attack this defends against that the naive approach doesn't.
Sample Answer
Direct answer
hash(key || message) is forgeable because of length-extension: with a Merkle-Damgard hash
function (MD5, SHA-1, plain SHA-256), the hash's output is its complete internal state after
processing the input, so an attacker who sees that output can resume hashing from it and
compute a valid hash for key || message || extra, appending attacker-chosen data, without
ever learning the key. HMAC defeats this with a nested double-hash construction where the
outer hash's input is prefixed with a value derived from the key, so an attacker extending the
inner hash's output has no way to continue the outer hash without knowing the key.
Structured elaboration
- Why the naive construction breaks: a Merkle-Damgard hash processes input in fixed-size
blocks, updating an internal state block by block, and the final output is simply that
internal state after the last block plus padding. GivenH(key || message)and the length
ofkey || message, an attacker knows exactly what padding was applied and can treat the
published hash as the internal state at that point, then keep hashing forward with any
additional blocks they choose. The result is a validH(key || message || padding || extension)for an attacker-chosen extension, with the key never touched at any point. - HMAC's nested construction:
HMAC(K, m) = H( (K' xor opad) || H( (K' xor ipad) || m ) ),
whereK'is the key padded (or, if too long, first hashed) to the hash's block size,ipad
is the byte0x36repeated to block length, andopadis the byte0x5crepeated to block
length. The message is hashed once with a key-derived prefix (the inner hash), and that
entire result is hashed again with a different key-derived prefix (the outer hash). - Why this defeats length extension: length extension lets an attacker continue a hash
computation from a known output, but it does not let them start a new hash computation
whose first block they don't know. The outer hash's input begins withK' xor opad, a value
that depends on the secret key; an attacker who performs length extension on the inner
hash's output still cannot reconstruct or extend the outer hash, because they never had
K' xor opadto begin with. The double nesting, not just prepending the key once, is what
closes the gap.
Worked example
A simplified toy illustrates the core idea without needing a real hash implementation. Say a
toy "hash" processes one block at a time by adding it into a running state modulo 256, and
the final state is the output (this mirrors, in miniature, how a Merkle-Damgard hash's output
is its last internal state). Let the secret key be block value 200 and the message be two
blocks, 10 and 5. Processing key || message: state starts at 0, then
0 + 200 = 200, then 200 + 10 = 210, then 210 + 5 = 215. The published "hash" is 215.
An attacker who never learned the key 200 can still continue from the published state: to
append an extension block of 50, they compute 215 + 50 = 265, and 265 mod 256 = 9. That
9 is exactly what hashing key || message || 50 would have produced, computed without ever
knowing 200. Real Merkle-Damgard hashes add a padding block in between that the attacker
must account for using the known message length, but the vulnerability is the same one this
toy exposes: the output does not hide the internal state, it is the internal state, so
anyone who sees it can keep going.
Trade-offs & pitfalls
- A common near-miss is
hash(message || key)(key at the end) instead of at the start; this
avoids classic length-extension but has its own weaknesses and is still not equivalent to
HMAC's proven construction, so it is not a safe substitute. - HMAC's security can be reduced to weaker, more provable assumptions about the underlying
compression function than "the hash behaves like a random oracle," which is part of why it
is the standard rather than an ad hoc double-hash of your own design. - SHA-3, unlike MD5/SHA-1/SHA-2, is not built on the Merkle-Damgard structure and is not
vulnerable to this specific length-extension attack even in the naivehash(key || message)form, though HMAC (or SHA-3's own keyed mode) is still the standard, well-analyzed
choice rather than relying on that property alone.
Unlock Full Question Bank
Get access to all 44 Cryptography Fundamentals interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.