Elliptic curve primality proving in Python
Proves n prime with a chain of elliptic curves of known order, built by complex multiplication. The proof is a certificate that is fast to check; it is the fastest general proof in practice.
- File
ecpp.py, 694 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/ecpp/. The authors are listed in prov/5ZT65R.jsonl.
"""Atkin-Morain elliptic curve primality proving (ECPP).
ecpp(n) returns a primality certificate (a list of Step records) when n is
prime, and None when n is composite. The answer is never wrong: a None comes
with a proof that n is composite, and a certificate is a proof that n is
prime, which verify_certificate checks with the Goldwasser-Kilian theorem.
Randomness affects only the running time. The last number of a chain is
below 2^64 and is proved prime by the strong test to the first 12 prime
bases, which is exact below 2^64.
Each step finds a fundamental discriminant D < 0 with 4n = u^2 + |D| v^2, an
order m of a curve with complex multiplication by D that splits as m = k q
with q a probable prime above (n^(1/4) + 1)^2, builds the curve from a root of
the Hilbert class polynomial H_D modulo n, and checks [k]P != O and [m]P = O
for a point P. Then the primality of n follows from the primality of q.
Cost, with lg n binary digits and arbitrary precision integers (Python int):
heuristically O((log n)^2) discriminants and orders per step, each order
costing a probable prime test of O((log n)^3), and O(log n) steps, so
O((log n)^5) to O((log n)^6) in all; no part of this is proved.
"""
import math
import random
from decimal import Decimal, localcontext
from typing import NamedTuple
LEAF = 2**64
TRIAL_BOUND = 2**16
MAX_ABS_DISCRIMINANT = 10000
MAX_CLASS_NUMBER = 40
POINT_TRIES = 20
FIRST_12_PRIMES = (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37)
class Composite(Exception):
"""Raised when the current number is proved composite."""
class Step(NamedTuple):
"""One link of a certificate: the curve y^2 = x^3 + a x + b modulo n,
the point P = (x, y), and the order m = k q with [k]P != O, [m]P = O."""
n: int
a: int
b: int
x: int
y: int
k: int
q: int
def primes_up_to(m: int) -> list[int]:
"""The primes p <= m, by the sieve of Eratosthenes."""
if m < 2:
return []
sieve = bytearray([1]) * (m + 1)
sieve[0] = sieve[1] = 0
for i in range(2, math.isqrt(m) + 1):
if sieve[i]:
sieve[i * i :: i] = bytearray(len(range(i * i, m + 1, i)))
return [i for i in range(m + 1) if sieve[i]]
SMALL_PRIMES = primes_up_to(TRIAL_BOUND)
# ---------------------------------------------------------------- integers
def jacobi(a: int, n: int) -> int:
"""The Jacobi symbol (a/n) for odd n >= 1."""
if n < 1 or n % 2 == 0:
raise ValueError("n must be odd and positive")
a %= n
result = 1
while a:
while a % 2 == 0:
a //= 2
if n % 8 in (3, 5):
result = -result
a, n = n, a
if a % 4 == 3 and n % 4 == 3:
result = -result
a %= n
return result if n == 1 else 0
def is_strong_probable_prime(n: int, base: int) -> bool:
"""The strong (Miller-Rabin) test of the odd number n > 2 to one base."""
d, s = n - 1, 0
while d % 2 == 0:
d //= 2
s += 1
x = pow(base, d, n)
if x in (1, n - 1):
return True
for _ in range(s - 1):
x = x * x % n
if x == n - 1:
return True
return False
def is_prime_below_leaf(n: int) -> bool:
"""Exact primality for 0 <= n < 2^64: the strong test to the first 12
primes has no composite liar below 2^64 (Sorenson and Webster 2017)."""
if n >= LEAF:
raise ValueError("n must be below 2^64")
if n < 2:
return False
for p in FIRST_12_PRIMES:
if n % p == 0:
return n == p
return all(is_strong_probable_prime(n, p) for p in FIRST_12_PRIMES)
def is_probable_prime(n: int) -> bool:
"""Exact below 2^64; above, the strong test to the first 12 primes."""
if n < LEAF:
return is_prime_below_leaf(n)
if n % 2 == 0 or not is_strong_probable_prime(n, 2):
return False
return all(is_strong_probable_prime(n, p) for p in FIRST_12_PRIMES[1:])
def inverse(x: int, n: int) -> int:
"""x^(-1) modulo n; raises Composite if gcd(x, n) is a proper factor."""
x %= n
g = math.gcd(x, n)
if g != 1:
if g == n:
raise ZeroDivisionError("x is 0 modulo n")
raise Composite(g)
return pow(x, -1, n)
def sqrt_mod(a: int, n: int) -> int | None:
"""Tonelli-Shanks: a root of y^2 = a modulo n, correct when n is an odd
prime and (a/n) = 1. For other n it may return None or a wrong value, so
the caller checks the answer."""
a %= n
if a == 0:
return 0
if n % 4 == 3:
return pow(a, (n + 1) // 4, n)
q, s = n - 1, 0
while q % 2 == 0:
q //= 2
s += 1
z = 2
while True:
j = jacobi(z, n)
if j == -1:
break
if j == 0:
raise Composite(math.gcd(z, n))
z += 1
m, c, t, r = s, pow(z, q, n), pow(a, q, n), pow(a, (q + 1) // 2, n)
while t != 1:
i, t2 = 0, t
while t2 != 1:
t2 = t2 * t2 % n
i += 1
if i == m:
return None
b = pow(c, 1 << (m - i - 1), n)
m, c = i, b * b % n
t, r = t * c % n, r * b % n
return r
def large_enough(q: int, n: int) -> bool:
"""Exact test of q > (n^(1/4) + 1)^2, that is n < (q^(1/2) - 1)^4."""
if q <= 1:
return False
rest = q * q + 6 * q + 1 - n
return rest > 0 and rest * rest > 16 * (q + 1) ** 2 * q
def cornacchia(d: int, n: int) -> tuple[int, int] | None:
"""Modified Cornacchia (Crandall and Pomerance, Algorithm 2.3.13): u, v
with u^2 + d v^2 = 4n, for -D = d, D = 0 or 1 modulo 4 and (D/n) = 1. For
a prime n the answer is None exactly when no solution exists. Raises
Composite if no square root of D modulo n is found."""
disc = -d % n
x0 = sqrt_mod(disc, n)
if x0 is None or x0 * x0 % n != disc:
raise Composite()
if (x0 + d) % 2:
x0 = n - x0
a, b, c = 2 * n, x0, math.isqrt(4 * n)
while b > c:
a, b = b, a % b
t = 4 * n - b * b
if t % d:
return None
v = math.isqrt(t // d)
if v * v != t // d:
return None
return b, v
# ------------------------------------------------- discriminants and H_D
def _squarefree(m: int) -> bool:
p = 2
while p * p <= m:
if m % (p * p) == 0:
return False
p += 1
return True
def is_fundamental(D: int) -> bool:
"""D < 0 is a fundamental discriminant."""
if D >= 0:
return False
if D % 4 == 1:
return _squarefree(-D)
if D % 4 == 0:
return (D // 4) % 4 in (2, 3) and _squarefree(-D // 4)
return False
def reduced_forms(D: int) -> list[tuple[int, int, int]]:
"""The reduced forms (a, b, c), b^2 - 4ac = D: |b| <= a <= c, and b >= 0
when |b| = a or a = c. Their number is the class number h(D)."""
forms = []
a = 1
while 3 * a * a <= -D:
for b in range(-a + 1, a + 1):
num = b * b - D
if num % (4 * a):
continue
c = num // (4 * a)
if c < a or (b < 0 and c == a):
continue
forms.append((a, b, c))
a += 1
return forms
_DISCRIMINANTS: list[int] = []
def discriminants() -> list[int]:
"""Fundamental D with |D| <= 10000 and h(D) <= 40, by h then |D|. The
class numbers come from one pass over all reduced forms."""
if not _DISCRIMINANTS:
count: dict[int, int] = {}
a = 1
while 3 * a * a <= MAX_ABS_DISCRIMINANT:
for b in range(-a + 1, a + 1):
c = a
while 4 * a * c - b * b <= MAX_ABS_DISCRIMINANT:
if not (b < 0 and c == a):
D = b * b - 4 * a * c
count[D] = count.get(D, 0) + 1
c += 1
a += 1
found = sorted((h, -D) for D, h in count.items() if h <= MAX_CLASS_NUMBER and is_fundamental(D))
_DISCRIMINANTS.extend(-d for _, d in found)
return _DISCRIMINANTS
def _pi() -> Decimal:
"""pi to the current precision (the recipe of the decimal module)."""
with localcontext() as ctx:
ctx.prec += 2
lasts, t, s, n, na, d, da = 0, Decimal(3), Decimal(3), 1, 0, 0, 24
while s != lasts:
lasts = s
n, na = n + na, na + 8
d, da = d + da, da + 32
t = (t * n) / d
s += t
return +s
def _cos_sin(x: Decimal) -> tuple[Decimal, Decimal]:
"""cos x and sin x by their Taylor series, for |x| <= 4."""
with localcontext() as ctx:
ctx.prec += 4
cos_sum, sin_sum = Decimal(0), Decimal(0)
term, k = Decimal(1), 0
while term:
if k % 2 == 0:
cos_sum += term if k % 4 == 0 else -term
else:
sin_sum += term if k % 4 == 1 else -term
k += 1
term = term * x / k
if abs(term) < Decimal(10) ** (-ctx.prec - 2):
break
return +cos_sum, +sin_sum
def _cmul(u: tuple[Decimal, Decimal], v: tuple[Decimal, Decimal]) -> tuple[Decimal, Decimal]:
return (u[0] * v[0] - u[1] * v[1], u[0] * v[1] + u[1] * v[0])
def _cdiv(u: tuple[Decimal, Decimal], v: tuple[Decimal, Decimal]) -> tuple[Decimal, Decimal]:
norm = v[0] * v[0] + v[1] * v[1]
return ((u[0] * v[0] + u[1] * v[1]) / norm, (u[1] * v[0] - u[0] * v[1]) / norm)
def _j_value(a: int, b: int, D: int, pi: Decimal, digits: int) -> tuple[Decimal, Decimal]:
"""j((-b + i sqrt|D|) / (2a)) as j = (256 f + 1)^3 / f, f = q (P(q^2)/P(q))^24,
with q = exp(2 pi i tau) and P(q) = prod (1 - q^k), the Euler series."""
one = (Decimal(1), Decimal(0))
decay = pi * Decimal(-D).sqrt() / a
rho = (-decay).exp()
c, s = _cos_sin(-pi * b / a)
q = (rho * c, rho * s)
top = int(Decimal(digits) * Decimal(10).ln() / decay) + 3
powers = [one, q]
for _ in range(2 * top + 2):
powers.append(_cmul(powers[-1], q))
def euler(step: int) -> tuple[Decimal, Decimal]:
total = [Decimal(1), Decimal(0)]
k = 1
while (k * (3 * k - 1) // 2) * step < len(powers):
sign = -1 if k % 2 else 1
for e in (k * (3 * k - 1) // 2, k * (3 * k + 1) // 2):
if e * step < len(powers):
total[0] += sign * powers[e * step][0]
total[1] += sign * powers[e * step][1]
k += 1
return (total[0], total[1])
ratio = _cdiv(euler(2), euler(1))
r24 = one
for _ in range(24):
r24 = _cmul(r24, ratio)
f = _cmul(q, r24)
g = (256 * f[0] + 1, 256 * f[1])
return _cdiv(_cmul(_cmul(g, g), g), f)
_HILBERT: dict[int, list[int]] = {}
def hilbert_class_polynomial(D: int) -> list[int]:
"""H_D(X), coefficients from X^0 up (Crandall and Pomerance, Algorithm
7.5.8), from the j-values of the reduced forms in high precision."""
if D in _HILBERT:
return _HILBERT[D]
forms = reduced_forms(D)
size = sum(math.pi * math.sqrt(-D) / a for a, _, _ in forms) / math.log(10)
digits = int(size) + len(forms) + 40
with localcontext() as ctx:
ctx.prec = digits
pi = _pi()
poly = [(Decimal(1), Decimal(0))]
for a, b, _ in forms:
j = _j_value(a, b, D, pi, digits)
neg = (-j[0], -j[1])
new = [(Decimal(0), Decimal(0))] * (len(poly) + 1)
for i, coef in enumerate(poly):
new[i + 1] = (new[i + 1][0] + coef[0], new[i + 1][1] + coef[1])
prod = _cmul(coef, neg)
new[i] = (new[i][0] + prod[0], new[i][1] + prod[1])
poly = new
result = []
tolerance = Decimal(10) ** -10
for re, im in poly:
near = int(re.to_integral_value())
if abs(im) > tolerance or abs(re - near) > tolerance:
raise ArithmeticError("precision too low for H_D")
result.append(near)
_HILBERT[D] = result
return result
# -------------------------------------------------- polynomials modulo n
def _trim(f: list[int]) -> list[int]:
while f and f[-1] == 0:
f.pop()
return f
def _poly_mul(f: list[int], g: list[int], n: int) -> list[int]:
"""f g modulo n, by Kronecker substitution."""
if not f or not g:
return []
width = 2 * n.bit_length() + max(len(f), len(g)).bit_length() + 1
big_f = 0
for c in reversed(f):
big_f = (big_f << width) | c
big_g = 0
for c in reversed(g):
big_g = (big_g << width) | c
prod = big_f * big_g
mask = (1 << width) - 1
out = []
for _ in range(len(f) + len(g) - 1):
out.append((prod & mask) % n)
prod >>= width
return _trim(out)
def _monic(f: list[int], n: int) -> list[int]:
lead = inverse(f[-1], n)
return [c * lead % n for c in f]
def _poly_rem(f: list[int], g: list[int], n: int) -> list[int]:
"""f modulo the monic polynomial g, over Z_n."""
f = list(f)
dg = len(g) - 1
for i in range(len(f) - 1, dg - 1, -1):
c = f[i] % n
if c:
for t in range(dg):
f[i - dg + t] -= c * g[t]
f[i] = 0
return _trim([c % n for c in f[:dg]])
def _poly_gcd(f: list[int], g: list[int], n: int) -> list[int]:
"""Monic gcd over Z_n; raises Composite if a leading coefficient is not
invertible."""
f, g = _trim(list(f)), _trim(list(g))
while g:
g = _monic(g, n)
f, g = g, _poly_rem(f, g, n)
return _monic(f, n) if f else f
def _poly_pow(base: list[int], e: int, g: list[int], n: int) -> list[int]:
"""base^e modulo (g, n), g monic, by left-to-right binary powering."""
result = [1]
for bit in bin(e)[2:]:
result = _poly_rem(_poly_mul(result, result, n), g, n)
if bit == "1":
result = _poly_rem(_poly_mul(result, base, n), g, n)
return result
def find_root(poly: list[int], n: int, rng: random.Random, tries: int = 64) -> int | None:
"""A root modulo n of poly, by the Cantor-Zassenhaus split, or None. A
returned value r always has poly(r) = 0 modulo n."""
h = _trim([c % n for c in poly])
if len(h) < 2:
return None
h = _monic(h, n)
# The distinct linear factors of h: g = gcd(h, X^n - X).
x_n = _poly_pow([0, 1], n, h, n)
x_n += [0] * (2 - len(x_n))
x_n[1] -= 1
g = _poly_gcd(h, _trim([c % n for c in x_n]), n)
while len(g) > 2:
# Split g by gcd(g, (X + t)^((n-1)/2) - 1) for a random t.
for _ in range(tries):
w = _poly_pow([rng.randrange(n), 1], (n - 1) // 2, g, n) or [0]
w[0] -= 1
d = _poly_gcd(g, _trim([c % n for c in w]), n)
if 1 < len(d) < len(g):
g = d
break
else:
return None
if len(g) != 2:
return None
root = -g[0] % n
value = 0
for c in reversed(poly):
value = (value * root + c) % n
return root if value == 0 else None
# ------------------------------------------------- curves modulo n
Point = tuple[int, int] | None
def ec_add(p: Point, r: Point, a: int, n: int) -> Point:
"""p + r on y^2 = x^3 + a x + b over Z_n (None is O). Every step is valid
modulo each prime factor of n, or Composite is raised."""
if p is None:
return r
if r is None:
return p
x1, y1 = p
x2, y2 = r
if x1 == x2:
if (y1 + y2) % n == 0:
return None
if y1 != y2:
# For a prime n, equal x forces y1 = y2 or y1 = -y2.
g = math.gcd(y1 - y2, n)
raise Composite(g) if 1 < g < n else Composite()
lam = (3 * x1 * x1 + a) * inverse(2 * y1, n) % n
else:
lam = (y2 - y1) * inverse(x2 - x1, n) % n
x3 = (lam * lam - x1 - x2) % n
return (x3, (lam * (x1 - x3) - y1) % n)
def ec_mul(k: int, p: Point, a: int, n: int) -> Point:
"""[k]p for k >= 1, by left-to-right double and add."""
result: Point = None
for bit in bin(k)[2:]:
result = ec_add(result, result, a, n)
if bit == "1":
result = ec_add(result, p, a, n)
return result
def curve_orders(d: int, n: int, u: int, v: int) -> list[int]:
"""The orders of the curves with complex multiplication by D = -d over
F_n, n prime, given u^2 + d v^2 = 4n."""
if d == 4:
return [n + 1 + u, n + 1 - u, n + 1 + 2 * v, n + 1 - 2 * v]
if d == 3:
s, t = (u + 3 * v) // 2, (u - 3 * v) // 2
return [n + 1 + u, n + 1 - u, n + 1 + s, n + 1 - s, n + 1 + t, n + 1 - t]
return [n + 1 + u, n + 1 - u]
def _nonresidue(n: int, noncube: bool) -> int | None:
"""The least g with (g/n) = -1 and, if asked, g^((n-1)/3) != 1."""
for g in range(2, min(n, 10**5)):
j = jacobi(g, n)
if j == 0:
raise Composite(math.gcd(g, n))
if j == -1 and (not noncube or pow(g, (n - 1) // 3, n) != 1):
return g
return None
def twists(D: int, j: int, n: int) -> list[tuple[int, int]] | None:
"""The curves (a, b) with j-invariant j (CM by D) up to isomorphism over
F_n, n prime: 6 for D = -3, 4 for D = -4, 2 otherwise (Algorithm 7.5.9)."""
if D == -3:
g = _nonresidue(n, True)
return None if g is None else [(0, -pow(g, e, n) % n) for e in range(6)]
g = _nonresidue(n, False)
if g is None:
return None
if D == -4:
return [(-pow(g, e, n) % n, 0) for e in range(4)]
if j % n in (0, 1728 % n):
return None
c = j * inverse(j - 1728, n) % n
a, b = -3 * c % n, 2 * c % n
return [(a, b), (a * g * g % n, b * g * g * g % n)]
def random_point(a: int, b: int, n: int, rng: random.Random, tries: int = 200) -> tuple[int, int] | None:
"""A point (x, y) on y^2 = x^3 + a x + b modulo n with y != 0, or None
after the given number of tries (for a prime n each try succeeds with
probability about 1/2)."""
for _ in range(tries):
x = rng.randrange(n)
rhs = (x * x * x + a * x + b) % n
j = jacobi(rhs, n)
if j == 1:
y = sqrt_mod(rhs, n)
if y is None or y * y % n != rhs:
raise Composite()
return (x, y)
if j == 0 and rhs:
raise Composite(math.gcd(rhs, n))
return None
def split_order(m: int, n: int, bad: set[int]) -> tuple[int, int] | None:
"""m = k q with k > 1, q a probable prime above (n^(1/4) + 1)^2 and not
in bad, after removing the primes up to min(2^16, n^(1/2)) from m."""
limit = min(TRIAL_BOUND, math.isqrt(n))
q = m
for p in SMALL_PRIMES:
if p > limit:
break
while q % p == 0:
q //= p
k = m // q
if k == 1 or q in bad or not large_enough(q, n) or not is_probable_prime(q):
return None
return k, q
# ------------------------------------------------------------- the test
def _step(n: int, rng: random.Random, bad: set[int]) -> Step:
"""One Atkin-Morain step for n >= 2^64 (or the leaf bound): returns a
Step whose q is a smaller probable prime, or raises Composite."""
for D in discriminants():
d = -D
if d >= 4 * n or jacobi(D, n) != 1: # @line L7
continue
solution = cornacchia(d, n)
if solution is None:
continue
u, v = solution
j = None
for m in curve_orders(d, n, u, v): # @line L8
split = split_order(m, n, bad)
if split is None:
continue
k, q = split
if j is None:
j = find_root(hilbert_class_polynomial(D), n, rng) # @line L9
if j is None:
break
curves = twists(D, j, n)
if curves is None:
break
refuted = 0
for a, b in curves: # @line L10
g = math.gcd((4 * a**3 + 27 * b * b) % n, n)
if g != 1:
raise Composite(g)
for _ in range(POINT_TRIES):
point = random_point(a, b, n, rng) # @line L11
if point is None:
break
x, y = point
u_point = ec_mul(k, point, a, n)
if u_point is None:
continue
if ec_mul(q, u_point, a, n) is None: # @line L12
return Step(n, a, b, x, y, k, q)
refuted += 1
break
if refuted == len(curves):
raise Composite() # @line L13
raise RuntimeError("no suitable discriminant in the table") # @line L14
def ecpp(n: int, rng: random.Random | None = None, leaf: int = LEAF) -> list[Step] | None:
"""A certificate of primality for n (a list of Step records, empty when
n < leaf), or None if n is composite. The leaf bound must lie in
[50, 2^64]; smaller values only make the chains longer."""
if not 50 <= leaf <= LEAF:
raise ValueError("leaf must be in [50, 2^64]")
rng = rng if rng is not None else random.Random()
chain: list[Step] = [] # @line L1
bad: set[int] = set()
while True: # @line L2
try:
if n < leaf: # @line L3
if is_prime_below_leaf(n):
return chain # @line L4
raise Composite() # @line L5
if n % 2 == 0 or n % 3 == 0 or math.isqrt(n) ** 2 == n or not is_strong_probable_prime(n, 2):
raise Composite() # @line L6
step = _step(n, rng, bad)
chain.append(step)
n = step.q
except Composite:
if not chain:
return None # @line L15
bad.add(n) # @line L16
n = chain.pop().n
except RuntimeError:
if not chain:
raise
bad.add(n)
n = chain.pop().n
def verify_certificate(n: int, chain: list[Step]) -> bool:
"""Check a certificate with the Goldwasser-Kilian theorem: for each step,
n coprime to 6, a nonsingular curve, P on it, q > (n^(1/4) + 1)^2,
[k]P != O and [q]([k]P) = O; and the last number prime below 2^64."""
for st in chain:
if st.n != n or n < 5 or math.gcd(n, 6) != 1 or st.k < 1:
return False
if math.gcd((4 * st.a**3 + 27 * st.b**2) % n, n) != 1:
return False
if (st.y * st.y - st.x**3 - st.a * st.x - st.b) % n:
return False
if not large_enough(st.q, n):
return False
try:
u_point = ec_mul(st.k, (st.x % n, st.y % n), st.a % n, n)
if u_point is None or ec_mul(st.q, u_point, st.a % n, n) is not None:
return False
except (Composite, ZeroDivisionError):
return False
n = st.q
return n < LEAF and is_prime_below_leaf(n)
test_ecpp.py 317 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/ecpp/. The authors are listed in prov/5ZT65R.jsonl.
import math
import random
from collections import Counter
from decimal import Decimal, localcontext
import pytest
from ecpp import (
LEAF,
Composite,
Step,
cornacchia,
curve_orders,
discriminants,
ec_add,
ec_mul,
ecpp,
find_root,
hilbert_class_polynomial,
inverse,
is_prime_below_leaf,
is_probable_prime,
large_enough,
random_point,
reduced_forms,
split_order,
sqrt_mod,
twists,
verify_certificate,
)
LIMIT = 10**5
def sieve(m: int) -> bytearray:
is_p = bytearray([1]) * (m + 1)
is_p[0] = is_p[1] = 0
for i in range(2, math.isqrt(m) + 1):
if is_p[i]:
is_p[i * i :: i] = bytearray(len(range(i * i, m + 1, i)))
return is_p
def is_prime_by_trial_division(n: int) -> bool:
if n < 2:
return False
i = 2
while i * i <= n:
if n % i == 0:
return False
i += 1
return True
def check_proof(n: int, rng: random.Random, leaf: int = LEAF) -> list[Step]:
chain = ecpp(n, rng, leaf)
assert chain is not None
assert verify_certificate(n, chain)
numbers = [n] + [st.q for st in chain]
assert all(a > b for a, b in zip(numbers, numbers[1:]))
assert numbers[-1] < leaf
return chain
def next_probable_prime(n: int) -> int:
while not is_probable_prime(n):
n += 1
return n
def count_points(a: int, b: int, p: int) -> int:
squares = [0] * p
for y in range(p):
squares[y * y % p] += 1
return 1 + sum(squares[(x * x * x + a * x + b) % p] for x in range(p))
def test_all_small_n_against_trial_division() -> None:
is_p = sieve(LIMIT)
rng = random.Random(1)
for n in range(LIMIT + 1):
chain = ecpp(n, rng, leaf=50)
assert (chain is not None) == bool(is_p[n]), n
if chain is not None:
assert verify_certificate(n, chain), n
for n in range(0, LIMIT + 1, 7):
assert (ecpp(n, rng) is not None) == bool(is_p[n])
assert all(is_prime_below_leaf(n) == bool(is_p[n]) for n in range(LIMIT + 1))
assert is_prime_by_trial_division(99991) and ecpp(99991, rng, leaf=50)
def test_special_inputs() -> None:
rng = random.Random(2)
for n in (-7, -1, 0, 1, 4, 6, 9, 49, 2**64, 2**200, 3 * (2**127 - 1)):
assert ecpp(n, rng) is None
for n in (2, 3, 5, 7, 37, 2**61 - 1, LEAF - 59):
assert ecpp(n, rng) == []
assert verify_certificate(n, [])
for n in (101**20, (10**9 + 7) ** 3, (2**89 - 1) ** 2, (2**127 - 1) ** 3):
assert ecpp(n, rng) is None
with pytest.raises(ValueError):
ecpp(101, rng, leaf=10)
with pytest.raises(ValueError):
ecpp(101, rng, leaf=LEAF + 1)
def test_composites_that_pass_the_base_2_test() -> None:
rng = random.Random(3)
carmichael = (561, 1105, 1729, 2465, 2821, 6601, 8911, 41041, 62745, 63973, 75361)
strong_base_2 = (2047, 3277, 4033, 4681, 8321, 15841, 29341, 42799, 49141, 52633, 65281)
for n in carmichael + strong_base_2 + (3825123056546413051, 318665857834031151167461):
assert ecpp(n, rng, leaf=50) is None, n
# Composite Mersenne and Fermat numbers are strong pseudoprimes to the
# base 2, so they reach the curve steps.
for n in (2**67 - 1, 2**71 - 1, 2**101 - 1, 2**64 + 1, 2**128 + 1, 2**256 + 1):
assert pow(2, n - 1, n) == 1
assert ecpp(n, rng) is None, n
k = 10**59 + 3
while True:
p1, p2, p3 = 6 * k + 1, 12 * k + 1, 18 * k + 1
if all(is_probable_prime(p) for p in (p1, p2, p3)):
break
k += 1
big = p1 * p2 * p3
assert pow(2, big - 1, big) == 1
assert ecpp(big, rng) is None
def test_primes_with_proofs() -> None:
rng = random.Random(4)
for n in (10**20 + 39, 2**89 - 1, 2**127 - 1, 10**30 + 57, 10**100 + 267):
check_proof(n, rng)
check_proof(2**521 - 1, rng)
check_proof(next_probable_prime(10**199), rng)
for n in (1009, 100003, 999983, 10**12 + 39):
check_proof(n, rng, leaf=50)
def test_large_composites_and_random_inputs() -> None:
rng = random.Random(5)
p = next_probable_prime(10**150 + rng.randrange(10**149))
q = next_probable_prime(10**150 + rng.randrange(10**149))
assert ecpp(p * q, rng) is None
assert ecpp(p * p, rng) is None
for _ in range(20):
n = rng.randrange(10**299, 10**300) | 1
assert (ecpp(n, rng) is not None) == is_probable_prime(n)
for _ in range(40):
n = rng.randrange(2**64, 2**130)
chain = ecpp(n, rng)
assert (chain is not None) == is_probable_prime(n)
if chain is not None:
assert verify_certificate(n, chain)
def test_hilbert_class_polynomials() -> None:
assert hilbert_class_polynomial(-3) == [0, 1]
assert hilbert_class_polynomial(-4) == [-1728, 1]
assert hilbert_class_polynomial(-7) == [3375, 1]
assert hilbert_class_polynomial(-8) == [-8000, 1]
assert hilbert_class_polynomial(-11) == [32768, 1]
assert hilbert_class_polynomial(-163) == [640320**3, 1]
assert hilbert_class_polynomial(-15) == [-121287375, 191025, 1]
assert hilbert_class_polynomial(-20) == [-681472000, -1264000, 1]
assert hilbert_class_polynomial(-23) == [12771880859375, -5151296875, 3491750, 1]
def test_class_numbers_of_the_table() -> None:
table = discriminants()
h = Counter(len(reduced_forms(D)) for D in table)
# The number of imaginary quadratic fields of class number 1 to 8
# (Watkins 2004); all of them have |D| < 10000.
assert [h[i] for i in range(1, 9)] == [9, 18, 16, 54, 25, 51, 31, 131]
assert table[:9] == [-3, -4, -7, -8, -11, -19, -43, -67, -163]
def test_cm_orders_match_point_counts() -> None:
rng = random.Random(6)
for p in (1009, 1201, 2003, 2999):
tried = 0
for D in discriminants()[:80]:
d = -D
if d >= 4 * p or (D % p) == 0 or pow(D % p, (p - 1) // 2, p) != 1:
continue
sol = cornacchia(d, p)
if sol is None:
continue
u, v = sol
assert u * u + d * v * v == 4 * p
j = find_root(hilbert_class_polynomial(D), p, rng)
assert j is not None
curves = twists(D, j, p)
assert curves is not None
counts = sorted(count_points(a, b, p) for a, b in curves)
assert counts == sorted(curve_orders(d, p, u, v)), (p, D)
tried += 1
assert tried >= 3
def test_cornacchia_and_square_roots() -> None:
primes = [p for p in range(5, 3000) if is_prime_by_trial_division(p)]
for p in primes:
for d in (3, 4, 7, 8, 15, 20, 23, 163):
if d >= 4 * p or pow(-d % p, (p - 1) // 2, p) != 1:
continue
sol = cornacchia(d, p)
exists = any(
(4 * p - d * v * v) >= 0 and math.isqrt(4 * p - d * v * v) ** 2 == 4 * p - d * v * v
for v in range(1, math.isqrt(4 * p // d) + 1)
)
assert (sol is not None) == exists, (p, d)
if sol:
assert sol[0] ** 2 + d * sol[1] ** 2 == 4 * p
for a in range(1, 30):
if pow(a, (p - 1) // 2, p) == 1:
y = sqrt_mod(a, p)
assert y is not None and y * y % p == a % p
def test_large_enough_is_exact() -> None:
for t in range(2, 200):
n = t**4
assert not large_enough((t + 1) ** 2, n)
assert large_enough((t + 1) ** 2 + 1, n)
rng = random.Random(7)
with localcontext() as ctx:
ctx.prec = 80
for _ in range(2000):
n = rng.randrange(10, 10**40)
bound = (Decimal(n).sqrt().sqrt() + 1) ** 2
base = int(bound)
for q in range(base - 3, base + 4):
assert large_enough(q, n) == (Decimal(q) > bound), (q, n)
def test_worked_example() -> None:
n = 100003
assert n % 3 == 1
assert cornacchia(3, n) == (632, 14)
assert 632**2 + 3 * 14**2 == 4 * n
assert curve_orders(3, n, 632, 14) == [100636, 99372, 100341, 99667, 100299, 99709]
assert split_order(100636, n, set()) is None
assert split_order(99372, n, set()) is None
assert split_order(100341, n, set()) == (9, 11149)
assert 352 < (n**0.25 + 1) ** 2 < 353
curves = twists(-3, 0, n)
assert curves == [(0, (-(2**e)) % n) for e in range(6)]
assert [count_points(a, b, n) for a, b in curves] == [100636, 100341, 99709, 99372, 99667, 100299]
x, y = 55125, 32871
assert (y * y - x**3 + 2) % n == 0
u_point = ec_mul(9, (x, y), 0, n)
assert u_point == (31794, 59883)
assert ec_mul(11149, u_point, 0, n) is None
rng = random.Random(8)
p = random_point(0, n - 1, n, rng)
assert p is not None
assert ec_mul(100636, p, 0, n) is None
assert ec_mul(100341, p, 0, n) is not None
# The second step and the leaf.
m = 11149
assert cornacchia(3, m) == (211, 5)
assert split_order(11361, m, set()) == (21, 541)
assert ec_mul(541, ec_mul(21, (5866, 10392), 0, m), 0, m) is None
assert ec_mul(21, (5866, 10392), 0, m) == (6858, 3248)
chain = [Step(n, 0, n - 2, x, y, 9, 11149), Step(m, 0, 1149, 5866, 10392, 21, 541)]
assert verify_certificate(n, chain)
assert ecpp(n, random.Random(0), leaf=1000) == chain
def test_verifier_rejects_bad_certificates() -> None:
rng = random.Random(9)
n = 10**30 + 57
chain = check_proof(n, rng)
st = chain[0]
assert not verify_certificate(n + 2, chain)
assert not verify_certificate(n, [st._replace(k=st.k + 1)] + chain[1:])
assert not verify_certificate(n, [st._replace(y=st.y + 1)] + chain[1:])
assert not verify_certificate(n, [st._replace(q=st.q + 2)] + chain[1:])
# A point of small order with a tiny q: [m]P = O holds, but q is too small.
small = Step(100003, 0, 100001, 55125, 32871, 100341 // 3, 3)
assert not verify_certificate(100003, [small])
# The leaf must be prime.
fake = Step(100003, 0, 100001, 55125, 32871, 9, 11149)
assert verify_certificate(100003, [fake])
assert not verify_certificate(100003 * 3, [fake._replace(n=100003 * 3)])
def test_curve_arithmetic_finds_factors() -> None:
p, q = 1000003, 1000033
n = p * q
with pytest.raises(Composite) as info:
inverse(p * 5, n)
assert info.value.args[0] == p
# Two points whose x-coordinates agree modulo p only.
x1 = 17
x2 = x1 + p * 3
with pytest.raises(Composite) as info:
ec_add((x1, 5), (x2, 9), 1, n)
assert info.value.args[0] == p
a, b = 2, 3
rng = random.Random(10)
pt = None
while pt is None:
x = rng.randrange(n)
rhs = (x**3 + a * x + b) % n
yp = sqrt_mod(rhs % p, p)
yq = sqrt_mod(rhs % q, q)
if yp is not None and yq is not None and yp * yp % p == rhs % p and yq * yq % q == rhs % q:
y = (yp * q * pow(q, -1, p) + yq * p * pow(p, -1, q)) % n
pt = (x, y)
order_p = count_points(a, b, p)
with pytest.raises(Composite) as info:
ec_mul(order_p, pt, a, n)
assert info.value.args[0] in (p, q)
What the next level needs
- the tests did not run in this build