[toc]
- It would be better trying many things, instead of detailed analysis, on CTF
- Simple strategy is good, but tuning parameters not good. Applying past genius works is definitely better.
#!/usr/bin/env python3
from Crypto.Util.number import *
import random
import signal
class PoW():
def __init__(self, kbits, L):
self.kbits = kbits
self.L = L
self.banner()
self.gen()
self.loop(1337)
def banner(self):
print("===================================")
print("=== Welcome to idek PoW Service ===")
print("===================================")
print("")
def menu(self):
print("")
print("[1] Broken Oracle")
print("[2] Verify")
print("[3] Exit")
op = int(input(">>> "))
return op
def loop(self, n):
for _ in range(n):
op = self.menu()
if op == 1:
self.broken_oracle()
elif op == 2:
self.verify()
elif op == 3:
print("Bye!")
break
def gen(self):
self.p = getPrime(self.kbits)
self.q = getPrime(self.kbits)
self.n = self.p * self.q
self.phi = (self.p - 1) * (self.q - 1)
t = random.randint(0, self.n-1)
print(f"Here is your random token: {t}")
print(f"The public modulus is: {self.n}")
self.d = random.randint(128, 256)
print(f"Do 2^{self.d} times exponentiation to get the valid ticket t^(2^(2^{self.d})) % n!")
self.r = pow(2, 1 << self.d, self.phi)
self.ans = pow(t, self.r, self.n)
return
def broken_oracle(self):
u = int(input("Tell me the token. "))
ans = pow(u, self.r, self.n)
inp = int(input("What is your calculation? "))
if ans == inp:
print("Your are correct!")
else:
print(f"Nope, the ans is {str(ans)[:self.L]}... ({len(str(ans)[self.L:])} remain digits)")
return
def verify(self):
inp = int(input(f"Give me the ticket. "))
if inp == self.ans:
print("Good :>")
with open("flag.txt", "rb") as f:
print(f.read())
else:
print("Nope :<")
if __name__ == '__main__':
signal.alarm(120)
service = PoW(512, 200)
We are given token t
, exponent d
, and modulus n=p*q
. The goal is to find t^r % n
for r=2^(2^d) % phi(n)
within 2 minutes. The assumption of Wesolowski's verifiable delay function (Efficient verifiable delay functions) is that this type of computation is slow if factorization of n
is unknown. So the author may add an extra interface. We are given weird oracle "broken_oracle", which outputs most significant L
digits for u^r % n
given user input u
.
I saw the challenge after having nice progress by @soon_haari. His idea is:
- obtain
u1=broken_token(t)
- obtain
u2=broken_token(t^2 % n)
- find rest of digits of
u
by LLL/BKZ
If we assume that u=u1*(10^Ludown)+x
, u^2 % n=u2*(10^Lu2down)+y
, then
x,y
are small (L=250
. In our setting, we have to solve it for L=200
.
Then, I started tuning lattice, but it failed. And I tried to apply another idea: using u^-1 % n
instead of u^2 % n
. Even though it solved it for L=210
, but it did not work for L=200
...
After that, I changed mind. I assume that these strategy does not work cause some high degree terms (x^2,x*y
etc.) are included. So I determined to apply another method: Coppersmith method.
Coppersmith method is general framework for solving a polynomial equation over integer,
from sage.all import *
# defund/coppersmith
load("coppersmith.sage")
def solve(u1, Ludown, u2, L2udown, n):
polyrng = PolynomialRing(Zmod(n), 2, "xy")
x,y = polyrng.gen()
f = (u1*(10**Ludown)+x)**2 - (u2*(10**L2udown)+y)
print("computing small_root...")
result = small_roots(f, [10**Ludown, 10**L2udown], m=2, d=2)
if result == []:
return None
print(result)
want_result_0 = int(int(result[0][0])%n)
want_result_1 = int(int(result[0][1])%n)
print((want_result_0, want_result_1))
ans = u1*(10**Ludown)+want_result_0
ans_2 = u2*(10**L2udown)+want_result_1
assert (ans**2 - ans_2) % n == 0
return ans
I run the code and it outputted some result within few seconds. But it did not pass answer checking for some reason. I manipulated small_roots parameters, but it did not change the status. And I added some small bruteforce for most significant digits for x, y
, but did not... What can I do?
After CTF ended, I saw the writeup by @maple3142. I astonished and depressed, cause the method is almost same except for using another alternative Coppersmith extension lattice-based-cryptanalysis by @joseph instead of defund one. And his code includes the comment:
sys.path.append("./lattice-based-cryptanalysis")
# idk why defund/coppersmith doesn't work...
# need to remove `algorithm='msolve'` from solve_system_with_gb
from lbc_toolkit import small_roots
OK... I have to analyze why we were wrong...
Note: The intended solution is to use hidden number problem with some manipulation: (chronophobia). It is also good way for avoiding high degree terms.
Then, I review Coppersmith method. Recently, sophisticated overview has published: A Gentle Tutorial for Lattice-Based Cryptanalysis, J. Surin and S. Cohney, 2023. So I skip basics of lattice except citing the following theorem.
Let
in polynomial time in
Especially, LLL finds a short vector
Then, I focus Coppersmith method.
For introduction, we assume we want to solve the following equation. The modulus N
is the product of some two 512-bit primes.
x^2 + 159605847057167852113841544295462218002383319384138362824655884275675114830276700469870681042821801038268322865164690838582106399495428579551586422305321813432139336575079845596286904837546652665334599379653663170007525230318464366496529369441190568769524980427016623617364193484215743218597383810178030701505*x + 159605847057167852113841544295462218002383319384138362824655884275675114830276700469870681042821801038268322865164690838582106399495428579551586422305321813432139336575079845596286904837546652665334599379653663170007525230318464366496529369441190568769524980427016623616357485735731880812507594614316394069963 = 0 %
159605847057167852113841544295462218002383319384138362824655884275675114830276700469870681042821801038268322865164690838582106399495428579551586422305321813432139336575079845596286904837546652665334599379653663170007525230318464366496529369441190568769524980427016623617364193484215743218633044486831389275043
If we could solve this type of equation in general, we could factor N
efficiently and could break RSA! (It would be impossible.) But, luckily, we can deduce the following equation by just subtracting N*x+N
:
If the solution x0
is small, we can assume that the modulus solution x0
can be find by just solving over integer. (The modulus equation can be reduced to infinitely integer equations x0
is small enough.) Solving modulus equation is hard, but solving integer equation is easier. In fact, Sagemath solve it in seconds.
sage: P=PolynomialRing(ZZ, 'x')
sage: x=P.gens()[0]
sage: f= x^2 -35660676653358573538*x-1006707748483862406125449872514995205080
sage: f.roots()
[(54225787401085700998, 1), (-18565110747727127460, 1)]
This is the essence of Coppersmith method: reducing modulus equation to small integer equation.
Let's state Howgrave-Graham theorem. First, let
$$ {|h(x_1X_1,\ldots,x_nX_n)|}2 := \sqrt{\sum{(i_1,i_2,\ldots,i_n)} {\left( h_{i_1,i_2,\ldots,i_n}{X_1}^{i_1} \cdot {X_2}^{i_2} \cdots {X_n}^{i_n} \right)}^2} $$
Then, we can prove the following:
Let
-
$h(r_1,\ldots,r_n)=0 \pmod{N}$ for some$|r_1|< X_1,\ldots,|r_n|<X_n$ ${|h(x_1X_1,\ldots,x_nX_n)|}_2< \frac{N}{\sqrt{\omega}}$
are satisfied, then
$$
|h(r_1,r_2,\ldots,r_n)|\
= \left|\sum_{(i_1,\ldots,i_n)}h_{i_1,\ldots,i_n}{r_1}^{i_1} \cdots {r_n}^{i_n}\right|\
\le \sum_{(i_1,\ldots,i_n)}|h_{i_1,\ldots,i_n}{X_1}^{i_1} \cdots {X_n}^{i_n}|\
\le \sqrt{\omega} {||h(x_1X_1,\ldots,x_nX_n)||}_2\
< N
$$
The last inequality follows from Cauchy-Schwaltz inequality.
On the proof of above, we uses Cauchy-Schwaltz for obtaining the condition about ${|\cdot|}2$ (L2-norm). But, obviously, it is sufficient to check the condition ${|h(x_1X_1,\ldots,x_nX_n)|}1 := \sum{(i_1,i_2,\ldots,i_n)} |h{i_1,i_2,\ldots,i_n}{X_1}^{i_1} \cdot {X_2}^{i_2} \cdots {X_n}^{i_n}| < N$. We will use the L1-norm condition for checking obtaining polynomials are good or not.
Then, if we want to find a solution
- Collect polynomials
$g_i(x_1,\ldots,x_n)$ which satisfies$g_i(r_1,\ldots,r_n)=0 \pmod{N^t}$ for fixed$t\ge 1$ - Find polynomials
$h_1,\ldots,h_n$ which satisfies Howgrave-Graham condition for modulus$N^t$ .$h_j$ are found by LLL for the lattice generated by the coefficients for$g_i(x_1X_1,\ldots,x_nX_n)$ . ($h_j$ is linear combination of$g_i$ ) - Find
$(r_1,\ldots,r_n)$ by solving$h_j$ over the integer
On first introductory example,
For
for
Though powering of
So we should choose good shift polynomials and tweak some modification for each tasks. We will analyze each case.
The paper Finding Small Solutions to Small Degree Polynomials, D. Coppersmith, 2001 states the following:
Let
$$
f(r) = 0 \pmod{b}\ (|r| < X)
$$
, if around
The leading coefficient of
$$ \begin{pmatrix} X^{t \delta+u-1} & * & \ldots & \ldots &\ldots & \ldots & * \ 0 & X^{t \delta+u-2} & * & \ldots & \ldots & \ldots & *\ \vdots & \vdots & \ddots & \vdots & \vdots & \vdots & \vdots\ 0 & 0 & 0 & X^{t \delta} & * & \ldots & \ 0 & 0 & 0 & 0 &N X^{t\delta-1} & \ldots & *\ \vdots & \vdots & \vdots & \vdots & \vdots & \ddots & \vdots \ 0 & 0 & 0 & 0 & 0 & \ldots & N^t\ \end{pmatrix} $$
Each row vector corresponds to:
$x^{i} {f(x)}^t\ (i=u-1,\ldots, 0)$ $x^j {f(x)}^{t-i} N^i\ (i=1,\ldots,t,\ j=\delta-1,\ldots,0)$
These polynomials satisfy
Above theorem is theoretically clean, but we sometimes need parameter tuning for applying this to each task in practice. You know, especially
For practice, we only have to construct lattice with specifically determined shift polynomials. Even if first choice of
from sage.all import *
import time
from coppersmith_common import RRh, shiftpoly, genmatrix_from_shiftpolys, do_LLL, filter_LLLresult_coppersmith
from rootfind_ZZ import rootfind_ZZ
from logger import logger
### one variable coppersmith
def coppersmith_one_var_core(basepoly, bounds, beta, t, u, delta):
logger.info("trying param: beta=%f, t=%d, u=%d, delta=%d", beta, t, u, delta)
basepoly_vars = basepoly.parent().gens()
basepoly = basepoly / basepoly.monomial_coefficient(basepoly_vars[0])
shiftpolys = []
for i in range(u-1, -1, -1):
# x^i * f(x)^t
shiftpolys.append(shiftpoly(basepoly, t, 0, [i]))
for i in range(1, t+1, 1):
for j in range(delta-1, -1, -1):
# x^j * f(x)^(t-i) * N^i
shiftpolys.append(shiftpoly(basepoly, t-i, i, [j]))
mat = genmatrix_from_shiftpolys(shiftpolys, bounds)
lll, trans = do_LLL(mat)
result = filter_LLLresult_coppersmith(basepoly, beta, t, shiftpolys, lll, trans)
return result
def coppersmith_onevariable(basepoly, bounds, beta, maxmatsize=100, maxu=8):
if type(bounds) not in [list, tuple]:
bounds = [bounds]
N = basepoly.parent().characteristic()
basepoly_vars = basepoly.parent().gens()
if len(basepoly_vars) != 1:
raise ValueError("not one variable poly")
try:
delta = basepoly.weighted_degree([1])
except:
delta = basepoly.degree()
log_N_X = RRh(log(bounds[0], N))
if log_N_X >= RRh(beta)**2/delta:
raise ValueError("too much large bound")
testimate = int(1/(((RRh(beta)**2)/delta)/log_N_X - 1))//2
logger.debug("testimate: %d", testimate)
t = min([maxmatsize//delta, max(testimate, 3)])
whole_st = time.time()
curfoundpols = []
while True:
if t*delta > maxmatsize:
raise ValueError("maxmatsize exceeded(on coppersmith_one_var)")
u0 = max([int((t+1)/RRh(beta) - t*delta), 0])
for u_diff in range(0, maxu+1):
u = u0 + u_diff
if t*delta + u > maxmatsize:
break
foundpols = coppersmith_one_var_core(basepoly, bounds, beta, t, u, delta)
if len(foundpols) == 0:
continue
curfoundpols += foundpols
curfoundpols = list(set(curfoundpols))
sol = rootfind_ZZ(curfoundpols, bounds)
if sol != [] and sol is not None:
whole_ed = time.time()
logger.info("whole elapsed time: %f", whole_ed-whole_st)
return sol
elif len(curfoundpols) >= 2:
whole_ed = time.time()
logger.warning(f"failed. maybe, wrong pol was passed.")
logger.info("whole elapsed time: %f", whole_ed-whole_st)
return []
t += 1
# never reached here
return None
The code imports the following functions. For details, see appendix.
- shiftpoly(basepoly, baseidx, Nidx, varsidx_lst): generate shift polynomials as
$\text{basepoly}^{\text{baseidx}}\cdot N^{\text{Nidx}}\cdot {x_1}^{j_1} \cdots {x_n}^{j_n}$ , whose$j_i$ is varsidx_lst - genmatrix_from_shiftpolys(shiftpolys, bounds): generate matrix corresponding to shiftpolys
- do_LLL(mat): output LLL result and transformation matrix from mat to LLL result
- filter_LLLresult_coppersmith(basepoly, beta, t, shiftpolys, lll, trans): output short polynomial which satisfies
$\text{output}(r)=0$ over integer for solution$r$ of$\text{basepoly}(r)=0 \pmod{b}$ . This function only output polynomials with L1 norm$<b^t$ - rootfind_ZZ(pollst, bounds): find solution of pollst over integer on specific bounds
Above algorithm, we need to input basepoly, bounds,
- If
$b=N$ , then choose$\beta=1.0$ - If
$b=p$ such as$p\mid N$ , then choose$(\text{bitsize}(p)-1)/(\text{bitsize}(N))$
I used to use
An simple extension of the univariate case, we collect many shift polynomials and apply LLL. But it is just heuristic, then we may find solution or may not. This forces us to tune parameters without knowing goal, and we may fail to a rabbit hole. Sometimes it turns out that this heuristic does not work and another heuristic works. I do not want to search "lucky" anymore.
Instead, we see well analyzed method for multivariate linear polynomial case.
The paper Solving Linear Equations Modulo Divisors: On Factoring Given Any Bits, M. Herrmann and A. May, 2008 states the following:
Let
, if around
The coefficient of
- each (column) element are corresponding to:
${X_1}^m,{X_2}\cdot{X_1}^{m-1},\ldots,{X_n}\cdot{X_1}^{m-1}, {X_2}^{2}\cdot{X_1}^{m-2},{X_2}\cdot{X_3}\cdot{X_1}^{m-2}\ldots,{X_n}^{2}\cdot{X_1}^{m-2},\ldots,{X_1}^{m-2},\ldots,1$ - each row are corresponding to:
$g_{(0,0,\ldots,0,m)},g_{(1,0,\ldots,0, m-1)}, \ldots,g_{(0,\ldots,0, m-1)}, g_{(2,0,\ldots,0,m-2)},\ldots,g_{(0,\ldots,0,m-2)},\ldots,g_{(0,\ldots,0,0)}$
Those vectors have triangular form.
We want to maximize
Like univariate case, we expect we can find good
Then, we can implement multivariate linear case straightforward. Note that, on the above proof, it forces the coefficient of
Let
It is important that assuming
from sage.all import *
import time
import itertools
from coppersmith_common import RRh, shiftpoly, genmatrix_from_shiftpolys, do_LLL, filter_LLLresult_coppersmith
from rootfind_ZZ import rootfind_ZZ
from logger import logger
### multivariate linear coppersmith (herrmann-may)
def coppersmith_linear_core(basepoly, bounds, beta, t, m):
logger.info("trying param: beta=%f, t=%d, m=%d", beta, t, m)
basepoly_vars = basepoly.parent().gens()
n = len(basepoly_vars)
shiftpolys = []
for i, basepoly_var in enumerate(basepoly_vars):
basepoly_i = basepoly / basepoly.monomial_coefficient(basepoly_var)
for k in range(m+1):
for j in range(m-k+1):
for xi_idx_sub in itertools.combinations_with_replacement(range(n-1), j):
xi_idx = [xi_idx_sub.count(l) for l in range(n-1)]
assert sum(xi_idx) == j
xi_idx.insert(i, 0)
# x2^i2 * ... * xn^in * f^k * N^max(t-k,0)
shiftpolys.append(shiftpoly(basepoly_i, k, max(t-k, 0), xi_idx))
mat = genmatrix_from_shiftpolys(shiftpolys, bounds)
lll, trans = do_LLL(mat)
result = filter_LLLresult_coppersmith(basepoly, beta, t, shiftpolys, lll, trans)
return result
def coppersmith_linear(basepoly, bounds, beta, maxmatsize=100, maxm=8):
if type(bounds) not in [list, tuple]:
raise ValueError("not linear polynomial (on coppersmith_linear)")
N = basepoly.parent().characteristic()
basepoly_vars = basepoly.parent().gens()
n = len(basepoly_vars)
if n == 1:
raise ValueError("one variable poly")
if not set(basepoly.monomials()).issubset(set(list(basepoly_vars)+[1])):
raise ValueError("non linear poly")
log_N_X = RRh(log(product(bounds), N))
log_N_X_bound = 1-(1-RRh(beta))**(RRh(n+1)/n) - (n+1)*(1-(1-RRh(beta))**(RRh(1)/n)) * (1-RRh(beta))
if log_N_X >= log_N_X_bound:
raise ValueError("too much large bound")
mestimate = (n*(-RRh(beta)*ln(1-beta) + ((1-RRh(beta))**(-0.278465))/pi)/(log_N_X_bound - log_N_X))/(n+1.5)
tau = 1 - (1-RRh(beta))**(RRh(1)/n)
testimate = int(mestimate * tau + 0.5)
logger.debug("testimate: %d", testimate)
t = max(testimate, 1)
while True:
if t == 1:
break
m = int(t/tau+0.5)
if binomial(n+1+m-1, m) <= maxmatsize:
break
t -= 1
whole_st = time.time()
curfoundpols = []
while True:
m0 = int(t/tau+0.5)
if binomial(n+1+m0-1, m0) > maxmatsize:
raise ValueError("maxmatsize exceeded(on coppersmith_linear)")
for m_diff in range(0, maxm+1):
m = m0 + m_diff
if binomial(n+1+m-1, m) > maxmatsize:
break
foundpols = coppersmith_linear_core(basepoly, bounds, beta, t, m)
if len(foundpols) == 0:
continue
curfoundpols += foundpols
curfoundpols = list(set(curfoundpols))
sol = rootfind_ZZ(curfoundpols, bounds)
if sol != [] and sol is not None:
whole_ed = time.time()
logger.info("whole elapsed time: %f", whole_ed-whole_st)
return sol
elif len(curfoundpols) >= 2 * n + 1:
whole_ed = time.time()
logger.warning(f"failed. maybe, wrong pol was passed.")
logger.info("whole elapsed time: %f", whole_ed-whole_st)
return []
t += 1
# never reached here
return None
A strategy for finding a root for modulus equation (and integer equation) on general case is proposed on A Strategy for Finding Roots of Multivariate Polynomials with New Applications in Attacking RSA Variants, E. Jochemez and A. May, 2006. But this method does not assure we could obtain solution. I think general multivarite polynomial root finding is complicated. You might consider a polynomial
For obtaining good result of partial key exposure attacks, many authors propose different lattice construction. Some results are refined on A Tool Kit for Partial Key Exposure Attacks on RSA, †. Takayasu and N. Kunihiro, 2016. I do not think it is realistic to construct good lattice in our own during short period such as CTF. So we would like to search papers instead of tuning parameters. It might be worth to try using pre-checked good heuristic implementions, but devoting only one implementation is bad. If you determine to use heuristics, trying various methods may lead to win.
Or you can apply Herrmann-May with linearization. If
On the other hand, I states just simple extention of univariate case/multivariate linear case: very restrictive bivariate case
Let
, if around
The coefficient of
- each (column) element are corresponding to:
$X^m,Y^\delta X^{m-1},\ldots,X^{m-1},Y^{2 \delta}*X^{m-2},\ldots,X^{m-2},\ldots, 1$ - each row are corresponding to:
$g_{(0,m)},g_{(\delta,m-1)},\ldots,g_{(0,m-1)},g_{(2 \delta,m-2)},\ldots,g_{(0,m-2)},\ldots,g_{(0,0)}$
Those vectors have triangular form.
We want to maximize
Note that
This type of lattices can be constructed for another polynomials. For example, it can be applied to
Then, I restate what we want to solve. Let
, where
As I just stated the proposition on general case section, we may solve this type of equation if
For extending the result, we consider the following equation (
This type of equation was analyzed at Attacking Power Generators Using Unravelled Linearization: When Do We Output Too Much?, Herrman and May, 2009. Let
Also,
Then, we construct the lattice
In our case,
I do not know above discussion assures we can construct
This shift polynomials have triangular form (full lattice). And the lattice can generate good polynomial related to the lattice
Then, we relook defund coppersmith. It turns out that the parameter
for i in range(m+1):
base = N^(m-i) * f^i
for shifts in itertools.product(range(d), repeat=f.nvariables()):
g = base * prod(map(power, f.variables(), shifts))
G.append(g)
Though defund coppersmith can generate arbitrary shift polynomials, it may generate many useless shift polynomials for an input multivariate polynomial, so sometimes we could not compute LLL for large lattice in practice. lbc_toolkit outputs fairly reasonable shift polynomials, it includes a few useless shift polynomials, though.
With above discussion, I suggest the following basic strategy.
- Construct input polynomial as no cross term. Or applying linearization if cross term bounds are small. (If not, search papers or change polynomial.)
- try univariate case or linear case. parameters are chose based on above discussion, and go up parameters slightly (first
$m$ and then$t$ ) - try heuristic (lbc_toolkit) with going up paremters (first
$d$ and then$m$ ), in parallel, try defund one
Also, I suggest to print debug messages on each stage. If LLL takes much times, we know these parameters are too much. If passes LLL and not output solution, then we may check Howgrave-Graham bounds are satisfied (especially,
Lattice is so wild. The detailed discussions will help us for solving many tasks. We are waiting more discussion for specific examples...
Finding roots over integer is not easy. For one variable polynomial, you only have to use Sagemath roots method. For multivarite polynomials, we do not know the efficient and exact method for root finiding task. So I implement three method:
- solve_root_jacobian_newton:
- numerical method (cannot find all roots, but efficient)
- iteration method (Newton method: compute gradient (jacobian) and update point to close a root)
- possibly, no root found (converge local minima or divergence)
- solve_root_hensel
- algebraic method (find all roots, slow)
- find root mod small p and update to mod large modulus
- possibly, cannot compute a root (too many candidates on modulus even if only a few roots over integer)
- solve_root_triangulate
- algebraic method (try to find all roots, slow)
- compute Groebner basis and then find solution by solve function
- For finding all roots, sometimes requires manual manipulation (no general method)
from sage.all import *
from random import shuffle as random_shuffle
from itertools import product as itertools_product
import time
from logger import logger
def solve_root_onevariable(pollst, bounds):
logger.info("start solve_root_onevariable")
st = time.time()
for f in pollst:
f_x = f.parent().gens()[0]
try:
rt_ = f.change_ring(ZZ).roots()
rt = [ele for ele, exp in rt_]
except:
f_QQ = f.change_ring(QQ)
f_QQ_x = f_QQ.parent().gens()[0]
rt_ = f_QQ.parent().ideal([f_QQ]).variety()
rt = [ele[f_QQ_x] for ele in rt_]
if rt != []:
break
result = []
for rtele in rt:
if any([pollst[i].subs({f_x: int(rtele)}) != 0 for i in range(len(pollst))]):
continue
if abs(int(rtele)) < bounds[0]:
result.append(rtele)
ed = time.time()
logger.info("end solve_root_onevariable. elapsed %f", ed-st)
return result
def solve_root_groebner(pollst, bounds):
logger.info("start solve_root_groebner")
st = time.time()
# I heard degrevlex is faster computation for groebner basis, but idk real effect
polrng_QQ = pollst[0].change_ring(QQ).parent().change_ring(order='degrevlex')
vars_QQ = polrng_QQ.gens()
G = Sequence(pollst, polrng_QQ).groebner_basis()
try:
# not zero-dimensional ideal raises error
rt_ = G.ideal().variety()
except:
logger.warning("variety failed. not zero-dimensional ideal?")
return None
rt = [[int(ele[v]) for v in vars_QQ] for ele in rt_]
vars_ZZ = pollst[0].parent().gens()
result = []
for rtele in rt:
if any([pollst[i].subs({v: int(rtele[i]) for i, v in enumerate(vars_ZZ)}) != 0 for i in range(len(pollst))]):
continue
if all([abs(int(rtele[i])) < bounds[i] for i in range(len(rtele))]):
result.append(rtele)
ed = time.time()
logger.info("end solve_root_groebner. elapsed %f", ed-st)
return result
def solve_ZZ_symbolic_linear_internal(sol_coefs, bounds):
mult = prod(bounds)
matele = []
for i, sol_coef in enumerate(sol_coefs):
denom = 1
for sol_coef_ele in sol_coef:
denom = LCM(denom, sol_coef_ele.denominator())
for sol_coef_ele in sol_coef:
matele.append(ZZ(sol_coef_ele * denom * mult))
matele += [0]*i + [-mult*denom] + [0] * (len(bounds)-i-1)
for idx, bd in enumerate(bounds):
matele += [0]*len(sol_coefs[0]) + [0] * idx + [mult//bd] + [0]*(len(bounds)-idx-1)
# const term
matele += [0]*(len(sol_coefs[0])-1) + [mult] + [0]*len(bounds)
mat = matrix(ZZ, len(sol_coefs)+len(bounds)+1, len(sol_coefs[0])+len(bounds), matele)
logger.debug(f"start LLL for solve_ZZ_symbolic_linear_internal")
mattrans = mat.transpose()
lll, trans = mattrans.LLL(transformation=True)
logger.debug(f"end LLL")
for i in range(trans.nrows()):
if all([lll[i, j] == 0 for j in range(len(sol_coefs))]):
if int(trans[i,len(sol_coefs[0])-1]) in [1,-1]:
linsolcoef = [int(trans[i,j])*int(trans[i,len(sol_coefs[0])-1]) for j in range(len(sol_coefs[0]))]
logger.debug(f"linsolcoef found: {linsolcoef}")
linsol = []
for sol_coef in sol_coefs:
linsol.append(sum([ele*linsolcoef[idx] for idx, ele in enumerate(sol_coef)]))
return [linsol]
return []
def solve_root_triangulate(pollst, bounds):
logger.info("start solve_root_triangulate")
st = time.time()
polrng_QQ = pollst[0].change_ring(QQ).parent().change_ring(order='lex')
vars_QQ = polrng_QQ.gens()
G = Sequence(pollst, polrng_QQ).groebner_basis()
if len(G) == 0:
return []
symbolic_vars = [var(G_var) for G_var in G[0].parent().gens()]
try:
sols = solve([G_ele(*symbolic_vars) for G_ele in G], symbolic_vars, solution_dict=True)
except:
return None
logger.debug(f"found sol on triangulate: {sols}")
result = []
# solve method returns parametrized solution. We treat only linear equation
# TODO: use solver for more general integer equations (such as diophautus solver, integer programming solver, etc.)
for sol in sols:
sol_args = set()
for symbolic_var in symbolic_vars:
sol_var = sol[symbolic_var]
sol_args = sol_args.union(set(sol_var.args()))
sol_args = list(sol_args)
sol_coefs = []
for symbolic_var in symbolic_vars:
sol_var = sol[symbolic_var]
sol_coefs_ele = []
for sol_arg in sol_args:
if sol_var.is_polynomial(sol_arg) == False:
logger.warning("cannot deal with non-polynomial equation")
return None
if sol_var.degree(sol_arg) > 1:
logger.warning("cannot deal with high degree equation")
return None
sol_var_coef_arg = sol_var.coefficient(sol_arg)
if sol_var_coef_arg not in QQ:
logger.warning("cannot deal with multivariate non-linear equation")
return None
sol_coefs_ele.append(QQ(sol_var_coef_arg))
# constant term
const = sol_var.subs({sol_arg: 0 for sol_arg in sol_args})
if const not in QQ:
return None
sol_coefs_ele.append(const)
sol_coefs.append(sol_coefs_ele)
ZZsol = solve_ZZ_symbolic_linear_internal(sol_coefs, bounds)
result += ZZsol
ed = time.time()
logger.info("end solve_root_triangulate. elapsed %f", ed-st)
return result
def solve_root_jacobian_newton_internal(pollst, startpnt):
# NOTE: Newton method's complexity is larger than BFGS, but for small variables Newton method converges soon.
pollst_Q = Sequence(pollst, pollst[0].parent().change_ring(QQ))
vars_pol = pollst_Q[0].parent().gens()
jac = jacobian(pollst_Q, vars_pol)
if all([ele == 0 for ele in startpnt]):
# just for prepnt != pnt
prepnt = {vars_pol[i]: 1 for i in range(len(vars_pol))}
else:
prepnt = {vars_pol[i]: 0 for i in range(len(vars_pol))}
pnt = {vars_pol[i]: startpnt[i] for i in range(len(vars_pol))}
maxiternum = 1024
iternum = 0
while True:
if iternum >= maxiternum:
logger.warning("failed. maybe, going wrong way.")
return None
evalpollst = [(pollst_Q[i].subs(pnt)) for i in range(len(pollst_Q))]
if all([int(ele) == 0 for ele in evalpollst]):
break
jac_eval = jac.subs(pnt)
evalpolvec = vector(QQ, len(evalpollst), evalpollst)
try:
pnt_diff_vec = jac_eval.solve_right(evalpolvec)
except:
logger.warning("pnt_diff comp failed.")
return None
prepnt = {key:value for key,value in prepnt.items()}
pnt = {vars_pol[i]: round(QQ(pnt[vars_pol[i]] - pnt_diff_vec[i])) for i in range(len(pollst_Q))}
if all([prepnt[vars_pol[i]] == pnt[vars_pol[i]] for i in range(len(vars_pol))]):
logger.warning("point update failed. (converged local sol)")
return None
prepnt = {key:value for key,value in pnt.items()}
iternum += 1
return [int(pnt[vars_pol[i]]) for i in range(len(vars_pol))]
def solve_root_jacobian_newton(pollst, bounds):
logger.info("start solve_root_jacobian newton")
st = time.time()
pollst_local = pollst[:]
vars_pol = pollst[0].parent().gens()
# not applicable to non-determined system
if len(vars_pol) > len(pollst):
return []
for _ in range(10):
random_shuffle(pollst_local)
for signs in itertools_product([1, -1], repeat=len(vars_pol)):
startpnt = [signs[i] * bounds[i] for i in range(len(vars_pol))]
result = solve_root_jacobian_newton_internal(pollst_local[:len(vars_pol)], startpnt)
# filter too much small solution
if result is not None:
if all([abs(ele) < 2**16 for ele in result]):
continue
ed = time.time()
logger.info("end solve_root_jacobian newton. elapsed %f", ed-st)
return [result]
def _solve_root_GF_smallp(pollst, smallp):
Fsmallp = GF(smallp)
polrng_Fsmallp = pollst[0].change_ring(Fsmallp).parent().change_ring(order='degrevlex')
vars_Fsmallp = polrng_Fsmallp.gens()
fieldpolys = [varele**smallp - varele for varele in vars_Fsmallp]
pollst_Fsmallp = [polrng_Fsmallp(ele) for ele in pollst]
G = pollst_Fsmallp[0].parent().ideal(pollst_Fsmallp + fieldpolys).groebner_basis()
rt_ = G.ideal().variety()
rt = [[int(ele[v].lift()) for v in vars_Fsmallp] for ele in rt_]
return rt
def solve_root_hensel_smallp(pollst, bounds, smallp):
logger.info("start solve_root_hensel")
st = time.time()
vars_ZZ = pollst[0].parent().gens()
smallp_exp_max = max([int(log(ele, smallp)+0.5) for ele in bounds]) + 1
# firstly, compute low order
rt_lows = _solve_root_GF_smallp(pollst, smallp)
for smallp_exp in range(1, smallp_exp_max+1, 1):
cur_rt_low = []
for rt_low in rt_lows:
evalpnt = {vars_ZZ[i]:(smallp**smallp_exp)*vars_ZZ[i]+rt_low[i] for i in range(len(vars_ZZ))}
nextpollst = [pol.subs(evalpnt)/(smallp**smallp_exp) for pol in pollst]
rt_up = _solve_root_GF_smallp(nextpollst, smallp)
cur_rt_low += [tuple([smallp**smallp_exp*rt_upele[i] + rt_low[i] for i in range(len(rt_low))]) for rt_upele in rt_up]
rt_lows = list(set(cur_rt_low))
if len(rt_lows) >= 800:
logger.warning("too much root candidates found")
return None
result = []
for rt in rt_lows:
rtele = [[ele, ele - smallp**(smallp_exp_max+1)][ele >= smallp**smallp_exp_max] for ele in rt]
if any([pollst[i].subs({v: int(rtele[i]) for i, v in enumerate(vars_ZZ)}) != 0 for i in range(len(pollst))]):
continue
if all([abs(int(rtele[i])) < bounds[i] for i in range(len(rtele))]):
result.append(rtele)
ed = time.time()
logger.info("end solve_root_hensel. elapsed %f", ed-st)
return result
def solve_root_hensel(pollst, bounds):
for smallp in [2, 3, 5]:
result = solve_root_hensel_smallp(pollst, bounds, smallp)
if result != [] and result is not None:
return result
return None
## wrapper function
def rootfind_ZZ(pollst, bounds):
vars_pol = pollst[0].parent().gens()
if len(vars_pol) != len(bounds):
raise ValueError("vars len is invalid (on rootfind_ZZ)")
# Note: match-case statement introduced on python3.10, but not used for backward compati
if len(vars_pol) == 1:
return solve_root_onevariable(pollst, bounds)
else:
# first numeric
result = solve_root_jacobian_newton(pollst, bounds)
if result != [] and result is not None:
return result
# next hensel (fast if the number of solutions mod smallp**a are small. in not case, cannot find solution)
result = solve_root_hensel(pollst, bounds)
if result != [] and result is not None:
return result
# last triangulate with groebner (slow, but sometimes solve when above methods does not work)
#return solve_root_groebner(pollst, bounds)
return solve_root_triangulate(pollst, bounds)
Footnotes
-
These equalities can be proven by calculation like sum of multichoose multiplied by its argument. I used Explicit form for sum of "multichoose" functions. (involving Hockey-stick identity). ↩