WP34 — Stage 5: machine-checked statements (Lean 4, core only)
Lean 4 project wp34 (library Wp34). No Mathlib: only the Lean core library (BitVec,
Nat, List) and the core tactics bv_decide, decide, omega, simp, ac_rfl.
Toolchain pinned in lean-toolchain: leanprover/lean4:v4.35.0
(Lean 4.35.0, commit eb6cafb7, arm64-apple-darwin; Lake 5.0.0).
bv_decide bit-blasts a BitVec/Bool goal to an And-Inverter Graph (AIG), hands the CNF to the
CaDiCaL SAT solver bundled with Lean and asks it for an LRAT unsatisfiability certificate,
which is then re-checked by Lean's verified LRAT checker. The solver is never trusted; the checker
is executed on the native (compiled) code path, which Lean 4.35 records as a per-theorem axiom
named <theorem>._native.bv_decide.ax_… (same trust base as Lean.ofReduceBool: Lean kernel +
Lean compiler). Everything else depends only on propext, Quot.sound, Classical.choice.
The complete #print axioms output for the headline theorems is in build.log (module Wp34.Audit).
cd formal
export PATH=$HOME/.elan/bin:$PATH
lake clean && lake build # 32 s wall on an Apple M5 Max, exit code 0 (see build.log)Tail of build.log (2026-11-01, lake clean && lake build):
✔ [2/9] Built Wp34.Group (315ms)
✔ [3/9] Built Wp34.Sha256Words (2.5s)
✔ [4/9] Built Wp34.Schedule (1.5s)
✔ [5/9] Built Wp34.Sha256Step (27s)
✔ [6/9] Built Wp34.LocalCollision (1.7s)
✔ [8/9] Built Wp34 (193ms)
# exit code 0 — wall time 32 s(The [7/9] Wp34.Audit line is absent from the tail because Lake prints that module's
#print axioms messages instead of a ✔ line; it is built — see build.log.)
Build artefacts: .lake/ ≈ 470 MB (the LRAT certificates are stored inside the .olean files;
Sha256Step.olean 150 MB, LocalCollision.olean 34 MB, Sha256Words.olean 31 MB).
Reproducibility gate: tests/test_formal_lean.py (static: no sorry, no axiom, no
native_decide, no Mathlib, toolchain pinned; WP34_LEAN_BUILD=1 additionally runs lake build).
Helper scripts: scripts/gf2_rank.py (GF(2) rank/kernel of Σ0, Σ1, σ0, σ1 and generation of the
explicit inverses), scripts/local_collision.py (simulation of the linearised local collisions and
the real-step counter-example), scripts/aig_sizes.py (per-theorem AIG sizes → scripts/aig_sizes.tsv).
What is machine-checked and what is not
Machine-checked (every theorem below compiles with lake build; no sorry; no user axiom
except the fields of the structure CyclicGroup, which are the explicit axioms of the abstract
group model in §5 and are listed there).
Not machine-checked / informal: the historical attributions (Gilbert–Handschuh 2003,
Chabaud–Joux 1998) and the probabilistic reading of the local collision (the 2⁻³⁹ estimate) are
literature statements, not theorems; the Python simulations in scripts/ are sanity checks, not
proofs; the "SAT instance size" column is a measurement, not a proof artefact.
Column "AIG" = number of AIG nodes of the bit-blasted instance given to CaDiCaL (from
scripts/aig_sizes.tsv); "—" = no SAT call. Build time is per module (whole file).
1. SHA-256 word functions — Wp34/Sha256Words.lean (2.5 s)
Definitions transcribed from src/wp34/sha256.py: rotr, Sigma0, Sigma1, sigma0,
sigma1, Ch, Maj on Word := BitVec 32.
| Theorem | Informal statement | Method | AIG |
|---|---|---|---|
Ch_opt |
Ch x y z = ((y ^ z) & x) ^ z |
bv_decide |
608 |
Maj_opt_or |
Maj x y z = (x & (y | z)) | (y & z) |
bv_decide |
608 |
Maj_opt_xor |
Maj x y z = ((x ^ y) & (y ^ z)) ^ y |
bv_decide |
768 |
Maj_comm |
Maj symmetric in its arguments |
bv_decide |
704 |
Sigma0_xor, Sigma1_xor, sigma0_xor, sigma1_xor |
GF(2)-linearity f (x ^ y) = f x ^ f y |
bv_decide |
960 / 960 / 933 / 870 |
Sigma0_zero, …, sigma1_zero |
f 0 = 0 |
bv_decide (closed by preprocessing) |
— |
Sigma0_injective, Sigma1_injective |
Function.Injective |
bv_decide |
704 / 704 |
sigma0_injective, sigma1_injective |
σ0, σ1 are injective on 32 bits (UNSAT certificate) | bv_decide |
686 / 644 |
shr3_not_injective |
control: x >>> 3 is not injective (0, 1) |
decide |
— |
sigma0_rotations_not_injective |
control: rotr x 7 ^ rotr x 18 kills 0xFFFFFFFF |
decide |
— |
sigma1_rotations_not_injective |
control: rotr x 17 ^ rotr x 19 kills 0xFFFFFFFF |
decide |
— |
rotr_xor |
rotr (x ^ y) k = rotr x k ^ rotr y k for every k |
bit-level simp (getLsbD_rotateRight) |
— |
bitMask_xor, xor_and_right |
helpers for the matrix-form inverses | simp / bv_decide |
— / 512 |
Sigma0Inv_xor, Sigma1Inv_xor, sigma0Inv_xor, sigma1Inv_xor |
the explicit inverses are GF(2)-linear | simp [rotr_xor] + ac_rfl |
— |
inv_of_quarters |
two linear maps whose composite fixes each byte compose to id |
bv_decide (byte split) + rewriting |
33 |
Sigma0Inv_Sigma0_b0..b3, … (32 lemmas) |
g (f (x & byte_i)) = x & byte_i for the 8 (map, inverse) pairs |
bv_decide |
508 – 4817 |
Sigma0Inv_Sigma0, Sigma0_Sigma0Inv, Sigma1Inv_Sigma1, Sigma1_Sigma1Inv |
Σ0⁻¹ = XOR of 17 rotations {0,1,2,5,7,8,9,10,12,16,17,19,22,23,25,29,30}, Σ1⁻¹ = XOR of 17 rotations {2,3,5,7,9,11,12,13,18,21,22,23,24,26,27,29,30} |
inv_of_quarters |
— |
sigma0Inv_sigma0, sigma0_sigma0Inv, sigma1Inv_sigma1, sigma1_sigma1Inv |
σ0⁻¹, σ1⁻¹ given as explicit 32×32 GF(2) matrices (column tables) | inv_of_quarters |
— |
Sigma0_bijective, Sigma1_bijective, sigma0_bijective, sigma1_bijective |
injective ∧ surjective (Bijective, defined locally since core lacks Function.Bijective) |
from the above | — |
Result for item 1(e): σ0 and σ1 are bijections of BitVec 32 — the logical shift does not
make them singular (GF(2) rank 32, trivial kernel, scripts/gf2_rank.py), and bv_decide found no
counter-example (UNSAT, certificate checked). The task brief expected them to be non-bijective;
that expectation was wrong, and the controls show why the intuition fails: each of the two parts
of σ0 (x >>> 3 alone; rotr 7 ^ rotr 18 alone, which has kernel {0, 0xFFFFFFFF} since
ρ⁷ + ρ¹⁸ = ρ⁷(1 + ρ¹¹) is not a unit) is non-injective, but their sum is a unit.
2. Step function bijection — Wp34/Sha256Step.lean (27 s)
State = 8 registers; step / stepInv transcribed from step / step_inverse in sha256.py.
| Theorem | Informal statement | Method | AIG |
|---|---|---|---|
add4_sub4 |
h + A + B + k + w − A − B − k − w = h |
bv_decide (120 s cap, ~2 s) |
2048 |
sub4_add4 |
x − A − B − k − w + A + B + k + w = x |
bv_decide |
2420 |
stepInv_step |
stepInv (step s k w) k w = s |
generalize Σ0+Maj, Σ1, Ch; simp [add_sub_cancel, add4_sub4] |
— |
step_stepInv |
step (stepInv n k w) k w = n |
generalize; simp [sub4_add4, sub_add_cancel] |
— |
step_injective, step_surjective, step_bijective |
the step is a bijection of the 256-bit state for fixed (k, w) |
from the two above | — |
stepsInv_steps, steps_stepsInv, steps_bijective |
any number of rounds with known (K_t, W_t) is a bijection |
induction on the list | — |
The direct 320-bit bv_decide call (cases s; simp only [...]; bv_decide) timed out (10 s
default) and bv_omega on the abstracted goal did not terminate (killed after 35 CPU-minutes); the
per-component split suggested in the brief is what is implemented, with the two addition-chain
lemmas proven by SAT.
3. Message schedule — Wp34/Schedule.lean (1.5 s)
| Theorem | Informal statement | Method | AIG |
|---|---|---|---|
scheduleStep_inv |
W_{t−16} = W_t − σ1(W_{t−2}) − W_{t−7} − σ0(W_{t−15}) |
bv_decide |
1921 |
scheduleStep_bijective |
W_{t−16} ↦ W_t is a bijection for fixed other inputs |
bv_decide + rewriting |
2200 |
schedule, schedule_lt, schedule_ge |
the expanded schedule as a well-founded recursion on t |
definition / simp |
— |
schedule_backward |
for the real schedule, W_{t−16} is determined by W_{t−15..t} (hence the 64 words by any 16 consecutive ones) |
rewriting | — |
4. XOR-linearised 9-step local collision — Wp34/LocalCollision.lean (1.7 s)
linStep boolf replaces every + of step by ^^^ and Ch, Maj by boolf. Two
linearisations are formalised (the choice is stated explicitly in the file header):
absorb (Ch = Maj = 0: the Boolean functions absorb all differences — the difference-domain
reading of the Gilbert–Handschuh 2003 conditions) and xor3 (Ch = Maj = x ^ y ^ z: the
Chabaud–Joux 1998 linearisation). Σ0, Σ1 are kept exactly (they are linear).
Correction vectors derived in the file header (the derivation is a comment, not a theorem):
corrAbsorb δ = (δ, Σ1δ^Σ0δ, Σ1(Σ0δ), 0, δ, Σ0δ^Σ1δ, 0, 0, δ);corrXor3 δ = (δ, Σ1δ^Σ0δ, Σ1(Σ0δ^δ)^Σ0δ^δ, Σ1δ^Σ0δ, Σ1δ^Σ0δ, Σ0δ^Σ1δ, δ, 0, δ).
Note: the brief's sketch "δW_{i+1} = Σ1(δ) through the e-register" is incomplete — at step i+1 the
a register also carries δ and injects Σ0(δ) through T2, so δW_{i+1} must be Σ1(δ) ⊕ Σ0(δ);
bv_decide confirms the vectors above and the simulation in scripts/local_collision.py agrees.
| Theorem | Informal statement | Method | AIG |
|---|---|---|---|
State.xor_zero, Words9.xor_zero |
s ⊕ 0 = s |
bv_decide |
257 / 289 |
linStep_xor_absorb, linStep_xor_xor3 |
one linearised step is a XOR-homomorphism in (state, k, w) |
bv_decide |
4000 / 6368 |
lin9_xor_absorb, lin9_xor_xor3 |
nine linearised steps are a XOR-homomorphism | simp rewriting |
— |
corrAbsorb_xor, corrXor3_xor |
the correction vectors are linear in δ | simp [Sigma0_xor, Sigma1_xor] + ac_rfl |
— |
diff_absorb_b0..b3, diff_xor3_b0..b3 |
difference-only statement with δ restricted to one byte | bv_decide |
5049 / 8769 |
byte_split |
δ = ⊕ of its four bytes |
bv_decide |
33 |
localCollision_absorb_diff, localCollision_xor3_diff |
lin9 f 0 0 (corr δ) = 0 for every δ (difference propagation) |
linearity + bytes | — |
localCollision_absorb, localCollision_xor3 |
lin9 f s K W = lin9 f s K (W ⊕ corr δ) for every s, K, W, δ (864 bits universally quantified) |
lin9_xor_* + *_diff |
— |
absorb_corrections_not_real_collision |
negative control: with the real modular step, zero state/constants/words and δ = 1, the two 9-step outputs differ | decide (kernel evaluation) |
— |
The full-value statements were first attempted directly with bv_decide and timed out (they
are pure parity identities; see §Failures). They are proven exactly as the brief's fallback
suggests — via the linear difference-propagation version — and then lifted to the full-value
statement by the proven linearity of lin9, so the general statement is machine-checked after all.
5. Abstract signature correctness — Wp34/Group.lean (0.3 s)
structure CyclicGroup (G : Type) — every field is an axiom of the model:
zero, add, neg, nsmul : Nat → G → G, xcoord : G → Nat, gen, order;
add_assoc, add_comm, zero_add, add_neg (commutative group); nsmul_zero,
nsmul_succ (recursive definition of n•P); one_lt_order, nsmul_order : order•gen = 0;
xcoord_neg : xcoord (−P) = xcoord P. Nothing else is assumed; in particular there is no
field about modular inverses, about xcoord being injective up to sign, or about order being
prime. Modular arithmetic is done on Nat with core lemmas (Nat.mul_mod, Nat.div_add_mod, …).
Derived (theorems, manual proofs): add_zero, neg_add, eq_neg_of_add_eq_zero, neg_add_rev,
nsmul_add ((m+k)•a = m•a + k•a), nsmul_zero_point, nsmul_mul ((m·k)•a = m•(k•a)),
add_add_add_comm, nsmul_add_point, nsmul_neg, nsmul_one, InSubgroup, gen_inSubgroup,
nsmul_inSubgroup, nsmul_order_of_mem (order•P = 0 on the subgroup), nsmul_mod
(scalars act modulo order), nsmul_congr_mod, nsmul_order_sub ((n−u)•P = −(u•P)),
sub_mul_sub_mod ((n−s)(n−w) ≡ s·w (mod n)).
| Theorem | Informal statement | Method |
|---|---|---|
schnorr_correct |
P = d•G, R = k•G, s = (k + e·d) mod n ⟹ s•G = R + e•P (schnorrVerify) |
nsmul_mod, nsmul_add, nsmul_mul |
schnorr_correct_nat |
same with s = k + e·d unreduced |
rewriting |
ecdsa_malleable |
ecdsaVerify P e r s → ecdsaVerify P e r (n − s) for P in the subgroup; the verification point becomes its negative and xcoord is unchanged |
manual (witness w' = n − (w mod n), sub_mul_sub_mod, neg_add_rev, xcoord_neg) |
ecdsa_correct |
r = x(k•G) mod n, k·s ≡ e + r·d, s·w ≡ 1 ⟹ ecdsaVerify (d•G) e r s |
manual (Nat.mul_mod, ac_rfl, nsmul_congr_mod) |
ecdsaVerify P e r s := 0 < r < n ∧ 0 < s < n ∧ ∃ w, s·w mod n = 1 ∧ xcoord((e·w)•G + (r·w)•P) mod n = r.
Scope: this is the abstract ECDSA/Schnorr verification equation in a cyclic group with an
xcoord map; BIP340's x-only keys/even-y normalisation and the "low-s" rule are not modelled.
Failures and timeouts (honest record)
| Attempt | Outcome | Resolution |
|---|---|---|
Sigma0Inv (Sigma0 x) = x by direct bv_decide (32 free bits, 51 rotation terms per bit) |
SAT timeout at 10 s; also at 240 s with acNf := true (256 s wall) |
linearity (rotr_xor proven bit-wise) + byte split (inv_of_quarters) |
Sigma0Inv_xor by bv_decide |
timeout at 60 s (34-literal parity) | simp [rotr_xor]; ac_rfl |
| 16-bit halves instead of bytes | proven, but each LRAT certificate ≈ 100 MB in the .olean (Sha256Words.olean was 1.3 GB) |
bytes: ≈ 0.6 MB each, file now 31 MB |
stepInv_step by direct 320-bit bv_decide |
timeout at 10 s | abstraction of Σ0+Maj, Σ1, Ch + two 160-bit SAT lemmas + rewriting |
stepInv_step by generalize + bv_omega |
did not terminate (killed after 35 CPU-min) | as above |
localCollision_absorb / _xor3 (864-bit) by direct bv_decide |
timeout at 10 s | linearity of lin9 + difference version |
localCollision_xor3_diff (32-bit δ) by direct bv_decide |
timeout at 10 s (absorb version passed) |
byte split in δ |
corrXor3_xor by bv_decide |
timeout at 10 s (24-literal parity) | simp [Sigma0_xor, Sigma1_xor]; ac_rfl |
Re-elaborating LocalCollision.lean with trace.Meta.Tactic.bv on |
heartbeat timeouts in the trace printer for the last 4 theorems (no SAT there); normal build unaffected | reported as-is in scripts/aig_sizes.py |
Lesson: CDCL SAT (CaDiCaL) is instantaneous on the mixed and/or/add goals of SHA-256 but systematically fails on pure-parity (XOR-chain) identities, which is where linear algebra over GF(2) — here encoded as rewriting with proven linearity lemmas — is the right tool.
Classification
Every statement above is known_reproduced: the Ch/Maj identities, linearity and
invertibility of the sigma functions, invertibility of the step function and of the message
schedule, the linearised local collision, and Schnorr/ECDSA correctness and malleability are all
standard facts. The contribution of this stage is their machine-checked status in Lean 4 core
with a 32-second build, the explicit inverses of Σ0, Σ1, σ0, σ1, and the documentation of which
formulations are and are not tractable for bv_decide.
References (informal; keys from paper/references.bib where they exist)
- FIPS 180-4, Secure Hash Standard —
nist2015fips180(definitions of Σ0, Σ1, σ0, σ1, Ch, Maj, the step and the schedule). - F. Chabaud, A. Joux, "Differential collisions in SHA-0", CRYPTO 1998 —
chabaud1998differential(XOR-linearisation technique,xor3model). - H. Gilbert, H. Handschuh, "Security analysis of SHA-256 and sisters", SAC 2003 — the 9-step local collision and its absorbing conditions (
absorbmodel). No entry inreferences.bibyet — to be added and verified at Stage 6; not cited in the Lean sources. - FIPS 186-5 (ECDSA) —
nist2023fips186; BIP340 (Schnorr) — abstract model only.