Canadian author and pythonista.

An Intermediate Guide To RSA

· Read in about 19 min · (3989 Words)
python cryptography RSA

The venerable RSA public key encryption algorithm is very elegant. It requires a basic understanding of modular arithmetic, which may sound scary if you haven’t studied it. It reduces to taking the remainder after integer long division. The RSA Wikipedia article describes five simple steps to generate the keys. Encryption and decryption are a matter of basic exponentiation. There’s no advanced math, and it’s easy to understand their example of working with small numbers.

So when I was looking for a simple project to practice a new programming language, I thought, “Hey, RSA isn’t that hard, let me try implementing that.”

Yeah.

I quickly learned that those five simple steps do not map to simple code. There is a gap in the online materials documenting RSA. Most give the “beginner” version from Wikipedia. The rest are highly technical, with either deep discussions proving the math or the complex realities a truly secure production RSA implementation requires.

Nothing I read used variable names that were meaningful at all. The symbology of mathematics was designed (in the days before tab-complete) for ease of hand-writing. It pains me no end to read python code that ruin the true beauty of the language—it’s readability forgettable one-letter variables. I used to think my brain was too small for advanced mathematics, but it actually just needs better mnemonics.

I wanted an intermediate description of the RSA algorithm oriented towards programmers. Instead, I wrote one. This is it.

Standard Caveat: This code is provided entirely for pedagogical purposes. It is not intended to be cryptographically secure, do not use it in production.

Goals

This article provides:

  • comprehensible Python code implementing basic RSA
  • implementations of arithmetic algorithms (the only external imports are namedtuple and secrets).
  • unit tested code
  • a detailed walk-through of the RSA algorithm, mapping code to theory

The RSA algorithm in (sort-of) plain English

Here are snippets of the RSA algorithm, copy pasted from Simple English Wikipedia:


Generating a key:

  1. Choose two different large random prime numbers p and q
  2. Calculate n = pq
    • n is the modulus for the public key and the private keys
  3. Calculate the totient: ϕ(n) = (p−1)(q−1)
  4. Choose an integer e such that 1 < e < ϕ(n), and e co-prime to ϕ(n) i.e: e and ϕ(n) share no factors other than 1; gcd(e ,ϕ(n) = 1.
    • e is released as the public key exponent
  5. Compute d to satisfy the congruence relation de ≡ 1 (modϕ(n)) i.e: de = 1+kϕ(n) for some integer k. (Simply to say : Calculate d = (1+kϕ(n))/e)
    • d is kept as the private key exponent

Encrypting messages:

  • c = m e mod n

This can be done quickly using the method of exponentiation by squaring.

Decrypting messages:

  • m = cd mod n

That may or may not make much sense, but if you read through the working example, it should come clear. It’s basic math, with the possible exception of de ≡ 1 (mod ϕ(n)). The symbol in modular arithmetic means “is congruent to” and the whole equation means “find d such that de mod ϕ(n) = 1”. “mod” means remainder. Python uses the % symbol for it.

Breaking it down, with code

The key to the steps above is to expect more computational work than I gave it credit for. None of the steps requires exponential brute forcing of a solution or anything (except, of course, hacking the original message from the cipher, but that is supposed to be hard), but they aren’t simple mathematical formulae. Each step is an algorithm in its own right. Only step 2, “Calculate n = pq is obviously straightforward, though step 3 is close.

Choose two different large random prime numbers

The only way to generate a random prime is to “guess and check”, which seems inefficient. Generating secure random data requires a little thought (though not much, in Python). Creating numbers as large as those needed for cryptography isn’t straightforward either. Making sure the number is prime is more computationally expensive than I expected.

Random numbers

Most people know not to use the python random module to create randomness for cryptography. The random module creates pseudo-random numbers. If you know the algorithm used to generate a random number (there are many), it’s straightforward to regenerate the sequence. These numbers are mathematically related, and are therefore not cryptographically secure.

Luckily, modern operating systems can create truly random numbers. I don’t know the techniques, but I believe it’s based on entropy (randomness) generated from the unpredictable flow of i/o through the various peripherals, storage drives, and network. Maybe someone can give us more information in the comments. Python (3.6 and above) kindly makes this random data available in the secrets module.

Large numbers

So making a number random is easy if you know the secrets, but how do we define “large number”? I may come across as not very bright here, but it took me a bit of research to understand. It’s not just a matter of picking some minimum and maximum “large enough” integers and picking an integer between them using randint. Aside from the fact that secrets doesn’t have a randint function, how do we arbitrarily decide what the minimum and maximum values are?

Recall that cryptographers usually talk about encryption in terms of bits. When I remember SSL becoming a thing, “really secure, like banks use” meant 128-bit encryption, which sounds funny today. I checked https://docs.python.org/ (I had it open to the secrets module, obviously), which uses 2048-bit encryption. That, plus the fact that secrets.randbits is a function is a hint as to how we can generate our “large random numbers”.

Remember, we need two numbers p and q which get multiplied together to get n. Those variables are meaningless to me, so I’m going to say prime1 and prime2 which are multiplied together to get the key_modulus instead. The key_modulus will be used as the modulus part of the key during encryption and decryption.

“2048-bit encryption” refers to the number of bits in this key_modulus. In order to make key_modulus have 2048 bits (give or take), the two prime numbers each need 1024 bits. A first attempt might look like this:

from secrets import randbits

def random_prime(num_bits:int) -> int:
return randbits(num_bits)

The problem with this code is that, while it returns 1024 random bits, the bits could be 1023 zeros and 1 one. It doesn’t matter how many zeros are in front of a 1, it’s still a 1. Just guess how cryptographically secure the number 1 is.

The solution is to “set the high bit”, that is: ensure the bit in the 1024th position is a 1. I don’t do a ton of bitwise arithmetic in Python, so I had to look up the syntax: value | 1 << (num_bits - 1).

To break that down, imagine numbits is 5. The 1 << x creates a number with x zeros. So 1 << 4 results in the binary number 10000, which is the integer 16. The value | x is the “bitwise or” operator. It yields a number where at any bit position, if either of the two numbers has a 1, the resulting number is also a 1. So the bitwise or for the integers 6 and 16 is as follows:

110
10000
-----
10110

which represents the integer number 22.

(If testing in the python interpreter, you can see the binary form of an integer using bin(the_integer) or f"{the_integer:b}")

Since I was doing bitwise arithmetic anyway, I added a couple extra bit shifts and a wrapper function to make my API a bit (no pun intended) cleaner:

def guarantee_bits(value: int, num_bits: int) -> int:
return value & (1 << num_bits) - 1 | (1 | 1 << (num_bits - 1))

def exact_randbits(num_bits: int) -> int:
return guarantee_bits(randbits(num_bits), num_bits)

In addition to setting the high, the bitwise operators in guarantee_bits do the following:

  • If the value has too many bits, truncate the extras. This situation can’t happen in the secrets.randbits scenario, but I wanted my unit test to be complete for the hypothetical case.
  • Make sure the lowest bit is set, which forces an odd number. This cuts the search space for a prime number in half (all even numbers above two are not prime).

Prime numbers

Having generated a random number, the next step is to check whether or not it is prime and discard it if not. Primality testing is also not trivial.

To be prime (by definition), an integer must not be divisible by any integer other than itself and one. I started with an implementation of the fairly obvious trial division method, which checks if any possible divisor evenly divides it. Some tricks can be applied to reduce the number of factors that need to be checked, but it’s still not efficient.

My initial implementation of this algorithm looked like so:

FIRST_PRIMES = [2, 3, 5, 7, 13, 17, 19, 23]

def trial_division(number: int) -> int:
if number <= 1:
return False
for prime in FIRST_PRIMES:
if number == prime:
return True
if not number % prime:
return False

    redundant_above = math.ceil(math.sqrt(number))
    for i in range(5, redundant_above + 1, 6):
        if not number % i or not number % (i + 2):
            return False
    return True

This works. It passed my unit test, which checks it against a first thousand primes list I found online. But it is too slow for cryptography. I gave up after waiting a few minutes for it to test one prime number.

In spite of popular belief, I rarely find that Python is “too slow”, but when it is, I reach for Cython. A Cython implementation of the algorithm gave more than an order of magnitude speedup. I think it might even have been “fast enough” for “educational purposes” except for one small issue:

Python knows how to do math on integers of any size. C (which Cython transpiles to), on the other hand has 64-bit, integers. 64 is obviously nowhere near the 1024 or more bits we need. I decided it wasn’t worth studying libraries for arbitrary precision integer arithmetic in C and set that solution aside. I won’t include it here, but my Cython code is in the GitHub repository.

Instead I turned to what is usually the correct solution when faced with Python code that is “too slow”: use a smarter algorithm.

Most of the RSA resources I read said to use the Miller-Rabin test, but a few suggested the Rabin-Miller test! I didn’t originally follow those suggestions, assuming that any algorithm named after two people is probably hard to understand and implement (RSA, after all is named for the initials of three people). I’m not looking to create a production implementation of RSA here, just to understand and demonstrate how it works.

However, after reading the Miller-Rabin Wikipedia page as well as most of the top Google results, I was able to understand the algorithm. This link, especially, gives an easier-to-understand description, although I rearranged it for my own code (probably introducing some errors along the way).

The Miller-Rabin test does not prove that a number is definitely prime. It can prove a number is composite (not prime), though. If you run the test multiple times, seeded with a random number, you can get near enough to proof that it doesn’t matter.

My implementation looks like this:

FIRST_PRIMES = [2, 3, 5, 7, 13, 17, 19, 23]

def miller_rabin(number: int) -> int:
if number <= 1:
return False
for prime in FIRST_PRIMES:
if number == prime:
return True
if not number % prime:
return False

    odd_factor = number - 1
    mod_levels = 0
    while odd_factor % 2 == 0:
        odd_factor = odd_factor // 2
        mod_levels += 1

    for trials in range(40):
        witness = random.randrange(2, number - 1)
        mod_pow = pow(witness, odd_factor, number)
        if mod_pow == 1:
            continue
        for i in range(mod_levels):
            if mod_pow == (number - 1):
                break
            else:
                i = i + 1
                mod_pow = (mod_pow ** 2) % number
        else:
            return False
    return True

The first section checks some trivial cases. It iterates the first few hard-code primes and tests if any of them evenly divides the candidate prime (if number % known_prime = 0 then number is composite).

The second section divides the number in half repeatedly down to an odd number. We know number - 1 is even because number is odd: all even numbers would have been weeded out by being multiples by the 2 in FIRST_PRIMES. This gives us the number of ‘levels’ of modular operations that need to be checked by the inner loop.

The third section does the actual test. It iterates 40 times, enough to “virtually guarantee” that the candidate number is truly prime. It picks a random number as a witness. If the witness proves the number is composite, we discard it, returning False.

I briefly understood the math of the inner loop, but you should probably look to Wikipedia or a search engine to get a decent proof.

You may not have seen the three-argument version of the pow function used in the first highlighted line. It does exponentiation with a modulus operator (i.e: xy mod z). The Python pow function can calculate x ** y % z more efficiently.

The for...else syntax may also be unfamiliar. I usually avoid this construct because it isn’t widely known, but seemed the most concise way to implement this algorithm. The else clause on a for loop is only executed in the event that the loop ran to completion without encountering a break statement.

If this loop reaches a state of mod_pow == (number - 1), then the number might be prime and it breaks to test another witness. If the loop passes through all the mod_levels without reaching that state, then the number is definitely composite and we can return False.

If 40 such witnesses cannot prove the number is composite, we assume (with tremendous confidence: my favourite stack overflow answer indicates a bit flip in your CPU is more likely) that the number is prime and return True.

I probably shouldn’t have gone all the way down that rabbit hole. The sympy.isprime function is certainly better. But rabbit holes can be fun and educational, and it was worth it.

Now the earlier random_prime function can finally be extended to loop until it finds a prime number:

from .is_prime import miller_rabin as is_prime

def random_prime(num_bits: int) -> int:
number = exact_randbits(num_bits)
while not is_prime(number):
number = exact_randbits(num_bits)
return number

With random_prime available and unit tested, we can start the generate_key function coordinating the four steps:

def generate_key(num_bits: int) -> RSAKey:
prime1 = random_prime(num_bits // 2)
prime2 = random_prime(num_bits // 2)
while prime2 == prime1:
prime2 = random_prime(num_bits // 2)

I threw the while loop in their for the unlikely scenario of getting the same 1024 bits twice. This makes the unit tests more robust when testing against small numbers.

A note on the return value

I’m using type hints throughout the article. That is overkill since every parameter and argument is an integer except this RSAKey construct returned here. `RSAKey is a dataclass (before Python 3.7 I would have used a namedtuple) containing the numbers required to perform encryption and decryption:

from dataclasses import dataclass

@dataclass
class RSAKey:
modulus: int
pub_exponent: int
priv_exponent: int

RSA’s public key and private key each require two numbers: the modulus and the appropriate exponent.

Calculating n and ϕ

Having two large prime numbers, steps 2 and 3 of the key generation algorithm are trivial. Here’s the beginnings of my generate_key function:

def generate_key(num_bits: int) -> RSAKey:
prime1 = random_prime(num_bits // 2)
prime2 = random_prime(num_bits // 2)
while prime2 == prime1:
prime2 = random_prime(num_bits // 2)
key_modulus = prime1 _ prime2
totient = (prime1 - 1) _ (prime2 - 1)

The totient, short for Euler’s totient represents the number of integers smaller than a number that are co-prime, such that the greatest common divisor of the two numbers needs to be 1.

The totient is an important link between modular arithmetic and a mathematical discipline known as group theory that is critical to proving cryptography algorithms, but not to understanding them.

For this purpose, we just need totient = (prime1 - 1) * (prime2 - 1). For prime numbers, the totient is prime - 1, and we can multiply the two prime totients to get that of the modulus.

Choose e, the public exponent

Researching this step was probably the most annoying part. Apparently, most algorithms just set e to 65537 and be done with it. The pub_exponent (as I prefer to call e) must have no common factors with the totient we just calculated in order to have a valid decryption key. My understanding is that most algorithms assign 65537 to pub_exponent before calculating the primes and totient. If the totient is invalid, they just pick different primes.

I felt like that was cheating! I’m no security expert, but it also seems less secure since it reduces the search space if you want to brute force the two primes. For fun, I decided to make pub_exponent choose a random number in my implementation since it matches the text description of the algorithm.

So pub_exponent and totient must have no common factors, which means the greatest common divisor must be 1.

I learned about gcd in grade school a quarter century ago, but that doesn’t mean I still remember how to do it. I could get away with using the math.gcd python library function, but I had it in my head to understand all the math I’m doing.

The algorithm to calculate a greatest common divisor between two numbers is known as the Euclidean Algorithm. It’s easy:

def gcd(a, b):
if b == 0:
return a
else:
return gcd_recursive(b, a % b)

I didn’t use this version, though. It would hit recursion limits with the ridiculously large numbers we are using. In the next section, I use an iterative version of the Extended Euclidean Algorithm to calculate modular inverse, so I get the gcd from that as well.

Assuming a working gcd function, Step 4 adds the following lines of code to generate_key:

pub_exponent = random_prime(num_bits - 2)
while pub_exponent >= totient or gcd(pub_exponent, totient) != 1:
pub_exponent = random_prime(num_bits - 2)

Subtracting 2 from num_bits ensures pub_exponent is lower than totient, a requirement for the algorithm to work.

Compute d to satisfy the congruence relation

Looking at step 5 in the RSA description, I originally thought that it was simple mathematics. After all Wikipedia actually uses the word “Simply”: “Simply to say : Calculate d = (1+kϕ(n))/e)“. Unfortunately, it’s not that simple because we don’t know what k is, so we can’t just plug it into the simple equation.

The value we are trying to find, priv_exponent (rather than d) is called the modular inverse of pub_exponent. The easy way to calculate this is to use the sympy.numbers.mod_inverse function. Honestly, if I were you, I’d just do that if I were you and skip the following details. ;-)

This site gives a pretty good English overview of what modular inverse is. It can be calculated using the Extended Euclidean Algorithm, described well here. This also gives us the gcd function to calculate the public key above.

In addition to the greatest common divisor returned by the normal Euclidean Algorithm, the extended version calculates two numbers, x and y such that the equation ax + by = gcd(a, b). If a is the pub_exponent and b is the totient, then the x returned by the algorithm is the modular inverse. This is only true if the gcd is 1, but that was guaranteed by the previous step.

The recursive definition of egcd is as follows:

def egcd(a, b):
if a == 0:
return (b, 0, 1)
else:
g, x, y = egcd(b % a, a)
return (g, y - (b // a) \* x, x)

It may be easy to understand, but it causes recursion errors on our huge numbers. The article I linked gives the full iterative algorithm, but I shortened it a bit since modinv doesn’t need the y. This code listing also includes the gcd and modinv functions called from generate_key:

def \_xgcd(b: int, a: int) -> typing.Tuple[int, int]:
x0, x1 = 1, 0
while a != 0:
x0, x1 = x1, x0 - b // a \* x1
b, a = a, b % a
return b, x0

def gcd(b: int, a: int) -> int:
return \_xgcd(b, a)[0]

def modinv(base: int, modulus: int) -> int:
gcd, x = \_xgcd(base, modulus)
if gcd != 1:
raise ValueError(f"No modular inverse for {base} {modulus}")
return x % modulus

With these helper functions in place, generate_key can be completed as follows(new lines highlighted):

def generate_key(num_bits: int) -> RSAKey:
prime1 = random_prime(num_bits // 2)
prime2 = random_prime(num_bits // 2)
while prime2 == prime1:
prime2 = random_prime(num_bits // 2)
key_modulus = prime1 _ prime2
totient = (prime1 - 1) _ (prime2 - 1)
pub_exponent = random_prime(num_bits - 2)
while pub_exponent >= totient or gcd(pub_exponent, totient) != 1:
pub_exponent = random_prime(num_bits - 2)
priv_exponent = modinv(pub_exponent, totient)
return RSAKey(key_modulus, pub_exponent, priv_exponent)

Encryption and Decryption

Encrypting and decrypting are as “easy” as raising the message to the power of the public or secret exponent mod the modulus. The pow function can do this modular arithmetic. It’s a good thing pow is a python builtin so it doesn’t violate my “don’t import any extra math modules” commitment!

Here are the encrypt and decrypt functions:

def encrypt(key: RSAKey, message: int) -> int:
return pow(message, key.pub_exponent, key.modulus)

def decrypt(key: RSAKey, ciphertext: int) -> int:
return pow(ciphertext, key.priv_exponent, key.modulus)

You’ll notice that message and ciphertext are both integers and may wonder how that is useful to encrypt a text message (or a jpg, for that matter). I’m not going to dive into the details, but the simple answer is they are just bits. Represent your string bytes as an integer and you’re set.

Almost. There are a couple caveats.

You can’t just throw int.to_bytes and int.from_bytes at it because the bytes won’t necessarily line up (you’ll end up with extra zeroes). Do a web search for “RSA padding schemes” to solve this (and improve the security).

In addition, the message (and ciphertext) integer must be smaller than the modulus. If your modulus is 2048 bits, that’s 256 bytes. That’s at most 256 characters using ASCII text, fewer if you like emojii. It’s not even enough to offend Twitter’s character limit. How do you send anything meaningful?

One obvious answer is to split the message into 256 byte chunks and encode each into packets to be reassembled at the other end. But that’s not the normal approach. Encryption using RSA is CPU intensive. There are much faster symmetric key encryption schemes (search for Advanced Encryption Standard). The problem with symmetric key encryption, compared to public key encryption is that you need to securely share the secret key with the receiving party. Asymmetric key cryptography doesn’t have that problem. If an attacker knows the public key, they still can’t decrypt the message without the private key.

So just take the best of both worlds. Create a random AES secret key, then encrypt and share it using the recipient’s public key. Once you both have the symmetric key, all communication can be securely encrypted using it. The maximum size of an AES key is 256 bits, which can easily be encrypted with a 2048-bit public key.

Conclusion

I hope this article helped clarify your understanding of RSA. Moreover, I hope you can implement it with less frustration than I experienced while reading the deceivingly simple descriptions I could find.

My implementation is definitely not production-grade. I designed it, as much as possible, to be readable and educational, not secure or optimal.

I am probably more of a novice in security and cryptography than some people reading this. Please point out any errors, omissions, or confusion in the comments and I’ll try to improve it.

All code available on GitHub.

Comments