Lattice basis reduction using the Hex Lean library
2026·07·07[computer algebra][lean]
We're starting work on a computational algebra library for Lean, called Hex.
We've just released the first collection of packages, providing dense matrices, row reduction, Gram-Schmidt, and lattice basis reduction.
These packages have a primarily computational focus (i.e. calculations on concrete inputs, rather than symbolic algebra),
and are optimized first for runtime performance, and secondarily for performance during kernel reduction.
The whole library is developed using "spec driven development". This means we write specifications ahead of time,
then use AIs to implement (both the executable components and the formal verifications of these).
Contributions to the library are welcome, but new features should come with a specification first!
In the rest of this blog post, I'll give you a very quick walkthrough of the basic functionality,
then show how we can put it together to execute the Coppersmith attack on RSA.
Everything here is written in Verso, the Lean-native document preparation system.
This means all the code samples are compile time checked,
then rendered into a webpage with type information in hovers
and intermediate goal states available in proofs.
Getting started: Hex provides a Matrix type for representing dense matrices over an arbitary type
(Right now, MatrixRnm := Vector(VectorRm)n, but we've encapsulated the definition so, for example,
we could swap it out later as VectorR(n*m) for the sake of performance, without affecting downstream users.)
We provide basic tools for constructing matrices, extracting rows, columns, and entries, and basic matrix arithmetic:
-- A 3×3 integer matrix.defA:MatrixInt33:=#m[2,0,1;1,3,2;0,1,1]#guardA.getRow1=#v[1,3,2]#guardA.mulVec#v[1,1,1]=#v[3,6,2]#guard(A+2•Matrix.identity3)[(1,1)]=5
The Matrix.det function is just the usual Leibniz formula for the determinant;
it's not efficient, but we provide some alternatives later. It's defined over any ring.
-- A matrix of rationals.defB:MatrixRat33:=#m[2,0,1;1,3/7,2;0,1,1]#guarddetB=-15/7#guarddet(transposeB)=-15/7#guarddet(rowSwapB01)=15/7-- A matrix with linearly dependent rows is singular.defS:MatrixInt22:=#m[1,2;2,4]#guarddetS=0
We can row reduce a matrix over a field,
compute the rank, a basis for the nullspace, and determine if a vector is in the span of the rows.
The function Matrix.rowReduce returns a RowEchelonData, from which we can compute the rest efficiently.
We can do Gram-Schmidt orthogonalization
(and under the hood there are efficient algorithms that avoid division when we work over the integers).
-- A 3×3 rational matrix, orthogonalized row by row (left to right).defm:Hex.MatrixRat33:=#m[1,1,0;1,0,1;0,1,1]-- Each row has its projection onto the earlier rows subtracted off.#guardbasisMatrixm=#m[1,1,0;1/2,-1/2,1;-2/3,2/3,2/3]-- The resulting rows are pairwise orthogonal.#guard((basisMatrixm).row0).dotProduct((basisMatrixm).row1)=0#guard((basisMatrixm).row0).dotProduct((basisMatrixm).row2)=0#guard((basisMatrixm).row1).dotProduct((basisMatrixm).row2)=0
Over the integers, the leading Gram determinants are computed exactly, without any division:
-- Over the integers, the leading Gram determinants d₀..d₃ (the squared volumes-- of the prefix sublattices) are computed exactly, without any division.defm:Hex.MatrixInt33:=#m[1,1,0;1,0,1;0,1,1]#guardgramDetm0=1#guardgramDetm1=2#guardgramDetm2=3#guardgramDetm3=4
Lattice basic reduction is available via a verified implementation of the LLL algorithm.
With this, we can efficiently find a short vector in a lattice,
and prove that this vector is only a constant (depending on the dimension)
times the length of the optimal shortest vector.
-- A skewed rank-2 basis: the first row is far from orthogonal.defB:Hex.MatrixInt22:=#m[1,12;0,1]-- Reduction returns the two unit vectors: same lattice, as short as possible.#guardlllB(3/4)=#m[0,1;1,0]-- The verified integer checker rejects the input (not size-reduced) and-- accepts the output.#guardlllReducedB(3/4)(11/20)=false#guardlllReduced(#m[0,1;1,0]:Hex.MatrixInt22)(3/4)(11/20)=true
This verified implementation is even pretty fast, e.g. faster than the existing
formally verified implementation in Isabelle.
Moreover we also have a version
that delegates the heavy computation to the optimized C library fplll, but still
formally verifies a certificate proving the length bound. Again, it's quite fast!
We can perform the standard parlour trick with lattice basis reduction:
discover the minimal polynomial from a decimal approximation of an algebraic number.
-- We recover the minimal polynomial of α = 1.220744…, knowing only-- that α is a root of some integer polynomial of degree ≤ 4.-- We form one lattice row for each power of α:-- eᵢ in the first five columns, and ⌊10⁶·αⁱ⌋ in the last.defL:Hex.MatrixInt56:=#m[1,0,0,0,0,1000000;0,1,0,0,0,1220744;0,0,1,0,0,1490216;0,0,0,1,0,1819173;0,0,0,0,1,2220744]-- The shortest reduced row reads off (a₀,a₁,a₂,a₃,a₄) = (−1,−1,0,0,1) with a-- zero last coordinate: the relation −1 − α + α⁴ = 0.#guard(lllL(3/4)).row0=#v[-1,-1,0,0,1,0]
And for a final trick, we can run the Coppersmith attack on RSA.
Here we assume that we're trying to decrypt a templated message,
where we already know most of the plain text, there's just a short unknown field.
We also assume we're using a small public exponent, in this case e := 3.
As a quick reminder of how RSA works, N is a product of two large primes
(but the factorization is known only to the intended receiver), for a message m,
the ciphertext is c = m³ mod N.
The intended receiver knows the factorization N = p·q, so they can compute
φ(N) = (p−1)(q−1) and a private exponent d with 3·d ≡ 1 (mod φ(N)).
Then cᵈ ≡ m^{3d} ≡ m (mod N) recovers the message.
An attacker who knows only N cannot compute φ(N) (or d) without factoring
N, which is believed to be hard, so this is where RSA's security usually lives.
The attack below sidesteps factoring entirely, exploiting the small exponent
e = 3 and the fact that we already know most of the message.
For the Coppersmith attack, we assume the message is of the form
m = a + x₀, where 0 ≤ x₀ < X for some relatively small X,
and a and X are both known to the attacker.
Since c = m³ mod N and m = a + x₀, the unknown x₀ is a root modulo N of
f(x) = (a + x)³ − c,
that is, f(x₀) ≡ 0 (mod N). This is much weaker than an honest integer root:
we can't just factor f over ℤ to read x₀ off.
Howgrave-Graham's idea is to trade f for a different polynomial g sharing
the same small root x₀, but with coefficients small enough that a congruence
mod N becomes an equation over ℤ. Suppose g(x) = Σⱼ gⱼ xʲ also satisfies
g(x₀) ≡ 0 (mod N). Since 0 ≤ x₀ < X,
|g(x₀)| ≤ Σⱼ |gⱼ| Xʲ.
If that bound is below N, then g(x₀) is an integer divisible by N yet
smaller than N in absolute value, so it is exactly 0. Now x₀ is a genuine
integer root of g, recoverable by a short scan (or, at scale, by factoring g).
So we want a g with g(x₀) ≡ 0 (mod N) and small Σⱼ |gⱼ| Xʲ. Both are linear
conditions, which is exactly what a lattice captures. Take integer combinations of
the four polynomials N, N·x, N·x², and f(x): each vanishes at x₀ modulo
N (the first three because they are multiples of N, and f by construction),
so every integer combination does too. We record a combination by its coefficient
vector, with column j scaled by Xʲ — that is the matrix B below. The
Euclidean length of a lattice vector is then √(Σⱼ gⱼ² X^{2j}), which controls
Σⱼ |gⱼ| Xʲ. A short vector is therefore precisely a g with the
small-coefficient property we need: LLL produces one, Howgrave-Graham's bound
upgrades ≡ 0 (mod N) to = 0, and the root drops out.
This single-shift lattice reaches roots up to about N^{1/6} (its determinant is
N³·X⁶, and LLL's shortest vector is around (N³X⁶)^{1/4}, which stays below N
exactly when X < N^{1/6}).
In the next example in this blog post we'll also consider shift polynomials
xⁱ·f(x)ʲ which enlarge the lattice and push the reachable bound toward N^{1/3}.
-- A courier RSA-encrypts a templated message m = a + x₀ with exponent 3 and no-- padding; a is the known template, x₀ a two-digit code with 0 ≤ x₀ < X. The-- ciphertext is c = m³ mod N. Only N, a, c, X are public; recover x₀.-- Public data the attacker starts from.defN:Nat:=10_000_004_400_000_259defX:Nat:=100defa:Int:=55_555_500defc:Int:=6_804_005_608_230_644-- Coefficients of f(x) = (a + x)³ − c, reduced mod N.defa0:=(a^3-c).bmodNdefa1:=(3*a^2).bmodNdefa2:=(3*a).bmodN-- The Coppersmith lattice: one polynomial per row, degree-j column scaled by-- Xʲ, so "small coefficients" becomes "short vector". Rows N, N·x, N·x², f.defB:MatrixInt44:=#m[(N:Int),0,0,0;0,N*X,0,0;0,0,N*X*X,0;a0,a1*X,a2*X*X,X*X*X]-- LLL-reduce with the default δ = 3/4.defreduced:MatrixInt44:=lllB-- De-scale a reduced row back to integer polynomial coefficients.defdescale(r:VectorInt4):Option(VectorInt4):=ifr[1]%X==0&&r[2]%(X*X)==0&&r[3]%(X*X*X)==0thensome#v[r[0],r[1]/X,r[2]/(X*X),r[3]/(X*X*X)]elsenone-- Horner evaluation of a degree-3 integer polynomial.defevalPoly(g:VectorInt4)(x:Int):Int:=((g[3]*x+g[2])*x+g[1])*x+g[0]-- Scan the reduced rows for an integer root that reproduces the ciphertext.defrecover:OptionInt:=Id.rundoforrowinreduced.rowsdomatchdescalerowwith|none=>pure()|someg=>forxin[0:100]doifevalPolygx==0&&(a+x)^3%N==cthenreturnsomexreturnnone-- LLL genuinely reduced the basis, and the recovered code is 37,-- perhaps no surprise given we're working in Lean!#guardlllReducedreduced==true#guardrecover==some37
We can scale this up if we like! The next example takes advantage of a
complete-but-unreleased part of the Hex library, namely fast integer polynomial factorization,
using Berkelekamp-Zassenhaus and van Hoeij reconstruction (in fact, this uses LLL internally again).
To access these, we have to import that hex-dev development monorepo.
This time we use a realistic 2048-bit RSA modulus, and a 400-bit secret.
We use a larger lattice, which gives us a better chance of finding a short vector with the desired property.
Because the unknown field in the message is now 400 bits long,
it is not feasible to search for a linear root of the polynomial g once we have it;
instead we directly factor g over the integers, and search its linear factors.
-- A 2048-bit RSA modulus N = p * q, with p and q the primes-- just below 2^1024. Exponent e = 3, no padding.defp:Nat:=2^1024-105defq:Nat:=2^1024-179defN:Nat:=p*q-- The known 1202-bit template a and unknown 400-bit secret-- x0 give c = (a + x0)^3 mod N. The attacker sees only the-- public data N, a, c, and the bound X.defa:Int:=3^758defx0:Int:=2^399+271828182defX:Nat:=2^400defc:Int:=(a+x0)^3%N-- f(x) = (a + x)^3 - c, reduced mod N to small coeffs, as a Hex polynomial.deff:ZPoly:=DensePoly.ofCoeffs#[(a^3-c).bmodN,(3*a^2).bmodN,(3*a).bmodN,1]-- Nine polynomials N^(2-j) · xⁱ · f(x)ʲ (j, i in 0,1,2). Each vanishes at x0-- mod N²; the added shift polynomials push the recoverable bound from ~N^(1/6)-- toward N^(1/3), covering a 400-bit root.defrows:ArrayZPoly:=Id.rundoletmutrs:ArrayZPoly:=#[]letmutfj:ZPoly:=1forjin[0:3]doforiin[0:3]dors:=rs.push(DensePoly.monomiali((N:Int)^(2-j))*fj)fj:=fj*freturnrs-- The lattice basis: row i, column k is the degree-k coefficient scaled by X^k.defB:MatrixInt99:=Matrix.ofFnfunik=>(rows.getDi.val0).coeffk.val*X^k.valdefreduced:MatrixInt99:=lllB-- De-scale a reduced row into a Hex polynomial: coefficient k divides by X^k.defdescale(r:VectorInt9):ZPoly:=DensePoly.ofCoeffs(Array.ofFnfunk:Fin9=>r[k]/X^k.val)-- LLL sorts the shortest vector into the first row; de-scale it to `g`.defg:ZPoly:=descale(reduced.row0)-- Factor g over Z with Berlekamp-Zassenhaus. The secret is-- the root of its linear factor x - x0. Scanning x below-- X ~ 2^400 is hopeless; factoring degree-8 g is instant.defrecovered:OptionInt:=Id.rundofor(fac,_)ing.factorsdomatchfac.toArraywith|#[q,p]=>-- a linear factor `p * X + q`ifq%p==0thenletr:=-q/pif0≤r&&r<X&&(a+r)^3%N==cthenreturnsomer|_=>continuereturnnone-- The modulus is 2048 bits; the secret is about 400 bits.#guardN>2^2047&&N<2^2048#guardx0>2^399-- LLL reduces the basis; factoring g gives back x0.#guardlllReducedreduced==true#guardrecovered==somex0
The computational libraries described above are independent of Mathlib
(indeed they have no dependencies except amongst themselves),
so can be used in any Lean project.
For each library in Hex, there is a companion library that additionally depends on Mathlib,
which identifies relevant structures, and sometimes proves additional theorems
about the computations in Hex whose proofs require more algebraic machinery.
(For the lattice basis reduction algorithm above,
we can in fact prove the short vector bound without needing anything in Mathlib.
In future sublibraries, e.g. the fact that the "fast path" for integer polynomial factorization
always completes successfully, and we never need to fall back to the "slow path",
requires some number theory from Mathlib.)
Here, with the additional import HexRowReduceMathlib, we demonstrate
how to compute the rank of a Mathlib matrix, via transfer to Hex's machinery.
Once the statement has been transferred, we resolve it with decide +kernel.
-- `A` is an ordinary Mathlib matrix; `A.rank` is Mathlib's noncomputable rank.defA:_root_.Matrix(Fin2)(Fin3)Rat:=!![1,2,3;2,4,6]theoremrank_eq_one:A.rank=1:=⊢ A.rank=1-- Rewrite the noncomputable rank into the executable one, then decide it in-- the kernel. No `native_decide`: the compiler is not in the trusted base.⊢ (matrixEquiv.symmA).rowReduce.rank=1decide+kernelAll goals completed! 🐙
Some final notes.
For now Hex is mostly optimized for runtime performance,
and you may find some places where kernel reduction is slow or impossible:
please report these!
We've only thought about efficiently calculating determinants over Int,
because we need this for lattice basis reduction, but the Bareiss algorithm
could be generalized, or we could use preliminary row reduction when working over a field.
Hex is a spec driven development library, and so little attempt has been made
to "golf" proofs or even to make them particularly readable.
Contributions cleaning up proofs are welcome.
You can get the entire released Hex package by adding
[[require]]
name = "hex"
git = "https://github.com/leanprover/hex"
rev = "main"
in your lakefile.toml, and then import Hex.
(If you want individual sublibraries, instead require just the one you need, e.g.
name = "hex-lll", git = "https://github.com/leanprover/hex-lll", and import HexLLL.)
Unreleased libraries, and all future development, live at github.com/kim-em/hex-dev.
Contributions and pull requests are welcome, but specs must be updated before any new features or substantial changes,
as separately reviewed PRs.