Function field sieve in Python
Sieving method for DLP in fields GF(p^n) with large n and small p.
- File
function_field_sieve.py, 363 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/function-field-sieve/. The authors are listed in prov/AJXW0K.jsonl.
import random
from math import isqrt
# A polynomial over F_p is a tuple of coefficients, lowest degree first, with
# no trailing zeros; () is the zero polynomial.
def _trim(a: list[int]) -> tuple[int, ...]:
while a and a[-1] == 0:
a.pop()
return tuple(a)
def padd(a, b, p: int):
"""a + b over F_p."""
if len(a) < len(b):
a, b = b, a
out = list(a)
for i, y in enumerate(b):
out[i] = (out[i] + y) % p
return _trim(out)
def pscale(a, c: int, p: int):
"""c a over F_p."""
return _trim([x * c % p for x in a])
def pmul(a, b, p: int):
"""a b over F_p, schoolbook."""
if not a or not b:
return ()
out = [0] * (len(a) + len(b) - 1)
for i, x in enumerate(a):
if x:
for j, y in enumerate(b):
out[i + j] += x * y
return _trim([x % p for x in out])
def pdivmod(a, m, p: int):
"""Quotient and remainder of a by m != 0 over F_p."""
r = list(a)
dm = len(m) - 1
if len(r) <= dm:
return (), _trim(r)
inv = pow(m[-1], -1, p)
q = [0] * (len(r) - dm)
low = m[:dm]
for i in range(len(r) - 1, dm - 1, -1):
c = r[i] * inv % p
if c:
q[i - dm] = c
k = i - dm
for j, y in enumerate(low):
if y:
r[k + j] = (r[k + j] - c * y) % p
return _trim(q), _trim(r[:dm])
def pmod(a, m, p: int):
return pdivmod(a, m, p)[1]
def pmonic(a, p: int):
return pscale(a, pow(a[-1], -1, p), p)
def pgcd(a, b, p: int):
"""Monic greatest common divisor over F_p (gcd(0, 0) = 0)."""
while b:
a, b = b, pmod(a, b, p)
return pmonic(a, p) if a else ()
def fmul(a, b, f, p: int):
return pmod(pmul(a, b, p), f, p)
def fpow(a, e: int, f, p: int):
"""a^e modulo f over F_p by square and multiply."""
out: tuple[int, ...] = (1,)
a = pmod(a, f, p)
while e:
if e & 1:
out = fmul(out, a, f, p)
a = fmul(a, a, f, p)
e >>= 1
return out
def compose(a, b, p: int):
"""a(b(t)) over F_p by Horner's rule."""
out: tuple[int, ...] = ()
for c in reversed(a):
out = padd(pmul(out, b, p), (c,), p)
return out
def monics(deg: int, p: int):
"""All monic polynomials of degree exactly deg over F_p."""
for v in range(p**deg):
c = []
for _ in range(deg):
c.append(v % p)
v //= p
yield tuple(c) + (1,)
def irreducibles(dmax: int, p: int) -> list[tuple[int, ...]]:
"""Monic irreducible polynomials over F_p of degree 1 to dmax, by trial division."""
irr: list[tuple[int, ...]] = []
for d in range(1, dmax + 1):
small = [s for s in irr if 2 * (len(s) - 1) <= d]
for a in monics(d, p):
if all(pdivmod(a, s, p)[1] for s in small):
irr.append(a)
return irr
def encode(a, p: int) -> int:
"""The int with base-p digits a_0, a_1, ..."""
return sum(c * p**i for i, c in enumerate(a))
def decode(v: int, p: int):
c = []
while v:
c.append(v % p)
v //= p
return tuple(c)
def ffs_field(p: int, n: int, seed: int = 0):
"""Find monic F1 of degree d = ceil(n^(1/2)) and monic F2 of degree d2 >= n / d
such that F2(F1(t)) - t has exactly one irreducible factor F of degree n;
return (F1, F2, F). The degree d2 grows by 1 after every 200 failed tries."""
if n < 2:
raise ValueError("need n >= 2")
rng = random.Random(seed)
d = isqrt(n - 1) + 1
d2 = -(-n // d)
divisors = [k for k in range(1, n) if n % k == 0]
tries = 0
while True:
tries += 1
if tries % 200 == 0:
d2 += 1
f1 = tuple(rng.randrange(p) for _ in range(d)) + (1,)
f2 = tuple(rng.randrange(p) for _ in range(d2)) + (1,)
big = padd(compose(f2, f1, p), (0, p - 1), p)
tpn = fpow((0, 1), p**n, big, p)
part = pgcd(big, padd(tpn, (0, p - 1), p), p)
for k in divisors:
if len(part) <= 1:
break
tk = padd(fpow((0, 1), p**k, part, p), (0, p - 1), p)
part = pdivmod(part, pgcd(part, tk, p), p)[0]
if len(part) - 1 == n:
return f1, f2, part
def _split(y, base, p: int):
"""Divide y by the monic members of base as often as possible; return (exponents, cofactor)."""
e = [0] * len(base)
for i, s in enumerate(base):
if len(y) < len(s):
continue
while True:
q, rem = pdivmod(y, s, p)
if rem:
break
y = q
e[i] += 1
return e, y
def _int_factors(m: int) -> dict[int, int]:
"""Factor the integer m >= 1 by trial division; return {prime: exponent}."""
out: dict[int, int] = {}
d = 2
while d * d <= m:
while m % d == 0:
out[d] = out.get(d, 0) + 1
m //= d
d += 1
if m > 1:
out[m] = out.get(m, 0) + 1
return out
def _solve(rels, q: int, mod: int) -> list[int] | None:
"""Solve sum_j e_j lambda_j = k (mod mod) for all relations, mod = q^f.
Gauss-Jordan elimination with pivots that are units modulo q. Return the
unique solution, or None if some column has no unit pivot.
"""
rows = [[x % mod for x in e] + [k % mod] for e, k in rels]
t = len(rels[0][0])
for j in range(t): # @line L19
piv = next((i for i in range(j, len(rows)) if rows[i][j] % q), None)
if piv is None:
return None
rows[j], rows[piv] = rows[piv], rows[j] # @line L20
inv = pow(rows[j][j], -1, mod)
rows[j] = [x * inv % mod for x in rows[j]]
for i in range(len(rows)):
if i != j and rows[i][j]:
c = rows[i][j]
rows[i] = [(x - c * y) % mod for x, y in zip(rows[i], rows[j])]
return [rows[j][t] for j in range(t)] # @line L21
def ffs_dlog(g: int, h: int, r: int, p: int, f1, f2, f, seed: int = 0, deg_base: int | None = None) -> int | None:
"""Function field sieve (Joux-Lercier form) for log_g h in F_(p^n)^* = (F_p[t]/(f))^*.
Field elements are ints in [1, p^n) whose base-p digits are the
coefficients of a polynomial in t of degree below n = deg f. Return the x
in [0, r) with g^x = h, or None if h is not a power of g.
Precondition: p is prime, f1 and f2 are monic, f is irreducible and
divides f2(f1(t)) - t, g has order exactly r, and h != 0.
The two factor bases hold the monic irreducibles of degree <= deg_base,
by default 4, 3 and 2 for p = 2, 3 and 5, and 1 for larger p.
Raises ValueError if r < 1 or g or h is not a reduced nonzero element.
Cost: heuristic L_q(1/3, c) bit operations, q = p^n, with c between
3^(1/3) and (32/9)^(1/3) for p <= L_q(1/3, (4/9)^(1/3)); this toy version
also spends O((p^n)^(1/2)) integer divisions to factor p^n - 1.
"""
n = len(f) - 1
if r < 1 or not (0 < g < p**n) or not (0 < h < p**n):
raise ValueError("need r >= 1 and reduced nonzero g, h")
gp, hp = decode(g, p), decode(h, p)
if fpow(hp, r, f, p) != (1,): # @line L1
return None
if hp == (1,): # @line L2
return 0
rng = random.Random(seed)
order = p**n - 1
fac = _int_factors(order)
gamma = next(c for c in (decode(v, p) for v in range(1, p**n)) if all(fpow(c, order // q, f, p) != (1,) for q in fac)) # @line L3
parts = {q: q**e for q, e in fac.items() if r % q == 0}
ell = 1
for m in parts.values():
ell *= m
omega = fpow(gamma, order // (p - 1), f, p) # a generator of F_p^*, a constant
log_const = {}
c, i = 1, 0
while c not in log_const:
log_const[c] = i * (order // (p - 1)) % ell # L(c) mod ell
c = c * (omega[0] if omega else 0) % p
i += 1
def lc_log(a) -> int:
return log_const[a[-1]]
if deg_base is None:
deg_base = {2: 4, 3: 3, 5: 2}.get(p, 1)
deg_base = max(1, min(deg_base, n - 1))
irr = irreducibles(2 * deg_base, p)
base1 = [s for s in irr if len(s) - 1 <= deg_base] # @line L4
images2 = [pmod(compose(s, f1, p), f, p) for s in base1]
base2 = [s for s, w in zip(base1, images2) if w]
w2 = [w for w in images2 if w]
t1, t2 = len(base1), len(base2)
medium = [s for s in irr if deg_base < len(s) - 1 < n]
d = len(f1) - 1
e_deg = 1
while p ** (2 * e_deg + 1) < 8 * (t1 + t2):
e_deg += 1
def both_sides(a, b):
# psi1(G) = A(x) + B(x) F1(x) and psi2(G) = A(F2(y)) + B(F2(y)) y
return padd(a, pmul(b, f1, p), p), padd(compose(a, f2, p), pmul(compose(b, f2, p), (0, 1), p), p)
rels: list[tuple[list[int], int]] = []
pairs = [(a, b) for db in range(e_deg + 1) for b in monics(db, p) for a in map(lambda v: decode(v, p), range(p ** (e_deg + 1)))]
for a, b in pairs: # @line L5
if len(rels) >= 2 * (t1 + t2) or len(pgcd(a, b, p)) != 1:
continue
s1, s2 = both_sides(a, b) # @line L6
if not s1 or not pmod(s1, f, p):
continue
e1, c1 = _split(s1, base1, p)
e2, c2 = _split(s2, base2, p)
if len(c1) == 1 and len(c2) == 1: # @line L7
rels.append((e1 + [-x for x in e2], (lc_log(c2) - lc_log(c1)) % ell))
lam: list[int] | None = None
while lam is None: # @line L8
m = rng.randrange(order)
j = rng.randrange(t2 + 1)
z = fpow(gamma, m, f, p) if j == t2 else fmul(w2[j], fpow(gamma, m, f, p), f, p) # @line L9
e1, c1 = _split(z, base1, p)
if len(c1) != 1:
continue
e2 = [0] * t2
if j < t2:
e2[j] = -1
rels.append((e1 + e2, (m - lc_log(c1)) % ell)) # @line L10
if len(rels) < t1 + t2 + 10:
continue
lam = [0] * (t1 + t2)
modulus = 1
for q, mq in parts.items():
sol = _solve(rels, q, mq)
if sol is None: # @line L11
lam = None
break
lam = [x + modulus * ((s - x) * pow(modulus, -1, mq) % mq) for x, s in zip(lam, sol)]
modulus *= mq
lam1, lam2 = lam[:t1], lam[t1:]
def special_q(qq) -> int | None:
# find G = A(x) + B(x) y with qq^a exactly dividing psi1(G) and both sides smooth
for db in range(e_deg + 1):
for b in monics(db, p):
a0 = pscale(pmod(pmul(b, f1, p), qq, p), p - 1, p)
for tv in range(p * p):
a = padd(a0, pmul(qq, decode(tv, p), p), p)
s1, s2 = both_sides(a, b)
if not s1 or not pmod(s1, f, p):
continue
mult = 0
while True:
qu, rem = pdivmod(s1, qq, p)
if rem:
break
s1 = qu
mult += 1
if any(mult % q == 0 for q in parts):
continue
e1, c1 = _split(s1, base1, p)
e2, c2 = _split(s2, base2, p)
if len(c1) == 1 and len(c2) == 1:
right = lc_log(c2) + sum(u * v for u, v in zip(e2, lam2))
left = lc_log(c1) + sum(u * v for u, v in zip(e1, lam1))
return (right - left) * pow(mult, -1, ell) % ell
return None
logs = []
for y in (gp, hp): # @line L12
while True:
m = rng.randrange(order)
z = fmul(y, fpow(gamma, m, f, p), f, p) # @line L13
e1, rest = _split(z, base1, p)
em, rest = _split(rest, medium, p)
if len(rest) != 1:
continue
total = lc_log(rest) + sum(u * v for u, v in zip(e1, lam1))
for qq, mult in zip(medium, em):
if mult:
lq = special_q(qq) # @line L14
if lq is None:
break
total += mult * lq
else: # @line L15
break
logs.append((total - m) % ell) # @line L16
c = ell // r # @line L17
u = logs[0] // c
return logs[1] // c * pow(u, -1, r) % r # @line L18
test_function_field_sieve.py 166 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/function-field-sieve/. The authors are listed in prov/AJXW0K.jsonl.
import random
import pytest
from function_field_sieve import (
compose,
decode,
encode,
ffs_dlog,
ffs_field,
fmul,
fpow,
irreducibles,
padd,
pdivmod,
)
_FIELDS: dict[tuple[int, int], tuple] = {}
def field(p: int, n: int):
if (p, n) not in _FIELDS:
_FIELDS[(p, n)] = ffs_field(p, n, seed=p * 100 + n)
return _FIELDS[(p, n)]
def powers(g: int, f, p: int) -> dict[int, int]:
"""Map the code of g^i to i for i in [0, ord g), by brute force."""
gp = decode(g, p)
table = {}
y, i = (1,), 0
while encode(y, p) not in table:
table[encode(y, p)] = i
y = fmul(y, gp, f, p)
i += 1
return table
def check(g: int, h: int, p: int, n: int, seed: int = 0) -> None:
f1, f2, f = field(p, n)
table = powers(g, f, p)
assert ffs_dlog(g, h, len(table), p, f1, f2, f, seed=seed) == table.get(h), (p, n, g, h)
def test_field_construction():
for p, n in [(2, 2), (2, 7), (2, 12), (3, 4), (3, 9), (5, 5), (7, 3), (13, 2)]:
f1, f2, f = field(p, n)
assert len(f) - 1 == n and f[-1] == 1
assert not pdivmod(padd(compose(f2, f1, p), (0, p - 1), p), f, p)[1]
assert all(pdivmod(f, s, p)[1] for s in irreducibles(n // 2, p))
def test_worked_example():
p, f1, f2 = 3, (0, 0, 1), (2, 0, 1)
f = padd(compose(f2, f1, p), (0, p - 1), p)
assert f == (2, 2, 0, 0, 1) # t^4 + 2t + 2
g, h = encode(fpow((0, 1), 16, f, p), p), encode(fpow((0, 1), 48, f, p), p)
assert decode(g, p) == (2, 2, 0, 1) and decode(h, p) == (2, 1, 2)
assert fmul(decode(g, p), (0, 1), f, p) == (1, 0, 2) # g t = 2 (t + 1)(t + 2)
for seed in range(5):
assert ffs_dlog(g, h, 5, p, f1, f2, f, seed=seed, deg_base=1) == 3
def test_trivial_targets():
for p, n in [(2, 3), (3, 3), (5, 2), (2, 9), (7, 2)]:
f1, f2, f = field(p, n)
for g in (1, 2, p**n - 1, p + 1):
if g >= p**n:
continue
r = len(powers(g, f, p))
assert ffs_dlog(g, 1, r, p, f1, f2, f) == 0
if g != 1:
assert ffs_dlog(g, g, r, p, f1, f2, f) == (1 if r > 1 else 0)
assert ffs_dlog(1, 2, 1, p, f1, f2, f) is None
def test_small_fields_exhaustive():
for p, n in [(2, 2), (2, 3), (3, 2), (2, 4), (5, 2)]:
step = 1 if p**n < 10 else 3
for g in range(1, p**n, step):
for h in range(1 + g % step, p**n, step):
check(g, h, p, n)
def test_random_fields_against_brute_force():
rng = random.Random(1)
sizes = [(2, n) for n in range(5, 13)] + [(3, n) for n in range(3, 7)] + [(5, 3), (5, 4), (7, 3), (11, 2), (13, 3), (31, 2)]
for _ in range(12):
p, n = rng.choice(sizes)
f1, f2, f = field(p, n)
g = rng.randrange(1, p**n)
table = powers(g, f, p)
h = rng.randrange(1, p**n)
check(g, h, p, n, seed=rng.randrange(1000))
x = rng.randrange(len(table))
y = encode(fpow(decode(g, p), x, f, p), p)
assert ffs_dlog(g, y, len(table), p, f1, f2, f, seed=x) == x
def test_generators_and_subgroups():
rng = random.Random(2)
for p, n in [(2, 10), (3, 5), (7, 3), (13, 2)]:
f1, f2, f = field(p, n)
order = p**n - 1
gamma = next(c for c in range(2, p**n) if len(powers(c, f, p)) == order)
divisors = [d for d in range(2, order + 1) if order % d == 0]
for r in rng.sample(divisors, min(2, len(divisors))) + [order]:
g = encode(fpow(decode(gamma, p), order // r, f, p), p)
x = rng.randrange(r)
y = encode(fpow(decode(g, p), x, f, p), p)
assert ffs_dlog(g, y, r, p, f1, f2, f, seed=x) == x
z = rng.randrange(1, p**n)
assert ffs_dlog(g, z, r, p, f1, f2, f) == powers(g, f, p).get(z)
def test_prime_and_composite_orders():
p, n = 3, 6 # 3^6 - 1 = 2^3 * 7 * 13
f1, f2, f = field(p, n)
order = p**n - 1
gamma = next(c for c in range(2, p**n) if len(powers(c, f, p)) == order)
for r in (2, 8, 13, 26, 91, order):
g = encode(fpow(decode(gamma, p), order // r, f, p), p)
for x in (0, 1, r - 1):
y = encode(fpow(decode(g, p), x, f, p), p)
assert ffs_dlog(g, y, r, p, f1, f2, f) == x
assert ffs_dlog(g, gamma, r, p, f1, f2, f) == (None if r < order else 1)
def test_no_solution_is_reported():
rng = random.Random(3)
found = 0
for _ in range(60):
p, n = rng.choice([(2, 8), (2, 10), (3, 5), (3, 6), (5, 3), (7, 3)])
f1, f2, f = field(p, n)
g = rng.randrange(1, p**n)
table = powers(g, f, p)
h = rng.randrange(1, p**n)
if h not in table:
found += 1
assert ffs_dlog(g, h, len(table), p, f1, f2, f) is None
assert found >= 10
def test_seed_independence():
p, n = 3, 6
f1, f2, f = field(p, n)
g = 5
table = powers(g, f, p)
x = 1000 % len(table)
h = encode(fpow(decode(g, p), x, f, p), p)
assert {ffs_dlog(g, h, len(table), p, f1, f2, f, seed=s) for s in range(4)} == {x}
def test_bad_input():
f1, f2, f = field(3, 3)
with pytest.raises(ValueError):
ffs_dlog(0, 1, 1, 3, f1, f2, f)
with pytest.raises(ValueError):
ffs_dlog(1, 27, 1, 3, f1, f2, f)
with pytest.raises(ValueError):
ffs_dlog(1, 1, 0, 3, f1, f2, f)
with pytest.raises(ValueError):
ffs_field(3, 1)
What the next level needs
- the tests did not run in this build