RSA in Python From Scratch: The Math, the Code, and What Breaks It

Every HTTPS connection you have ever made runs on a 1978 paper. You can build the core of it in about fifty lines of Python — and then see exactly why those fifty lines would get you destroyed in production.

Python Code
Photo by Chris Ried / Unsplash

Every HTTPS connection you've ever made runs on math published in a 1978 paper by Rivest, Shamir and Adleman. Not a variant. Not a descendant. The same core algorithm, scaled up and hardened, but structurally identical to what you're about to build in about fifty lines of Python.

Most articles on RSA stop at the concept. They tell you it's "asymmetric," that one key encrypts and the other decrypts, hand you a diagram with padlocks, and point you at the cryptography library. That teaches you nothing about what's happening. You end up making decisions about key sizes and padding schemes with no foundation under them.

This goes all the way down. Number theory, each component built from scratch, algorithms traced by hand, the result tested, and then an honest account of what it cannot do.

Every figure printed below is generated by running the code, not transcribed. The full implementation, a test suite asserting every claim in both parts, and a script that regenerates each output are in the companion repository: rsa-from-scratch.

The Math, Briefly

RSA gives you two keys. One encrypts, one decrypts, and the same key cannot do both. Publish one, keep the other, and anyone can send you something only you can read.

ciphertext = m^e mod n
plaintext  = c^d mod n

m is the message as a number, e the public exponent, d the private exponent, n the modulus. The relationship between e and d is constructed so that applying both returns the original:

(m^e)^d ≡ m (mod n)

Security rests on one asymmetry: computing n = p * q from two large primes is trivial, factoring n back into p and q at 2048 bits is not.

Three concepts do the work.

Modular arithmetic. a ≡ b (mod n) means a and b leave the same remainder when divided by n. 17 ≡ 5 (mod 12) — clock arithmetic. Seventeen hours after midnight is 5 AM. Modulo keeps numbers bounded, which is what makes RSA practical at all.

Greatest common divisor. gcd(a, b) is the largest integer dividing both. Coprime means gcd(a, b) = 1 — no shared factors. RSA needs e coprime with the key generation parameter, or the private key derivation has no solution.

The Carmichael function. λ(n) is the smallest m where x^m ≡ 1 (mod n) for every x coprime to n. The property that matters: if e * d ≡ 1 (mod λ(n)), then

(m^e)^d = m^(e*d) ≡ m^1 = m (mod n)

Decryption undoes encryption exactly. Not approximately, not probabilistically.

Generating the Keys

Two distinct primes p and q, product n = p * q.

For a prime, λ(p) = p - 1. For the product:

λ(n) = lcm(p - 1, q - 1)

A note on what this code actually does, because it matters and most tutorials paper over it. The implementation below computes (p-1)*(q-1), which is Euler's totient φ(n), not the Carmichael function. Both produce a working key pair, because λ(n) always divides φ(n) — any d valid modulo φ(n) is also valid modulo λ(n). φ(n) just gives you a larger d than necessary. Real implementations use λ(n) for smaller keys; FIPS 186-5 actually requires it. The variable is called lambda_n below for continuity with the rest of the series, and that name is a small lie.

Then choose e. The standard is 65537. This implementation uses 35537 so the worked examples stay small enough to check by hand. It's a legitimate choice here — it's prime, and gcd(35537, φ(n)) = 1 for our primes, which is all the math requires. It is not a value you would ever ship.

Find d by solving e * d ≡ 1 (mod λ(n)), equivalently finding integers d and x where:

e * d + x * λ(n) = 1

That's the Extended Euclidean Algorithm. Then discard p and q. Public key (e, n), private key (d, n). Recovering d from (e, n) requires computing λ(n), which requires factoring n.

The Extended Euclidean Algorithm

It finds (x, y) such that a*x + b*y = gcd(a, b). Call it with a = e and b = λ(n), and since those are coprime the equation becomes e*x + λ(n)*y = 1 — so x is d.

def eucalg(a, b):
    swapped = False
    if a < b:
        a, b = b, a
        swapped = True
    # ca and cb track current a and b as linear combinations
    # of the original inputs:
    #   current_a = ca[0]*original_a + ca[1]*original_b
    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

Trace eucalg(35537, 3120) by hand and then check it.

Initial: a = 35537, b = 3120, ca = (1, 0), cb = (0, 1).

Iteration 1: k = 35537 // 3120 = 11. New b = 35537 - 34320 = 1217. New cb = (1, -11). Check: 1*35537 + (-11)*3120 = 1217. Correct.

Iteration 2: k = 3120 // 1217 = 2. New b = 686. New cb = (-2, 23).

Iteration 3: k = 1217 // 686 = 1. New b = 531. New cb = (3, -34).

Run it to the end:

eucalg(35537, 3120) = (-1087, 12381)
  35537*-1087 + 3120*12381 = 1
  corrected d=2033, (35537*2033) % 3120 = 1

Note the negative. Bezout coefficients oscillate sign throughout, and the final x here is -1087. Adding the modulus gives 2033, which is the positive representative of the same value — -1087 ≡ 2033 (mod 3120). Skip that correction and your implementation silently fails on certain prime pairs.

Runtime is O(log max(a, b)), and the constants are worth knowing because the worst case and the real case are wildly different.

Lamé's theorem gives the bound: the worst input is a pair of consecutive Fibonacci numbers, and the iteration count is log_φ of the input. Measured:

  worst case (consecutive Fibonacci, 2048 bits): 2949 iterations
  Lame's theorem bound for 2048-bit inputs:      2949 iterations

Theory and measurement agree exactly. But that worst case never happens in RSA, because you aren't calling it on adversarial inputs — you're calling it with e = 65537 and a 2048-bit λ(n):

  realistic eucalg(lambda_n, 65537), 200 samples: max 16, mean 10.8

Eleven iterations on average. The small second argument collapses it almost immediately. Key generation spends essentially all its time finding primes, and none worth mentioning on the extended Euclidean algorithm.

Fast Modular Exponentiation

Computing m^e mod n looks straightforward until you consider what m^e is before the modulo. Binary exponentiation takes the modulo at every step, keeping intermediates bounded by n.

def modpow(b, e, n):
    # Find the position of the highest set bit in e
    tst = 1
    siz = 0
    while e >= tst:
        tst <<= 1
        siz += 1
    siz -= 1
    # Binary exponentiation: read bits of e high-to-low
    r = 1
    for i in range(siz, -1, -1):
        r = (r * r) % n          # Square
        if (e >> i) & 1:
            r = (r * b) % n      # Multiply by base if bit is 1
    return r

The largest intermediate is (n-1)^2 — 4096 bits for a 2048-bit key. Multiplication count is 2 * floor(log2(e)); for e = 65537 that's at most 32 multiplications, not 65537.

modpow(2,10,10000) == 1024 -> True
modpow(2,10,7)     == 2    -> True
modpow(3,4,5)      == 1    -> True

Python's built-in pow(b, e, n) does the same thing in C, and it is faster. Build modpow to understand it, then use pow() for anything that runs.

With one caveat that matters later, and that most tutorials skip: pow() is not constant-time either. Same test, same exponents, both implementations:

  exponent weight ->  modpow      pow()
     2 bits set      11.448ms   11.288ms
  1024 bits set      12.482ms   13.284ms
  ratio                1.09x     1.18x

CPython's implementation leaks more signal than the Python version here, not less. Being written in C makes it faster; it does not make it safe against an attacker measuring how long your decryption took.

So "use the built-in" is the right advice for correctness and speed, and it is not a fix for the side-channel problem. That takes blinding, which is Part 3.

Why the Naive Version Isn't Just Slow

You'll see benchmarks comparing (b ** e) % n against modular exponentiation, and a lot of them quote a "naive" time. Be careful with the exponent you pick, because past a certain size the naive version doesn't run slowly — it doesn't run.

Take b = 65537, e = 10^100, a figure that shows up in exactly this kind of comparison:

b**e would have ~4.816e+100 decimal digits
atoms in observable universe ~1e80
memory needed: ~2.000e+91 GB

Python will not compute that in "a few seconds." It will not compute it before the heat death of the universe, and it will exhaust your memory trying. Any benchmark quoting a completion time for that has not been run.

So here is one that does run. Exponent small enough for the naive path to finish, n a real 2048-bit modulus:

e=  4096: naive      0.75 ms | modpow  0.064 ms | pow()  0.055 ms | b**e is    65,537 bits
e= 65537: naive     40.95 ms | modpow  0.111 ms | pow()  0.097 ms | b**e is 1,048,594 bits

The scaling is the whole point. Sixteen times the exponent gives fifty-five times the naive runtime, because the intermediate grew from 65 thousand bits to over a million. modpow went from 0.064ms to 0.111ms — it barely noticed, because its intermediate never exceeded either time.

Extrapolate that curve to e = 10^100 and you get the memory figure above.

Keys, Encryption, Decryption

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)}
 
def numencrypt(m, pub):
    return modpow(m, pub[0], pub[1])
 
def numdecrypt(m, priv):
    return modpow(m, priv[0], priv[1])

numencrypt and numdecrypt are identical. Both call modpow(m, key[0], key[1]). RSA's math does not distinguish encryption from decryption at the algorithmic level — the asymmetry is entirely in which key is secret. That symmetry is also what makes digital signatures possible: sign by "encrypting" with the private key, verify by "decrypting" with the public one.

Running It

from rsa import keysgen, numencrypt, numdecrypt
 
keys = keysgen(31337, 31357)
priv, pub = keys['priv'], keys['pub']
 
enc = numencrypt(80087, pub)
dec = numdecrypt(enc, priv)

Real output:

{'priv': (720926705, 982634309), 'pub': (35537, 982634309)}
message   = 80087
encrypted = 539186383
decrypted = 80087

And the relationship that makes it work:

from math import gcd
p, q = 31337, 31357
lambda_n = (p - 1) * (q - 1)
print(gcd(35537, lambda_n))              # 1  -- e is coprime, as required
print((35537 * 720926705) % lambda_n)    # 1  -- e*d ≡ 1 (mod lambda_n)
print(31337 * 31357)                     # 982634309 == n

All three hold.

What This Cannot Do

This is where most tutorials get quiet. Five gaps, each of which matters.

Message size. numencrypt only works for m < n, and the boundary is exact:

  n = 982634309
  m=       80087 -> decrypts to        80087  OK
  m=   982634308 -> decrypts to    982634308  OK        <- n-1, the last valid message
  m=   982634309 -> decrypts to            0  WRONG
  m=   982634310 -> decrypts to            1  WRONG
  m=   982714396 -> decrypts to        80087  WRONG (got m mod n)

Note the last row. A message of n + 80087 decrypts to 80087 — a perfectly valid-looking number that is not what you encrypted. There is no exception and no signal. pow reduces modulo n on the way in, so what you get back is m mod n — decrypted correctly, into the wrong number.

That's the same class of silent corruption as the block-size trap in Part 2, and it's why real implementations pad and chunk below n rather than trusting callers to check.

Toy primes. We hand-picked two 15-bit primes. Watch how long the resulting modulus survives:

trial division of n=982634309 found p=31337 in 1.87 ms

Under two milliseconds, using the most naive factoring method there is. Not "slow" or "impractical" — trivially broken. Real RSA needs ~1024-bit primes from a cryptographically secure source.

No padding. Textbook RSA is deterministic, and it's worth seeing rather than being told:

  plaintext -> ciphertext
      80087 -> 539186383
      80087 -> 539186383
         42 -> 392297916
      80087 -> 539186383
         42 -> 392297916

Same input, same output, every time. An attacker who never breaks a single key still learns that messages 1, 2 and 4 are identical, and that 3 and 5 are identical. In a structured format — a status field, a boolean, a country code — that is frequently the whole secret.

This is the same failure that made Adobe's 2013 breach so damaging: passwords encrypted in ECB mode, so identical passwords produced identical ciphertext, and the top 100 were recovered without anyone touching the key.

OAEP fixes it by mixing in randomness before encryption, so the same plaintext encrypts differently every time. Part 2 covers the second half of the problem — that raw RSA is also malleable, meaning an attacker can modify the plaintext without ever decrypting it.

No side-channel protection. modpow branches on the bits of the exponent — it squares every iteration but only multiplies when a bit is set. So its running time is a function of the exponent, and during decryption the exponent is the private key.

That's measurable. Two exponents of identical bit length, one with 2 bits set and one with 1024:

  modpow with    2 bits set:  12.075 ms
  modpow with 1024 bits set:  12.791 ms
  difference: 0.716 ms  (1.06x)

Six percent. Small — but consistent and in the right direction, and it's the whole basis of Kocher's 1996 timing attack.

Six percent sounds unusable as an attack, and it isn't — the exploitation is statistical, and Part 3 covers exactly how a signal that small becomes a private key.

The point for now is narrower: the fix is not "make it faster." Speed is irrelevant. The requirement is that the running time stop depending on the key at all.

No CRT optimization. Every real implementation decrypts by working modulo p and q separately and recombining. You'll see "four times faster" quoted for this. Measured on a freshly generated 2048-bit key, median of 20 decryptions:

    plain pow(c, d, n) :  28.05 ms
    CRT (mod p and q)  :   8.48 ms
    speedup            :   3.31x
  exponent bits: d=2045, dP=1023, dQ=1023

3.31×, not 4×. The theoretical argument for 4 is that modular exponentiation costs roughly the cube of the operand size, so halving the modulus should be 8× cheaper — but you do it twice, giving 4×. Reality lands lower because the recombination step isn't free and the halved exponents aren't exactly half the work.

The exponent line explains where the win comes from: d is 2045 bits, while dP and dQ are 1023 each. Two exponentiations on 1024-bit moduli beat one on a 2048-bit modulus by a wide margin.

This is also why a real private key stores p, q, dP, dQ and qInv rather than just d — and why leaking any one of them is fatal.

This implementation Production RSA
Core algorithm Correct Correct
Key size 30-bit (toy) 2048–4096 bit
Prime generation Manual input Cryptographically random
Message encoding Integers only OAEP-padded bytes
Timing safety Vulnerable Constant-time
CRT optimization No Yes
Standard compliance No PKCS#1, OAEP

Two Ways to Break It With Valid-Looking Parameters

The failures above are about size. These two are about choice, and both produce code that looks fine.

Using the same prime twice. p and q must be distinct, and it isn't a stylistic preference:

p==q: round-trip 12345 -> 772124688  OK=False
and n is a perfect square: sqrt(n)=31337 -> factors instantly

Decryption returns the wrong number. The reason is that with p = q, the totient is no longer (p-1)(q-1) — it's p(p-1) — so the d you derived satisfies the wrong congruence and e*d ≢ 1 for the actual group.

And even if you patched the math, n = p² is a perfect square, so anyone can factor it with isqrt(n) in microseconds. Two failures for one mistake.

Choosing e that shares a factor with λ(n). The coprimality requirement isn't decoration — it decides whether d exists at all:

e=65537: gcd=1, d=674413217, e*d mod lambda = 1
e=3:     gcd(e,lambda)=3 -> no modular inverse exists, keygen must retry
e=4:     gcd(e,lambda)=4 -> no modular inverse exists, keygen must retry

With e = 3 and these particular primes, no d exists. There is no clever fix — the extended Euclidean algorithm returns a gcd of 3 rather than 1, and the equation 3d + xλ(n) = 1 has no integer solution.

This is why real key generation runs in a loop. Part 1's keysgen doesn't check, which means it can hand you a key pair that silently fails to decrypt on certain prime inputs. Production code either verifies gcd(e, λ(n)) == 1 and regenerates the primes, or picks a fresh e. Go's FIPS implementation does the former, discarding both primes and starting over.

The general lesson, and it applies well beyond RSA: a cryptographic implementation that produces output is not the same as one that produces correct output. Every example in this article round-trips successfully with the parameters given, and three of the four failure modes above produce no error at all.

Key Size and What Actually Breaks

Security is the difficulty of factoring n. The best known general algorithm is the General Number Field Sieve, running in sub-exponential time:

exp( (64/9)^(1/3) * (ln n)^(1/3) * (ln ln n)^(2/3) )

Slower than exponential, much faster than polynomial. Adding bits raises the cost sharply but not linearly.

That's checkable rather than decorative. The leading constant evaluates to the figure you'll see quoted in the literature, and running the formula across key sizes reproduces the table below:

  (64/9)^(1/3) = 1.922999   (literature constant ~1.9229)
 
   key bits   log2(GNFS ops)     vs 512-bit
        512             63.9              1x
        768             76.5       6.13e+03x
       1024             86.8       7.49e+06x
       2048            116.9       8.73e+15x
       4096            156.5       7.34e+27x

A 2048-bit modulus is roughly nine quadrillion times harder to factor than the 512-bit one that costs $75. That is what "adding bits" buys, and it's why the jump from 1024 to 2048 was worth the performance cost.

One honesty note on those numbers: the raw heuristic runs slightly above the standardised security equivalents — it predicts about 117 bits for a 2048-bit key where NIST publishes 112. The formula is an asymptotic estimate that ignores constant factors and assumes an idealised implementation, so the standards bodies quote the more conservative figure. Use the published equivalents for decisions; use the formula to understand the shape.

Key size Security equivalent Status
512-bit ~60-bit Factored 1999 — now $75 and four hours
768-bit ~76-bit Factored December 2009
1024-bit ~80-bit Deprecated by NIST in 2010
2048-bit ~112-bit Current minimum — NIST deprecates this strength in 2030
3072-bit ~128-bit Recommended for new systems
4096-bit ~140-bit Long-term, real performance cost

Those first two rows are worth expanding, because "broken in 1999" understates the situation badly.

RSA-155, the 512-bit challenge modulus, fell on 22 August 1999. It cost roughly 8,400 MIPS-years: seven calendar months end to end, with the sieving alone consuming 35.7 CPU-years spread across about 300 workstations in six countries.

That was 1999. Valenta et al. later optimised the number field sieve for Amazon EC2 and reported factoring 512-bit RSA keys in under four hours for about $75. Not a nation-state. A credit card and an afternoon.

The part that should genuinely worry you is what the same paper found when it went looking: hundreds or thousands of deployed 512-bit keys across DNSSEC, HTTPS, IMAP, POP3, SMTP, DKIM, SSH and PGP. The math has been dead for twenty-five years and the deployments haven't caught up.

RSA-768 fell in December 2009, and the published figure is that it would have taken more than 1,700 years on a single standard core — done in roughly two and a half calendar years across a heterogeneous mix of clusters and grid computing.

Extrapolate that curve to 2048 bits and you get a number with no physical meaning. That gap — trivial to multiply, infeasible to factor — is the entire trapdoor.

Against classical computers, anyway. That 112-bit row has an expiry date attached to it now: NIST IR 8547 proposes deprecating 112-bit-strength algorithms in 2030 and disallowing them in 2035, and the post-quantum replacements were standardised in August 2024. Part 3 covers what that means and why it matters before a quantum computer exists.

Why Build It If You Shouldn't Ship It

You should use cryptography or pycryptodome for anything real. They're audited, maintained, and handle every gap listed above.

But understanding the implementation changes how you use them. When you call rsa.generate_private_key(public_exponent=65537, key_size=2048) you now know why 65537 and not something else, that 2048 bits describes n rather than the key material, that generation means finding random primes rather than random bytes, and that the private key stores p, q, d and CRT parameters — not just d.

That's what stops people picking a 512-bit key because "it's probably fine," or encrypting a file directly with RSA instead of using RSA to wrap an AES key.

Understanding the math doesn't replace the library. It makes you competent to use it.

Part 2 replaces the hand-picked primes with a real generator — a Fermat and Lucas-Fibonacci pair drawing from secrets, audited against Miller-Rabin — and adds byte-level encryption so this handles actual data. It also inherits e = 35537 into a real key, which turns out to matter. Part 3 covers the attacks that break a mathematically correct implementation: timing, blinding, and constant-time exponentiation. Part 4 covers the 2023 attack that showed those defenses were insufficient, and Part 5 covers the deprecation timeline RSA now has.