RSA: A Lock Anyone Can Close
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
rsa.ts
import { EuclideanAlgorithm } from "../number-theory/euclidean-algorithm";import { ModularArithmetic } from "../number-theory/modular-arithmetic";import type { Tracer } from "../trace";/** Primes below 2^16, so n stays below 2^32 and every power is exact. */export const MAX_PRIME = 65521;/** The public exponents in use: small, with few 1 bits, so encryption is a couple of multiplications. */export const PUBLIC_EXPONENTS: readonly number[] = [3, 5, 17, 257];/** The factoring attack gives up after this many trial divisors, which covers any n below 2^16. */export const MAX_TRIALS = 128;/** Letters per message on the stage: one block each. */export const MAX_LETTERS = 8;export interface PublicKey { n: number; e: number;}export interface PrivateKey { n: number; d: number; privateExponent: number; p: number; q: number; phi: number; totient: number;}export interface RsaKeyPair { publicKey: PublicKey; privateKey: PrivateKey;}export interface Factoring { n: number; p: number | null; q: number | null; trials: number; found: boolean;}export interface RsaRun { keys: RsaKeyPair; plain: string; cipher: number[]; decrypted: string; roundTrip: boolean; factoring: Factoring; /** The private key the attacker rebuilt from the public one, or null. */ cracked: PrivateKey | null;}/** * RSA: two primes p and q make a modulus n = pq that anyone may see, * and a public exponent e that is coprime to phi = (p - 1)(q - 1). The * private exponent d is the inverse of e mod phi, which Euclid finds * in a moment for whoever knows phi, and phi needs the factors. A * message m below n is encrypted as m to the e mod n and decrypted * as the result to the d, because ed is 1 plus a multiple of phi and * Euler's theorem makes m to a multiple of phi equal 1. The attacker * who wants d must factor n, and the stage's attacker tries every * divisor, which works below 2^16 and fails at the 2048 bits real * keys use. One letter per block, as here, is textbook RSA and leaks * like codebook mode: real use pads each block with randomness. * * Time: O(log e) multiplications to encrypt, O(log d) to decrypt. Space: O(1). */export class RsaCipher { constructor( private readonly tracer?: Tracer, private readonly modular: ModularArithmetic = new ModularArithmetic(), private readonly euclid: EuclideanAlgorithm = new EuclideanAlgorithm(), ) {} /** The key pair from two distinct primes and a public exponent coprime to phi. */ keygen(p: number, q: number, e: number): RsaKeyPair { for (const prime of [p, q]) if (!ModularArithmetic.isPrime(prime) || prime > MAX_PRIME) throw new RangeError(`${prime} is not a prime up to ${MAX_PRIME}`); if (p === q) throw new RangeError("the two primes must differ"); const n = p * q; // ▸ n = p times q is public; p and q are not this.tracer?.emit("modulus", { p, q, n }); const phi = (p - 1) * (q - 1); const totient = (p - 1) * (q - 1); // ▸ phi counts the residues coprime to n, and needs p and q this.tracer?.emit("totient", { p, q, phi }); this.tracer?.emit("totient", { p, q, totient }); const gcd = this.euclid.gcd(e, phi); const gcd = this.euclid.gcd(e, totient); const coprime = gcd === 1; // ▸ e must share no factor with phi, or it has no inverse this.tracer?.emit("exponent", { e, phi, gcd, coprime }); this.tracer?.emit("exponent", { e, totient, gcd, coprime }); if (!coprime) throw new RangeError(`${e} shares the factor ${gcd} with phi = ${phi}`); const d = this.euclid.inverse(e, phi).inverse!; const privateExponent = this.euclid.inverse(e, totient).inverse!; // ▸ d is the inverse of e mod phi: euclid, for whoever knows phi this.tracer?.emit("private-key", { e, d, phi, check: (e * d) % phi }); this.tracer?.emit("private-key", { e, privateExponent, totient, check: (e * privateExponent) % totient }); return { publicKey: { n, e }, privateKey: { n, d, p, q, phi } }; return { publicKey: { n, e }, privateKey: { n, privateExponent, p, q, totient } }; } /** m to the e mod n. Untraced: the text methods narrate per letter. */ encrypt(message: number, key: PublicKey): number { if (!Number.isInteger(message) || message < 0 || message >= key.n) throw new RangeError(`a message is a whole number below n = ${key.n}, got ${message}`); return this.modular.pow(message, key.e, key.n); } /** c to the d mod n. */ decrypt(cipher: number, key: PrivateKey): number { return this.modular.pow(cipher, key.d, key.n); return this.modular.pow(cipher, key.privateExponent, key.n); } /** Uppercase letters only, one block each. */ static clean(text: string): string { return text.toUpperCase().replace(/[^A-Z]/g, "").slice(0, MAX_LETTERS); } /** Each letter's code raised to e: textbook RSA, one block per letter. */ encryptText(text: string, key: PublicKey): number[] { return Array.from(text, (letter, index) => { const message = letter.charCodeAt(0); const cipher = this.encrypt(message, key); // ▸ a block to the e, mod n: anyone can do this this.tracer?.emit("encrypt", { index, letter, message, e: key.e, n: key.n, cipher }); return cipher; }); } /** Each block raised to d and read back as a letter. */ decryptText(blocks: readonly number[], key: PrivateKey): string { return blocks .map((cipher, index) => { const message = this.decrypt(cipher, key); const letter = String.fromCharCode(message); // ▸ the block to the d, mod n: only the holder of d can this.tracer?.emit("decrypt", { index, cipher, d: key.d, n: key.n, message, letter }); this.tracer?.emit("decrypt", { index, cipher, privateExponent: key.privateExponent, n: key.n, message, letter }); return letter; }) .join(""); } /** Trial division: 2, then every odd number up to the square root, within the trial budget. */ factor(n: number): Factoring { let trials = 0; const divisors = [2]; for (let d = 3; d * d <= n && divisors.length < MAX_TRIALS; d += 2) divisors.push(d); for (let privateExponent = 3; privateExponent * privateExponent <= n && divisors.length < MAX_TRIALS; privateExponent += 2) divisors.push(privateExponent); for (const divisor of divisors) { trials += 1; const remainder = n % divisor; const hit = remainder === 0; // ▸ the attacker tries a divisor of n this.tracer?.emit("trial", { divisor, n, remainder, hit }); if (hit) return { n, p: divisor, q: n / divisor, trials, found: true }; } return { n, p: null, q: null, trials, found: false }; } /** The attacker's whole job: factor n, recompute phi, invert e. */ crack(key: PublicKey): { factoring: Factoring; privateKey: PrivateKey | null } { const factoring = this.factor(key.n); let privateKey: PrivateKey | null = null; if (factoring.found && factoring.p !== null && factoring.q !== null) { const phi = (factoring.p - 1) * (factoring.q - 1); const totient = (factoring.p - 1) * (factoring.q - 1); const d = this.euclid.inverse(key.e, phi).inverse; const privateExponent = this.euclid.inverse(key.e, totient).inverse; if (d !== null) privateKey = { n: key.n, d, p: factoring.p, q: factoring.q, phi }; if (privateExponent !== null) privateKey = { n: key.n, privateExponent, p: factoring.p, q: factoring.q, totient }; } // ▸ with p and q, phi and then d follow at once this.tracer?.emit("cracked", { n: key.n, p: factoring.p, q: factoring.q, phi: privateKey?.phi ?? null, d: privateKey?.d ?? null, trials: factoring.trials, found: privateKey !== null }); this.tracer?.emit("cracked", { n: key.n, p: factoring.p, q: factoring.q, totient: privateKey?.totient ?? null, privateExponent: privateKey?.privateExponent ?? null, trials: factoring.trials, found: privateKey !== null }); return { factoring, privateKey }; } /** Make the keys, encrypt a short text letter by letter, decrypt it, then attack the public key. */ run(p: number, q: number, e: number, text: string): RsaRun { const keys = this.keygen(p, q, e); const plain = RsaCipher.clean(text); const cipher = this.encryptText(plain, keys.publicKey); const decrypted = this.decryptText(cipher, keys.privateKey); const { factoring, privateKey } = this.crack(keys.publicKey); return { keys, plain, cipher, decrypted, roundTrip: decrypted === plain, factoring, cracked: privateKey }; }}n = 12091, d = 9777; the message came back intact, and the attacker rebuilt d after 54 trials.