Skip to content

Commit 995f971

Browse files
committed
fix(prover): cover L2G binding rejection, dedupe ELF re-parse in continuation verify
- Add tests that splice a different run's global proof onto valid epochs (same shape, different L2G data) so verify_l2g_commitment_binding_view's own reject branch actually executes, both for the owned and archived verify paths. The existing tampered-root tests were caught earlier by verify_epoch's per-epoch root check and never reached this binding. - Thread entry_point out of verify_continuation_archived so verify_continuation_and_attest can fold program_id via program_id_from_digest instead of re-parsing the ELF with program_id_from_elf. - Extract access_recursion_archive, sharing the aligned-fallback + rkyv::access boilerplate between verify_recursion_blob and verify_continuation_and_attest.
1 parent 772a8a7 commit 995f971

3 files changed

Lines changed: 153 additions & 42 deletions

File tree

‎prover/src/continuation.rs‎

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,27 +1179,31 @@ pub fn verify_continuation_with_roots(
11791179
decode_commitment: Option<Commitment>,
11801180
page_genesis_commitments: Option<&[(u64, Commitment)]>,
11811181
) -> Result<Option<Vec<u8>>, Error> {
1182-
verify_continuation_view(
1182+
let result = verify_continuation_view(
11831183
ContinuationProofView::Owned(bundle),
11841184
elf_bytes,
11851185
opts,
11861186
decode_commitment,
11871187
page_genesis_commitments,
1188-
)
1188+
)?;
1189+
Ok(result.map(|(public_output, _entry_point)| public_output))
11891190
}
11901191

11911192
/// [`verify_continuation_with_roots`]'s zero-copy counterpart, for the
11921193
/// recursion `continuation` guest: reads every per-epoch/global proof in
11931194
/// place via [`ContinuationProofView::Archived`] instead of deserializing an
11941195
/// owned [`MultiProof`]. Only small per-epoch metadata is materialized. Roots
11951196
/// are always supplied here (the guest never recomputes from the ELF in-VM).
1197+
///
1198+
/// Also returns `entry_point` so callers can fold a `program_id` via
1199+
/// [`crate::recursion::program_id_from_digest`] without a second `Elf::load`.
11961200
pub(crate) fn verify_continuation_archived(
11971201
archived: &ArchivedContinuationProof,
11981202
elf_bytes: &[u8],
11991203
opts: &ProofOptions,
12001204
decode_commitment: Commitment,
12011205
page_genesis_commitments: &[(u64, Commitment)],
1202-
) -> Result<Option<Vec<u8>>, Error> {
1206+
) -> Result<Option<(Vec<u8>, u64)>, Error> {
12031207
verify_continuation_view(
12041208
ContinuationProofView::Archived(archived),
12051209
elf_bytes,
@@ -1213,13 +1217,14 @@ pub(crate) fn verify_continuation_archived(
12131217
/// [`verify_continuation_archived`] (archived), operating on a
12141218
/// [`ContinuationProofView`] rather than either's concrete type — the same
12151219
/// split [`crate::verify_recursion_blob`] uses for the monolithic path.
1220+
/// Returns the public output plus `entry_point` (see [`verify_continuation_archived`]).
12161221
fn verify_continuation_view(
12171222
bundle: ContinuationProofView<'_>,
12181223
elf_bytes: &[u8],
12191224
opts: &ProofOptions,
12201225
decode_commitment: Option<Commitment>,
12211226
page_genesis_commitments: Option<&[(u64, Commitment)]>,
1222-
) -> Result<Option<Vec<u8>>, Error> {
1227+
) -> Result<Option<(Vec<u8>, u64)>, Error> {
12231228
// Bound the claimed private-input page count before using it to size/allocate AIRs
12241229
// (mirrors `verify_with_options`). The count is also bound into the global proof's
12251230
// Fiat-Shamir statement (`absorb_continuation_global_statement`), so any wrong value
@@ -1342,7 +1347,7 @@ fn verify_continuation_view(
13421347
return Ok(None);
13431348
}
13441349

1345-
Ok(Some(public_output))
1350+
Ok(Some((public_output, elf.entry_point)))
13461351
}
13471352

13481353
/// Precompute the ELF-derived roots [`verify_continuation_with_roots`] accepts:
@@ -2153,10 +2158,11 @@ mod tests {
21532158
);
21542159
}
21552160

2156-
// Negative: corrupting an epoch's claimed L2G table root must be rejected —
2157-
// `verify_l2g_commitment_binding_view` compares each epoch's `l2g_root` against the
2158-
// corresponding sub-proof root in the global proof, so a mismatched root causes
2159-
// the binding to fail. Guards the L2G root↔global commitment binding.
2161+
// Negative: corrupting an epoch's claimed L2G table root must be rejected. This
2162+
// tamper is caught by `verify_epoch`'s own root-consistency check (the epoch's
2163+
// claimed `l2g_root` no longer matches what its own proof committed) before the
2164+
// cross-epoch `verify_l2g_commitment_binding_view` ever runs — see
2165+
// `test_split_verify_rejects_global_proof_from_a_different_run` for that.
21602166
#[test]
21612167
fn test_split_verify_rejects_tampered_l2g_root() {
21622168
let _ = env_logger::builder().is_test(true).try_init();
@@ -2177,9 +2183,8 @@ mod tests {
21772183

21782184
// Same tamper as `test_split_verify_rejects_tampered_l2g_root`, but through the
21792185
// zero-copy blob path (`verify_continuation_and_attest`) rather than
2180-
// `verify_continuation`. Guards `verify_continuation_archived`'s view-based
2181-
// `verify_l2g_commitment_binding_view` against the same corruption its owned
2182-
// counterpart already catches.
2186+
// `verify_continuation`. Guards the archived path's per-epoch root check against
2187+
// the same corruption the owned path already catches.
21832188
#[test]
21842189
fn test_continuation_blob_rejects_tampered_l2g_root() {
21852190
let _ = env_logger::builder().is_test(true).try_init();
@@ -2209,4 +2214,91 @@ mod tests {
22092214
"a tampered l2g_root must be rejected over the archived blob path too"
22102215
);
22112216
}
2217+
2218+
// Negative: `verify_l2g_commitment_binding_view`'s own reject branch, which the two
2219+
// tests above don't reach (they're caught earlier by `verify_epoch`'s per-epoch root
2220+
// check). Two bundles proved from the same ELF/epoch size with different
2221+
// same-length private inputs share every shape value (`n`, `table_counts`,
2222+
// `touched_page_bases`, `num_private_input_pages`) but commit different actual L2G
2223+
// data, so splicing one's `global` proof onto the other's epochs leaves every
2224+
// per-epoch check and `verify_global`'s own `multi_verify` passing (each half is
2225+
// independently valid for that exact shape) while the per-epoch claimed roots no
2226+
// longer match what the spliced-in global proof's L2G sub-tables actually commit.
2227+
#[test]
2228+
fn test_split_verify_rejects_global_proof_from_a_different_run() {
2229+
let _ = env_logger::builder().is_test(true).try_init();
2230+
let elf_bytes = asm_elf_bytes("test_private_input_xpage");
2231+
let opts = ProofOptions::default_test_options();
2232+
2233+
let input_a: Vec<u8> = (0u8..16).collect();
2234+
let input_b: Vec<u8> = (0u8..16).map(|b| b ^ 0xFF).collect();
2235+
2236+
let mut bundle_a = prove_continuation(&elf_bytes, &input_a, 2, &opts).unwrap();
2237+
let bundle_b = prove_continuation(&elf_bytes, &input_b, 2, &opts).unwrap();
2238+
assert!(
2239+
verify_continuation(&elf_bytes, &bundle_a, &opts)
2240+
.unwrap()
2241+
.is_some(),
2242+
"bundle_a must verify standalone before splicing"
2243+
);
2244+
assert!(
2245+
verify_continuation(&elf_bytes, &bundle_b, &opts)
2246+
.unwrap()
2247+
.is_some(),
2248+
"bundle_b must verify standalone before splicing"
2249+
);
2250+
assert_eq!(
2251+
bundle_a.epochs.len(),
2252+
bundle_b.epochs.len(),
2253+
"same ELF/epoch size/input length must yield the same epoch split"
2254+
);
2255+
assert_eq!(
2256+
bundle_a.touched_page_bases, bundle_b.touched_page_bases,
2257+
"same-length private inputs must touch the same pages"
2258+
);
2259+
assert_ne!(
2260+
bundle_a.epochs[0].l2g_root, bundle_b.epochs[0].l2g_root,
2261+
"different private-input bytes must commit different L2G data"
2262+
);
2263+
2264+
bundle_a.global = bundle_b.global;
2265+
2266+
assert!(
2267+
verify_continuation(&elf_bytes, &bundle_a, &opts)
2268+
.unwrap()
2269+
.is_none(),
2270+
"a global proof spliced in from a different run must be rejected"
2271+
);
2272+
}
2273+
2274+
// Same construction as `test_split_verify_rejects_global_proof_from_a_different_run`,
2275+
// but through the zero-copy blob path — guards
2276+
// `verify_l2g_commitment_binding_view`'s archived call site.
2277+
#[test]
2278+
fn test_continuation_blob_rejects_global_proof_from_a_different_run() {
2279+
let _ = env_logger::builder().is_test(true).try_init();
2280+
let elf_bytes = asm_elf_bytes("test_private_input_xpage");
2281+
let opts = crate::recursion::MIN_PROOF_OPTIONS;
2282+
2283+
let input_a: Vec<u8> = (0u8..16).collect();
2284+
let input_b: Vec<u8> = (0u8..16).map(|b| b ^ 0xFF).collect();
2285+
2286+
let mut bundle_a = prove_continuation(&elf_bytes, &input_a, 2, &opts).unwrap();
2287+
let bundle_b = prove_continuation(&elf_bytes, &input_b, 2, &opts).unwrap();
2288+
assert_eq!(bundle_a.epochs.len(), bundle_b.epochs.len());
2289+
assert_eq!(bundle_a.touched_page_bases, bundle_b.touched_page_bases);
2290+
assert_ne!(bundle_a.epochs[0].l2g_root, bundle_b.epochs[0].l2g_root);
2291+
2292+
bundle_a.global = bundle_b.global;
2293+
2294+
let blob =
2295+
crate::recursion::encode_continuation_guest_input(bundle_a, &elf_bytes, &opts)
2296+
.expect("encode_continuation_guest_input failed");
2297+
let result = crate::recursion::verify_continuation_and_attest(&blob, &opts)
2298+
.expect("verify_continuation_and_attest errored");
2299+
assert!(
2300+
result.is_none(),
2301+
"a global proof spliced in from a different run must be rejected over the archived blob path too"
2302+
);
2303+
}
22122304
}

‎prover/src/lib.rs‎

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,39 @@ pub fn recursion_archive_bytes(blob: &[u8]) -> Option<&[u8]> {
267267
Some(&blob[RECURSION_INPUT_PREFIX_LEN..])
268268
}
269269

270+
/// Validate a recursion-input blob's wire prefix and bytecheck-validate its
271+
/// archive, in place. Shared by [`verify_recursion_blob`] and
272+
/// [`crate::recursion::verify_continuation_and_attest`].
273+
///
274+
/// Returns the archived value, the original (possibly-unaligned) archive
275+
/// bytes, and the base pointer `archived` reads from — callers rebasing
276+
/// zero-copy subslices back onto `blob` need that base.
277+
pub(crate) fn access_recursion_archive<'s, 'a: 's, T>(
278+
blob: &'a [u8],
279+
aligned_fallback: &'s mut rkyv::util::AlignedVec<RECURSION_INPUT_ALIGN>,
280+
) -> Result<(&'s T, &'a [u8], *const u8), Error>
281+
where
282+
T: rkyv::Portable
283+
+ for<'b> rkyv::bytecheck::CheckBytes<rkyv::api::high::HighValidator<'b, rkyv::rancor::Error>>,
284+
{
285+
let archive_bytes: &'a [u8] = recursion_archive_bytes(blob)
286+
.ok_or_else(|| Error::Execution(String::from("recursion blob: bad magic or version")))?;
287+
288+
let archive: &'s [u8] = if (archive_bytes.as_ptr() as usize).is_multiple_of(RECURSION_INPUT_ALIGN)
289+
{
290+
archive_bytes
291+
} else {
292+
aligned_fallback.extend_from_slice(archive_bytes);
293+
aligned_fallback
294+
};
295+
let archive_base = archive.as_ptr();
296+
297+
let archived: &'s T = rkyv::access::<T, rkyv::rancor::Error>(archive).map_err(|e| {
298+
Error::Execution(format!("recursion blob: bytecheck validation failed: {e}"))
299+
})?;
300+
Ok((archived, archive_bytes, archive_base))
301+
}
302+
270303
/// Result of a recursion-blob verification: the verdict plus the inner
271304
/// proof's committed public output (zero-copy from the blob), which the
272305
/// recursion guest folds into `program_id(...) ‖ public_output`.
@@ -310,31 +343,15 @@ pub fn verify_recursion_blob<'a>(
310343
) -> Result<RecursionVerification<'a>, Error> {
311344
use rkyv::rancor::Error as RkyvError;
312345

313-
// Validate + strip the aligning magic/version prefix. In the guest the
314-
// returned slice starts at the 16-aligned archive base (the prefix exists
315-
// precisely so the archive lands aligned at
346+
// In the guest the blob's archive starts at the 16-aligned archive base
347+
// (the wire prefix exists precisely so the archive lands aligned at
316348
// `PRIVATE_INPUT_START + 4 + PREFIX_LEN`), so the in-place doubleword
317-
// loads do not trap.
318-
let archive_bytes = recursion_archive_bytes(blob)
319-
.ok_or_else(|| Error::Execution(String::from("recursion blob: bad magic or version")))?;
320-
321-
// A host caller's buffer carries no alignment guarantee (`Vec<u8>` is
322-
// align-1) — in-place access there would be UB. Fall back to one aligned
323-
// copy when the base is misaligned; the guest path is aligned by
324-
// construction and stays zero-copy.
325-
let mut aligned_fallback = rkyv::util::AlignedVec::<{ RECURSION_INPUT_ALIGN }>::new();
326-
let archive: &[u8] = if (archive_bytes.as_ptr() as usize).is_multiple_of(RECURSION_INPUT_ALIGN)
327-
{
328-
archive_bytes
329-
} else {
330-
aligned_fallback.extend_from_slice(archive_bytes);
331-
&aligned_fallback
332-
};
333-
334-
// `blob` is untrusted; validate before the zero-copy access.
335-
let archived = rkyv::access::<ArchivedGuestInput, RkyvError>(archive).map_err(|e| {
336-
Error::Execution(format!("recursion blob: bytecheck validation failed: {e}"))
337-
})?;
349+
// loads do not trap. A host caller's buffer carries no such guarantee
350+
// (`Vec<u8>` is align-1), so `access_recursion_archive` falls back to one
351+
// aligned copy in `aligned_fallback` when the base is misaligned.
352+
let mut aligned_fallback = rkyv::util::AlignedVec::<RECURSION_INPUT_ALIGN>::new();
353+
let (archived, archive_bytes, archive_base): (&ArchivedGuestInput, &[u8], *const u8) =
354+
access_recursion_archive(blob, &mut aligned_fallback)?;
338355

339356
// Materialize only the small metadata; the proof stays in the buffer.
340357
let table_counts: TableCounts =
@@ -356,11 +373,11 @@ pub fn verify_recursion_blob<'a>(
356373
let public_output: &[u8] = archived.vm_proof.public_output.as_slice();
357374
let decode_commitment: Commitment = archived.decode_commitment;
358375

359-
// Rebase the returned slices onto the caller's buffer: `archive` may be
360-
// the aligned fallback copy, whose lifetime ends with this call. Same
361-
// bytes at the same offsets in both buffers.
376+
// Rebase the returned slices onto the caller's buffer: `archived` may
377+
// point into the aligned fallback copy, whose lifetime ends with this
378+
// call. Same bytes at the same offsets in both buffers.
362379
let rebase = |s: &[u8]| -> &'a [u8] {
363-
let offset = s.as_ptr() as usize - archive.as_ptr() as usize;
380+
let offset = s.as_ptr() as usize - archive_base as usize;
364381
&archive_bytes[offset..offset + s.len()]
365382
};
366383
let inner_elf_rebased = rebase(inner_elf);

‎prover/src/recursion.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ pub fn verify_continuation_and_attest(
314314
let decode_commitment: Commitment = archived.decode_commitment;
315315
let inner_elf: &[u8] = archived.inner_elf.as_slice();
316316

317-
let Some(public_output) = crate::continuation::verify_continuation_archived(
317+
let Some((public_output, entry_point)) = crate::continuation::verify_continuation_archived(
318318
&archived.bundle,
319319
inner_elf,
320320
proof_options,
@@ -325,7 +325,9 @@ pub fn verify_continuation_and_attest(
325325
return Ok(None);
326326
};
327327

328-
let id = program_id_from_elf(inner_elf, &decode_commitment, &page_commitments)?;
328+
// Avoids a second `Elf::load` (already done by `verify_continuation_archived`).
329+
let digest = elf_digest(inner_elf);
330+
let id = program_id_from_digest(&digest, entry_point, &decode_commitment, &page_commitments);
329331
let mut attestation = id.to_vec();
330332
attestation.extend_from_slice(&public_output);
331333
Ok(Some(attestation))

0 commit comments

Comments
 (0)