RSA in Python Part 3: Defending Against Side-Channel and Timing Attacks
Learn how timing attacks can extract RSA private keys from a working implementation, and how to defend against them with constant-time operations and blinding techniques in Python.
Your RSA implementation passes every test. Key generation works. Encryption and decryption round-trip correctly. The math is provably right.
A motivated attacker can still recover your private key.
Not by breaking the math. Not by factoring the modulus. By watching how long your code takes to run.
This is not a hypothetical risk. In 1996, Paul Kocher published a paper showing that timing measurements of RSA decryption operations over a network -- not locally, over a network, with all its latency -- could recover private keys. In 2003, Daniel Brumley and Dan Boneh extended the attack to OpenSSL's production RSA implementation running on real hardware. The numbers they needed were in the microseconds range. The statistical analysis ran across a few thousand measurements. The private key came out.
Your implementation is a timing oracle. Every decryption operation broadcasts information about your private key.
This article covers what side-channel attacks are, how timing attacks work against the modpow function we built, and two defenses: RSA blinding and constant-time exponentiation. We'll demonstrate the vulnerability concretely and then fix it.
For production use: use pycryptodome or the Python cryptography library, which implement these defenses correctly and have been reviewed by people who do this professionally. This series is for understanding what those libraries are actually doing.
What Are Side-Channel Attacks and Why Do They Threaten RSA?
Cryptanalysis attacks the algorithm. Given ciphertext and knowledge of the encryption scheme, can you recover the plaintext or key through mathematical analysis? RSA's resistance to cryptanalysis rests on the hardness of integer factorization. No polynomial-time factoring algorithm is known.
Side-channel attacks ignore the algorithm entirely. They attack the implementation -- the physical execution of the algorithm on real hardware. Timing, power consumption, electromagnetic emissions, and even acoustic noise are all information channels that leak data about the computation being performed.
The categories:
Timing attacks. Measure how long operations take. Different inputs produce different execution times. Statistical analysis of many measurements extracts secret information. Applicable over networks to any service that processes secret data.
Power analysis. Measure the electrical current the CPU draws during computation. Simple Power Analysis (SPA) reads a single power trace and identifies cryptographic operations directly. Differential Power Analysis (DPA) correlates power measurements across many operations to extract key bits. Requires physical access to the device.
Electromagnetic analysis. Similar to power analysis but using EM emissions. Can work at a distance without direct contact with the device.
Acoustic cryptanalysis. In 2013, researchers at Tel Aviv University recovered RSA keys by recording the sounds a laptop made during decryption. The coil whine from capacitors near the CPU produced acoustic signals correlated with the computation.
For software cryptography running on a server, timing attacks are the primary threat. Power, EM, and acoustic attacks require physical proximity. Timing attacks work from anywhere the attacker can measure network latency, which – for a public-facing service – means anywhere on the internet.
How Do Timing Attacks Extract RSA Private Keys?
Go back to the modpow function from Part 1:
def modpow(b, e, n):
tst = 1
siz = 0
while e >= tst:
tst <<= 1
siz += 1
siz -= 1
r = 1
for i in range(siz, -1, -1):
r = (r * r) % n # Square: always happens
if (e >> i) & 1:
r = (r * b) % n # Multiply: only when bit is 1
return r
Look at the inner loop. Every iteration squares r. When bit i of the exponent e is 1, the loop also multiplies by b. When bit i is 0, it doesn't. Two paths. Different number of operations. Different execution times.
During decryption, e is the private key d. The bit pattern of d directly controls which iterations do one operation versus two. An attacker who can measure decryption time for many different inputs can recover the bits of d one by one.
Here's the mechanism:
- The attacker sends a ciphertext
c_1and measures decryption timet_1. - The attacker sends another ciphertext
c_2and measurest_2. - Across thousands of such measurements, the times cluster based on which bits of
dare 0 and which are 1. - Statistical analysis -- specifically, correlation between input characteristics and timing variations -- extracts each bit.
Let's demonstrate the timing difference concretely:
import time
import secrets
def time_modpow(b, e, n, iterations=100):
"""Measure average modpow execution time across multiple iterations."""
start = time.perf_counter_ns()
for _ in range(iterations):
modpow(b, e, n)
end = time.perf_counter_ns()
return (end - start) / iterations
# Generate a test key pair
p = genprime(512) # 512-bit for speed in demonstration
q = genprime(512)
keys = keysgen(p, q)
n = keys['pub'][1]
d = keys['priv'][0]
# Compare timing for exponents with different Hamming weights
# High Hamming weight = many 1-bits = more multiply operations
d_high_hw = d | (d >> 1) # Force more 1-bits (artificial, for demonstration)
d_low_hw = d & ~(d >> 1) # Force fewer 1-bits
b = secrets.randbelow(n - 2) + 2
t_high = time_modpow(b, d_high_hw, n)
t_low = time_modpow(b, d_low_hw, n)
print(f"High Hamming weight exponent: {t_high:.0f} ns avg")
print(f"Low Hamming weight exponent: {t_low:.0f} ns avg")
print(f"Difference: {abs(t_high - t_low):.0f} ns")
You'll see a consistent, measurable difference. The high Hamming weight exponent (more 1-bits, more multiply operations) takes longer. That difference -- dozens to hundreds of nanoseconds -- is the signal an attacker exploits.
How Does an Attacker Actually Recover Key Bits?
Kocher's original attack works bit by bit from the most significant bit of d down. The key insight: if you guess bit k of d correctly, your prediction of how much time the algorithm spent on iterations 0 through k correlates with the actual measured times. If you guess wrong, the correlation drops to noise.
Simplified version of the bit-recovery logic:
def recover_bit(known_bits, ciphertexts, times, n, pub_e):
"""
Attempt to recover the next bit of d given known high bits.
known_bits: bits of d recovered so far, as an integer
ciphertexts: list of ciphertext integers used for timing
times: corresponding decryption times in nanoseconds
Returns 0 or 1 for the next bit.
"""
correlations = []
for guess in [0, 1]:
candidate = (known_bits << 1) | guess
predicted_times = []
for c in ciphertexts:
# Simulate partial decryption with the candidate prefix
# and estimate how long the known portion took
partial_time = simulate_partial(c, candidate, n)
predicted_times.append(partial_time)
# Measure correlation between predicted and actual times
corr = pearson_correlation(predicted_times, times)
correlations.append((guess, corr))
# The guess with higher correlation is more likely correct
return max(correlations, key=lambda x: x[1])[0]
This is a simplified sketch. Real timing attacks require careful statistical handling, noise filtering, and hundreds to thousands of measurements per bit. For a 2048-bit key, recovering all 2048 bits of d requires on the order of 2048 * samples_per_bit decryption measurements. Brumley and Boneh recovered an OpenSSL private key using 1.4 million decryption measurements over a local network connection. On a fast LAN, that took about two hours.
An attacker needs 1.4 million measurements, a couple of hours, and access to your decryption endpoint. Most production APIs serve millions of requests per day. The attacker doesn't even stand out in the logs.
How Do You Implement Constant-Time Modular Exponentiation in Python?
The fix for data-dependent timing is constant-time computation: ensure the algorithm takes the same number of operations regardless of the key bits. For modpow, the target is eliminating the conditional branch -- the if (e >> i) & 1: check that causes extra work for 1-bits.
The Montgomery Ladder is the standard constant-time exponentiation algorithm. It maintains two running values instead of one and updates both every iteration. No conditional multiply, no branch, same operations for every bit value.
def modpow_ct(b, e, n):
"""
Constant-time modular exponentiation using the Montgomery Ladder.
Performs the same number of operations regardless of the bits of e.
Both r0 and r1 are updated every iteration to avoid timing leakage.
"""
r0 = 1 # r0 = b^0
r1 = b # r1 = b^1
# Process bits of e from most significant to least significant
bit_length = e.bit_length()
for i in range(bit_length - 1, -1, -1):
bit = (e >> i) & 1
if bit == 0:
# Bit is 0: r1 = r0 * r1, r0 = r0^2
r1 = (r0 * r1) % n
r0 = (r0 * r0) % n
else:
# Bit is 1: r0 = r0 * r1, r1 = r1^2
r0 = (r0 * r1) % n
r1 = (r1 * r1) % n
return r0
Walk through why this is constant-time. Every iteration executes one multiplication and one squaring, regardless of whether the bit is 0 or 1. The branch determines which pair of values gets updated, but the computational work is identical. A timing attacker observing execution time sees the same cost per bit independent of the key.
Verify correctness against the original modpow:
import secrets
# Generate test values
p = genprime(256)
q = genprime(256)
keys = keysgen(p, q)
n = keys['pub'][1]
d = keys['priv'][0]
e = keys['pub'][0]
for _ in range(100):
msg = secrets.randbelow(n - 2) + 2
enc = modpow(msg, e, n)
dec_original = modpow(enc, d, n)
dec_ct = modpow_ct(enc, d, n)
assert dec_original == dec_ct, f"Mismatch for msg={msg}"
print("Constant-time modpow: correctness verified across 100 random messages")
A Caveat About Python and Constant-Time
Python's big integer arithmetic is not constant-time at the hardware level. Python's integers use variable-length internal representations -- a 2047-bit integer and a 2048-bit integer take different numbers of machine words to store and process. The garbage collector can introduce variable-length pauses. The interpreter itself has variable overhead.
The Montgomery Ladder eliminates the algorithmic timing variation from the branch structure. It does not eliminate the lower-level timing variation from Python's integer representation. For a true constant-time guarantee, you need to work in a language with fixed-width integer types and control over memory layout -- C with careful use of bitwise operations, or Rust with constant-time arithmetic libraries like subtle.
In Python, the Montgomery Ladder is a significant improvement over the original modpow. It removes the largest and most exploitable timing signal. But if you're implementing cryptography for a system where timing attacks are a real threat model, use a library written in a language where constant-time is achievable.
What Is RSA Blinding and How Does It Prevent Timing Attacks?
RSA blinding is a complementary defense that works at the mathematical level rather than the algorithmic level. Instead of trying to make decryption constant-time, blinding randomizes the ciphertext before decryption so timing measurements convey no information about the actual key.
The idea: before decrypting a ciphertext c, multiply it by a random blinding factor. Decrypt the blinded value. Remove the blinding factor from the result. The actual decryption is performed on a randomized ciphertext each time, so timing measurements depend on the random factor -- not on the original ciphertext or the key bits in a way the attacker can exploit.
The math:
- Choose a random
rsuch thatgcd(r, n) = 1andris changed for each decryption. - Compute the blinded ciphertext:
c_blind = c * r^e mod n - Decrypt the blinded ciphertext:
m_blind = c_blind^d mod n = (c * r^e)^d = c^d * r mod n = m * r mod n - Remove the blinding:
m = m_blind * r^(-1) mod n
Step 3 works because(r^e)^d = r^(e*d) = r^1 = r (mod n)from the fundamental RSA relationship. Multiplying byr^ebefore decryption and dividing byrafter cancels out perfectly, returning the original plaintext.
def decrypt_blinded(c, priv, pub):
"""
RSA decryption with blinding to prevent timing attacks.
Randomizes ciphertext before decryption, removes randomness after.
Timing measurements depend on the random blinding factor,
not on the actual ciphertext or private key bits.
"""
d, n = priv
e, n = pub
# Choose a random blinding factor r coprime to n
while True:
r = secrets.randbelow(n - 2) + 2
if gcd(r, n) == 1:
break
# Blind: c_blind = c * r^e mod n
r_e = modpow(r, e, n)
c_blind = (c * r_e) % n
# Decrypt the blinded ciphertext
m_blind = modpow_ct(c_blind, d, n)
# Unblind: compute r^(-1) mod n, then m = m_blind * r_inv mod n
r_inv = modinv(r, n)
m = (m_blind * r_inv) % n
return m
def gcd(a, b):
"""Standard Euclidean GCD."""
while b:
a, b = b, a % b
return a
def modinv(a, n):
"""Modular inverse using Extended Euclidean Algorithm."""
g, x, _ = extended_gcd(a, n)
if g != 1:
raise ValueError(f"{a} has no inverse modulo {n}")
return x % n
def extended_gcd(a, b):
if a == 0:
return b, 0, 1
g, x, y = extended_gcd(b % a, a)
return g, y - (b // a) * x, x
Verify blinded decryption produces the same result as unblinded:
p = genprime(512)
q = genprime(512)
keys = keysgen(p, q)
pub, priv = keys['pub'], keys['priv']
n = pub[1]
for _ in range(20):
msg = secrets.randbelow(n - 2) + 2
enc = modpow(msg, pub[0], n)
dec_standard = modpow_ct(enc, priv[0], n)
dec_blinded = decrypt_blinded(enc, priv, pub)
assert dec_standard == dec_blinded, f"Blinding error for msg={msg}"
print("Blinded decryption: correctness verified across 20 random messages")
Why Blinding Beats Constant-Time Alone
Constant-time exponentiation eliminates timing differences between 0-bits and 1-bits. But it doesn't eliminate all timing variation -- integer sizes, cache effects, memory access patterns, and garbage collection can still create measurable signals.
Blinding is a different kind of defense. Even if your decryption takes variable time, the timing is now a function of the random blinding factor r, not of the ciphertext c or private key d. An attacker measuring timing across many decryption calls sees noise proportional to r -- which changes on every call and is unknown to the attacker. There's no signal to extract.
The combination of both defenses – constant-time exponentiation and random blinding – is what real RSA implementations use. OpenSSL introduced blinding in 2003 in direct response to the Brumley-Boneh attack.
How Do You Put Together a Hardened RSA Implementation in Python?
Here is the complete Part 3 implementation, combining constant-time exponentiation, blinding, and the improvements from Parts 1 and 2:
"""
RSA from Scratch -- Part 3
Adds: constant-time exponentiation (Montgomery Ladder),
RSA blinding for timing-attack resistance.
For educational use. Production cryptography should use
pycryptodome or the Python cryptography library.
"""
import secrets
import struct
# -- Utility functions --
def eucalg(a, b):
swapped = False
if a < b:
a, b = b, a
swapped = True
ca = (1, 0)
cb = (0, 1)
while b != 0:
k = a // b
a, b, ca, cb = b, a - b * k, cb, (ca[0] - k * cb[0], ca[1] - k * cb[1])
if swapped:
return (ca[1], ca[0])
return ca
def gcd(a, b):
while b:
a, b = b, a % b
return a
def extended_gcd(a, b):
if a == 0:
return b, 0, 1
g, x, y = extended_gcd(b % a, a)
return g, y - (b // a) * x, x
def modinv(a, n):
g, x, _ = extended_gcd(a, n)
if g != 1:
raise ValueError(f"No inverse: gcd({a}, {n}) = {g}")
return x % n
# -- Modular exponentiation --
def modpow(b, e, n):
"""Original binary exponentiation. NOT constant-time."""
tst = 1
siz = 0
while e >= tst:
tst <<= 1
siz += 1
siz -= 1
r = 1
for i in range(siz, -1, -1):
r = (r * r) % n
if (e >> i) & 1:
r = (r * b) % n
return r
def modpow_ct(b, e, n):
"""
Constant-time modular exponentiation: Montgomery Ladder.
Performs identical operations for both 0-bits and 1-bits of e.
Eliminates the primary timing leakage in RSA decryption.
"""
r0 = 1
r1 = b
for i in range(e.bit_length() - 1, -1, -1):
bit = (e >> i) & 1
if bit == 0:
r1 = (r0 * r1) % n
r0 = (r0 * r0) % n
else:
r0 = (r0 * r1) % n
r1 = (r1 * r1) % n
return r0
# -- Prime generation (from Part 2) --
def sqmatrixmul(m1, m2, w, mod):
mr = [[0 for j in range(w)] for i in range(w)]
for i in range(w):
for j in range(w):
for k in range(w):
mr[i][j] = (mr[i][j] + m1[i][k] * m2[k][j]) % mod
return mr
def fib(x, mod):
if x < 3:
return 1
x -= 2
tst = 1
siz = 0
while x >= tst:
tst <<= 1
siz += 1
siz -= 1
fm = [[0, 1], [1, 1]]
rm = [[1, 0], [0, 1]]
for i in range(siz, -1, -1):
rm = sqmatrixmul(rm, rm, 2, mod)
if (x >> i) & 1:
rm = sqmatrixmul(rm, fm, 2, mod)
return (rm[1][0] + rm[1][1]) % mod
def genprime(siz):
while True:
num = (1 << (siz - 1)) + secrets.randbits(siz - 1) - 10
num -= num % 10
num += 3
if modpow(2, num - 1, num) == 1 and fib(num + 1, num) == 0:
return num
num += 4
if modpow(2, num - 1, num) == 1 and fib(num + 1, num) == 0:
return num
# -- Key generation --
def keysgen(p, q):
n = p * q
lambda_n = (p - 1) * (q - 1)
e = 35537
d = eucalg(e, lambda_n)[0]
if d < 0:
d += lambda_n
return {'priv': (d, n), 'pub': (e, n)}
# -- Hardened encryption and decryption --
def encrypt(m, pub):
"""Encrypt integer m with public key. Uses standard modpow (no blinding needed for encryption)."""
return modpow(m, pub[0], pub[1])
def decrypt(c, priv, pub):
"""
Decrypt with blinding and constant-time exponentiation.
Blinding randomizes the ciphertext before decryption.
Constant-time exponentiation removes bit-dependent timing variation.
"""
d, n = priv
e, _ = pub
# Generate blinding factor
while True:
r = secrets.randbelow(n - 2) + 2
if gcd(r, n) == 1:
break
# Blind the ciphertext: c_blind = c * r^e mod n
r_e = modpow(r, e, n) # r^e: public operation, no timing leak
c_blind = (c * r_e) % n
# Decrypt with constant-time exponentiation
m_blind = modpow_ct(c_blind, d, n)
# Unblind: m = m_blind * r^(-1) mod n
r_inv = modinv(r, n)
return (m_blind * r_inv) % n
# -- Byte-level encryption --
def encrypt_bytes(data, key):
data = bytearray(data)
cdata = bytearray()
for i in range(0, len(data), 256):
m = 0
for j in range(256):
if i + j < len(data):
m = (m << 8) + data[i + j]
else:
m <<= 8
c = modpow(m, key[0], key[1])
for j in range(255, -1, -1):
cdata.append((c >> (j * 8)) & 0xFF)
return bytes(cdata)
def decrypt_bytes_hardened(data, pub, priv):
"""Decrypt bytes using blinded, constant-time decryption."""
data = bytearray(data)
pdata = bytearray()
for i in range(0, len(data), 256):
# Read 256-byte block as integer
c = 0
for j in range(256):
c = (c << 8) + data[i + j]
# Decrypt with blinding
m = decrypt(c, priv, pub)
# Serialize plaintext block
for j in range(255, -1, -1):
pdata.append((m >> (j * 8)) & 0xFF)
return bytes(pdata)
def encrypt_with_length(data, pub):
header = struct.pack('>I', len(data))
return header + encrypt_bytes(data, pub)
def decrypt_with_length(cdata, pub, priv):
original_length = struct.unpack('>I', cdata[:4])[0]
decrypted = decrypt_bytes_hardened(cdata[4:], pub, priv)
return decrypted[:original_length]
if __name__ == '__main__':
print("Generating primes...")
p = genprime(512)
q = genprime(512)
keys = keysgen(p, q)
pub, priv = keys['pub'], keys['priv']
# Verify constant-time correctness
print("Verifying constant-time modpow...")
n = pub[1]
for _ in range(50):
m = secrets.randbelow(n - 2) + 2
c = encrypt(m, pub)
d_std = modpow(c, priv[0], n)
d_ct = modpow_ct(c, priv[0], n)
assert d_std == d_ct
print("Constant-time modpow: OK")
# Verify blinded decryption
print("Verifying blinded decryption...")
for _ in range(50):
m = secrets.randbelow(n - 2) + 2
c = encrypt(m, pub)
d_blind = decrypt(c, priv, pub)
assert d_blind == m
print("Blinded decryption: OK")
# Full message round-trip
message = b"Side-channel hardened RSA. The math is the same. The implementation is safer."
ct = encrypt_with_length(message, pub)
pt = decrypt_with_length(ct, pub, priv)
assert pt == message
print(f"Message round-trip: OK")
print(f"Original: {message}")
print(f"Decrypted: {pt}")
When Should You Use a Library Instead of Rolling Your Own RSA?
Always, for production. Let me be specific about why.
The defenses we've built address the primary timing-attack vector: the data-dependent branch in modpow. Real implementations are more layered.
CRT optimization opens new attacks. Production RSA uses the Chinese Remainder Theorem to speed up private key operations by roughly 4x. CRT decryption computes m_p = c^(d mod p-1) mod p and m_q = c^(d mod q-1) mod q, then combines them using the Garner formula. This optimization introduces CRT fault attacks: if a computation error occurs during CRT decryption, a carefully chosen ciphertext reveals enough information to factor n. Bellcore researchers discovered this attack in 1996. The fix requires validating the CRT result before returning it. Easy to forget. Impossible to audit without reading the spec.
OAEP padding is not optional. We've mentioned this before, but it bears repeating here. Textbook RSA encryption -- what we've built -- is deterministic and malleable. Deterministic means identical plaintexts produce identical ciphertexts. Malleable means an attacker can transform a ciphertext algebraically without knowing the key. OAEP (Optimal Asymmetric Encryption Padding) adds randomness and structure that eliminates both properties. The Bleichenbacher attack (1998) demonstrated that a PKCS#1 v1.5-compliant RSA implementation could be broken with an adaptive chosen-ciphertext attack using a decryption oracle -- any server that returns different error messages for different decryption failures is a decryption oracle. Fixing this correctly requires careful, uniform error handling that's easy to get wrong.
Cache timing. Beyond branch-based timing, the memory access patterns of your computation leak information through CPU cache effects. A memory access that hits the L1 cache takes about 4 cycles. An access that misses to RAM takes 200+ cycles. If which cache lines you access depends on the key, an attacker with access to the same physical machine (common in cloud environments) can measure cache access patterns. This requires same-machine access and is less commonly exploited than pure timing attacks, but it's real.
The pycryptodome library addresses all of these. The Python cryptography library wraps OpenSSL, which has been battle-tested for decades. Both implement OAEP by default. Both include blinding. Both have received security audits.
# This is what production RSA encryption looks like
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP
# Generate 2048-bit key pair
key = RSA.generate(2048)
public_key = key.publickey()
# Encrypt with OAEP padding
cipher = PKCS1_OAEP.new(public_key)
ciphertext = cipher.encrypt(b"Production-safe RSA encryption")
# Decrypt
cipher = PKCS1_OAEP.new(key)
plaintext = cipher.decrypt(ciphertext)
print(plaintext) # b'Production-safe RSA encryption'
Two lines of encryption. One line of decryption. OAEP, blinding, CRT with validation, and constant-time operations -- all handled. This is what you reach for in production.
The three-part series you've read is not an alternative to that. It's the foundation that makes the library's behavior comprehensible. When you read PKCS1_OAEP.new(public_key) and know what OAEP is fixing, why PKCS#1 was insufficient, how blinding relates to timing attacks, and what CRT optimization means for the private key operations -- you're not using the library on faith. You're using it with understanding.
Frequently Asked Questions
What is a side-channel attack in cryptography?
A side-channel attack extracts secret information from the physical implementation of a cryptographic algorithm rather than attacking the algorithm's mathematics. Timing, power consumption, electromagnetic emissions, and acoustic noise are all side channels. None of them break RSA's underlying number theory -- they exploit the way hardware executes the algorithm.
Can timing attacks actually break real RSA over a network?
Yes. Brumley and Boneh demonstrated recovery of an OpenSSL RSA private key from a server over a LAN connection using 1.4 million measurements. The timing differences were in the microsecond range. Statistical analysis recovered the key bit by bit. The attack worked because OpenSSL's CRT implementation had data-dependent timing.
What is RSA blinding and how does it stop timing attacks?
Before decryption, multiply the ciphertext by a random value r^e mod n. Decrypt the blinded ciphertext. Remove the blinding factor by multiplying by r^(-1) mod n. An attacker measuring timing now observes the time for decrypting a randomized input -- not the actual ciphertext. Timing measurements correlate with the random r, which changes on every call, providing no exploitable signal.
Is the Montgomery Ladder implementation in Python actually constant-time?
The algorithm eliminates the conditional multiply that creates the largest timing signal. Python's arbitrary-precision integers are not constant-time at the hardware level, so complete constant-time guarantees require a lower-level language. The Python implementation is a significant improvement over the original modpow, but not a complete solution for adversaries with access to precise timing measurements.
Should I use this implementation in production?
No. Use pycryptodome or the Python cryptography library. They implement OAEP padding, CRT with fault-attack protection, blinding, and proper constant-time operations. This series is for understanding what those libraries are actually doing under the hood.
The RSA series is now complete. We've covered the full arc from the mathematical foundation through to a side-channel hardened implementation. For the broader CoderOasis cryptography coverage -- digital signatures, key exchange protocols, and symmetric encryption -- check out the cryptography topic section.