fix(verifier): pin each trace-opening column width to the AIR, not just their sum - #909
Open
MauroToscano wants to merge 8 commits into
Open
fix(verifier): pin each trace-opening column width to the AIR, not just their sum#909MauroToscano wants to merge 8 commits into
MauroToscano wants to merge 8 commits into
Conversation
…st their sum
The verifier pinned only the SUM of a query opening's precomputed/main/aux
column counts (against the AIR-pinned OOD width). Nothing pinned the split,
and the Merkle leaf hash pins neither: hash_data_from_slices streams
evaluations || evaluations_sym with no length prefix and no separator.
Each of the three trees is transcript-bound at a different time, so both
splits are exploitable:
* precomputed<->main: a non-preprocessed AIR never absorbs the precomputed
root, so columns declared 'precomputed' are bound by nothing. A prover can
sample the round-2 challenges and then solve for them.
* main<->aux: the aux root is absorbed after the shared LogUp challenges, so
a column moved from main to aux is chosen after challenges it must precede.
trace_opening_widths_well_formed pins all three widths, for both the regular
and the symmetric slot, once per table before any opening is read.
Six end-to-end cases against a hostile prover that declares one column 'precomputed' for an AIR that is not preprocessed, plus direct tests of the guard on a RAP proof covering all three widths in both the regular and the symmetric slot. On stock main, three of these fail (the proof is accepted): the honest trace under a split declaration, the adaptively forged trace, and a demonstrably false statement. The other three pass on both and are the non-vacuity controls - in particular a genuinely preprocessed table, which has num_precomputed_columns() > 0, must still verify. The end-to-end cases need TEST_ONLY_SKIP_PRECOMPUTED_ROOT_ABSORB: a hostile prover does not absorb a root the verifier never reads, and without that the same proof is rejected for transcript divergence instead of for its split, which would prove nothing.
…gUp break) Ports the aux-instance PoC into a permanent regression: a hostile AIR declaring layout (4, 2) against LogReadOnlyRAP's honest (5, 1) moves the multiplicity column into the auxiliary tree, which is transcript-bound only AFTER the shared LogUp challenges. The prover then solves that column against the sampled z/alpha, and the multiset equality the AIR exists to enforce degenerates into one scalar equation. On stock main both break tests are accepted - the structural mis-split and a false memory read (address 3 carrying two values) - the latter also over the rkyv wire through multi_verify_archived, the recursion-guest path. Unlike the precomputed instance this needs no prover change at all: both sides absorb main-root-then-aux-root either way. Three controls (corrupted aux opening, the same lie without the split, the split without the challenge solve) plus an honest LogReadOnlyRAP round trip pass on both, so the harness discriminates and the pin is not vacuous.
…in the guard doc The aux arm authenticates against the aux root but constrains no width; say so, and point at the upstream pin. Same class of stale comment as the two this PR already corrects.
The precomputed regression no longer needs the #[cfg(test)] absorb switch in prover.rs. Handing the prover and the verifier AIRs that disagree about num_precomputed_columns, while both absorb the same commitment constant, keeps the transcripts in sync - so the honest in-repo prover builds a proof that stock main accepts and this branch rejects. prover.rs is back to stock: the whole change is now verifier + tests. What the dropped end-to-end tests covered is kept: the 'a non-preprocessed AIR must declare zero precomputed columns' direction is pinned by the direct guard tests (its end-to-end form is masked by transcript divergence and proves nothing on its own), and the aux file demonstrates an executed false statement. Adds a tripwire (precheck_the_width_pin_is_compiled_in) plus attribution asserts in the break tests, so a rejection cannot be read as evidence unless it comes from the guard - the failure mode that made a sibling PoC look non-reproducing.
… not catch The precomputed-width test's comment implied real preprocessed tables are exploitable through this shape. They are not directly: an honest constant is a root over exactly num_precomputed_columns() columns, so a narrower tree hashes differently and round 1 rejects it. Say that, and say why the defence is incidental - nothing states the invariant, nothing checks it, and it is absent entirely for a non-preprocessed AIR.
The header carried the two exploit narratives in full, at ~33 lines for a ~40 line function -- 3x the sibling ood_blocks_well_formed. The mechanics belong in the tests that demonstrate them and in the PR; the header only needs the invariant, why an unpinned split is exploitable at all, and where to look.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A query opening carries the trace row as three prover-supplied vectors —
precomputed ‖ main(base field) andaux(extension field) — which the DEEP reconstruction consumes as one concatenated row. The verifier pinned only their sum, against the AIR-pinned OOD width (verifier.rs:972-979, withood_widthfixed byood_blocks_well_formedat:191-196).Nothing pinned the individual terms, and the Merkle leaf hash pins neither split:
hash_data_from_slices(crypto/crypto/src/merkle_tree/backends/field_element_vector.rs:173-179) streamsevaluations ‖ evaluations_symwith no length prefix and no separator, so a leaf can be re-split freely. The verifier never callednum_precomputed_columns()at all — its only non-definition use in the repo wasprover.rs:3171.Each of the three trees is transcript-bound at a different time, so the split was a soundness parameter the prover was choosing. Both splits were live breaks, each demonstrated end to end by a false statement the stock verifier accepts.
This PR changes the verifier only —
prover.rsis byte-identical tomain.precomputed↔main
For a non-preprocessed AIR the precomputed root is never absorbed —
verifier.rs:1208is the only absorption and it sits inside theis_preprocessed()branch. A prover declares real trace columns "precomputed", leaving them bound by nothing, reads the round-2 challengebeta, and solves for them.It works because
step_2folds boundary and transition constraints into a single sum with all coefficients powers of onebeta— so the composition identity imposes exactly one condition per trace row, and one unbound column supplies exactly one free value per row. Executed on 2-col Fibonacci with a public output: the verifier accepts a claimed output of999where the only valid execution for(a0,a1)=(1,1)yields2178309, with 30 of 30 constraint instances violated.main↔aux
The aux root is absorbed only in round 1 phase C (
verifier.rs:1269-1271), after the shared LogUp challenges are sampled (:1221-1227). Moving a column frommaininto the aux tree therefore lets the prover choose it afterzandalpha.Demonstrated on the in-repo
LogReadOnlyRAP(read-only memory) by moving the multiplicity column into the aux tree. The verifier's frame split (:290) uses the AIR's honest aux count, so it still evaluates the column as main — only the binding time changes. That collapses LogUp from a multiset equality (which must hold as a rational identity inz) into one scalar equation the prover solves after the fact. The in-test diagnostic states it exactly:The accepted proof asserts address 3 holds both
30and999999— false for any single-valued memory. No fingerprint collision is needed, no prover modification is needed (prover and verifier absorb main-root-then-aux-root either way, so the transcripts agree), and it survives the rkyv wire: 3272 bytes, accepted bymulti_verify_archived, the recursion-guest path.This is the more dangerous of the two — LogUp is the VM's entire cross-table integrity argument, and
traits.rs:182-188notes the trailing main columns of every preprocessed table are exactly the multiplicities.The fix
trace_opening_widths_well_formed(air, proof, num_queries)— a sibling ofood_blocks_well_formed— pins all three widths, independently forevaluations()andevaluations_sym():Called once per table from
verify_rounds_2_to_4, immediately after thequery_list_lenguard — before the challenge replay and beforestep_3_verify_fri, the first consumer of any opening. It needs no challenges: the query count comes fromair.options().fri_number_of_queries, identical tochallenges.iotas.len(). Absent optional openings count as 0, matching the.unwrap_or(&[])in the reconstruction.The old sum guards remain, but as pure panic guards; their comment now says so rather than implying they are the soundness argument.
The fourth opening vector — composition parts — was already pinned (
number_of_partsfromcomposition_parts_ood.len(), itself pinned toair.composition_poly_degree_bound(trace_length)/trace_lengthat:1167-1170, with per-query lengths checked at:1135). So the "prover chooses a width" class is now closed across trace and composition openings.Supersedes #906
#906 guards one shape (
root.is_some()for a non-preprocessed AIR) at one site. Every proof it rejects is rejected here, earlier and for the general reason — including the root-absent variant it misses (its own PoC 3). Its regression test also passes on unpatchedmain, so it does not pin its own fix. #906 can be closed in favour of this.Three comments that claimed guarantees which did not exist
This class hid behind its own documentation, and that is worth as much attention as the diff:
:972-979— presented as a runtime panic guard and implying a width guarantee. It only ever checked the sum. Now says so and points at the authoritative pin.verify_trace_openings— "Mismatched presence is unreachable in practice (multi_verify rejects such proofs upstream)". Upstream rejected nothing; the claim was simply false. Now states exactly what that arm still catches (the degenerate zero-width opening).Tests
main—precomputed_opening_narrower_than_the_air_declares_is_rejected,mis_split_aux_opening_is_rejected, andfalse_memory_read_under_aux_split_is_rejected(the last also asserts the archived/wire path).mainand this branch, so the pin is not vacuous — includinghonest_preprocessed_proof_still_verifies(a table withnum_precomputed_columns() > 0, which must keep verifying),honest_logup_rap_proof_still_verifies, and the two "same lie without the split" / "split without the challenge solve" cases that show the split itself is what buys the attack.Discrimination was verified by running the same test files against a stock-
maincontrol worktree withprover.rsstock in both: 3 failed, 7 passed there.cargo test --release -p stark→ 217 passed, 0 failed (15 of them new).cargo test --release -p lambda-vm-prover→ 520 passed; the 5 failures are the pre-existing missingprogram_artifacts/recursion/*.elfpanics, verified identical on the control worktree.make lint→ exit 0.Two notes for review
No prover changes. The aux break needs none by nature — prover and verifier absorb
main-root-then-aux-root either way, so the transcripts agree. The precomputed term is reached
hook-free by handing the prover an AIR declaring 1 precomputed column and the verifier one declaring
2, with the same commitment constant.
One honest caveat on that precomputed test's strength, which is stated in its doc comment rather than
left to read as a second exploit: for a real preprocessed table the round-1 root equality would
also catch that shape, since an honest constant is a root over exactly
num_precomputed_columns()columns. That defence is incidental — nothing states the invariant, nothing checks it, and it does
not exist at all for a non-preprocessed AIR. The stronger precomputed demonstration (a false
statement accepted, against a hostile prover — which is what a real attacker runs) is preserved
outside this branch; it needs a test-only prover switch to be discriminating, and that did not seem
worth a permanent oddity in a security-critical file.
A tripwire, not a source hash.
precheck_the_width_pin_is_compiled_inasserts the guard is bothdefined and called in the compiled verifier — deleting the call site while keeping the function is
the refactor that would otherwise turn this file green for the wrong reason. A source-hash check was
considered and rejected: right for a throwaway branch, wrong in-repo where it breaks on every
unrelated verifier edit. The three break tests additionally assert attribution behaviourally, each
calling the guard on the very proof it rejects, so a green "rejected" cannot come from elsewhere.
Guest-cycle cost is unmeasured. The check is O(queries × tables) length reads, and the recursion
verifier runs it in-circuit — roughly 219 queries × ~20 tables. If
/profile_recursionlater showsit, the zero-extra-pass variant is to thread the three expected widths into the existing per-query
loop in
reconstruct_deep_composition_poly_evaluations_for_all_queries: same coverage, later andrepeated rather than early and once. Placement here favours rejecting before any consumer reads
attacker-shaped data.