34.2. Why the AES modulus works
34.2.1. The story
The AES byte arithmetic tutorial builds the
field GF(2⁸) as the quotient of 𝔽₂[x] by the Rijndael modulus
x⁸ + x⁴ + x³ + x + 1
and leans on one theorem to do it: the modulus is irreducible over 𝔽₂.
Without that fact the quotient is only a ring, some nonzero bytes have no
inverse, and the S-box construction collapses. That tutorial takes the
theorem as given. This one earns it.
The machinery is HexBerlekamp's Rabin
irreducibility test: an executable criterion, a replayable certificate,
and a soundness theorem that turns a passing check into a proof. The plan
is to run the test on the modulus, run it on a plausible-looking impostor
that fails, inspect the certificate the test leaves behind, and finish
with the kernel-checked irreducibility theorem the AES construction needs.
34.2.2. The modulus as a dense polynomial
HexBerlekamp works over Hex.FpPoly, the dense polynomial type
with ZMod64 p coefficients from HexPolyFp. For
p = 2 a coefficient is a bit, and the Rijndael modulus is nine of them,
listed from the constant term up.
open Hex
namespace AESModulusTutorial
/-- The Rijndael modulus `x⁸ + x⁴ + x³ + x + 1` over
`𝔽₂`, coefficients from the constant term up. -/
def m : FpPoly 2 := #p[1, 1, 0, 1, 1, 0, 0, 0, 1]
theorem m_monic : DensePoly.Monic m := ⊢ DensePoly.Monic m All goals completed! 🐙
-- Reading the coefficients as bits recovers the word
-- 0x11B; its low byte 0x1B is exactly how the AES
-- tutorial passes this modulus to the packed field
-- type, with the leading x⁸ implicit.
#guard (m.toArray.toList.map ZMod64.toNat).foldr
(fun b acc => acc * 2 + b) 0 == 0x11B
end AESModulusTutorial
The monicity proof is by rfl because the leading coefficient is
literally the bit 1; every test and certificate below takes it as an
argument, since Rabin's criterion is stated for monic polynomials.
34.2.3. What goes wrong without irreducibility
Before certifying the real modulus, it is worth seeing concretely what a
bad one does. Take x⁸ + 1, which differs from the Rijndael modulus in
three bits and looks just as plausible. Over 𝔽₂ it is (x + 1)⁸, about
as far from irreducible as a degree-eight polynomial can be.
open Hex
namespace AESModulusTutorial
/-- The impostor `x⁸ + 1`, which over `𝔽₂` is
`(x + 1)⁸`. -/
def r : FpPoly 2 := #p[1, 0, 0, 0, 0, 0, 0, 0, 1]
theorem r_monic : DensePoly.Monic r := ⊢ DensePoly.Monic r All goals completed! 🐙
def sq (f : FpPoly 2) : FpPoly 2 := f * f
-- Squaring x + 1 three times really gives x⁸ + 1.
#guard sq (sq (sq #p[1, 1])) == r
/-- `(x + 1)⁴`, a nonzero residue modulo `x⁸ + 1`. -/
def u : FpPoly 2 := #p[1, 0, 0, 0, 1]
-- u has degree 4, so it is its own remainder: as a
-- residue modulo x⁸ + 1 it is not zero.
#guard FpPoly.modByMonic r u r_monic == u
-- Yet u · u reduces to zero: u is a zero divisor.
#guard (FpPoly.modByMonic r (u * u) r_monic).isZero
end AESModulusTutorial
A quotient with zero divisors is not a field, and the failure is not
abstract: u would be a byte with no multiplicative inverse, and the
S-box's inversion stage would have nothing to send it to. Irreducibility
of the modulus is exactly the condition that rules this out, for every
nonzero residue at once.
34.2.4. Rabin's criterion
The classical fact behind the test is that X^(2^k) - X is the product
of all monic irreducible polynomials over 𝔽₂ whose degree divides k.
Rabin's criterion reads that fact twice. A monic f of degree n is
irreducible over 𝔽₂ exactly when
-
fdividesX^(2^n) - X, so every irreducible factor offhas degree dividingn; and -
gcd(f, X^(2^d) - X) = 1for every maximal proper divisordofn, so no factor has degree strictly smaller thann.
For n = 8 the divisor lattice collapses pleasantly: the proper divisors
of 8 are 1, 2, and 4, and every one of them divides 4, so a
single gcd check at d = 4 covers them all.
open Hex
namespace AESModulusTutorial
-- One maximal proper divisor, so one gcd leg.
#guard Berlekamp.maximalProperDivisors 8 == [4]
-- The Rijndael modulus passes both legs.
#guard Berlekamp.rabinTest m m_monic
#guard Berlekamp.rabinDividesTest m m_monic
#guard Berlekamp.rabinWitnesses m m_monic == [(4, true)]
-- The impostor fails both.
#guard Berlekamp.rabinTest r r_monic == false
#guard Berlekamp.rabinDividesTest r r_monic == false
#guard Berlekamp.rabinWitnesses r r_monic
== [(4, false)]
end AESModulusTutorial
The impostor's failures are instructive. X^(2⁸) - X is square-free, so
the eightfold repeated factor (x + 1)⁸ cannot divide it: the divides
leg fails. And x + 1 divides both x⁸ + 1 and X^(2⁴) - X, so the gcd
at d = 4 is not a unit: the coprimality leg fails too. A polynomial
that is merely reducible but square-free with a low-degree factor would
pass the first leg and fail only the second; this one fails everything.
34.2.5. The certificate
Running Hex.Berlekamp.rabinTest costs repeated Frobenius steps
and a gcd. That is cheap for a compiled program and expensive for a proof
checker, so HexBerlekamp splits the work: an untrusted generator runs
the test once in compiled code and writes down enough intermediate data
that checking becomes easy. The data is a
Hex.Berlekamp.IrreducibilityCertificate: the chain of Frobenius
powers X^(2^k) mod f for k = 0, …, n, plus one Bezout witness per
maximal proper divisor, a pair (left, right) with
left · f + right · (X^(2^d) - X mod f) = 1. The Bezout identity is the
point: verifying that a gcd is 1 requires re-running the Euclidean
algorithm, but verifying a Bezout combination is two multiplications and
an addition, and it is just as conclusive.
open Hex
namespace AESModulusTutorial
/-- The Rabin certificate for the modulus, built by the
compiled generator. -/
def cert? : Option Berlekamp.IrreducibilityCertificate :=
Berlekamp.buildIrreducibilityCertificate? m m_monic
#guard cert?.isSome
-- Nine Frobenius powers: X^(2^k) mod m for k = 0..8.
#guard (cert?.map fun c => c.powChain.size) == some 9
-- One Bezout witness, for the single divisor 4.
#guard (cert?.map fun c => c.bezout.size) == some 1
-- The checker accepts the generated certificate.
#guard (cert?.map fun c =>
Berlekamp.checkIrreducibilityCertificate m m_monic c)
== some true
end AESModulusTutorial
Hex.Berlekamp.checkIrreducibilityCertificate recomputes each pow
chain entry by one Frobenius step from its predecessor, reads the divides
leg off the last entry (which must reduce to X mod f), and replays each
Bezout identity. The generator carries no soundness proof of its own and
needs none: a wrong certificate makes the checker return false, never a
false pass.
34.2.6. From a Boolean to a theorem
Everything so far is a #guard: evaluated when this manual builds, by
the same compiled code any caller would run, but checked by the
evaluator rather than the kernel. The last step promotes the passing
check to a kernel-checked theorem, using the irreducibility elaborator
from HexBerlekamp.
open Hex
namespace AESModulusTutorial
/-- Kernel-checked: the Rijndael modulus is irreducible
over `𝔽₂`. -/
theorem m_irreducible : FpPoly.Irreducible m :=
irreducibility m
-- The tactic form closes the same goal.
example : FpPoly.Irreducible m := ⊢ m.Irreducible All goals completed! 🐙
end AESModulusTutorial
What happens under irreducibility m is the certifying pattern in
miniature. At elaboration time the compiled generator produces the
certificate from the previous section; the elaborator then emits a proof
term applying the soundness theorem to the certificate as reified
literal data. The kernel never runs the generator, the Frobenius
exponentiation, or the extended Euclidean algorithm. What it checks is
that the certificate check reduces to true on the literals, and the
soundness theorem, proved once in the library as
Hex.Berlekamp.checkIrreducibilityCertificate_imp_irreducible
(resting on Hex.Berlekamp.rabinTest_imp_irreducible), turns that
reduction into Hex.FpPoly.Irreducible. The trusted surface is the
small checker and its proof; the search that found the certificate is
outside it. No native_decide is involved anywhere.
Hex.FpPoly.Irreducible itself says exactly what the zero-divisor
section needed: m is nonzero, and any factorization a * b = m has a
constant on one side. That is the hypothesis under which the quotient
𝔽₂[x] / (m) has inverses for all nonzero residues.
34.2.7. Back to the AES field
The AES tutorial's field type is built on the
committed theorem Hex.GF2Poly.aes_modulus_irreducible, which
lives in HexGF2 and is stated for the packed
polynomial type GF2Poly, where a polynomial over 𝔽₂ is machine words
of bits rather than an array of ZMod64 2 coefficients. That statement
is proved by exactly the pipeline this page just walked through, on the
packed representation: a committed certificate with the same shape (a
Frobenius pow chain and one Bezout witness at d = 4) is replayed by
Hex.GF2Poly.checkIrreducibilityCertificate in the kernel, and
the packed Rabin soundness theorem lifts the
passing check to irreducibility. Same criterion, same certificate
discipline, two representations: the dense form here is where the
general factorization machinery operates for any prime p, and the
packed form is what the AES field type consumes at speed.
So the debt is paid twice over. The byte arithmetic of the first tutorial rests on an irreducibility theorem, and that theorem is not a table lookup or a trusted comment: it is a replayed certificate, checked by the kernel, for a criterion whose soundness is itself a Lean theorem.
34.2.8. Cross-references
-
AES byte arithmetic in GF(2⁸) is the downstream consumer: the field this page's theorem legitimizes.
-
HexBerlekampdocuments the Rabin test, the certificate checker, and the Berlekamp factorization this page did not need. -
HexPolyFpis the dense prime-field polynomial layer the test runs on, including the quotient construction whose field laws take irreducibility as a hypothesis. -
Rabin irreducibility in
HexGF2is the packed counterpart, where the committed AES certificate lives. -
The factor tactics chapter documents the
factor_polyandirreducibilityelaborators in full, including the Mathlib-facing forms.