fips205-slhdsa-verified/verification/Proofs/ChainSpec.lean

165 lines
7.6 KiB
Text
Raw Normal View History

2026-07-23 09:34:59 +00:00
/- Proofs/ChainSpec.lean — Algorithm 5 (chain / WOTS+ chaining) fidelity.
THEOREM chain_free_loop_eq: the extracted `chain_free` loop equals the
explicit s-fold application of the hash F, with the hash-address set to
i, i+1, …, i+s1 in turn. This rules out — machine-checked, for the
review round 2: in-Lean exact-cone audit + reproducibility + doc honesty Addresses the round-2 reviewer punch-list. No theorem statement, proof term, or fold definition changed; the eleven cones are unchanged (independent collectAxioms dump in verification/RECORDED-RUN.md). AUDIT GATE (both reviewers, the critical one) - Retire the bash #print-axioms text parser (fail-open on empty/truncated reports, and only a SUBSET check). Replace with verification/Proofs/Audit.lean: reads each certificate's cone from the kernel via collectAxioms and asserts EXACT set equality against its expected boundary. Extra axiom, dropped oracle, renamed/deleted cert, or an axiom/opaque sham each throw -> non-zero Lean exit. No text to misparse; nothing fails open. check.sh Phase 3 now just compiles it (and still requires the explicit PASSED line). - check-selftest.sh rewritten to attack the new gate: dead-file, smuggled extra axiom (named), dropped-oracle (subset would pass, exact must not), and a vanished certificate (the collectAxioms-returns-[] trap). All four rejected. REPRODUCIBILITY (GPT B1.4 / B1.5) - extract.sh refuses a wrong-commit or dirty source tree (fail-closed), takes an optional source-path arg, and pins the source commit. - verification/PROVENANCE.json: single machine-readable pin set (source + charon + aeneas commits/channel + lean + ocaml) with generated-file sha256. - Re-running extract.sh reproduces gen/SlhVerify/{Types,Funs}.lean byte-identically (companion fips205-source commit adds Cargo.lock + rust-toolchain.toml; verified not to perturb the model). DOC HONESTY (both reviewers) - README: fix the self-contradiction (apex "not yet proven" trailer vs the proven apex), the false "oracles kept OUTSIDE every cone" (they are INSIDE, by design), "deployed monomorphic path" and "semantics-identical for every parameter set" overclaims, "only two lines changed", stale snapshot head; retitle the stale future-tense "what will be claimed" section. - TRUSTED-BASE: drop "nothing proven yet"; add base_2b-inner and deployment- bridge non-claims explicitly; current pin. - ChainSpec header: "deployed monomorphic path" -> private verify_mono facade (comment only). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:13:55 +00:00
monomorphic SHA2-128s `verify_mono` path (a private facade, not the
review round 4: bind the cert set, statements, and model bytes (F1/F2/F3) The third reviewer demonstrated that the round-2 in-Lean exact-cone audit, though sound for LISTED certs, left three fail-opens OUTSIDE the cone check — and made check.sh print ALL GREEN over a repo proving False. All closed; no theorem, proof, or fold changed (the 11 cones are unchanged). F1 — the audited SET was unbound. Audit.lean now (a) enumerates EVERY theorem defined in the eight certificate modules and requires each cone ⊆ boundary, so an un-manifested `theorem _ : False := cheat _` fails regardless of naming (this is the exact exploit the reviewer used); and (b) prints a MANIFEST fingerprint over the whole committed manifest, which check.sh binds to — so deleting/swapping a cert row fails outside Lean too. F2 — only cones were bound, not statements. Each cert now also carries the structural fingerprint (Expr.hash) of its elaborated type; a statement gutted to a tautology of the same cone changes the fingerprint and fails. F3 — the gen/ model bytes were unbound. New check.sh Phase 0 sha256-pins all four gen/SlhVerify/*.lean (incl. the two hand-maintained *External files, now hashed in PROVENANCE.json) BEFORE compiling; a hand-edited model fails first. F4/F5 — docs. README cone diagram now roots honestly at slh_verify_internal and states the pure/prehash domain-separator byte, the ctx>255 check, M' assembly, and deserialization are ABOVE the root and uncovered (new TRUSTED-BASE item 10). The false "rules out a wrong ADRS field" claim is corrected in README + ChainSpec (a transliteration makes the field visible, not excluded). check-selftest.sh: eight attacks, all rejected (dead file; extra axiom; dropped oracle; vanished cert; un-manifested False theorem; gutted statement; hand-edited model; deleted manifest row). Full transcript + green check.sh in verification/RECORDED-RUN.md. Standing limit unchanged and disclosed: an audit cannot defend against an author who edits the manifest AND check.sh AND the proofs together; the consumer defense is the pinned commit reviewed at the pin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 17:47:39 +00:00
deployed generic verifier) — an off-by-one loop bound and wrong state
threading. It does NOT rule out a wrong ADRS field: `chainFoldN` calls the
same extracted `set_hash_address` the loop does, so a wrong field would be
copied into the fold and the theorem would still hold — the field is made
VISIBLE in the certificate (a transliteration), not excluded by it. F stays
opaque (verify_mono.oracle.f), so the certificate cone is the three kernel
axioms + oracle.f, and nothing else (audited by check.sh Phase 3).
2026-07-23 09:34:59 +00:00
The proof: an induction on the step count. `chain_step` is one loop step =
one fold step, proven by unfolding the Aeneas `loop` fixpoint one turn
(loop_unfold_bind), reducing the range iterator to a clean equation
(fips205_hnext) and the loop body to a clean do-block (fips205_hbody), and
aligning the monadic u32 index increment (u32_succ / fwd_succ) with the
fold's. The succ case threads the IH under the opaque binds with
bind_congr.
-/
import SlhVerify.Funs
open Aeneas Aeneas.Std Result ControlFlow
open fips205
set_option maxHeartbeats 4000000
namespace fips205
/-- The successful u32 increment as a clean equation (no overflow). -/
theorem u32_succ {start : Std.U32} (hb : start.val + 1 < 2 ^ 32) :
∃ w : Std.U32, start + 1#u32 = ok w ∧ w.val = start.val + 1 := by
have he := Std.UScalar.add_equiv start (1#u32)
cases hc : start + 1#u32 with
| ok w =>
refine ⟨w, rfl, ?_⟩
rw [hc] at he
have : (1#u32 : Std.U32).val = 1 := by rfl
omega
| fail e =>
exfalso; rw [hc] at he; simp [Std.UScalar.inBounds] at he
have : (1#u32 : Std.U32).val = 1 := by rfl
omega
| div => rw [hc] at he; simp at he
/-- The range iterator's forward step, when start+1 succeeds. -/
theorem fwd_succ {start w : Std.U32} (hw : start + 1#u32 = ok w) :
U32.Insts.CoreIterRangeStep.forward_checked start 1#usize = ok (some w) := by
unfold U32.Insts.CoreIterRangeStep.forward_checked
have h1 : (1#usize : Std.Usize).val < 2 ^ 32 := by decide
simp only [h1, dif_pos]
have hone : Std.U32.ofNatCore (1#usize : Std.Usize).val h1 = (1#u32 : Std.U32) := by
apply Std.UScalar.eq_of_val_eq; rfl
rw [hone]
unfold Std.U32.checked_add core.num.checked_add_UScalar Option.ofResult
rw [hw]
/-- The Aeneas `loop` fixpoint, unfolded one turn into a bind. The `casesOn`
continuation matches loop's own reduction, so it closes by cases+rfl
(a hand-written `match` would compile to a different, non-defeq matcher). -/
theorem loop_unfold_bind {α β : Type} (body : α → Result (ControlFlow α β)) (x : α) :
loop body x = body x >>= (fun r => ControlFlow.casesOn r (fun c => loop body c) (fun d => ok d)) := by
conv_lhs => rw [loop.eq_1]
cases body x with
| ok cf => cases cf <;> rfl
| fail e => rfl
| div => rfl
/-- The range iterator step on a non-empty range, as a clean equation. -/
theorem hnext {start stop w : Std.U32}
(hd : decide (start.val < stop.val) = true) (hwok : start + 1#u32 = ok w) :
core.iter.range.IteratorRange.next U32.Insts.CoreIterRangeStep
{ start := start, «end» := stop }
= ok (some start, { start := w, «end» := stop }) := by
unfold core.iter.range.IteratorRange.next
simp only [core.cmp.impls.PartialOrdU32.lt, hd, decide_true, if_true,
bind_tc_ok, bind_ok, core.clone.impls.CloneU32.clone, fwd_succ hwok]
/-- The loop body on a non-empty range reduces to a clean do-block. -/
theorem hbody {N : Std.Usize} (pk_seed : Slice Std.U8) (start stop w : Std.U32)
(adrs : types.Adrs) (tmp : Array Std.U8 N)
(hd : decide (start.val < stop.val) = true) (hwok : start + 1#u32 = ok w) :
verify_mono.chain_free_loop.body pk_seed { start := start, «end» := stop } adrs tmp
= (do
let adrs1 ← helpers.Adrs.set_hash_address adrs start
let s ← lift (Array.to_slice tmp)
let tmp1 ← verify_mono.oracle.f N pk_seed adrs1 s
ok (cont (({ start := w, «end» := stop } : core.ops.range.Range Std.U32), adrs1, tmp1))) := by
unfold verify_mono.chain_free_loop.body
rw [hnext hd hwok]
simp
/-- The mathematical chaining fold: at each step set the hash address to the
current index, hash, advance the index (monadically, matching the u32
range iterator). Recursion on the step count. Agreement with the extracted
loop holds under `start.val + s < 2^32`, which makes every increment
succeed. -/
noncomputable def chainFoldN {N : Std.Usize} (pk_seed : Slice Std.U8) :
types.Adrs → Array Std.U8 N → Std.U32 → Nat → Result (Array Std.U8 N)
| _, tmp, _, 0 => ok tmp
| adrs, tmp, start, (k+1) => do
let adrs1 ← helpers.Adrs.set_hash_address adrs start
let s ← lift (Array.to_slice tmp)
let tmp1 ← verify_mono.oracle.f N pk_seed adrs1 s
let start1 ← start + 1#u32
chainFoldN pk_seed adrs1 tmp1 start1 k
/-- One full loop step on a non-empty range = one fold step, tail as the
continuation loop. -/
theorem chain_step {N : Std.Usize} (pk_seed : Slice Std.U8) (start stop : Std.U32)
(adrs : types.Adrs) (tmp : Array Std.U8 N)
(hlt : start.val < stop.val) (hb : start.val + 1 < 2 ^ 32) :
verify_mono.chain_free_loop { start := start, «end» := stop } pk_seed adrs tmp
= (do
let adrs1 ← helpers.Adrs.set_hash_address adrs start
let s ← lift (Array.to_slice tmp)
let tmp1 ← verify_mono.oracle.f N pk_seed adrs1 s
let start1 ← start + 1#u32
verify_mono.chain_free_loop { start := start1, «end» := stop } pk_seed adrs1 tmp1) := by
obtain ⟨w, hwok, _⟩ := u32_succ hb
have hd : decide (start.val < stop.val) = true := by simp [hlt]
conv_lhs => rw [verify_mono.chain_free_loop, loop_unfold_bind]
dsimp only
rw [hbody pk_seed start stop w adrs tmp hd hwok]
simp only [bind_assoc, bind_ok]
conv_rhs => rw [show (start + 1#u32) = ok w from hwok]
simp only [bind_tc_ok, bind_ok]
rfl
/-- **Algorithm 5 fidelity.** The extracted chain loop over [start, start+s)
equals the explicit s-fold hash-chain. -/
theorem chain_free_loop_eq {N : Std.Usize} (pk_seed : Slice Std.U8) (s : Nat) :
∀ (start : Std.U32) (adrs : types.Adrs) (tmp : Array Std.U8 N),
start.val + s < 2 ^ 32 →
∀ (stop : Std.U32), stop.val = start.val + s →
verify_mono.chain_free_loop { start := start, «end» := stop } pk_seed adrs tmp
= chainFoldN pk_seed adrs tmp start s := by
induction s with
| zero =>
intro start adrs tmp _ stop hstop
have hse : start = stop := by apply Std.UScalar.eq_of_val_eq; omega
subst hse
unfold verify_mono.chain_free_loop chainFoldN
rw [loop.eq_1]
unfold verify_mono.chain_free_loop.body core.iter.range.IteratorRange.next
simp [core.cmp.impls.PartialOrdU32.lt]
| succ k ih =>
intro start adrs tmp hb stop hstop
have hlt : start.val < stop.val := by omega
have hb1 : start.val + 1 < 2 ^ 32 := by omega
obtain ⟨w, hwok, hwv⟩ := u32_succ hb1
rw [chain_step pk_seed start stop adrs tmp hlt hb1]
unfold chainFoldN
rw [hwok]
simp only [bind_tc_ok, bind_ok]
have hbound : w.val + k < 2 ^ 32 := by omega
have hstop' : stop.val = w.val + k := by omega
apply bind_congr; intro adrs1
apply bind_congr; intro s
apply bind_congr; intro tmp1
exact ih w adrs1 tmp1 hbound stop hstop'
end fips205