A study project: the simplest possible implementation of asymmetric encryption with a public key in Python. The idea is close to RSA, but the code is written to be read and understood rather than to be used in production.
Python's built-in math functions are not used inside the algorithms, for clarity. Only basic operators are allowed:
- floor division
- modulo division
- exponentiation
- bitwise operators
The project has no third-party dependencies.
crypto_math.py # pure mathematical primitives
crypto_text.py # pure text <-> bytes <-> int helpers and block splitting
main.py # scenario: key generation, encryption, transcript
test_math.py # tests for crypto_math
test_crypto_text.py# tests for crypto_text
test_main.py # tests for main (attack, CLI helpers, round-trip)
README.md # this document
LICENSE # MIT license
crypto_math.py and crypto_text.py are both side-effect free: no input or
output, no randomness, no access to files or the clock. main.py imports from
both and only prints the transcript.
In crypto_math.py (all side-effect free, no I/O and no randomness):
mod_exp(x, y, z)— modular exponentiation by repeated squaring, for fast computation of large powers moduloz.bin_sqrt(x)— floor integer square root by binary search (likemath.isqrt).gcd(x, y)— greatest common divisor by the Euclidean algorithm.extended_gcd(a, b)— extended Euclidean algorithm; returns(g, x, y)witha*x + b*y = g = gcd(a, b)(Bezout coefficients).mod_mul_inverse(a, b)— modular multiplicative inverse: thexsuch that(x * a) % b == 1.trial_division(x)— deterministic primality test by trying the divisors.fermat_test(n, k)— Fermat primality test, exponentially faster but probabilistic.
In crypto_text.py (also side-effect free):
DEFAULT_MESSAGE— the built-in message:A..Z,a..z,0..9and the common special characters (94 characters in total, no space).COLUMNS— number of table columns used by the character breakdown (5).BREAKDOWN_THRESHOLD—COLUMNS * len(DEFAULT_MESSAGE)(470): the largest message for which the per-character table is printed.text_to_int(text)/int_to_text(n)— convert a string to a message integer and back using big-endian UTF-8.encoding_steps(text)— the whole chain(text, bytes, hex, int)shown in the transcript.char_records(text)— per character:(index, char, 'U+XXXX', utf8_hex).char_categories(text)— group characters into uppercase / lowercase / digits / special / other.block_size(modulus)— largest block size in bytes that always stays belowmodulus.split_blocks(data, k)/join_blocks(blocks, k)— cut a byte string into blocks and rebuild it (leading zero bytes are preserved).
In main.py:
get_rand(b)— a random integer ofbbytes (os.urandom, or a seeded generator for reproducible runs).is_prime(x, prime_method)— primality dispatcher selected by--prime(trial_divisionorfermat_test).validate_prime(x, name, prime_method)— always deterministic re-check of an explicitly suppliedporq; reports a composite (and names a Carmichael number) with a readable error instead of crashing.common_divisor(a, b, gcd_method)— GCD dispatcher selected by--gcd(gcdorextended_gcd).gen_prime(b, prime_method)— generate a prime ofbbytes using the selected primality test.gen_close_primes(b, prime_method)— generate two primes ofbbytes that are close to each other, for the factorization attack.fermat_factor(n)— factorn = p * qwith Fermat's method (usesbin_sqrt); fast when the factors are close.decimal_digits(x)/print_big_int(label, value)— print an integer, or a summary when its decimal form would exceed theint -> strlimit.looks_like_int(s)— whether--mshould be read as a number or as text.show_stat(...)— inline progress indicator (debug helper).- the encryption scheme and its readable transcript.
The scheme follows the classic RSA key generation:
- choose two primes
pandq; - compute the modulus
N = p * q; - compute the Carmichael function
lambda(N) = lcm(p - 1, q - 1)(computed as(p - 1) * (q - 1) // gcd(p - 1, q - 1)). It plays the same role as Euler's totientphi(N) = (p - 1) * (q - 1)but is smaller; - choose the public exponent
Ewith1 < E < lambda(N)andgcd(E, lambda(N)) == 1(the usual choice is65537 = 2 ** 16 + 1, the fourth Fermat number — note this is2 ** 16 + 1, not2 ** 16); - compute the private exponent
D = E ** -1 mod lambda(N), that is(D * E) % lambda(N) == 1.
Encryption raises each plaintext value to the power E modulo N; decryption
raises the ciphertext to the power D modulo N:
c = m ** E mod N
m = c ** D mod N
Modular exponentiation by repeated squaring:
mod_exp(x, y, z):
r = 1
while y:
if y is odd: r = r * x mod z
y = y // 2
x = x * x mod z
return r
Extended Euclidean algorithm gives Bezout coefficients, from which the inverse is obtained:
extended_gcd(a, b) -> (g, x, y) with a*x + b*y = g
mod_mul_inverse(a, b) -> x mod b
Text is encoded exactly the way UTF-8 works: every character is turned into
its bytes, the bytes are concatenated, and the result is read as one big
big-endian integer m. The transcript shows each step
(text -> UTF-8 -> hex -> int) and, for messages up to
BREAKDOWN_THRESHOLD characters, a per-character table
(index, char, code point U+XXXX, UTF-8 hex) laid out in COLUMNS
columns. The default message is the full alphabet plus digits plus the common
special characters, so the table shows one character of every kind.
A single character is far too small to be encrypted on its own, and encrypting character by character would be weak anyway. Instead the message is split into blocks:
k = (N.bit_length() - 1) // 8 # block size in bytes
for each block: c = block ** E mod N # encrypt
for each block: r = c ** D mod N # decrypt
k is chosen so that every k-byte block is smaller than N. All blocks but
the last are left-padded with zero bytes when the text is rebuilt, so leading
zero bytes are not lost. For the default 6-byte primes k is 11 bytes, so the
94-character default message is one block, while a longer sentence is split
into several and every block is genuinely encrypted.
When --m is a number, the historical single-value scheme is kept: m is
split against N as
n = m // N— the quotient (a block counter, not encrypted);r = m % N— the remainder, this is the part actually encrypted;block = N - 1— a fixed marker.
The marker N - 1 is a deliberate teaching device: for any odd E,
(N - 1) ** E mod N = N - 1
so the marker survives the round-trip unchanged and demonstrates that the RSA
formula works. Reconstruction is d = (block_dec + 1) * n + r_dec, which
relies on block_dec == N - 1. A direct consequence is that the marker does
not hide any information: in the numeric path only r is genuinely
encrypted. The text path above does not use the marker at all.
One command, sensible defaults:
python3 main.py
By default two 6-byte primes are generated and the built-in alphabet message
(DEFAULT_MESSAGE) is encoded and encrypted end to end, text block by block.
python3 main.py --m "Hello, World! 123" # text (not all digits -> text)
python3 main.py --text 12345 # force text even when numeric
python3 main.py --m 1234567 # a number -> legacy numeric path
python3 main.py --m "-42" # any signed integer is a number
--m— the message: if it looks like an integer it is encrypted as a number (legacy numeric path), otherwise it is treated as UTF-8 text;--text— force the text path, which is the only way to send a message made of digits only.
The last result block prints recovered int and, on the text path,
recovered text; the numeric path prints recovered int only.
python3 main.py --p 61 --q 53 # deterministic key parameters
python3 main.py --seed 42 # fix the random generator
python3 main.py --bits 8 # size of the generated primes
--seed— seed the random generator so a run can be reproduced exactly;--p,--q— explicit primes (deterministic scenario);--bits— size in bytes of the generated primes.
Two algorithms are interchangeable and can be selected explicitly; the program works without any flag and then uses the defaults:
python3 main.py --gcd extended # gcd via extended_gcd instead of gcd
python3 main.py --prime fermat # primality via fermat_test instead of trial_division
python3 main.py --gcd extended --prime fermat
--gcd {euclid,extended}—euclid(default) usesgcd;extendedusesextended_gcd, which also returns the Bezout coefficients(g, x, y)witha*x + b*y = g. The shared factorg— and thereforelambda(N)and the whole transcript — is identical either way; only the algorithm differs.--prime {trial,fermat}—trial(default) uses the deterministictrial_division;fermatuses the probabilisticfermat_test(withFERMAT_ROUNDSbases).
When a non-default method is selected, a short educational note is printed before the transcript, for example:
primality: fermat_test is probabilistic and accepts Carmichael numbers
(e.g. 561, 1105, 1729, 2465, 2821); p and q are re-checked
deterministically anyway.
Nothing extra is printed on a plain run with the defaults.
Carmichael numbers.
fermat_testpasses every Carmichael number for the bases coprime to it, so it can report a composite as prime. All five smallest examples are561, 1105, 1729, 2465, 2821. Whatever--primeselects, the explicitpandqare always re-checked with the deterministictrial_division: a composite is refused with a readable error and a Carmichael number is called out by name (see "Provoking the edge cases" below).
Modular inverse and integer square root are not configurable: they have a single
implementation each (mod_mul_inverse via extended_gcd, and bin_sqrt).
--attack generates two deliberately close primes, publishes N and E,
then recovers the factors from N alone with Fermat's method and rebuilds the
private exponent D:
python3 main.py --attack --seed 42
Fermat's method is fast only when the factors are close, which is why the
attack uses gen_close_primes. bin_sqrt supplies both the starting value
ceil(sqrt(N)) and the exact perfect-square test for each step.
The program never terminates with a traceback for the situations below; it prints a readable message (and exits with status 2 for the CLI).
python3 main.py --p 4 --q 53 # p is composite
python3 main.py --p 561 --q 1105 # p is a Carmichael number (fermat_test accepts it)
python3 main.py --text "$(python3 -c 'print("A" * 6000)')" # text too long for int -> str
python3 main.py --m "$(python3 -c 'print("1" + "0" * 5000)')" # huge decimal number
- Composite
porq(--p 4) —validate_primerefuses it:error: p = 4 is not a prime number. - Carmichael
porq(--p 561) — recognised and named:error: p = 561 is a composite Carmichael number: it passes fermat_test yet is not prime (examples: 561, 1105, 1729, 2465, 2821). This is exactly the case--prime fermatcould otherwise let through, so it is always caught. - Very long text — the message integer exceeds Python's
int -> strlimit (4300 digits by default), som (int)andrecovered intare summarised (digit count, bit length and a hexadecimal prefix) instead of converting a huge decimal number. This needs nosys.get_int_max_str_digits: the decision is made frombit_length()before any conversion. - Huge decimal
--m— such an argument cannot even be parsed (the same limit blocksstr -> intin base 10), so it is reported:error: --m has 5001 digits, which exceeds the int -> str limit; use a shorter number or pass the value as text with --text. - Many blocks — with more than
BLOCK_PRINT_LIMIT(20) blocks the transcript prints only the first and lastBLOCK_PRINT_EDGE(3) and a... N blocks omitted ...line; the underlying round-trip is still complete.
This project is intentionally simple and must not be used for real cryptography:
- The legacy numeric path does not encrypt the whole number. As explained
above, only the remainder
ris actually hidden; the markerN - 1is left in the clear. Use the text path for a scheme that encrypts every block. - The Fermat test is probabilistic.
fermat_testcan accept a Carmichael number (such as561, or any of561, 1105, 1729, 2465, 2821) as prime. The deterministic path used for the demonstration istrial_division, and even with--prime fermatthe chosenpandqare re-checked deterministically; no stronger algorithm is added on purpose, to keep the code readable. - Text is encoded without padding, so the scheme offers no semantic security.
- Primes that are too close are factorable. The
--attackmode shows that whenpandqare near each other,Ncan be split by Fermat's method in negligible time (a real key must use primes chosen independently and large enough that factoring is infeasible).
The test suite uses only the standard unittest module:
python3 -m unittest
It is split by module: test_math.py checks the primitives against Python's
built-in equivalents (pow, math.gcd, math.isqrt) and the regression cases
for trial_division (9, 15, 25 are composite) and bin_sqrt(0) == 0;
test_crypto_text.py checks the text/int round-trip, the per-character records
and categories, the block split/join (including leading zero bytes) and the
block size; test_main.py checks the Fermat attack, the --gcd / --prime
dispatchers, prime validation (including the Carmichael case), the long-number
summary, block truncation, the CLI error paths, the CLI helper looks_like_int,
the end-to-end round-trip on fixed parameters and the two output paths. Both
pure modules are asserted to contain no I/O and no randomness.
Released under the MIT license. See LICENSE.