Algorithm Search⌘K

Elliptic curve factorization method in Python

Lenstra's method computes [k]P on random elliptic curves mod N and finds a prime factor p when a point order mod p is smooth. Its cost depends on p, about L_p[1/2, 2^(1/2)] heuristically.

File
elliptic_curve_factorization.py, 214 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
elliptic_curve_factorization.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/elliptic-curve-factorization/. The authors are listed in prov/FMGN8V.jsonl.

import random
from math import gcd, isqrt
from typing import Optional

def primes_up_to(b: int) -> list[int]:
    """The primes p <= b, by the sieve of Eratosthenes in O(b log log b) time."""
    if b < 2:
        return []
    sieve = bytearray([1]) * (b + 1)
    sieve[0] = sieve[1] = 0
    for i in range(2, isqrt(b) + 1):
        if sieve[i]:
            sieve[i * i :: i] = bytearray(len(range(i * i, b + 1, i)))
    return [i for i in range(b + 1) if sieve[i]]



Point = Optional[tuple[int, int]]  # None is the point at infinity O


class Found(Exception):
    """Raised when an inverse mod n does not exist; g = gcd(d, n) > 1."""

    def __init__(self, g: int) -> None:
        super().__init__(g)
        self.g = g


def inverse(d: int, n: int) -> int:
    """d^(-1) mod n, or raise Found(gcd(d, n)) when d is not invertible."""
    g = gcd(d, n)
    if g != 1:
        raise Found(g)  # @line L6
    return pow(d, -1, n)


def add(p: Point, q: Point, a: int, n: int) -> Point:
    """The sum of two points of y^2 = x^3 + a x + b over Z/nZ, by the chord and
    tangent rules. The cases are told apart modulo n; a denominator that is not
    invertible raises Found. Cost: O(1) multiplications and one inverse mod n."""
    if p is None:
        return q
    if q is None:
        return p
    x1, y1 = p
    x2, y2 = q
    if (x1 - x2) % n:
        lam = (y2 - y1) * inverse(x2 - x1, n) % n
    elif (y1 + y2) % n == 0:
        return None
    else:
        lam = (3 * x1 * x1 + a) * inverse(y1 + y2, n) % n
    x3 = (lam * lam - x1 - x2) % n
    return x3, (lam * (x1 - x3) - y1) % n


def multiply(p: Point, m: int, a: int, n: int) -> Point:
    """[m]p for m >= 1, by left to right double and add with O(log m) additions."""
    r = p
    for bit in bin(m)[3:]:
        r = add(r, r, a, n)
        if bit == "1":
            r = add(r, p, a, n)
    return r


def ecm(n: int, b1: int, b2: int, curves: int, seed: int = 0) -> int:
    """Lenstra's elliptic curve method with a stage two up to b2.

    Precondition: n > 1 odd, gcd(n, 6) = 1, b2 >= b1 >= 2. Returns a proper factor
    of n, or 1 if no curve finds one. Each curve costs O(b1 + pi(b2) + G) point
    additions, where G is the largest gap between primes in (b1, b2], and each
    addition costs O(1) multiplications and one inverse mod n."""
    rng = random.Random(seed)
    small = primes_up_to(b1)
    large = [q for q in primes_up_to(b2) if q > b1]
    gap = max([v - u for u, v in zip(large, large[1:])], default=2)
    for _ in range(curves):  # @line L1
        x, y, a = rng.randrange(n), rng.randrange(n), rng.randrange(n)  # @line L2
        b = (y * y - x * x * x - a * x) % n
        g = gcd(4 * a * a * a + 27 * b * b, n)
        if g == n:  # @line L3
            continue
        if g > 1:
            return g
        try:
            p: Point = (x, y)
            for q in small:  # @line L4
                qe = q
                while qe <= b1:
                    p = multiply(p, q, a, n)
                    qe *= q
                if p is None:
                    break
            if p is None or not large:
                continue
            r = multiply(p, large[0], a, n)  # @line L5
            table: list[Point] = [None, add(p, p, a, n)]  # table[j] = [2j]p
            while len(table) <= gap // 2:
                table.append(add(table[-1], table[1], a, n))
            for i in range(1, len(large)):
                if r is None:
                    break
                r = add(r, table[(large[i] - large[i - 1]) // 2], a, n)
        except Found as f:  # @line L6
            if f.g < n:
                return f.g
    return 1  # @line L7


_BASES = (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41)


def is_probable_prime(n: int) -> bool:
    """Strong probable-prime test to the 13 prime bases up to 41.

    It is a proof of primality for n < 3317044064679887385961981 (about 3.3 * 10^24)."""
    if n < 2:
        return False
    for p in _BASES:
        if n % p == 0:
            return n == p
    d, s = n - 1, 0
    while d % 2 == 0:
        d, s = d // 2, s + 1
    for a in _BASES:
        x = pow(a, d, n)
        if x in (1, n - 1):
            continue
        for _ in range(s - 1):
            x = x * x % n
            if x == n - 1:
                break
        else:
            return False
    return True


_PRIMES: list[int] = primes_up_to(1 << 12)
_PRIME_CAP = 1 << 22


def _primes_to(b: int) -> list[int]:
    """A cached list of primes that covers every prime up to min(b, 2^22)."""
    global _PRIMES
    b = min(b, _PRIME_CAP)
    if _PRIMES[-1] < b:
        _PRIMES = primes_up_to(max(b, 2 * _PRIMES[-1]))
    return _PRIMES

def iroot(m: int, k: int) -> int:
    """The integer k-th root floor(m^(1/k)) of m >= 0, by Newton's method."""
    if m < 2:
        return m
    x = 1 << ((m.bit_length() + k - 1) // k)
    while True:
        y = ((k - 1) * x + m // x ** (k - 1)) // k
        if y >= x:
            return x
        x = y


def _split(m: int) -> int:
    """A proper factor of a composite m with no prime factor below 1000.

    A perfect power r^k gives r. Otherwise ECM runs with B1 doubling from 100 and
    B2 = 50 B1, 20 curves per round. Trial division is the last resort."""
    for k in range(2, m.bit_length() + 1):
        r = iroot(m, k)
        if r ** k == m:
            return r
    b1 = 100
    for rnd in range(14):
        g = ecm(m, b1, 50 * b1, 20, seed=rnd)
        if g > 1:
            return g
        b1 *= 2
    d = 1001
    while m % d:
        d += 2
    return d


_SMALL = primes_up_to(1000)


def factorize(n: int) -> list[int]:
    """Prime factors of n >= 1 in non-decreasing order, with multiplicity.

    Trial division removes the primes below 1000, then each composite part is split
    by ECM. A loop with an explicit stack replaces recursion."""
    if n < 1:
        raise ValueError("n must be a positive integer")
    out: list[int] = []
    for d in _SMALL:
        if d * d > n:
            break
        while n % d == 0:
            out.append(d)
            n //= d
    stack = [n]
    while stack:
        m = stack.pop()
        if m == 1:
            continue
        if is_probable_prime(m):
            out.append(m)
            continue
        g = _split(m)
        stack.extend((g, m // g))
    return sorted(out)
test_elliptic_curve_factorization.py 200 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/elliptic-curve-factorization/. The authors are listed in prov/FMGN8V.jsonl.

import math
import random

from elliptic_curve_factorization import Found, add, ecm, factorize, iroot, multiply, primes_up_to

import pytest

CARMICHAEL = [561, 1105, 1729, 2465, 2821, 6601, 8911, 10585, 15841, 29341, 41041, 46657,
              52633, 62745, 63973, 75361, 101101, 115921, 126217, 162401, 172081, 188461,
              252601, 278545, 294409, 314821, 334153, 340561, 399001, 410041, 449065,
              488881, 512461, 3215031751]


def is_prime(n: int) -> bool:
    """Deterministic Miller-Rabin for n < 3.3 * 10^24, written here independently."""
    assert n < 3317044064679887385961981
    if n < 2:
        return False
    small = (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41)
    for p in small:
        if n % p == 0:
            return n == p
    d, s = n - 1, 0
    while d % 2 == 0:
        d, s = d // 2, s + 1
    for a in small:
        x = pow(a, d, n)
        if x in (1, n - 1):
            continue
        for _ in range(s - 1):
            x = x * x % n
            if x == n - 1:
                break
        else:
            return False
    return True


def random_prime(rng: random.Random, bits: int) -> int:
    while True:
        p = rng.getrandbits(bits) | (1 << (bits - 1)) | 1
        if is_prime(p):
            return p


def check(n: int) -> list[int]:
    f = factorize(n)
    assert math.prod(f) == n
    assert all(is_prime(p) for p in f)
    assert f == sorted(f)
    return f




def brute_count(a: int, b: int, p: int) -> int:
    """The number of points of y^2 = x^3 + a x + b over F_p, O included."""
    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_example() -> None:
    p, q = 1129, 2029
    n = p * q
    assert n == 2290741
    x, y, a = 2, 3, 1
    b = (y * y - x**3 - a * x) % n
    assert b == n - 1 and math.gcd(4 * a**3 + 27 * b * b, n) == 1
    assert brute_count(a, b % p, p) == 1120 and brute_count(a, b % q, q) == 2083
    pt = (x, y)
    for r in (2, 2, 2, 2, 3, 3, 5):
        pt = multiply(pt, r, a, n)
    assert pt == (1115156, 449422)
    two = add(pt, pt, a, n)
    three = add(two, pt, a, n)
    six = add(three, three, a, n)
    assert six == (2200125, 2229695)
    assert (pt[0] - six[0]) % n == 1205772 and math.gcd(1205772, n) == p
    with pytest.raises(Found) as e:
        add(six, pt, a, n)
    assert e.value.g == p
    # Over F_p the point P has order 560 = 2^4 5 7; over F_q it has the prime order 2083.
    mod_p = (x, y)
    for _ in range(558):
        mod_p = add(mod_p, (x, y), a, p)
        assert mod_p is not None
    assert add(mod_p, (x, y), a, p) is None
    assert multiply((x, y), 2083, a, q) is None


def test_group_law_mod_prime() -> None:
    rng = random.Random(7)
    for p in (101, 307, 1009):
        for _ in range(5):
            a, x, y = rng.randrange(p), rng.randrange(p), rng.randrange(p)
            b = (y * y - x**3 - a * x) % p
            if (4 * a**3 + 27 * b * b) % p == 0:
                continue
            order = brute_count(a, b, p)
            assert abs(order - (p + 1)) <= 2 * math.isqrt(p) + 1
            assert multiply((x, y), order, a, p) is None
            u = multiply((x, y), 5, a, p)
            v = multiply((x, y), 7, a, p)
            w = multiply((x, y), 11, a, p)
            assert add(add(u, v, a, p), w, a, p) == add(u, add(v, w, a, p), a, p)
            assert add(u, v, a, p) == multiply((x, y), 12, a, p)


def test_returns_divisor() -> None:
    rng = random.Random(11)
    for _ in range(200):
        n = rng.randrange(10**6, 10**9) | 1
        if n % 3 == 0:
            continue
        g = ecm(n, 50, 500, 3, seed=rng.randrange(1000))
        assert g == 1 or (1 < g < n and n % g == 0)
    for p in (1000003, 2**31 - 1, 2**61 - 1):
        assert ecm(p, 200, 2000, 5) == 1


def test_ecm_splits_small() -> None:
    # Every composite n < 40000 with no prime factor below 100 and not a perfect power.
    count = 0
    for n in range(10201, 40001, 2):
        if any(n % p == 0 for p in range(3, 101, 2)) or is_prime(n):
            continue
        if math.isqrt(n) ** 2 == n:
            continue
        g = ecm(n, 30, 300, 40, seed=n)
        assert 1 < g < n and n % g == 0, n
        count += 1
    assert count > 400


def test_edges() -> None:
    with pytest.raises(ValueError):
        factorize(0)
    assert factorize(1) == []
    assert factorize(2) == [2]
    assert factorize(3) == [3]
    assert factorize(2**61 - 1) == [2**61 - 1]
    assert factorize(2**40) == [2] * 40
    assert factorize(101**2) == [101, 101]
    assert factorize(101**3) == [101] * 3
    assert factorize(1000003**2) == [1000003, 1000003]
    assert factorize(10007**3 * 10009) == [10007] * 3 + [10009]
    assert factorize(2**67 - 1) == [193707721, 761838257287]
    assert iroot(10**30, 3) == 10**10 and iroot(10**30 - 1, 3) == 10**10 - 1


def test_all_small() -> None:
    for n in range(1, 100001):
        f = factorize(n)
        assert math.prod(f) == n
        assert all(is_prime(p) for p in f)


def test_carmichael() -> None:
    for n in CARMICHAEL + [2199733160881]:
        check(n)


def test_random_semiprimes() -> None:
    rng = random.Random(1403)
    for bits in range(4, 31):
        for _ in range(2):
            p, q = random_prime(rng, bits), random_prime(rng, bits)
            assert check(p * q) == sorted([p, q])


def test_small_factor_of_large_number() -> None:
    rng = random.Random(77)
    for bits in (16, 20, 24):
        p = random_prime(rng, bits)
        big = random_prime(rng, 80)
        assert check(p * big) == sorted([p, big])


def test_close_primes() -> None:
    p = 1000003
    for _ in range(6):
        q = p + 2
        while not is_prime(q):
            q += 2
        assert check(p * q) == [p, q]
        p = q


def test_random_smooth() -> None:
    rng = random.Random(2047)
    primes = [p for p in range(101, 3000) if is_prime(p)]
    for k in range(1, 9):
        f = sorted(rng.choice(primes) for _ in range(k))
        assert check(math.prod(f)) == f
    assert primes_up_to(30) == [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

What the next level needs

  • the tests did not run in this build

Read the full page about Elliptic curve factorization method