Algorithm Search⌘K

Coppersmith's algorithm for discrete logarithms in GF(2^n) in Python

Index-calculus method for characteristic-2 fields with L(1/3) complexity.

File
coppersmith_discrete_logarithm.py, 255 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
coppersmith_discrete_logarithm.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/coppersmith-discrete-logarithm/. The authors are listed in prov/TEVZ61.jsonl.

import random
from math import log

# A polynomial over F_2 is an int: bit i is the coefficient of x^i.


def pmul(a: int, b: int) -> int:
    """Product of two polynomials over F_2 (carry-less multiplication)."""
    out = 0
    while b:
        if b & 1:
            out ^= a
        a <<= 1
        b >>= 1
    return out


def pdivmod(a: int, m: int) -> tuple[int, int]:
    """Quotient and remainder of a by m != 0 over F_2."""
    q = 0
    dm = m.bit_length()
    while a.bit_length() >= dm:
        s = a.bit_length() - dm
        q ^= 1 << s
        a ^= m << s
    return q, a


def pgcd(a: int, b: int) -> int:
    """Greatest common divisor of two polynomials over F_2."""
    while b:
        a, b = b, pdivmod(a, b)[1]
    return a


def fmul(a: int, b: int, f: int) -> int:
    """Product of a and b in F_2[x]/(f)."""
    return pdivmod(pmul(a, b), f)[1]


def fpow(a: int, e: int, f: int) -> int:
    """a^e in F_2[x]/(f) by square and multiply."""
    out = 1
    a = pdivmod(a, f)[1]
    while e:
        if e & 1:
            out = fmul(out, a, f)
        a = fmul(a, a, f)
        e >>= 1
    return out


def spread(a: int, k: int) -> int:
    """a(x^(2^k)), which equals a(x)^(2^k) over F_2."""
    out = 0
    i = 0
    while a:
        if a & 1:
            out |= 1 << (i << k)
        a >>= 1
        i += 1
    return out


def irreducibles(dmax: int) -> list[int]:
    """All irreducible polynomials over F_2 of degree 1 to dmax, by a sieve on products."""
    red = set()
    irr: list[int] = []
    for p in range(2, 1 << (dmax + 1)):
        if p not in red:
            irr.append(p)
        for s in irr:
            if (s.bit_length() - 1) + (p.bit_length() - 1) > dmax:
                break
            red.add(pmul(s, p))
    return irr


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 _split(y: int, base: list[int]) -> tuple[list[int], int]:
    """Divide y by the members of base as often as possible; return (exponents, cofactor)."""
    e = [0] * len(base)
    for i, s in enumerate(base):
        while True:
            q, rem = pdivmod(y, s)
            if rem:
                break
            y = q
            e[i] += 1
    return e, y


def _solve(rels: list[tuple[list[int], int]], 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 coppersmith_dlog(g: int, h: int, r: int, f: int, seed: int = 0, b: int | None = None) -> int | None:
    """Coppersmith's index calculus for log_g h in F_(2^n)^* = (F_2[x]/(f))^*.

    Field elements are ints in [1, 2^n) read as polynomials, n = deg f.
    Return the x in [0, r) with g^x = h, or None if h is not a power of g.
    Precondition: f = x^n + f1 is irreducible over F_2, g != 0 has order
    exactly r, and h != 0.
    The factor base holds the irreducible polynomials of degree at most b,
    by default b = round(0.974 n^(1/3) (ln n)^(2/3)).
    Raises ValueError if deg f < 1, r < 1, or g or h is not a reduced nonzero element.
    Cost: heuristic L_q(1/3, (32/9)^(1/3)) bit operations for q = 2^n when
    deg f1 is small; this toy version also spends O(2^(n/2)) integer
    divisions to factor 2^n - 1 and find a generator.
    """
    n = f.bit_length() - 1
    if n < 1 or r < 1 or not (0 < g < 1 << n) or not (0 < h < 1 << n):
        raise ValueError("need deg f >= 1, r >= 1 and reduced nonzero g, h")
    if fpow(h, r, f) != 1:  # @line L1
        return None
    if h == 1:  # @line L2
        return 0
    rng = random.Random(seed)
    order = (1 << n) - 1
    fac = _int_factors(order)
    gamma = next(c for c in range(1, 1 << n) if all(fpow(c, order // q, f) != 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
    if b is None:
        b = round(0.974 * n ** (1 / 3) * log(n) ** (2 / 3))
    b = max(1, min(n - 1, b))
    big = min(n - 1, 2 * b)
    irr = irreducibles(max(big, 1))
    base = [s for s in irr if s.bit_length() - 1 <= b]  # @line L4
    large = [s for s in irr if b < s.bit_length() - 1 <= big]
    k = 0
    while (2 << (2 * k)) * b <= n:  # 2^k is about (n / b)^(1/2)
        k += 1
    two_k = 1 << k
    ell_l = -(-n // two_k)
    f1 = f ^ (1 << n)
    shift = two_k * ell_l - n

    def frob(a: int, bb: int) -> int:
        # D = a(x^(2^k)) x^(2^k l - n) f1 + bb(x^(2^k)), congruent to (a x^l + bb)^(2^k)
        return pmul(pmul(spread(a, k), 1 << shift), f1) ^ spread(bb, k)

    rels: list[tuple[list[int], int]] = []
    for a in range(1, 1 << b):  # @line L5
        for bb in range(1 << b):
            if len(rels) >= 2 * len(base) or pgcd(a, bb) != 1:
                continue
            c = pmul(a, 1 << ell_l) ^ bb  # @line L6
            d = frob(a, bb)
            if pdivmod(c, f)[1] == 0:
                continue
            ec, rc = _split(c, base)
            ed, rd = _split(d, base)
            if rc == 1 and rd == 1:  # @line L7
                rels.append(([two_k * u - v for u, v in zip(ec, ed)], 0))
    lam: list[int] | None = None
    while lam is None:  # @line L8
        m = rng.randrange(order)
        e, rest = _split(fpow(gamma, m, f), base)  # @line L9
        if rest != 1:
            continue
        rels.append((e, m))
        if len(rels) < len(base) + 10:  # @line L10
            continue
        lam = [0] * len(base)
        modulus = 1
        for q, mq in parts.items():
            sol = _solve(rels, q, mq)
            if sol is None:  # @line L11
                lam = None
                break
            lam = [a + modulus * ((s - a) * pow(modulus, -1, mq) % mq) for a, s in zip(lam, sol)]
            modulus *= mq
    inv_two_k = pow(two_k, -1, ell)

    def special_q(gq: int) -> int | None:
        # find C = A x^l + B with gq^a exactly dividing C, C / gq^a and D smooth
        for a in range(1, 1 << b):
            quo, rem = pdivmod(pmul(a, 1 << ell_l), gq)
            for t in range(4):
                bb = rem ^ pmul(gq, t)
                c = pmul(a, 1 << ell_l) ^ bb  # c = gq * (quo + t)
                if c == 0 or pdivmod(c, f)[1] == 0:
                    continue
                mult = 0
                while pdivmod(c, gq)[1] == 0:
                    c = pdivmod(c, gq)[0]
                    mult += 1
                if any(mult % q == 0 for q in parts):
                    continue
                ec, rc = _split(c, base)
                ed, rd = _split(frob(a, bb), base)
                if rc == 1 and rd == 1:
                    lc = sum(u * v for u, v in zip(ec, lam))
                    ld = sum(u * v for u, v in zip(ed, lam))
                    return (inv_two_k * ld - lc) * pow(mult, -1, ell) % ell
        return None

    logs = []
    for y in (g, h):  # @line L12
        while True:
            m = rng.randrange(order)
            dvec, rest = _split(fmul(y, fpow(gamma, m, f), f), base)  # @line L13
            elarge, rest = _split(rest, large)
            if rest != 1:
                continue
            total = sum(u * v for u, v in zip(dvec, lam))
            for gq, mult in zip(large, elarge):
                if mult:
                    lg = special_q(gq)  # @line L14
                    if lg is None:
                        break
                    total += mult * lg
            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_coppersmith_discrete_logarithm.py 146 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/coppersmith-discrete-logarithm/. The authors are listed in prov/TEVZ61.jsonl.

import random

import pytest

from coppersmith_discrete_logarithm import coppersmith_dlog, fmul, fpow, irreducibles, pdivmod

_IRR = irreducibles(8)


def field(n: int) -> int:
    """x^n + f1 with the least f1 that makes it irreducible over F_2."""
    for f1 in range(1, 1 << n):
        f = (1 << n) | f1
        if all(pdivmod(f, s)[1] for s in _IRR if 2 * (s.bit_length() - 1) <= n):
            return f
    raise AssertionError("no irreducible trinomial-like polynomial")


def powers(g: int, f: int) -> dict[int, int]:
    """Map g^i 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 = fmul(y, g, f)
        i += 1
    return table


def check(g: int, h: int, f: int, seed: int = 0) -> None:
    table = powers(g, f)
    got = coppersmith_dlog(g, h, len(table), f, seed=seed)
    assert got == table.get(h), (g, h, f)


def test_galbraith_example():
    f = (1 << 15) | 0b11
    g = 0b100010100101
    h = (1 << 14) | (1 << 11) | (1 << 10) | (1 << 9) | 1
    assert coppersmith_dlog(g, h, 151, f, b=3) == 23
    assert coppersmith_dlog(g, h, 151, f) == 23
    assert fpow(g, 23, f) == h


def test_trivial_targets():
    for n in (1, 2, 3, 8, 13):
        f = field(n)
        for g in (1, 2, 3, (1 << n) - 1):
            if g >= 1 << n:
                continue
            table = powers(g, f)
            r = len(table)
            assert coppersmith_dlog(g, 1, r, f) == 0
            if g != 1:
                assert coppersmith_dlog(g, g, r, f) == (1 if r > 1 else 0)
    f = field(5)
    assert coppersmith_dlog(1, 2, 1, f) is None


def test_small_fields_exhaustive():
    for n in range(1, 6):
        f = field(n)
        for g in range(1, 1 << n):
            for h in range(1, 1 << n):
                check(g, h, f)


def test_random_fields_against_brute_force():
    rng = random.Random(1)
    for _ in range(60):
        n = rng.randrange(6, 15)
        f = field(n)
        g = rng.randrange(1, 1 << n)
        h = rng.randrange(1, 1 << n)
        check(g, h, f, seed=rng.randrange(1000))
        table = powers(g, f)
        x = rng.randrange(len(table))
        check(g, fpow(g, x, f), f, seed=rng.randrange(1000))


def test_generators_and_subgroups():
    rng = random.Random(2)
    for n in (6, 8, 9, 10, 11, 12, 14, 15, 16):
        f = field(n)
        order = (1 << n) - 1
        gamma = next(c for c in range(2, 1 << n) if len(powers(c, f)) == order) if n <= 12 else None
        divisors = [d for d in range(2, order + 1) if order % d == 0]
        for r in rng.sample(divisors, min(4, len(divisors))):
            base = gamma if gamma is not None else rng.randrange(2, 1 << n)
            g = fpow(base, order // r, f)
            table = powers(g, f)
            r = len(table)
            x = rng.randrange(r)
            assert coppersmith_dlog(g, fpow(g, x, f), r, f, seed=x) == x
            y = rng.randrange(1, 1 << n)
            assert coppersmith_dlog(g, y, r, f) == table.get(y)


def test_prime_and_composite_orders():
    f = field(12)  # 2^12 - 1 = 3^2 * 5 * 7 * 13
    full = powers(2, f)
    gamma = 2 if len(full) == 4095 else next(c for c in range(3, 4096) if len(powers(c, f)) == 4095)
    for r in (3, 5, 7, 13, 9, 15, 45, 63, 91, 315, 4095):
        g = fpow(gamma, 4095 // r, f)
        for x in (0, 1, r - 1, r // 2):
            assert coppersmith_dlog(g, fpow(g, x, f), r, f) == x
        outside = fpow(gamma, 1, f)
        assert coppersmith_dlog(g, outside, r, f) == (None if r < 4095 else 1)


def test_no_solution_is_reported():
    rng = random.Random(3)
    found = 0
    for _ in range(60):
        n = rng.randrange(4, 13)
        f = field(n)
        g = rng.randrange(1, 1 << n)
        table = powers(g, f)
        h = rng.randrange(1, 1 << n)
        if h not in table:
            found += 1
            assert coppersmith_dlog(g, h, len(table), f) is None
    assert found >= 10


def test_seed_independence():
    f = field(13)
    g = 0b1011011
    table = powers(g, f)
    h = fpow(g, 1234 % len(table), f)
    assert {coppersmith_dlog(g, h, len(table), f, seed=s) for s in range(8)} == {1234 % len(table)}


def test_bad_input():
    f = field(5)
    with pytest.raises(ValueError):
        coppersmith_dlog(0, 1, 1, f)
    with pytest.raises(ValueError):
        coppersmith_dlog(1, 32, 1, f)
    with pytest.raises(ValueError):
        coppersmith_dlog(1, 1, 0, f)
    with pytest.raises(ValueError):
        coppersmith_dlog(1, 1, 1, 1)

What the next level needs

  • the tests did not run in this build

Read the full page about Coppersmith's algorithm for discrete logarithms in GF(2^n)