Number field sieve for discrete logarithms in Python
Index-calculus variant over prime fields with heuristic complexity L(1/3, c).
- File
number_field_sieve_discrete_logarithm.py, 309 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/number-field-sieve-discrete-logarithm/. The authors are listed in prov/YRSSHK.jsonl.
import random
from math import exp, gcd, isqrt, log
# Monic f1 = x^2 + u x + v such that Z[w] = Z[x]/(f1) is the ring of integers of
# an imaginary quadratic field with class number 1; the discriminants are
# -4, -8, -3, -7, -11, -19, -43, -67 and -163. An element a + b w is the pair (a, b).
QUADRATIC_FIELDS = [(0, 1), (0, 2), (1, 1), (1, 2), (1, 3), (1, 5), (1, 11), (1, 17), (1, 41)]
def norm(z, u: int, v: int) -> int:
a, b = z
return a * a - u * a * b + v * b * b
def qmul(x, y, u: int, v: int):
"""x y in Z[w], with w^2 = -u w - v."""
a, b = x
c, d = y
return (a * c - b * d * v, a * d + b * c - b * d * u)
def qdiv(x, y, u: int, v: int):
"""x / y in Z[w] if y divides x, else None."""
a, b = x
c, d = y
n = norm(y, u, v)
re, im = a * (c - d * u) + b * d * v, b * c - a * d # x times the conjugate of y
if re % n or im % n:
return None
return (re // n, im // n)
def _primes(bound: int) -> list[int]:
sieve = bytearray([1]) * (bound + 1)
out = []
for i in range(2, bound + 1):
if sieve[i]:
out.append(i)
for j in range(i * i, bound + 1, i):
sieve[j] = 0
return out
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 nfs_polynomials(p: int) -> tuple[int, int, int, int]:
"""Return (u, v, c, d) with f1 = x^2 + u x + v from QUADRATIC_FIELDS, d >= 1 and
c^2 + u c d + v d^2 = p, so that f1 and f2 = d x - c have the common root
c / d modulo p. Raise ValueError if p splits in none of the nine fields."""
for u, v in QUADRATIC_FIELDS:
disc = u * u - 4 * v
if p == 2 or pow(disc % p, (p - 1) // 2, p) != 1:
continue
d = 1
while 4 * p + disc * d * d >= 0:
t = isqrt(4 * p + disc * d * d)
if t * t == 4 * p + disc * d * d:
return u, v, (t - u * d) // 2, d
d += 1
raise ValueError("p splits in no imaginary quadratic field of class number 1 in the list")
def _unit_powers(u: int, v: int):
"""The units of Z[w] as the powers e^0, e^1, ... of a generator e."""
units = [(a, b) for a in range(-2, 3) for b in range(-2, 3) if norm((a, b), u, v) == 1]
best: list = []
for e in units:
pw = [(1, 0)]
while True:
nxt = qmul(pw[-1], e, u, v)
if nxt == (1, 0):
break
pw.append(nxt)
if len(pw) > len(best):
best = pw
return best
def _algebraic_primes(bound: int, u: int, v: int, units) -> list[tuple[int, int]]:
"""One generator for each prime ideal of Z[w] of norm <= bound."""
disc = 4 * v - u * u # minus the discriminant, > 0
ymax = isqrt(4 * bound // disc) + 1
xmax = isqrt(bound) + abs(u) * ymax + 1
elems = [(x, y) for y in range(-ymax, ymax + 1) for x in range(-xmax, xmax + 1) if 0 < norm((x, y), u, v) <= bound]
norms = {norm(z, u, v) for z in elems}
rprimes = set(_primes(bound))
out = set()
for z in elems:
n = norm(z, u, v)
inert = z[1] == 0 and z[0] in rprimes and z[0] not in norms
if n in rprimes or inert:
out.add(max(qmul(z, e, u, v) for e in units))
return sorted(out, key=lambda z: (norm(z, u, v), z))
def _split_int(n: int, plist: list[int]) -> tuple[list[int], int]:
e = [0] * len(plist)
for i, q in enumerate(plist):
while n % q == 0:
n //= q
e[i] += 1
return e, n
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 _reduce(v1, v2):
"""Lagrange reduction of a basis of a lattice in Z^2."""
def dot(x, y):
return x[0] * y[0] + x[1] * y[1]
while True:
if dot(v2, v2) < dot(v1, v1):
v1, v2 = v2, v1
n1 = dot(v1, v1)
mu = (2 * dot(v1, v2) + n1) // (2 * n1)
if mu == 0:
return v1, v2
v2 = (v2[0] - mu * v1[0], v2[1] - mu * v1[1])
def nfs_dlog(g: int, h: int, r: int, p: int, seed: int = 0, bound: int | None = None) -> int | None:
"""Number field sieve with a quadratic field for log_g h in (Z/pZ)^*.
Return the x in [0, r) with g^x = h mod p, or None if h is not a power of g.
Precondition: p is prime, 0 < g, h < p and g has order exactly r.
The algebraic side is Z[x]/(x^2 + u x + v), a ring of integers with class
number 1 in which p splits; the rational side is f2 = d x - c with
c, d about p^(1/2). The factor bases hold the primes up to `bound` on both
sides, with bound clipped to [1, (p - 1) / 2].
Raises ValueError if r < 1, g or h is out of range, or p >= 3 splits in
none of the nine fields of QUADRATIC_FIELDS.
Cost: heuristic L_p(1/2, c) operations for this degree 2 version, against
L_p(1/3, (64/9)^(1/3)) for the general number field sieve; it also spends
O(p^(1/2)) integer divisions to factor p - 1 and to find c and d.
"""
if r < 1 or not (0 < g < p) or not (0 < h < p):
raise ValueError("need r >= 1 and 0 < g, h < p")
if pow(h, r, p) != 1: # @line L1
return None
if h == 1: # @line L2
return 0
rng = random.Random(seed)
order = p - 1
fac = _int_factors(order)
gamma = next(c for c in range(2, p) if all(pow(c, order // q, 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
u, v, c, d = nfs_polynomials(p)
s = c * pow(d, -1, p) % p # the common root of f1 and f2 modulo p
if bound is None:
bound = round(3 * exp(0.5 * (log(p) * log(log(p))) ** 0.5))
bound = max(1, min(bound, (p - 1) // 2))
rat = _primes(bound) # @line L4
units = _unit_powers(u, v)
pis = _algebraic_primes(bound, u, v, units)
pinorm = [norm(z, u, v) for z in pis]
# columns: [-1, rational primes] then [unit e, algebraic primes, the constant d]
images = [(units[1][0] + units[1][1] * s) % p] + [(a + b * s) % p for a, b in pis] + [d % p]
nrat, nalg = 1 + len(rat), len(images)
ncol = nrat + nalg
def rat_split(n: int):
e, rest = _split_int(abs(n), rat)
return [1 if n < 0 else 0] + e, rest
def alg_split(z):
e = [0] * len(pis)
n = norm(z, u, v)
for i, (pi, npi) in enumerate(zip(pis, pinorm)):
while n % npi == 0:
w = qdiv(z, pi, u, v)
if w is None:
break
z, n = w, n // npi
e[i] += 1
return (units.index(z), e) if n == 1 else None
def balanced(z: int) -> int:
return z - p if 2 * z > p else z
rels: list[tuple[list[int], int]] = []
width = max(10, 2 * ncol)
for b in range(1, width + 1):
for a in range(-width, width + 1): # @line L5
if len(rels) >= 2 * ncol or gcd(a, b) != 1:
continue
n2 = a * d + b * c # @line L6
if n2 % p == 0:
continue
er, rest = rat_split(n2)
fa = alg_split((a, b)) if rest == 1 else None
if fa is not None: # @line L7
rels.append((er + [-fa[0]] + [-x for x in fa[1]] + [-1], 0))
lam: list[int] | None = None
while lam is None: # @line L8
m = rng.randrange(order)
j = rng.randrange(nalg + 1)
z = pow(gamma, m, p) * (images[j] if j < nalg else 1) % p # @line L9
er, rest = rat_split(balanced(z))
if rest != 1:
continue
row = er + [0] * nalg
if j < nalg:
row[nrat + j] = -1
rels.append((row, m % ell)) # @line L10
if len(rels) < ncol + 10:
continue
lam = [0] * ncol
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 * ((y - x) * pow(modulus, -1, mq) % mq) for x, y in zip(lam, sol)]
modulus *= mq
lam_rat, lam_alg = lam[:nrat], lam[nrat:]
medium = [q for q in _primes(bound * bound) if q > bound]
def special_q(qq: int) -> int | None:
# find (a, b) with qq^k exactly dividing a d + b c and both sides smooth
if d % qq:
b1, b2 = _reduce((qq, 0), (-c * pow(d, -1, qq) % qq, 1))
else:
b1, b2 = _reduce((1, 0), (0, qq))
for i in range(-4, 5):
for k in range(-4, 5):
a, b = i * b1[0] + k * b2[0], i * b1[1] + k * b2[1]
if b < 0:
a, b = -a, -b
if b == 0 or gcd(a, b) != 1:
continue
n2 = a * d + b * c
if n2 % p == 0:
continue
mult = 0
while n2 % qq == 0:
n2 //= qq
mult += 1
if any(mult % q == 0 for q in parts):
continue
er, rest = rat_split(n2)
fa = alg_split((a, b)) if rest == 1 else None
if fa is None:
continue
right = lam_alg[-1] + fa[0] * lam_alg[0] + sum(x * y for x, y in zip(fa[1], lam_alg[1:-1]))
left = sum(x * y for x, y in zip(er, lam_rat))
return (right - left) * pow(mult, -1, ell) % ell
return None
logs = []
for y in (g, h): # @line L12
while True:
m = rng.randrange(order)
z = balanced(y * pow(gamma, m, p) % p) # @line L13
er, rest = rat_split(z)
em, rest = _split_int(rest, medium)
if rest != 1:
continue
total = sum(x * w for x, w in zip(er, lam_rat))
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
w = logs[0] // c
return logs[1] // c * pow(w, -1, r) % r # @line L18
test_number_field_sieve_discrete_logarithm.py 181 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/number-field-sieve-discrete-logarithm/. The authors are listed in prov/YRSSHK.jsonl.
import random
import pytest
from number_field_sieve_discrete_logarithm import (
QUADRATIC_FIELDS,
_algebraic_primes,
_unit_powers,
nfs_dlog,
nfs_polynomials,
norm,
qdiv,
qmul,
)
def is_prime(n: int) -> bool:
return n > 1 and all(n % i for i in range(2, int(n**0.5) + 1))
def covered(p: int) -> bool:
try:
nfs_polynomials(p)
return True
except ValueError:
return False
def powers(g: int, p: int) -> dict[int, int]:
"""Map g^i mod p to i for i in [0, ord g), by brute force."""
table, y, i = {}, 1, 0
while y not in table:
table[y] = i
y = y * g % p
i += 1
return table
def generator(p: int) -> int:
return next(c for c in range(1, p) if len(powers(c, p)) == p - 1)
PRIMES = [p for p in range(3, 2000) if is_prime(p)]
def test_polynomials():
for p in PRIMES:
u, v, c, d = nfs_polynomials(p)
assert (u, v) in QUADRATIC_FIELDS and d >= 1 and c * c + u * c * d + v * d * d == p
s = c * pow(d, -1, p) % p
assert (s * s + u * s + v) % p == 0
for p in (2, 3167, 8543):
with pytest.raises(ValueError):
nfs_polynomials(p)
def test_ring_arithmetic():
rng = random.Random(4)
for u, v in QUADRATIC_FIELDS:
units = _unit_powers(u, v)
assert len(units) == {(0, 1): 4, (1, 1): 6}.get((u, v), 2)
for _ in range(50):
x = (rng.randrange(-30, 31), rng.randrange(-30, 31))
y = (rng.randrange(-30, 31), rng.randrange(1, 31))
assert norm(qmul(x, y, u, v), u, v) == norm(x, u, v) * norm(y, u, v)
assert qdiv(qmul(x, y, u, v), y, u, v) == x
disc = u * u - 4 * v
pis = _algebraic_primes(60, u, v, units)
for q in (q for q in range(2, 61) if is_prime(q)):
count = sum(1 for z in pis if norm(z, u, v) == q)
if disc % q == 0:
assert count == 1 # ramified
elif q > 2:
assert count == (2 if pow(disc % q, (q - 1) // 2, q) == 1 else 0)
inert = sum(1 for z in pis if norm(z, u, v) == q * q)
assert inert == (1 if count == 0 and q * q <= 60 else 0)
def test_worked_example():
p = 37
assert nfs_polynomials(p) == (0, 1, 6, 1) # f1 = x^2 + 1, f2 = x - 6
assert _algebraic_primes(5, 0, 1, _unit_powers(0, 1)) == [(1, 1), (2, -1), (2, 1)]
g = 16 # 2^4, of order 9
assert len(powers(g, p)) == 9 and pow(g, 3, p) == 26 and pow(g, 5, p) == 33
for seed in range(5):
assert nfs_dlog(g, 26, 9, p, seed=seed, bound=5) == 3
assert nfs_dlog(g, 33, 9, p, seed=seed, bound=5) == 5
def test_trivial_targets():
assert nfs_dlog(1, 1, 1, 2) == 0
for p in (3, 5, 7, 11, 13, 101, 1009):
assert nfs_dlog(1, 1, 1, p) == 0
assert nfs_dlog(1, 2, 1, p) is None
for g in (2, p - 1, generator(p)):
r = len(powers(g, p))
assert nfs_dlog(g, 1, r, p) == 0
assert nfs_dlog(g, g, r, p) == (1 if r > 1 else 0)
def test_small_primes_exhaustive():
for p in [q for q in PRIMES if q < 32]:
for g in range(1, p, 1 if p < 20 else 2):
table = powers(g, p)
for h in range(1, p):
assert nfs_dlog(g, h, len(table), p, seed=h) == table.get(h), (p, g, h)
def test_random_primes_against_brute_force():
rng = random.Random(1)
done = 0
while done < 20:
p = rng.randrange(3, 100000)
if not is_prime(p) or not covered(p):
continue
done += 1
g = rng.randrange(1, p)
table = powers(g, p)
h = rng.randrange(1, p)
assert nfs_dlog(g, h, len(table), p, seed=done) == table.get(h), (p, g, h)
x = rng.randrange(len(table))
assert nfs_dlog(g, pow(g, x, p), len(table), p, seed=x) == x
def test_generators_and_subgroups():
rng = random.Random(2)
for p in (1009, 10007, 65537, 99991):
gamma = generator(p)
divisors = [d for d in range(2, p) if (p - 1) % d == 0]
for r in rng.sample(divisors, min(2, len(divisors))) + [p - 1]:
g = pow(gamma, (p - 1) // r, p)
x = rng.randrange(r)
assert nfs_dlog(g, pow(g, x, p), r, p, seed=x) == x
z = rng.randrange(1, p)
assert nfs_dlog(g, z, r, p) == powers(g, p).get(z)
def test_prime_and_composite_orders():
p = 7561 # p - 1 = 2^3 * 3^3 * 5 * 7
gamma = generator(p)
for r in (2, 8, 3, 27, 5, 7, 35, 216, 1890, p - 1):
g = pow(gamma, (p - 1) // r, p)
for x in (0, 1, r - 1):
assert nfs_dlog(g, pow(g, x, p), r, p) == x
assert nfs_dlog(g, gamma, r, p) == (None if r < p - 1 else 1)
def test_no_solution_is_reported():
rng = random.Random(3)
found = 0
for _ in range(80):
p = rng.choice([101, 257, 1009, 4001, 12289])
g = rng.randrange(1, p)
table = powers(g, p)
h = rng.randrange(1, p)
if h not in table:
found += 1
assert nfs_dlog(g, h, len(table), p) is None
assert found >= 10
def test_seed_independence():
p = 50021
g = generator(p)
h = pow(g, 31337, p)
assert {nfs_dlog(g, h, p - 1, p, seed=s) for s in range(3)} == {31337}
assert {nfs_dlog(g, h, p - 1, p, seed=s, bound=b) for s, b in ((0, 8), (1, 60))} == {31337}
def test_bad_input():
with pytest.raises(ValueError):
nfs_dlog(0, 1, 1, 13)
with pytest.raises(ValueError):
nfs_dlog(1, 13, 1, 13)
with pytest.raises(ValueError):
nfs_dlog(1, 1, 0, 13)
with pytest.raises(ValueError):
nfs_dlog(5, 3, 3166, 3167) # 3167 splits in none of the nine fields
What the next level needs
- the tests did not run in this build
Read the full page about Number field sieve for discrete logarithms