Asymmetric Encryption and Key Exchange Questions
The construction and mathematics-adjacent mechanics of public-key (asymmetric) cryptography: how RSA, Diffie-Hellman, and elliptic-curve schemes actually work, including the group law and point-arithmetic formulas, scalar-multiplication algorithms (double-and-add, Montgomery ladder, windowed methods, GLV, multi-scalar batching), curve models and coordinate systems, and the hardness assumptions (integer factorization, discrete log, ECDLP) each scheme rests on. Covers key-establishment and authenticated key-exchange protocol design: forward-secrecy mechanics, key confirmation, downgrade protection, key-derivation and context binding, group and multi-party key agreement, and hybrid classical/post-quantum key-exchange composition. Also covers implementation-level attacks against these primitives and their mitigations: timing and side-channel leakage in modular exponentiation and scalar multiplication, invalid-curve and small-subgroup attacks, fault attacks, and padding-oracle attacks. Distinct from selecting, deploying, and operating these primitives in production: PKI certificate lifecycle, CA hierarchy, revocation, and key storage and rotation belong to applied cryptography and key management.
What is key confirmation in a key-exchange protocol? Give two mechanisms for mutual key confirmation (for example, a MAC over the transcript using the derived key, and an explicit signature over the key material) and explain how key confirmation prevents certain active and reflection attacks.
Sample Answer
Direct answer
Key confirmation is the step, after a key-agreement protocol has produced a shared secret, where each party proves to the other it actually holds a matching copy of that secret. It closes the gap between "we agreed on some value" and "we agreed on the same value, with the party we think we're talking to."
Structured elaboration
Why agreement alone isn't enough. A Diffie-Hellman-style exchange can complete successfully and still leave open questions neither side can otherwise answer: did the values actually reach the intended peer unmodified, and did the peer derive the exact same key bytes? Even under an authenticated key exchange (AKE, one where the exchanged public values are tied to verified identities), a subtle protocol bug or role-confusion issue can still leave two parties holding different derived keys without either one noticing until something later fails to decrypt.
Mechanism 1: MAC over the transcript. Each side computes a message authentication code (MAC) keyed by the derived secret, over the full handshake transcript plus a role identifier: MACK(transcript∥role), and sends it to the peer. The peer recomputes the same value independently and compares; a match proves possession of K and confirms the transcript wasn't tampered with along the way.
Mechanism 2: explicit signature over the key material. Each party signs a message covering the session's key material or transcript with its own long-term signing key: Sigsk("confirm"∥K∥transcript). This ties key possession to a pre-existing, externally verified identity rather than relying solely on a freshly derived MAC key.
Preventing reflection attacks. A reflection attack works by taking one party's own outgoing confirmation message and replaying it straight back at them, hoping they'll accept it as if it came from the peer. Including an explicit role identifier (literally "initiator" or "responder", or each party's own identity) inside the MAC or signature input defeats this directly: a reflected message carries the wrong role tag and fails verification.
Preventing active tampering. Because the confirmation value depends on the entire transcript, every negotiated parameter, nonce, and exchanged public value, any active tampering earlier in the handshake changes the transcript and therefore changes the expected confirmation value. An attacker who tampered without knowing the derived key cannot produce a matching confirmation, so the honest party detects the mismatch and aborts.
Worked example
import hmac, hashlib
K = bytes.fromhex("5f" * 32) # the derived session key both sides should now share
transcript = b"pinned-illustrative-transcript-nonceA-nonceB-pubA-pubB"
mac_responder = hmac.new(K, transcript + b"|responder", hashlib.sha256).digest()
mac_initiator = hmac.new(K, transcript + b"|initiator", hashlib.sha256).digest()
print("MAC_K(transcript || \'responder\') =", mac_responder.hex())
print("MAC_K(transcript || \'initiator\') =", mac_initiator.hex())
print("replaying responder\'s MAC back tagged as initiator verifies:", mac_responder == mac_initiator)
Running this prints:
MAC_K(transcript || 'responder') = 7d07915c8d3f5c69c02fc510798022aa90bc52e7eae461cd40ab5aed9198f709
MAC_K(transcript || 'initiator') = 8343127362cc2e76b6ae51404f15f62132ccb7d210774ee007261554f457d1c1
replaying responder's MAC back tagged as initiator verifies: False
The responder's genuine confirmation value does not match what an initiator-tagged confirmation should be, confirming that an attacker who captures one party's confirmation and tries to replay it back as if it came from the other side is caught immediately, exactly the reflection defense the role tag is there to provide.
Trade-offs and pitfalls
Confirmation has to be mutual: if only one side confirms, the other side's liveness and identity are never actually proven, and an unbalanced protocol can itself become a new reflection or unknown-key-share vector. Confirmation typically doesn't need an extra round trip, well-designed protocols fold it into a message that was already planned (Transport Layer Security, TLS, is a good example: its Finished messages do exactly this). Confirming too early, before the transcript is fully fixed, defeats the purpose, since any later message wouldn't be covered by the confirmation and could still be tampered with undetected.
Explain why RSA encryption requires padding and what OAEP (Optimal Asymmetric Encryption Padding) provides. Describe at a high level how OAEP encoding and decoding protect against chosen-ciphertext attacks and why deterministic RSA is dangerous.
Sample Answer
Direct answer
Raw ("textbook") RSA is deterministic and algebraically structured, the same plaintext always produces the same ciphertext, and ciphertexts can be combined algebraically without the private key, so it leaks equality and structure on its own. Optimal Asymmetric Encryption Padding (OAEP) randomizes the message before the RSA exponentiation and adds a redundancy check so any tampering fails decoding uniformly, which is what makes RSA-OAEP resistant to chosen-ciphertext attacks.
Structured elaboration
Why padding is needed at all. With raw RSA, c=memodn: identical messages give identical ciphertexts, letting an attacker with a small set of candidate messages just try encrypting each one and compare. Worse, RSA is multiplicatively malleable: m1e⋅m2e≡(m1m2)e(modn), so an attacker can combine known ciphertexts to produce a new, valid ciphertext of a related message without ever touching the private key.
How OAEP works, at a high level. OAEP masks the message using a mask generation function (MGF, typically built from a cryptographic hash function via a construction called MGF1) driven by a fresh random seed, in two linked passes:
maskedDB=DB⊕MGF(seed),maskedSeed=seed⊕MGF(maskedDB)Here DB (data block) packs the message together with a fixed delimiter and zero-padding. Because maskedDB depends on the random seed, and maskedSeed depends on maskedDB, encoding the same message twice with two different random seeds produces two completely unrelated-looking encoded blocks.
Why this resists chosen-ciphertext attacks. Decoding reverses both masks and then checks that the recovered structure (the fixed delimiter, expected zero-padding) is intact. Because that check depends on both masks matching correctly, an attacker who flips bits in a ciphertext and resubmits it produces garbage that fails the check with overwhelming probability. A correctly implemented decryption routine can then return one generic "invalid" answer, without leaking where it became invalid, denying the attacker the bit-by-bit oracle a scheme without this structure would hand them, this is exactly the leverage an adaptive chosen-ciphertext attack against the older, non-randomized PKCS#1 v1.5 padding scheme (PKCS, Public-Key Cryptography Standards, a family of RSA conventions) exploits when a server's error responses are NOT uniform.
Why deterministic RSA is dangerous, concretely. Without a random seed, identical plaintexts always produce identical ciphertexts (breaking semantic security), and for a small enough message, me<n can hold literally, meaning c is me with no modular reduction at all, and an ordinary integer e-th root recovers m directly, no factoring required.
Worked example
Encoding the same two-byte message "OK" under two different random seeds, using MGF1-SHA256 (a toy 96-byte block size for illustration, real RSA-2048 uses a 256-byte block, the masking mechanics are identical regardless of size):
import hashlib
def mgf1(seed, length, hash_func=hashlib.sha256):
hlen = hash_func().digest_size
out = b""
counter = 0
while len(out) < length:
out += hash_func(seed + counter.to_bytes(4, "big")).digest()
counter += 1
return out[:length]
def xor_bytes(a, b):
return bytes(x ^ y for x, y in zip(a, b))
def oaep_encode_toy(message, seed, k=96, hlen=32):
ps_len = k - len(message) - 2 * hlen - 2
DB = hashlib.sha256(b"label").digest() + bytes(ps_len) + b"\x01" + message
dbMask = mgf1(seed, len(DB))
maskedDB = xor_bytes(DB, dbMask)
seedMask = mgf1(maskedDB, hlen)
maskedSeed = xor_bytes(seed, seedMask)
return b"\x00" + maskedSeed + maskedDB
message = b"OK"
seed_a = bytes.fromhex("11" * 32)
seed_b = bytes.fromhex("22" * 32)
enc_a = oaep_encode_toy(message, seed_a)
enc_b = oaep_encode_toy(message, seed_b)
print("encoding A:", enc_a.hex())
print("encoding B:", enc_b.hex())
print("encodings differ:", enc_a != enc_b)
Running this prints:
encoding A: 000812e508c07e6ce7631bd0572a072bd9185abd4ee5d6733dcb32edac8f50156d1d954e7114d0dabb1e319be8d336d5dcc2308dae0d9ac658fb633751cf24766ca1dfbd1c519b9323fd7fd8e498ac16c2e502f05929306962da2407bf3692b7
encoding B: 00b2f8f7b2fc3aa4e7786b685d0ab15b2bff5c5591aa4cb0bd0a7fff77696e37724ff3d453e8ecc244f8e8a841336cabd2da7916497a7ae46287f239625d7124a2eb3beba53fc6649081e8d84495d16b339a2551b32c95c882aeb08a7084324a
encodings differ: True
Same message, same masking algorithm, two completely different encoded blocks, exactly the property that stops an attacker from testing plaintext guesses by comparing ciphertexts.
Trade-offs and pitfalls
OAEP's redundancy check must be rejected uniformly, same error, same timing, regardless of where or why it failed, or the check itself becomes a new oracle; this is precisely the class of implementation bug that later broke real deployments of RSA's older, non-randomized PKCS#1 v1.5 padding scheme. OAEP also does not make RSA fast enough to encrypt large payloads directly, in practice it is combined with symmetric encryption (hybrid encryption: use RSA-OAEP only to wrap a fresh, random symmetric key, then use that symmetric key for the actual payload), rather than trying to pad an entire large message.
Describe in detail the classes of side-channel and fault-injection attacks that can extract private keys during scalar multiplication (simple power analysis, differential power analysis, injection of faults to induce incorrect curve operations). For each class, propose layered mitigations covering algorithmic changes, hardware features, detection, and protocol-level countermeasures.
Sample Answer
Direct answer
Simple power analysis (SPA) recovers a private scalar by observing a SINGLE power trace and reading off which operations happened when, if the sequence of doublings and additions differs based on the scalar's bits. Differential power analysis (DPA) is more powerful still: it statistically correlates MANY traces (across many operations using the same secret) against a hypothesized intermediate value, which can succeed even against implementations whose operation sequence is constant, by exploiting data-dependent power consumption WITHIN a fixed-shape operation. Fault-injection attacks are a third, active class: deliberately corrupt a computation (via voltage glitching, clock glitching, or laser fault injection) and read the private key out of the RESULTING incorrect output. Layered defenses are needed because these three classes attack different assumptions.
Structured elaboration
- Simple power analysis (SPA). The classic vulnerability is textbook double-and-add: doublings happen every bit, additions only on 1-bits, so the OPERATION SEQUENCE ITSELF, visible as distinguishable power-trace shapes even from a SINGLE trace, directly reveals the scalar's bit pattern.
- Mitigation: make the operation sequence data-INDEPENDENT. A Montgomery ladder performs exactly one "double-then-add" pair per bit regardless of its value, so the trace shape is identical no matter what the scalar is.
- Differential power analysis (DPA). Even with a constant-shape ladder, the ACTUAL DATA values being multiplied differ based on secret bits, and DPA exploits that via statistical correlation across many traces (correlating hypothesized intermediate values against measured power, a technique closely related to correlation power analysis, CPA). A single trace tells an attacker nothing under DPA's threat model; hundreds to thousands of traces against the SAME secret, statistically averaged, can.
- Mitigation: randomize what the attacker is correlating AGAINST, changing every run: scalar blinding (adding a random multiple of the group order) and point/coordinate blinding (re-randomizing the Jacobian representation so the literal coordinate values differ every run even though the affine point does not). If the underlying data differs unpredictably every trace, the statistical correlation DPA depends on breaks down.
- Fault injection. Rather than passively observing, an attacker actively corrupts computation (glitching a clock edge, spiking supply voltage, or targeting a specific gate with a laser) to induce a WRONG intermediate result, then reads the private key out of the resulting faulty signature or output (this is the same family of attack as a Bellcore-style RSA-CRT fault attack (corrupting one modular exponentiation of an RSA-CRT signature to expose a prime factor via a gcd), applied here to EC scalar multiplication instead).
- Mitigation is layered, because no single check catches every fault: algorithmic redundancy (compute the result twice, independently, and compare before releasing it; or verify the output point actually lies back on the curve and in the correct subgroup before ever using or exporting it), hardware features (voltage/clock/temperature sensors that halt operation on out-of-spec conditions, memory integrity checks), detection (differential computation analysis, DCA: the same statistical-correlation idea as DPA, but applied to internal computation traces, such as memory-access patterns or intermediate register values captured directly from the device or a simulation of it, rather than to an externally-measured power signal; techniques built INTO the crypto library), and protocol-level countermeasures (never releasing a signature or shared secret computed from unvalidated intermediate state, and rate-limiting or alerting on repeated computation failures, which is itself a detectable fingerprint of an ongoing fault-injection campaign).
Worked example
I traced the actual operation SEQUENCE (not power measurements, since we cannot capture real hardware traces here, but the sequence of double/add operations a real trace would reveal the SHAPE of) for two same-bit-length scalars under plain double-and-add versus a Montgomery ladder, on a small curve over F10007:
# Simple power analysis (SPA) trace comparison: double-and-add vs the Montgomery ladder.
# Curve: y^2 = x^3 + a*x + b over F_p, short Weierstrass, a = -3 (matches NIST-style curves)
p = 10007 # small prime field, large enough for a healthy-sized group
a = -3 % p
b = 31
def inv(x):
return pow(x, p-2, p) # Fermat inverse, since p is prime
# ---- Affine arithmetic (needs one modular inverse per add/double) ----
def affine_add(P, Q):
if P is None: return Q
if Q is None: return P
x1,y1 = P; x2,y2 = Q
if x1 == x2 and (y1 + y2) % p == 0:
return None
if P == Q:
lam = (3*x1*x1 + a) * inv(2*y1) % p
else:
lam = (y2 - y1) * inv(x2 - x1) % p
x3 = (lam*lam - x1 - x2) % p
y3 = (lam*(x1 - x3) - y1) % p
return (x3, y3)
def affine_mul(k, P):
R = None
Q = P
while k > 0:
if k & 1:
R = affine_add(R, Q)
Q = affine_add(Q, Q)
k >>= 1
return R
# ---- script body ----
def traced_double_and_add(k, P):
"""Textbook left-to-right double-and-add. Records the operation performed per bit."""
trace = []
R = None
for bit in bin(k)[2:]:
trace.append("D")
R = affine_add(R, R) if R is not None else None
if bit == "1":
trace.append("A")
R = affine_add(R, P)
return R, trace
def traced_montgomery_ladder(k, P):
"""Montgomery ladder: does a double AND an add every single bit, regardless of its value."""
trace = []
R0, R1 = None, P
for bit in bin(k)[2:]:
trace.append("D+A") # exactly one doubling and one (dummy-or-real) addition, every bit
if bit == "0":
R1 = affine_add(R0, R1)
R0 = affine_add(R0, R0)
else:
R0 = affine_add(R0, R1)
R1 = affine_add(R1, R1)
return R0, trace
if __name__ == "__main__":
P = (2, 283)
k1 = 0b10110 # 22
k2 = 0b11111 # 31, same bit-length, all-ones
for k in (k1, k2):
R_da, trace_da = traced_double_and_add(k, P)
R_ml, trace_ml = traced_montgomery_ladder(k, P)
assert R_da == affine_mul(k, P) == R_ml
print(f"k = {bin(k)} ({k})")
print(f" double-and-add op sequence: {' '.join(trace_da)} (length {len(trace_da)})")
print(f" Montgomery ladder sequence: {' '.join(trace_ml)} (length {len(trace_ml)})")
print()
print("Observation: double-and-add's sequence LENGTH and SHAPE both change with the bit pattern")
print(f" (k1={k1} produced {len(traced_double_and_add(k1,P)[1])} ops, k2={k2} produced {len(traced_double_and_add(k2,P)[1])} ops)")
print("while the Montgomery ladder emits exactly one 'double-then-add' pair per bit no matter what")
print(f" (both k1 and k2 produced {len(traced_montgomery_ladder(k1,P)[1])} ops: fixed at bit-length, not popcount).")
Output:
k = 0b10110 (22)
double-and-add op sequence: D A D D A D A D (length 8)
Montgomery ladder sequence: D+A D+A D+A D+A D+A (length 5)
k = 0b11111 (31)
double-and-add op sequence: D A D A D A D A D A (length 10)
Montgomery ladder sequence: D+A D+A D+A D+A D+A (length 5)
Observation: double-and-add's sequence LENGTH and SHAPE both change with the bit pattern
(k1=22 produced 8 ops, k2=31 produced 10 ops)
while the Montgomery ladder emits exactly one 'double-then-add' pair per bit no matter what
(both k1 and k2 produced 5 ops: fixed at bit-length, not popcount).
Double-and-add's trace LENGTH changes with the scalar's Hamming weight (8 operations for one 5-bit scalar, 10 for another same-length scalar with more 1-bits), which is exactly the kind of length/shape difference SPA reads directly off a single trace. The Montgomery ladder produces IDENTICAL trace length and shape for both scalars (5 "double-then-add" pairs each, fixed at bit-length regardless of the actual bit values), removing that specific SPA leak entirely, though as noted above it does NOT by itself defend against DPA, which looks at the DATA within each fixed-shape step rather than the shape itself.
Trade-offs and pitfalls
- A fix for SPA is not a fix for DPA, and neither is a fix for fault injection. Treating "constant-time ladder" as a complete side-channel solution is the single most common mistake; each class of attack needs ITS OWN countermeasure layered on top of the others, not a single silver-bullet fix.
- Constant-shape countermeasures cost real performance: a Montgomery ladder does a "wasted" dummy operation on every 0-bit that plain double-and-add would have skipped, and blinding adds extra scalar bits and re-randomization overhead on every single operation.
- Detection and hardware defenses require capabilities software alone doesn't have. A pure-software EC library cannot detect a voltage glitch; that requires hardware sensors, which is why physically-exposed deployments (smart cards, HSMs, IoT secure elements) budget for dedicated hardware countermeasures that a purely cloud-side server implementation does not need to consider.
A library you're reviewing computes an ECDH shared secret straight from whatever public key a peer sends, with no validation at all before use. Walk through the full set of checks you'd insist go in before that value is trusted, from confirming the point actually belongs to the curve through to how you'd handle its subgroup, and for each one, name the specific class of attack it closes off.
Sample Answer
Direct answer
Before ever using a received Elliptic Curve Diffie-Hellman (ECDH) public value to derive a shared secret, I would check that it decodes to coordinates actually in range, that it truly satisfies this curve's equation, that it isn't the group's identity element, and that it lies in the correct large-prime-order subgroup, or is cofactor-cleared into it. Skipping any one of these hands an active attacker a lever, either to crash the exchange or to leak bits of the static private key one exchange at a time.
Structured elaboration
| Check | What it verifies | Attack it closes |
|---|---|---|
| Coordinate-range / decode | x,y are valid field elements in range for the chosen encoding | Malformed-input crashes and parser confusion that could bypass the checks below entirely |
| Point-on-curve | The point satisfies this curve's equation | Invalid-curve attacks: a peer supplies a point that is valid on a different, weaker curve sharing the same field, one where the attacker already knows the discrete-log structure |
| Not the identity element | The point is not the group's identity (informally, "point at infinity") | A trivially forced shared secret (the identity itself), enabling denial-of-service or degenerate key confirmation |
| Subgroup / cofactor | The point lies in the intended large-prime-order subgroup, verified directly ([n]P=O) or enforced via cofactor multiplication before deriving the secret | Small-subgroup confinement: an attacker supplies a low-order point and, from the handful of possible shared secrets that can result, learns bits of the static private key across repeated exchanges |
Worked example
The small-subgroup leak, made concrete on a small illustrative curve y2=x3+2x+3mod97 (nowhere near production size, but the mechanics are identical at real curve sizes):
p, a, b = 97, 2, 3 # small illustrative curve: y^2 = x^3 + 2x + 3 mod 97
def on_curve(P):
if P is None: return True
x, y = P
return (y*y - (x**3 + a*x + b)) % p == 0
def ec_add(P, Q):
if P is None: return Q
if Q is None: return P
x1, y1 = P; x2, y2 = Q
if x1 == x2 and (y1 + y2) % p == 0:
return None
if P == Q:
lam = (3*x1*x1 + a) * pow(2*y1, -1, p) % p
else:
lam = (y2 - y1) * pow((x2 - x1) % p, -1, p) % p
x3 = (lam*lam - x1 - x2) % p
y3 = (lam*(x1 - x3) - y1) % p
return (x3, y3)
# points with y = 0 have order exactly 2 (they are their own additive inverse)
order2 = [(x, 0) for x in range(p) if (0 - (x**3 + a*x + b)) % p == 0]
T = order2[0]
print("T =", T, " on_curve(T) =", on_curve(T), " 2*T =", ec_add(T, T))
def ec_mul(k, P):
R, Q = None, P
while k > 0:
if k & 1: R = ec_add(R, Q)
Q = ec_add(Q, Q)
k >>= 1
return R
for k in [7, 8, 13, 20]:
print("k =", k, "-> k*T =", ec_mul(k, T), " (k is", "odd" if k % 2 else "even", ")")
# invalid-curve check: a point that satisfies a DIFFERENT curve (a=5, b=9), not this one
for x in range(p):
rhs2 = (x**3 + 5*x + 9) % p
hit = None
for y in range(p):
if (y*y) % p == rhs2:
hit = (x, y); break
if hit:
print("point valid on a DIFFERENT curve:", hit, " on_curve(this curve) =", on_curve(hit))
break
Running this prints:
T = (30, 0) on_curve(T) = True 2*T = None
k = 7 -> k*T = (30, 0) (k is odd )
k = 8 -> k*T = None (k is even )
k = 13 -> k*T = (30, 0) (k is odd )
k = 20 -> k*T = None (k is even )
point valid on a DIFFERENT curve: (0, 3) on_curve(this curve) = False
The point T genuinely lies on the curve (its coordinates satisfy the curve equation) and has order exactly 2, adding it to itself gives the group's identity element. An implementation that skips the subgroup check and just multiplies whatever point it receives by its private scalar k would compute k*T, and that result is either the identity element or T, depending only on whether k is even or odd, handing the attacker one bit of the private key per malicious exchange. The last two lines show the complementary invalid-curve check: a point that is perfectly valid on a different curve (with different curve parameters a,b) fails the on-curve equation for the real one, exactly what the on-curve check exists to catch before it ever reaches the shared-secret computation.
Trade-offs and pitfalls
The small-subgroup leak above is most dangerous against a static, long-lived private key, repeating the attack with different low-order points across many exchanges eventually recovers the whole key; a one-time ephemeral private key limits the attacker to a single leaked bit before it's discarded, still worth preventing, but a much smaller blast radius. Cofactor multiplication (multiplying the received point by the curve's cofactor before using it) is cheaper than an explicit subgroup-order check, but it only closes the leak if every implementation on both sides applies it consistently. Curves purpose-built with a small, fixed cofactor and a Montgomery-ladder scalar multiplication (like Curve25519) bake correct handling into key generation itself via scalar "clamping", which makes most of this validation close to automatic, but that convenience is curve-specific and should never be assumed to generalize to an arbitrary Weierstrass curve.
Explain step-by-step how RSA key generation works for a 2048-bit key. Include selecting strong primes p and q, computing modulus n = p * q, computing phi(n) (or lambda), choosing a public exponent e, computing the private exponent d as modular inverse, and producing the public/private key pair. Describe necessary checks for prime quality, randomness sources, CRT parameters, and common pitfalls such as low entropy or small primes.
Sample Answer
Direct answer
RSA key generation comes down to picking two large random primes, multiplying them to get the modulus, computing a value that describes the group structure of that modulus, picking a public exponent, and inverting it to get the private exponent. At 2048-bit scale, the actual arithmetic is the easy part, what separates a secure key from a broken one is the quality of the randomness and the independence of the two primes.
Structured elaboration
- Select strong primes p,q (each roughly 1024 bits, so their product is roughly 2048 bits). Generate candidates from a cryptographically secure pseudorandom number generator (CSPRNG, a random source designed so its output is computationally indistinguishable from true randomness, e.g. the operating system's own CSPRNG), then test primality (typically several rounds of the Miller-Rabin probabilistic test). Reject candidates that are too close together (∣p−q∣ too small invites Fermat factorization) and make sure p=q.
- Compute the modulus.
- Compute the totient (or Carmichael function).
Using λ(n) (Carmichael's function) instead of Euler's φ(n) gives a smaller valid private exponent and is what modern implementations typically use.
4. Choose a public exponent e with gcd(e,φ(n))=1. The near-universal choice is e=65537, a Fermat prime large enough to resist small-exponent attacks (broadcast and low-message attacks) while still being fast to compute with (its binary form has only two set bits).
5. Compute the private exponent as the modular inverse of e:
- Compute Chinese Remainder Theorem (CRT) parameters used to speed up private-key operations: dp=dmod(p−1), dq=dmod(q−1), qinv=q−1modp. Decrypting with two half-sized modular exponentiations (one mod p, one mod q) and recombining is commonly cited as roughly four times faster than one full-sized exponentiation mod n.
- Sanity checks. Confirm e⋅d≡1(modφ(n)), confirm d is not suspiciously small (a small d is recoverable directly via Wiener's attack), and zeroize the intermediate values (p, q, φ(n)) once the key material derived from them is safely stored.
Worked example
A tiny, textbook-scale example to show the arithmetic (nowhere near 2048-bit, but every step is the same shape):
p, q = 61, 53
n = p * q # 3233
phi = (p - 1) * (q - 1) # 3120
e = 17
d = pow(e, -1, phi) # modular inverse of e mod phi
m = 65
c = pow(m, e, n) # encrypt
m_recovered = pow(c, d, n) # decrypt
print(n, phi, d, c, m_recovered)
Running this prints n=3233 phi=3120 d=2753 c=2790 m_recovered=65, matching the original message m=65 exactly, confirming e⋅d≡1(modφ(n)) holds and the encrypt/decrypt round-trip is correct.
Trade-offs and pitfalls
The dominant real-world failure mode is not the arithmetic, it is entropy. A widely cited 2012 academic study (Heninger et al., "Mining Your Ps and Qs") found that RSA keys generated on embedded devices with insufficient boot-time entropy sometimes shared a prime factor with keys generated on completely different devices, which lets anyone compute a private key from nothing but a single GCD (greatest common divisor) between two public moduli, no factoring algorithm required. Separately, a 2017 vulnerability nicknamed ROCA affected an RSA key-generation library used in certain smart cards and security chips, which generated primes with a recognizable mathematical structure that made factoring practically feasible even at 2048-bit key sizes. Neither failure is visible from the public key alone in the way a "weak e" or "small d" check would catch it, which is exactly why they went undetected for years.
Unlock Full Question Bank
Get access to all 15 Asymmetric Encryption and Key Exchange interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.