Quasi-polynomial discrete logarithm algorithm in Python
Quasi-polynomial time DLP for small-characteristic finite fields.
- File
quasi_polynomial_discrete_logarithm.py, 362 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/quasi-polynomial-discrete-logarithm/. The authors are listed in prov/7RM8BX.jsonl.
import random
# The field F_(q^2) = F_q[i]/(i^2 - nu) for an odd prime q, with elements
# coded as ints a + b q for a + b i. A polynomial over F_(q^2) is a tuple of
# codes, lowest degree first, with no trailing zeros; () is zero. The big
# field is K = F_(q^2)[X]/(I) with I an irreducible factor of h1 X^q - h0.
class Fq2:
"""Tables for F_(q^2), q an odd prime."""
def __init__(self, q: int):
if q < 3 or any(q % d == 0 for d in range(2, int(q**0.5) + 1)):
raise ValueError("q must be an odd prime")
self.q = q
n = q * q
self.n = n
nu = next(a for a in range(2, q) if pow(a, (q - 1) // 2, q) == q - 1)
def mul(x: int, y: int) -> int:
a, b, c, d = x % q, x // q, y % q, y // q
return (a * c + nu * b * d) % q + q * ((a * d + b * c) % q)
self.add = [[(x % q + y % q) % q + q * ((x // q + y // q) % q) for y in range(n)] for x in range(n)]
self.neg = [(-(x % q)) % q + q * ((-(x // q)) % q) for x in range(n)]
gen = next(x for x in range(1, n) if self._order(x, mul) == n - 1)
self.exp = [1] * (n - 1)
for k in range(1, n - 1):
self.exp[k] = mul(self.exp[k - 1], gen)
self.log = [0] * n
for k, x in enumerate(self.exp):
self.log[x] = k
self.frob = [0] + [self.exp[self.log[x] * q % (n - 1)] for x in range(1, n)]
@staticmethod
def _order(x: int, mul) -> int:
y, k = x, 1
while y != 1:
y, k = mul(y, x), k + 1
return k
def mul(self, x: int, y: int) -> int:
if x == 0 or y == 0:
return 0
return self.exp[(self.log[x] + self.log[y]) % (self.n - 1)]
def inv(self, x: int) -> int:
return self.exp[(-self.log[x]) % (self.n - 1)]
def _trim(a: list[int]) -> tuple[int, ...]:
while a and a[-1] == 0:
a.pop()
return tuple(a)
def padd(F: Fq2, a, b):
if len(a) < len(b):
a, b = b, a
out = list(a)
for i, y in enumerate(b):
out[i] = F.add[out[i]][y]
return _trim(out)
def psub(F: Fq2, a, b):
return padd(F, a, tuple(F.neg[y] for y in b))
def pscale(F: Fq2, a, c: int):
return _trim([F.mul(x, c) for x in a])
def pmul(F: Fq2, a, b):
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):
if y:
out[i + j] = F.add[out[i + j]][F.mul(x, y)]
return _trim(out)
def pdivmod(F: Fq2, a, m):
"""Quotient and remainder of a by m != 0."""
r = list(a)
dm = len(m) - 1
if len(r) <= dm:
return (), _trim(r)
inv = F.inv(m[-1])
quo = [0] * (len(r) - dm)
for i in range(len(r) - 1, dm - 1, -1):
c = F.mul(r[i], inv)
if c:
quo[i - dm] = c
for j in range(dm + 1):
if m[j]:
r[i - dm + j] = F.add[r[i - dm + j]][F.neg[F.mul(c, m[j])]]
return _trim(quo), _trim(r[:dm])
def pmonic(F: Fq2, a):
return pscale(F, a, F.inv(a[-1]))
def pgcd(F: Fq2, a, b):
while b:
a, b = b, pdivmod(F, a, b)[1]
return pmonic(F, a) if a else ()
def ppowmod(F: Fq2, a, e: int, m):
out: tuple[int, ...] = (1,)
a = pdivmod(F, a, m)[1]
while e:
if e & 1:
out = pdivmod(F, pmul(F, out, a), m)[1]
a = pdivmod(F, pmul(F, a, a), m)[1]
e >>= 1
return out
def peval(F: Fq2, a, x: int) -> int:
out = 0
for c in reversed(a):
out = F.add[F.mul(out, x)][c]
return out
class QPField:
"""K = F_(q^2)[X]/(I) where I is irreducible of degree k and divides h1 X^q - h0,
with deg h0, deg h1 <= 2, gcd(h0, h1) = 1, and h1 a product of linear factors."""
def __init__(self, q: int, k: int, seed: int = 0, h0=None, h1=None):
"""Search h0 and h1 at random with the given seed, or take the given
h0 (and h1, by default 1); raise ValueError if the given pair has no
unique irreducible factor of degree k or h1 does not split."""
if not 2 <= k <= q + 2:
raise ValueError("need 2 <= k <= q + 2")
F = Fq2(q)
self.F, self.q, self.k = F, q, k
rng = random.Random(seed)
n = F.n
xq = (0,) * q + (1,)
fixed = h0 is not None
while True:
if fixed:
h0, h1 = _trim(list(h0)), _trim(list(h1 or (1,)))
if len(h0) > 3 or not 1 <= len(h1) <= 3 or len(split_linear(F, h1)[2]) != 1:
raise ValueError("need deg h0, deg h1 <= 2 and h1 a product of linear factors")
else:
dh1 = rng.choice([d for d in (0, 1, 2) if q + d >= k])
h1 = (1,)
for _ in range(dh1):
h1 = pmul(F, h1, (rng.randrange(n), 1))
h0 = _trim([rng.randrange(n) for _ in range(3)])
if not h0 or len(pgcd(F, h0, h1)) != 1:
if fixed:
raise ValueError("need gcd(h0, h1) = 1")
continue
big = psub(F, pmul(F, h1, xq), h0)
part = pgcd(F, big, psub(F, ppowmod(F, (0, 1), n**k, big), (0, 1)))
for d in range(1, k):
if k % d == 0 and len(part) > 1:
part = pdivmod(F, part, pgcd(F, part, psub(F, ppowmod(F, (0, 1), n**d, part), (0, 1))))[0]
if len(part) - 1 == k:
self.h0, self.h1, self.I = h0, h1, part
return
if fixed:
raise ValueError("h1 X^q - h0 has no unique irreducible factor of degree k")
def size(self) -> int:
return self.F.n**self.k
def encode(self, a) -> int:
return sum(c * self.F.n**j for j, c in enumerate(a))
def decode(self, v: int):
c = []
while v:
c.append(v % self.F.n)
v //= self.F.n
return tuple(c)
def mul(self, a, b):
return pdivmod(self.F, pmul(self.F, a, b), self.I)[1]
def pow(self, a, e: int):
return ppowmod(self.F, a, e, self.I)
def split_linear(F: Fq2, a):
"""Write a != 0 as c * prod (X + beta)^(e_beta) * rest with rest monic and
without roots in F_(q^2); return (c, e, rest), e indexed by beta."""
e = [0] * F.n
c = a[-1]
a = pmonic(F, a)
for beta in range(F.n):
if len(a) == 1:
break
while len(a) > 1 and peval(F, a, F.neg[beta]) == 0:
a = pdivmod(F, a, (beta, 1))[0]
e[beta] += 1
return c, e, a
def coset_representatives(F: Fq2) -> list[tuple[int, int, int, int]]:
"""Matrices (a, b, c, d), one for each coset of PGL2(F_(q^2)) / PGL2(F_q),
up to the scaling of all four entries by F_(q^2)^*: q^3 + q matrices."""
q, n = F.q, F.n
fq = list(range(q)) # F_q inside F_(q^2)
reps = []
deltas = sorted({min(F.mul(lam, x) for lam in fq[1:]) for x in range(1, n)})
for delta in deltas: # Y = X + b, Z = delta
bs = sorted({min(F.add[b][F.mul(al, delta)] for al in fq) for b in range(n)})
reps += [(1, b, 0, delta) for b in bs]
norm1 = [x for x in range(1, n) if F.mul(F.frob[x], x) == 1]
ts = sorted({min(F.mul(z, t) for z in norm1) for t in range(1, n)})
w = q # the element i, not in F_q
for t in ts: # Y = X + phi(1), Z = i X + phi(i), phi(x) = s x + t x^q
for s in range(n):
reps.append((1, F.add[s][t], w, F.add[F.mul(s, w)][F.mul(t, F.frob[w])]))
return reps
def _int_factors(m: int) -> dict[int, int]:
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, rho: int, mod: int) -> list[int] | None:
"""Gauss-Jordan elimination modulo mod = rho^f with unit pivots; None if a column has no unit pivot."""
rows = [[x % mod for x in e] + [v % mod] for e, v in rels]
t = len(rels[0][0])
for j in range(t): # @line L18
piv = next((i for i in range(j, len(rows)) if rows[i][j] % rho), None)
if piv is None:
return None
rows[j], rows[piv] = rows[piv], rows[j] # @line L19
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 L20
def qp_dlog(g: int, h: int, r: int, K: QPField, seed: int = 0) -> int | None:
"""Discrete logarithm in K^* = (F_(q^2)[X]/(I))^* with relations from the
systematic equation X^q - X = prod over alpha in F_q of (X - alpha).
Field elements are ints in [1, q^(2k)) whose base-q^2 digits are the codes
of the coefficients of a polynomial in X of degree below k. Return the x in
[0, r) with g^x = h, or None if h is not a power of g.
Precondition: g has order exactly r and h != 0.
The factor base is the q^2 monic linear polynomials; the descent of this
toy version stops at degree 2 (shifts of the target must split into
linear factors and irreducible quadratics that one homography removes).
Raises ValueError if r < 1 or g or h is not a reduced nonzero element.
Cost: heuristic (log Q)^(O(log log Q)) for Q = q^(2k) with the full
descent; this toy version uses O(q^5) operations in F_(q^2) for the
relations, O(q^6) for the linear algebra, random shifts for the targets
and O(Q^(1/2)) integer divisions to factor Q - 1.
"""
F, n, k = K.F, K.F.n, K.k
if r < 1 or not (0 < g < K.size()) or not (0 < h < K.size()):
raise ValueError("need r >= 1 and reduced nonzero g, h")
gp, hp = K.decode(g), K.decode(h)
if K.pow(hp, r) != (1,): # @line L1
return None
if hp == (1,): # @line L2
return 0
rng = random.Random(seed)
order = K.size() - 1
fac = _int_factors(order)
gamma = next(c for c in (K.decode(v) for v in range(n, K.size())) if all(K.pow(c, order // p) != (1,) for p in fac)) # @line L3
parts = {p: p**e for p, e in fac.items() if r % p == 0}
ell = 1
for mq in parts.values():
ell *= mq
omega = K.pow(gamma, order // (n - 1))[0] # a generator of F_(q^2)^*
log_const = [0] * n
c = 1
for j in range(n - 1):
log_const[c] = j * (order // (n - 1)) % ell
c = F.mul(c, omega)
h0, h1 = K.h0, K.h1
ch1, eh1, _ = split_linear(F, h1) # h1 = ch1 prod (X + beta)^(eh1)
rels: list[tuple[list[int], int]] = [] # @line L4
elim: dict[tuple[int, ...], tuple[list[int], int]] = {}
for a, b, c, d in coset_representatives(F): # @line L5
y, z = _trim([b, a]), _trim([d, c])
rm = psub(F, pmul(F, padd(F, pscale(F, h0, F.frob[a]), pscale(F, h1, F.frob[b])), z),
pmul(F, y, padd(F, pscale(F, h0, F.frob[c]), pscale(F, h1, F.frob[d])))) # @line L6
e = list(eh1)
v = -log_const[ch1]
for f in [z] + [psub(F, y, pscale(F, z, al)) for al in range(F.q)]:
if len(f) == 1:
v -= log_const[f[0]]
else:
e[F.mul(f[0], F.inv(f[1]))] += 1
v -= log_const[f[1]]
c0, er, rest = split_linear(F, rm)
row = [x - y2 for x, y2 in zip(e, er)]
if len(rest) == 1: # @line L7
rels.append((row, (v + log_const[c0]) % ell))
elif len(rest) == 3: # @line L8
elim.setdefault(rest, (row, (v + log_const[c0]) % ell))
lam: list[int] | None = None
need = n + 10 # rows needed before the next try of SOLVE
while lam is None: # @line L9
m = rng.randrange(order)
cz, ez, rest = split_linear(F, K.pow(gamma, m))
if len(rest) != 1:
continue
rels.append((ez, (m - log_const[cz]) % ell)) # @line L10
if len(rels) < need:
continue
need = len(rels) + max(10, n // 4)
lam = [0] * n
modulus = 1
for p, mq in parts.items():
sol = _solve(rels, p, 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
logs = []
for yy in (gp, hp): # @line L12
while True:
m = rng.randrange(order)
cz, ez, rest = split_linear(F, K.mul(yy, K.pow(gamma, m))) # @line L13
quads = []
for qq in elim:
while len(rest) > 1 and not pdivmod(F, rest, qq)[1]:
rest = pdivmod(F, rest, qq)[0]
quads.append(qq)
if len(rest) == 1: # @line L14
break
total = log_const[cz] + sum(u * w for u, w in zip(ez, lam))
for qq in quads:
row, v = elim[qq]
total += sum(u * w for u, w in zip(row, lam)) - v # @line L15
logs.append((total - m) % ell)
cc = ell // r # @line L16
u = logs[0] // cc
return logs[1] // cc * pow(u, -1, r) % r # @line L17
test_quasi_polynomial_discrete_logarithm.py 218 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/quasi-polynomial-discrete-logarithm/. The authors are listed in prov/7RM8BX.jsonl.
import random
import pytest
from quasi_polynomial_discrete_logarithm import (
QPField,
coset_representatives,
pdivmod,
pgcd,
pmul,
ppowmod,
psub,
pscale,
padd,
qp_dlog,
split_linear,
)
_FIELDS: dict[tuple[int, int], QPField] = {}
def field(q: int, k: int) -> QPField:
if (q, k) not in _FIELDS:
_FIELDS[(q, k)] = QPField(q, k, seed=q * 100 + k)
return _FIELDS[(q, k)]
def powers(K: QPField, g: int) -> dict[int, int]:
"""Map the code of g^i to i for i in [0, ord g), by brute force."""
gp = K.decode(g)
table = {}
y, i = (1,), 0
while K.encode(y) not in table:
table[K.encode(y)] = i
y = K.mul(y, gp)
i += 1
return table
def element_of_order(K: QPField, r: int, rng: random.Random) -> int:
"""A random element of order exactly r, r | |K^*|."""
n = K.size() - 1
primes = [p for p in range(2, r + 1) if r % p == 0 and all(p % d for d in range(2, p))]
while True:
y = K.pow(K.decode(rng.randrange(1, n + 1)), n // r)
if all(K.pow(y, r // p) != (1,) for p in primes):
return K.encode(y)
def check(K: QPField, g: int, h: int, seed: int = 0) -> None:
table = powers(K, g)
assert qp_dlog(g, h, len(table), K, seed=seed) == table.get(h), (K.q, K.k, g, h)
def test_field_construction():
for q, k in [(3, 2), (3, 3), (3, 5), (5, 2), (5, 4), (5, 7), (7, 3), (7, 9)]:
K = field(q, k)
F = K.F
assert len(K.I) - 1 == k and K.I[-1] == 1
big = psub(F, pmul(F, K.h1, (0,) * q + (1,)), K.h0)
assert not pdivmod(F, big, K.I)[1]
assert len(split_linear(F, K.h1)[2]) == 1
# irreducible: X^(q^(2k)) = X mod I and gcd(X^(q^(2d)) - X, I) = 1 for d < k
assert ppowmod(F, (0, 1), F.n**k, K.I) == (0, 1)
for d in range(1, k):
assert len(pgcd(F, K.I, psub(F, ppowmod(F, (0, 1), F.n**d, K.I), (0, 1)))) == 1
def test_coset_representatives_are_the_baer_sublines():
for q in (3, 5, 7):
K = field(q, 2)
F = K.F
reps = coset_representatives(F)
assert len(reps) == q**3 + q
keys = set()
for a, b, c, d in reps:
assert F.add[F.mul(a, d)][F.neg[F.mul(b, c)]] != 0
pts = []
for al in [None] + list(range(q)):
u, v = (c, d) if al is None else (F.add[a][F.neg[F.mul(al, c)]], F.add[b][F.neg[F.mul(al, d)]])
pts.append(-1 if u == 0 else F.mul(v, F.inv(u)))
assert len(set(pts)) == q + 1
keys.add(frozenset(pts))
assert len(keys) == q**3 + q
def test_systematic_equation():
rng = random.Random(5)
for q, k in [(3, 4), (5, 3), (7, 2), (7, 9)]:
K = field(q, k)
F = K.F
for a, b, c, d in rng.sample(coset_representatives(F), 20):
y = (b, a) if a else (b,) # a X + b, with a != 0 or b != 0
z = (d, c) if c else (d,)
left = pmul(F, K.h1, z)
for al in range(q):
left = pmul(F, left, psub(F, y, pscale(F, z, al)))
rm = psub(F, pmul(F, padd(F, pscale(F, K.h0, F.frob[a]), pscale(F, K.h1, F.frob[b])), z),
pmul(F, y, padd(F, pscale(F, K.h0, F.frob[c]), pscale(F, K.h1, F.frob[d]))))
assert pdivmod(F, left, K.I)[1] == pdivmod(F, rm, K.I)[1]
def test_worked_example():
K = QPField(3, 3, h0=(1, 1))
F = K.F
assert K.I == (2, 2, 0, 1) # X^3 - X - 1 over F_9
g, h = K.encode((1, 1)), K.encode((1, 2, 2)) # X + 1 and 2X^2 + 2X + 1
assert len(powers(K, g)) == 13
assert K.encode(K.pow((1, 1), 11)) == h
gamma = (3, 1) # X + i
z = K.mul((1, 2, 2), K.pow(gamma, 5))
assert z == (3, 1, 1) # X^2 + X + i, no root in F_9
assert len(split_linear(F, z)[2]) == 3
for seed in range(5):
assert qp_dlog(g, h, 13, K, seed=seed) == 11
def test_trivial_targets():
rng = random.Random(1)
for q, k in [(3, 2), (3, 4), (5, 3), (7, 2)]:
K = field(q, k)
n = K.size() - 1
for r in sorted({p for p in range(2, 200) if n % p == 0 and all(p % d for d in range(2, p))})[:2]:
g = element_of_order(K, r, rng)
assert qp_dlog(g, 1, r, K) == 0
assert qp_dlog(g, g, r, K) == 1
def test_small_fields_exhaustive():
rng = random.Random(2)
for q, k in [(3, 2), (3, 3)]:
K = field(q, k)
g = element_of_order(K, K.size() - 1, rng)
table = powers(K, g)
hs = list(range(1, K.size()))
for h in rng.sample(hs, 12) + [1, g]:
assert qp_dlog(g, h, len(table), K, seed=h) == table[h]
def test_random_against_brute_force():
rng = random.Random(3)
sizes = [(3, 2), (3, 3), (3, 4), (3, 5), (5, 2), (5, 3), (7, 2), (7, 3)]
for it in range(16):
q, k = sizes[it % len(sizes)]
K = field(q, k)
n = K.size() - 1
divs = [d for d in range(2, min(n, 3000) + 1) if n % d == 0]
r = rng.choice(divs)
g = element_of_order(K, r, rng)
h = K.encode(K.pow(K.decode(g), rng.randrange(r)))
check(K, g, h, seed=it)
def test_generators_and_non_generators():
rng = random.Random(4)
for q, k in [(3, 3), (3, 4), (5, 2)]:
K = field(q, k)
n = K.size() - 1
g = element_of_order(K, n, rng)
table = powers(K, g)
for h in rng.sample(range(1, K.size()), 3):
assert qp_dlog(g, h, n, K, seed=h) == table[h]
g2 = K.encode(K.pow(K.decode(g), 2)) # order n / 2, not a generator
h_out = g # an odd power of g is outside <g^2>
assert qp_dlog(g2, h_out, n // 2, K) is None
assert qp_dlog(g2, K.encode(K.pow(K.decode(g), 6)), n // 2, K) == 3
def test_prime_and_composite_orders():
rng = random.Random(6)
K = field(3, 4) # |K^*| = 6560 = 2^5 * 5 * 41
for r in (2, 4, 5, 8, 10, 32, 41, 82, 205, 656):
g = element_of_order(K, r, rng)
for _ in range(2):
h = K.encode(K.pow(K.decode(g), rng.randrange(r)))
check(K, g, h)
def test_no_solution_is_reported():
rng = random.Random(7)
for q, k in [(3, 3), (3, 4), (5, 3), (7, 2)]:
K = field(q, k)
n = K.size() - 1
r = next(d for d in range(2, n) if n % d == 0 and all(d % e for e in range(2, d)))
g = element_of_order(K, n // r, rng) # a subgroup of index r
table = powers(K, g)
outside = [h for h in rng.sample(range(1, K.size()), 40) if h not in table]
assert outside
for h in outside[:3]:
assert qp_dlog(g, h, len(table), K) is None
def test_seed_independence():
rng = random.Random(8)
K = field(5, 3)
n = K.size() - 1
g = element_of_order(K, n, rng)
h = K.encode(K.pow(K.decode(g), 9876))
assert {qp_dlog(g, h, n, K, seed=s) for s in range(4)} == {9876}
def test_bad_input():
K = field(3, 3)
with pytest.raises(ValueError):
qp_dlog(0, 1, 1, K)
with pytest.raises(ValueError):
qp_dlog(2, K.size(), 1, K)
with pytest.raises(ValueError):
qp_dlog(2, 1, 0, K)
with pytest.raises(ValueError):
QPField(3, 6)
with pytest.raises(ValueError):
QPField(4, 2)
with pytest.raises(ValueError):
QPField(3, 3, h0=(0, 1)) # X^3 - X splits over F_3
What the next level needs
- the tests did not run in this build
Read the full page about Quasi-polynomial discrete logarithm algorithm