The Algorithms - Python Python TheAlgorithms/Python

How RSA Encryption Works

Split the message into fixed-size integer blocks, raise each block to a modular power, and undo it with the private exponent.

6 stops ~20 min Verified 2026-05-05
What you will learn
  • Why RSA security rests on the difficulty of factoring the product of two large primes
  • How the key file format encodes key size, modulus n, and either exponent e or d in a comma-separated string
  • How get_blocks_from_text encodes a text message as a list of large integers using positional byte weighting
  • How encrypt_message applies pow(block, e, n) to each block using Python's built-in modular exponentiation
  • How decrypt_message applies pow(block, d, n) and how d is the modular inverse of e
  • Why the block size must not exceed the key size in bits and how encrypt_and_write_to_file enforces that
  • What the main driver does and why this file is for education only, not production use
Prerequisites
  • Familiarity with modular arithmetic (the % operator)
  • Comfort reading Python functions that read and write files
  • No prior knowledge of public-key cryptography required
The story behind RSA

Ron Rivest, Adi Shamir, and Leonard Adleman published their cryptosystem in 1977 at MIT, one year after Whitfield Diffie and Martin Hellman proposed the concept of public-key cryptography without providing a concrete construction. The RSA scheme was the first practical realization. The key insight is that multiplying two large primes p and q into n = pq is fast, but factoring n back into p and q is computationally infeasible for large enough values.

Key generation works as follows: pick two large primes p and q, compute n = pq and the totient phi = (p-1)(q-1), pick a public exponent e coprime to phi (65537 is conventional), then compute the private exponent d as the modular inverse of e modulo phi. Encryption raises each message block m to the power e modulo n: ciphertext c = pow(m, e, n). Decryption raises c to d modulo n, recovering m because pow(m, e*d, n) == m by Euler's theorem. RSA is the foundation of TLS handshakes, SSH host keys, and S/MIME certificate signatures. This file is a pedagogical implementation only: it uses no padding (real RSA requires OAEP), 1024-bit keys (production requires 2048+ bits), and does not validate key provenance.

1 / 6

Imports, Constants, and the Companion Module

ciphers/rsa_cipher.py:1

rsa_cipher.py depends on rsa_key_generator for key creation and sets two block-encoding constants: DEFAULT_BLOCK_SIZE and BYTE_SIZE.

RSA key generation (picking primes, computing n, phi, e, d) and RSA message processing are split across two files. This file handles message encoding, encryption, decryption, and file I/O. Key generation lives in rsa_key_generator.py, imported on line 4 as rkg; the only call this file makes into it is rkg.make_key_files("rsa", 1024) in the main driver when no key files exist yet. Line 6 sets DEFAULT_BLOCK_SIZE = 128 bytes per block, which is the chunk size at which the message is split before encryption. Line 7 sets BYTE_SIZE = 256, the number of distinct ASCII byte values (0-255). Both constants appear in the block-encoding and block-decoding functions below. The 128-byte block size is why the key must be at least 1024 bits: a block of 128 bytes is 1024 bits, and the modular arithmetic requires the message integer to be smaller than n.

Key takeaway

Block size and key size are coupled. A 128-byte block is 1024 bits, so the key must be at least 1024 bits for the modular math to hold.

import os
import sys

from . import rsa_key_generator as rkg

DEFAULT_BLOCK_SIZE = 128
BYTE_SIZE = 256
2 / 6

read_key_file: Parsing the Key from Disk

ciphers/rsa_cipher.py:63

Key files store key_size, n, and either e or d as comma-separated integers. read_key_file parses them and returns a 3-tuple of ints.

The key file format is intentionally minimal: a single line with three comma-separated integers. The first integer is the key size in bits, which the caller uses to enforce the block-size limit. The second is the modulus n (the product of the two primes). The third is either the public exponent e (in the public key file) or the private exponent d (in the private key file). Both the encryption path and the decryption path call this same function; they pass different filenames. Line 66 splits on commas and line 67 converts each token to an integer. That simplicity is intentional: contributors can inspect a key file in a text editor and see all three components. A production key format like PKCS#8 carries metadata, algorithm identifiers, and encoding, none of which are needed for understanding the mathematical structure.

Key takeaway

The key file is three comma-separated integers: key size in bits, modulus n, and exponent e or d. Both encrypt and decrypt paths call the same parser with different filenames.

def read_key_file(key_filename: str) -> tuple[int, int, int]:
    with open(key_filename) as fo:
        content = fo.read()
    key_size, n, eor_d = content.split(",")
    return (int(key_size), int(n), int(eor_d))
3 / 6

encrypt_message: pow(block, e, n) on Each Integer Block

ciphers/rsa_cipher.py:39

encrypt_message unpacks the public key into n and e, then maps pow(block, e, n) over every integer block produced by get_blocks_from_text.

RSA encryption is one operation: raise each plaintext block to the public exponent e modulo n. Python's built-in pow(block, e, n) on line 46 does this efficiently with fast modular exponentiation (square-and-multiply), avoiding the need to compute the intermediate giant integer block**e before taking the remainder. Line 43 unpacks the 2-tuple key into n and e, matching the format that read_key_file returns after dropping the key-size field. get_blocks_from_text converts the message string into a list of integers, one per 128-byte chunk, by treating each byte as a positional digit in base 256. The result is a list of ciphertext integers, one per plaintext block. To decrypt, a holder of the private key d computes pow(ciphertext_block, d, n), which Euler's theorem guarantees recovers the original integer.

Key takeaway

RSA encryption is pow(block, e, n). Python's three-argument pow uses modular exponentiation internally, so no intermediate giant integer is formed.

def encrypt_message(
    message: str, key: tuple[int, int], block_size: int = DEFAULT_BLOCK_SIZE
) -> list[int]:
    encrypted_blocks = []
    n, e = key

    for block in get_blocks_from_text(message, block_size):
        encrypted_blocks.append(pow(block, e, n))
    return encrypted_blocks
4 / 6

decrypt_message: pow(block, d, n) Reverses the Encryption

ciphers/rsa_cipher.py:50

decrypt_message applies pow(block, d, n) to each ciphertext integer and then calls get_text_from_blocks to reassemble the original string.

Decryption mirrors encryption in structure and is shorter in code. Line 57 unpacks the private key into n and d. The loop on lines 58 and 59 applies pow(block, d, n) to each ciphertext integer. Euler's theorem guarantees that if d is the modular inverse of e modulo phi(n), then pow(pow(m, e, n), d, n) == m. So each decrypted block recovers the original plaintext integer. Line 60 passes those integers to get_text_from_blocks along with the original message length, which is needed because the last block may be shorter than 128 bytes and the decoder uses the length to trim padding zeroes. Without the message length, the decoder cannot distinguish a trailing null byte that was part of the message from one introduced by block-size padding.

Key takeaway

Decryption is pow(block, d, n) per block, then reassembly. The message length is passed explicitly because the last block may be shorter than the block size.

def decrypt_message(
    encrypted_blocks: list[int],
    message_length: int,
    key: tuple[int, int],
    block_size: int = DEFAULT_BLOCK_SIZE,
) -> str:
    decrypted_blocks = []
    n, d = key
    for block in encrypted_blocks:
        decrypted_blocks.append(pow(block, d, n))
    return get_text_from_blocks(decrypted_blocks, message_length, block_size)
5 / 6

encrypt_and_write_to_file: Block-Size Guard and File Format

ciphers/rsa_cipher.py:70

Before encrypting, the function checks that the key is at least as large as the block in bits, then writes a self-describing output file.

The block-size guard on line 77 is where the mathematical constraint is enforced in code. RSA requires that every plaintext integer be smaller than n. If a 128-byte block is 1024 bits and the key modulus is only 512 bits, the block integer can exceed n, and modular arithmetic breaks the round-trip. Line 77 multiplies the byte block size by 8 to get bits and compares it against the key size recorded in the key file. A mismatch exits with a diagnostic message rather than silently producing garbage ciphertext. Line 87 writes the output file in a self-describing format: message_length_block_size_comma_separated_blocks. The decryption path reads this prefix to know how many characters to recover and which block size was used, making the file independent of any external configuration.

Key takeaway

The guard at line 77 prevents encrypting a block larger than the key modulus. The output file header embeds message length and block size so the decryption path is self-contained.

def encrypt_and_write_to_file(
    message_filename: str,
    key_filename: str,
    message: str,
    block_size: int = DEFAULT_BLOCK_SIZE,
) -> str:
    key_size, n, e = read_key_file(key_filename)
    if key_size < block_size * 8:
        sys.exit(
            f"ERROR: Block size is {block_size * 8} bits and key size is {key_size} "
            "bits. The RSA cipher requires the block size to be equal to or greater "
            "than the key size. Either decrease the block size or use different keys."
        )

    encrypted_blocks = [str(i) for i in encrypt_message(message, (n, e), block_size)]

    encrypted_content = ",".join(encrypted_blocks)
    encrypted_content = f"{len(message)}_{block_size}_{encrypted_content}"
    with open(message_filename, "w") as fo:
        fo.write(encrypted_content)
    return encrypted_content
6 / 6

main: Interactive Driver and the __main__ Guard

ciphers/rsa_cipher.py:115

The main driver prompts for encrypt or decrypt mode, generates keys on first run if missing, and reads or writes the encrypted file.

The main function ties the file together and shows the full encrypt-decrypt cycle at the interactive level. Line 125 checks whether a public key file already exists before generating one; on the first run rkg.make_key_files("rsa", 1024) generates a 1024-bit keypair and writes two files, rsa_pubkey.txt and rsa_privkey.txt, to the current directory. Encryption reads the public key, encrypts the message, and writes the ciphertext to encrypted_file.txt. Decryption reads the private key and the ciphertext file, decrypts, and writes the result to rsa_decryption.txt. The file is explicit about what it is not: there is no certificate verification, no padding scheme, no key exchange, and no protection against timing attacks. Running this file is the fastest way to see RSA round-trip a message end to end at the integer level.

Key takeaway

The driver generates a 1024-bit keypair on first run, then encrypts to one file and decrypts from another. This is the fastest path to seeing RSA work end to end.

def main() -> None:
    filename = "encrypted_file.txt"
    response = input(r"Encrypt\Decrypt [e\d]: ")

    if response.lower().startswith("e"):
        mode = "encrypt"
    elif response.lower().startswith("d"):
        mode = "decrypt"

    if mode == "encrypt":
        if not os.path.exists("rsa_pubkey.txt"):
            rkg.make_key_files("rsa", 1024)

        message = input("\nEnter message: ")
        pubkey_filename = "rsa_pubkey.txt"
        print(f"Encrypting and writing to {filename}...")
        encrypted_text = encrypt_and_write_to_file(filename, pubkey_filename, message)

        print("\nEncrypted text:")
        print(encrypted_text)

    elif mode == "decrypt":
        privkey_filename = "rsa_privkey.txt"
        print(f"Reading from {filename} and decrypting...")
        decrypted_text = read_from_file_and_decrypt(filename, privkey_filename)
        print("writing decryption to rsa_decryption.txt...")
        with open("rsa_decryption.txt", "w") as dec:
            dec.write(decrypted_text)

        print("\nDecryption:")
        print(decrypted_text)


if __name__ == "__main__":
    main()
Your codebase next

Create code tours for your project

Intraview lets AI create interactive walkthroughs of any codebase. Install the free VS Code extension and generate your first tour in minutes.

Install Intraview Free