Algorithm Search⌘K

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
number_field_sieve_discrete_logarithm.pyRawGitHub
# 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