Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions crypto/stark/src/tests/bus_tests/soundness_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,155 @@ fn test_malformed_ood_table_shape_rejected() {
);
}

/// A next-row (g·z) OOD block whose advertised dimensions disagree with its
/// backing data must be rejected, not panic. Unlike the current-row block
/// (`test_malformed_ood_table_shape_rejected`), the next-row block is absorbed
/// into the transcript via `get_row` in Round 3 BEFORE step_2's own shape guard
/// runs, so without a pre-absorption guard a lying shape is an out-of-bounds
/// slice panic rather than a `false` verdict. Owned path.
#[test_log::test]
fn test_malformed_ood_next_block_shape_rejected_owned() {
// Same valid trace as `test_malformed_ood_table_shape_rejected`.
let mut cpu_trace = TraceTable::from_columns_main(
vec![
vec![FE::one(), FE::zero(), FE::zero(), FE::zero()], // add_flag
vec![FE::zero(); 4], // mul_flag
vec![FE::from(5), FE::zero(), FE::zero(), FE::zero()],
vec![FE::from(3), FE::zero(), FE::zero(), FE::zero()],
vec![FE::from(8), FE::zero(), FE::zero(), FE::zero()],
],
1,
);
let mut add_trace = TraceTable::from_columns_main(
vec![
vec![FE::from(5), FE::zero(), FE::zero(), FE::zero()],
vec![FE::from(3), FE::zero(), FE::zero(), FE::zero()],
vec![FE::from(8), FE::zero(), FE::zero(), FE::zero()],
vec![FE::one(), FE::zero(), FE::zero(), FE::zero()], // multiplicity = 1
],
1,
);
let mut mul_trace = TraceTable::from_columns_main(vec![vec![FE::zero(); 4]; 4], 1);

let proof_options = ProofOptions::default_test_options();
let cpu_air = new_cpu_air_with_lookup(&proof_options);
let add_air = new_add_air_with_lookup(&proof_options);
let mul_air = new_mul_air_with_lookup(&proof_options);

let air_trace_pairs: Vec<(
&dyn AIR<Field = F, FieldExtension = E, PublicInputs = ()>,
_,
_,
)> = vec![
(&cpu_air, &mut cpu_trace, &()),
(&add_air, &mut add_trace, &()),
(&mul_air, &mut mul_trace, &()),
];

let mut multi_proof =
multi_prove_ram(air_trace_pairs, &mut DefaultTranscript::<E>::new(&[])).unwrap();

// Forge the ADD table's next-row OOD block to advertise a far larger shape
// than its data backs (the canonical hostile archive: width/height huge, one
// data element). `get_row` would slice `data[0..width]` out of bounds during
// Round-3 absorption; the Phase A guard must reject before that.
let add_proof = &mut multi_proof.proofs[1];
assert!(
add_proof.trace_ood_next_evaluations.width >= 1,
"next-row OOD block must open at least one column for this to be an OOB test"
);
add_proof.trace_ood_next_evaluations.width = 1000;
add_proof.trace_ood_next_evaluations.height = 1000;

let airs: Vec<&dyn AIR<Field = F, FieldExtension = E, PublicInputs = ()>> =
vec![&cpu_air, &add_air, &mul_air];

assert!(
!Verifier::multi_verify(
&airs,
&multi_proof,
&mut DefaultTranscript::<E>::new(&[]),
&FieldElement::zero(),
),
"Proof with a lying next-row OOD block shape must be rejected, not panic"
);
}

/// The same attack through the rkyv-archived, read-in-place path — the real
/// attack surface, since the recursion guest verifies archived proofs.
/// `ArchivedTable::get_row` is the same unchecked slice, and rkyv's bytecheck
/// does NOT enforce `width * height == data.len()`, so a forged archive reaches
/// absorption. The Phase A guard must reject it; it must never panic.
#[test_log::test]
fn test_malformed_ood_next_block_shape_rejected_archived() {
// Same valid trace as the owned variant above.
let mut cpu_trace = TraceTable::from_columns_main(
vec![
vec![FE::one(), FE::zero(), FE::zero(), FE::zero()], // add_flag
vec![FE::zero(); 4], // mul_flag
vec![FE::from(5), FE::zero(), FE::zero(), FE::zero()],
vec![FE::from(3), FE::zero(), FE::zero(), FE::zero()],
vec![FE::from(8), FE::zero(), FE::zero(), FE::zero()],
],
1,
);
let mut add_trace = TraceTable::from_columns_main(
vec![
vec![FE::from(5), FE::zero(), FE::zero(), FE::zero()],
vec![FE::from(3), FE::zero(), FE::zero(), FE::zero()],
vec![FE::from(8), FE::zero(), FE::zero(), FE::zero()],
vec![FE::one(), FE::zero(), FE::zero(), FE::zero()], // multiplicity = 1
],
1,
);
let mut mul_trace = TraceTable::from_columns_main(vec![vec![FE::zero(); 4]; 4], 1);

let proof_options = ProofOptions::default_test_options();
let cpu_air = new_cpu_air_with_lookup(&proof_options);
let add_air = new_add_air_with_lookup(&proof_options);
let mul_air = new_mul_air_with_lookup(&proof_options);

let air_trace_pairs: Vec<(
&dyn AIR<Field = F, FieldExtension = E, PublicInputs = ()>,
_,
_,
)> = vec![
(&cpu_air, &mut cpu_trace, &()),
(&add_air, &mut add_trace, &()),
(&mul_air, &mut mul_trace, &()),
];

let mut multi_proof =
multi_prove_ram(air_trace_pairs, &mut DefaultTranscript::<E>::new(&[])).unwrap();

// Forge before serialization: rkyv archives `data` (by its real length),
// `width`, and `height` as independent fields, so a width/height that
// disagree with the data survive `to_bytes` and surface on the archived
// table exactly as a hostile prover would craft them.
multi_proof.proofs[1].trace_ood_next_evaluations.width = 1000;
multi_proof.proofs[1].trace_ood_next_evaluations.height = 1000;

let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&multi_proof).unwrap();
let archived = rkyv::access::<
crate::proof::stark::ArchivedMultiProof<F, E, ()>,
rkyv::rancor::Error,
>(&bytes)
.unwrap();

let airs: Vec<&dyn AIR<Field = F, FieldExtension = E, PublicInputs = ()>> =
vec![&cpu_air, &add_air, &mul_air];

assert!(
!Verifier::multi_verify_archived(
&airs,
&archived.proofs,
&mut DefaultTranscript::<E>::new(&[]),
&FieldElement::zero(),
),
"Archived proof with a lying next-row OOD block shape must be rejected, not panic"
);
}

/// The transition window (`trace_ood_next_row_columns`) of a LogUp table is
/// exactly the accumulator column — the sole column read at the next row after
/// forward accumulation — expressed as a full-width `[main | aux]` index.
Expand Down
99 changes: 51 additions & 48 deletions crypto/stark/src/verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,42 @@ pub trait IsStarkVerifier<
/// Checks whether the purported evaluations of the composition polynomial parts and the trace
/// polynomials at the out-of-domain challenge are consistent.
/// See https://lambdaclass.github.io/lambdaworks/starks/protocol.html#step-2-verify-claimed-composition-polynomial
/// Soundness (I3): both OOD blocks' shapes are a public function of the AIR,
/// never of the (prover-controlled) proof. The current-row block opens every
/// column over `step_size` rows; the next-row block opens only the
/// transition-window columns over the remaining rows, and is empty when the
/// AIR reads none.
///
/// Must run before Round 3, which absorbs the next-row block through
/// `get_row` — an unchecked `data[start..start + width]` slice. A hostile
/// archive whose advertised dims disagree with its data length would panic
/// there rather than be rejected as a false proof; `dimensions_consistent()`
/// closes that gap, which rkyv's bytecheck leaves open.
fn ood_blocks_well_formed(
air: &dyn AIR<Field = Field, FieldExtension = FieldExtension, PublicInputs = PI>,
proof: StarkProofView<'_, Field, FieldExtension, PI>,
) -> bool {
let step_size = air.step_size();
let num_eval_points = air.context().transition_offsets.len() * step_size;
let expected_next_width = air.trace_ood_next_row_columns().len();
let expected_next_height = if expected_next_width == 0 {
0
} else {
num_eval_points.saturating_sub(step_size)
};
let current = proof.trace_ood_evaluations();
let next = proof.trace_ood_next_evaluations();

// `height == step_size` also rejects a height-0 current block: every AIR
// reports `step_size >= 1`.
current.dimensions_consistent()
&& current.width() == air.trace_layout().0 + air.num_auxiliary_rap_columns()
&& current.height() == step_size
&& next.dimensions_consistent()
&& next.width() == expected_next_width
&& next.height() == expected_next_height
}

fn step_2_verify_claimed_composition_polynomial(
air: &dyn AIR<Field = Field, FieldExtension = FieldExtension, PublicInputs = PI>,
proof: StarkProofView<'_, Field, FieldExtension, PI>,
Expand All @@ -142,33 +178,17 @@ pub trait IsStarkVerifier<
.bus_table_contribution()
.map(BusPublicInputs::from_contribution);

// Soundness (I3): both OOD blocks' shapes are a public function of the
// AIR, never of the (prover-controlled) proof. The current-row block
// opens every column over `step_size` rows; the next-row block opens only
// the transition-window columns over the remaining rows (and is empty
// when there are none). Reject any mismatch (including an archive whose
// advertised dims disagree with its data length) before using either
// block, so a malicious prover cannot reshape them to dodge a check or
// desync the frame reconstruction below.
// Reject either OOD block whose shape disagrees with the AIR before
// reading it, so a malicious prover cannot reshape them to dodge a check
// or desync the frame reconstruction below.
if !Self::ood_blocks_well_formed(air, proof) {
return false;
}
let step_size = air.step_size();
let num_eval_points = air.context().transition_offsets.len() * step_size;
let next_row_cols = air.trace_ood_next_row_columns();
let expected_next_width = next_row_cols.len();
let expected_next_height = if expected_next_width == 0 {
0
} else {
num_eval_points.saturating_sub(step_size)
};
let ood_current = proof.trace_ood_evaluations();
let ood_next = proof.trace_ood_next_evaluations();
if ood_current.height() != step_size
|| !ood_current.dimensions_consistent()
|| ood_next.width() != expected_next_width
|| ood_next.height() != expected_next_height
|| !ood_next.dimensions_consistent()
{
return false;
}

// Reconstruct the full current+next-row OOD grid (surviving values placed,
// pruned next-row entries zero -- those are never read by any constraint).
Expand Down Expand Up @@ -1017,32 +1037,15 @@ pub trait IsStarkVerifier<
{
return false;
}
// The archive is read in place without validation; reject an OOD
// table whose advertised dimensions disagree with its data length,
// has no rows, whose width doesn't match the AIR's column layout, or
// whose height isn't a whole number of AIR steps (which `into_frame`
// below only `debug_assert!`s, not checks) — all before any row
// access indexes into it.
//
// The width check is load-bearing and prevents two distinct faults:
// (a) the AIR-derived column index `main_trace_width + c.col` in
// `step_2_verify_claimed_composition_polynomial` indexing past a
// too-narrow OOD row (a release-mode out-of-bounds panic), and
// (b) a width-0 table, whose `width * height == 0 == data.len()`
// satisfies `dimensions_consistent()` for an arbitrary advertised
// height and would otherwise slip through this guard entirely.
// An honest proof always commits exactly `main_trace_width + num_aux`
// OOD columns (the same quantities `column_idx` and the `checked_sub`
// boundary use), so exact equality never rejects a valid proof.
let trace_ood_evaluations = proof.trace_ood_evaluations();
let expected_ood_width = air.trace_layout().0 + air.num_auxiliary_rap_columns();
if !trace_ood_evaluations.dimensions_consistent()
|| trace_ood_evaluations.height() == 0
|| trace_ood_evaluations.width() != expected_ood_width
|| !trace_ood_evaluations
.height()
.is_multiple_of(air.step_size())
{
// The archive is read in place without validation, so both OOD blocks
// must be shape-checked here — before Round 3 absorbs the next-row
// block and before any row access indexes into either. The width check
// is load-bearing: it stops the AIR-derived column index
// `main_trace_width + c.col` in `step_2_verify_claimed_composition_polynomial`
// from indexing past a too-narrow OOD row, and it rejects a width-0
// table, whose `width * height == 0 == data.len()` would otherwise
// satisfy `dimensions_consistent()` for any advertised height.
if !Self::ood_blocks_well_formed(*air, proof) {
return false;
}
if air.is_preprocessed() {
Expand Down
Loading