Skip to content

Fix(Continuations): Make private input private#758

Merged
MauroToscano merged 16 commits into
mainfrom
fix-continuation-private-input-main
Jul 3, 2026
Merged

Fix(Continuations): Make private input private#758
MauroToscano merged 16 commits into
mainfrom
fix-continuation-private-input-main

Conversation

@nicole-graus

Copy link
Copy Markdown
Collaborator

Motivation

The continuation prover shipped the raw private input inside the proof bundle, and the verifier recomputed the starting memory from it, so private input was exposed to the verifier.

Description

  • Private-input GLOBAL_MEMORY pages are now built non-preprocessed: their genesis is a committed, bus-enforced column the verifier never recomputes (mirroring the monolithic PAGE table).
  • The bundle drops the raw private_inputs bytes for a num_private_input_pages count, classified count-based from PRIVATE_INPUT_START_INDEX (like the monolithic verifier), bound-checked and absorbed into the global proof's Fiat-Shamir statement.
  • verify_continuation now works from the bundle + ELF alone, it never sees the raw private input.
  • Elf::load now rejects any loadable segment overlapping the reserved private-input region, so ELF data can never sit on a page whose genesis isn't recomputed from the ELF (closes a pre-existing forgeable-genesis gap shared with the monolithic path).

@MauroToscano MauroToscano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check the following issues:

  1. Plaintext private-input leak through serialized boundaries

    • Severity: High/Critical

    • EpochProof.boundary serializes CellBoundary.init.value, which contains genesis values for touched cells. For private-input reads, those values are the private
      bytes.

    • This defeats the PR’s headline privacy goal: the bundle no longer carries private_inputs, but read private bytes still appear in plaintext.

    • Fix: serialize only verifier-needed boundary data, likely touched addresses/page bases. The verifier uses boundary via b.address; values are redundant.

  2. Unconstrained OFFSET on non-preprocessed private pages

    • Severity: Critical
    • Private GLOBAL_MEMORY pages make OFFSET a prover-controlled main-trace column, while OFFSET is used to derive bus addresses.
    • This lets a malicious prover forge genesis tokens for same-high-32-bit addresses by choosing invalid offsets.
    • Caveats: this appears shared with monolithic private PAGE handling, so it is pre-existing; it is structurally verified but not PoC’d.
    • Fix: keep OFFSET preprocessed for private pages, or constrain it to the row index/range.
  3. Loader reservation misses the honest max private-input spill page

    • Severity: High
    • The loader reserves [START, START + MAX_PRIVATE_INPUT_SIZE), but the wire image is 4 + len.
    • At max legal input length, the 4-byte prefix spills into page index 256 (0x103000000), which can be classified private but is not reserved from ELF segments.
    • Fix: reserve the full page-rounded wire span: ceil((MAX_PRIVATE_INPUT_SIZE + 4) / PAGE_SIZE) pages.
  4. Verifier bound allows one extra private page

    • Severity: High

    • The verifier accepts ceil((MAX_PRIVATE_INPUT_SIZE + 4) / PAGE_SIZE) + 1, allowing page index 257 (0x103040000) even though honest max is 257 total pages,
      indexed 0..256.

    • This is complementary to #3, not redundant: #3 is the honest max spill page; #4 is the extra slack page.

    • Fix: remove the + 1; check both continuation and monolithic verifier bounds.

  5. Overflowing PT_LOAD ranges are accepted

    • Severity: Medium
    • Elf::load uses saturating arithmetic and never rejects p_vaddr + p_memsz overflow.
    • Downstream code uses unchecked base_addr + ..., causing malformed-input acceptance and debug/release divergence.
    • Fix: use checked_add and return the existing ElfError::AddrTooLarge.
  6. Missing regression tests

    • Severity: Medium
    • Missing serialized-bundle byte-absence test; this would fail today because of #1.
    • Missing continuation test for multi-page private input.
    • Missing in-bounds inflated-count tamper test. This guards bundle tampering via Fiat-Shamir divergence, not the self-consistent forgery path in #2.
    • Fix: add all three, with comments that correctly describe what each guards.
  7. Residual privacy claim overstates non-ZK STARK privacy

    • Severity: Low/Medium

    • Even after fixing #1, non-preprocessed private columns are still opened as deterministic STARK trace evaluations unless the proof system is zero-knowledge/
      blinded.

    • Fix: phrase the claim as “raw private input is not bundled/recomputed by the verifier,” unless actual blinding is added.

  8. Stale/misleading docs and comments

    • Severity: Low
    • Several comments now encode false reasoning, including “verifier never sees” private genesis and boundary-value tampering being inconsequential.
    • These claims mask #1 and #2 and should be corrected with the code changes.
    • Fix: update continuation.rs comments and docs/continuations_design.md to match the actual security model.

…the continuation bundle by dropping EpochProof.boundary and instead carrying only the value-free, sorted touched page-base set the verifier needs, derived from a single source shared with the committed GLOBAL_MEMORY tables, canonicalized on the verify side, and bound into the global Fiat-Shamir statement.
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Benchmark Results for unmodified programs 🚀

Command Mean [ms] Min [ms] Max [ms] Relative
base binary_search 58.9 ± 0.8 57.8 60.2 1.00 ± 0.02
head binary_search 58.8 ± 0.8 58.0 60.2 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base bitwise_ops 58.2 ± 0.2 57.9 58.5 1.00
head bitwise_ops 58.4 ± 0.9 57.7 60.9 1.00 ± 0.02
Command Mean [ms] Min [ms] Max [ms] Relative
base ecsm 3.2 ± 0.1 3.0 3.5 1.02 ± 0.05
head ecsm 3.1 ± 0.0 3.1 3.2 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base fibonacci_26 63.4 ± 1.0 62.4 64.8 1.00
head fibonacci_26 63.6 ± 0.8 62.7 64.6 1.00 ± 0.02
Command Mean [ms] Min [ms] Max [ms] Relative
base hashmap 131.2 ± 5.0 123.6 139.0 1.01 ± 0.05
head hashmap 129.8 ± 4.1 124.7 136.7 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base keccak 129.7 ± 2.6 125.2 133.5 1.02 ± 0.03
head keccak 127.7 ± 2.0 124.2 130.3 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base matrix_multiply 65.0 ± 1.2 62.8 67.1 1.01 ± 0.02
head matrix_multiply 64.2 ± 0.9 63.1 65.4 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base modular_exp 62.1 ± 5.1 58.5 75.3 1.06 ± 0.09
head modular_exp 58.8 ± 0.5 58.0 59.7 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base quicksort 63.7 ± 2.3 61.9 69.9 1.01 ± 0.04
head quicksort 63.0 ± 0.6 62.0 63.7 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base sieve 65.2 ± 2.1 63.8 70.8 1.00
head sieve 69.8 ± 7.1 63.9 86.2 1.07 ± 0.11
Command Mean [ms] Min [ms] Max [ms] Relative
base sum_array 74.3 ± 0.4 73.5 74.7 1.00
head sum_array 75.4 ± 2.4 73.7 81.2 1.02 ± 0.03
Command Mean [ms] Min [ms] Max [ms] Relative
base syscall_commit 90.7 ± 0.7 90.1 92.0 1.00
head syscall_commit 90.8 ± 0.8 89.8 92.0 1.00 ± 0.01

@nicole-graus

Copy link
Copy Markdown
Collaborator Author

Issues checked:

  1. Fixed in f7777b1 (doc follow-up 7f108b3).
  2. Not a soundness bug: OFFSET/INIT/FINI/FINI_EPOCH are unconstrained in the AIR on private pages, but they're pinned by the GlobalMemory LogUp bus: a forged OFFSET either points a genesis token at an address another page/L2G already claims → unmatched token → bus can't balance → rejected, or changes a cell that's written before it's read → overwritten before use → never affects any output.
  3. Fixed in 3cab6b6
  4. Fixed in c10ba95
  5. Fixed in 852dd0f
  6. Fixed in bab015b
  7. Fixed in dbac770
  8. Fixed in dbac770

@nicole-graus

Copy link
Copy Markdown
Collaborator Author

/ai-review

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codex Code Review

Findings

  • High: touched_page_bases is not validated as page-aligned before private-page classification. In prover/src/continuation.rs, the verifier canonicalizes the prover-supplied list but does not reject non-page-aligned bases. That list is then used to build global memory configs, and any base inside the private-input range is marked private at prover/src/continuation.rs. For private configs, prover/src/continuation.rs returns a non-preprocessed AIR with no constraints, so OFFSET and INIT are prover-committed. A malicious bundle can therefore supply a fake base such as PRIVATE_INPUT_START_INDEX + 1; because OFFSET is unconstrained, the global-memory table can emit genesis tokens for arbitrary addresses/values instead of the true aligned page. This bypasses the intended ELF/zero-init binding for first-read memory. The verifier should reject any touched_page_bases entry where base != page::page_base_for_address(base) before building AIRs.

I did not run builds or tests per the review constraints.

@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review: Fix(Continuations) — Make private input private

Reviewed statically against the base tree (the diff, plus the monolithic PAGE path in page.rs/lib.rs and the prover-side private-page enumeration in trace_builder.rs). No blocking issues found — a careful, well-tested change.

Verified as sound

  • Private-genesis mechanism mirrors the monolithic PAGE exactly: global_memory_air returns the non-preprocessed AIR for is_private_input pages (INIT/OFFSET become committed main columns), identical to VmAirs::new (lib.rs:531-535). Correctness falls back to the GlobalMemory bus telescoping into the epochs' first reads, so a forged genesis/offset leaves an unmatched bus term.
  • Count formula matches the monolithic path: private_input_page_count = ceil((4 + len)/page_size), equal to the distinct-page-base count in trace_builder.rs:2461-2469. Classification (is_private_input_page, first n pages from PRIVATE_INPUT_START_INDEX) matches page_configs_from_elf_and_runtime (trace_builder.rs:3882-3888).
  • Tampering caught on both paths: deflation flips a touched page's preprocessed mode -> committed-AIR mismatch; inflation over untouched pages -> Fiat-Shamir divergence (absorb_continuation_global_statement now binds count + touched-page set). Both covered by new negative tests. touched_page_bases is canonicalized on ingest, and prove/verify seed global_transcript identically.
  • Removing the +1 slack in lib.rs:1294 is safe: an honest max-size input spans exactly ceil((MAX+4)/page_size) pages, so no legitimate monolithic proof reaches the old bound; the continuation bound uses the same tight max.
  • Elf::load reservation (elf.rs) rejects any loadable segment reaching at/above PRIVATE_INPUT_START_INDEX, closing the forgeable-genesis gap for both paths. The checked_add guards on the segment span and file-offset math are correct; boundary tests (ends exactly at region start = accepted) are right.
  • Value-free bundle: dropping EpochProof.boundary and shipping only touched_page_bases removes the touched-cell-value leak; test_bundle_carries_no_touched_cell_values is a good regression guard.

Minor / non-blocking

  • canonical_page_bases/touched_page_bases re-collect a BTreeSet from an untrusted, unbounded Vec<u64> at verify time. Already bounded by bincode deserialization (and a wrong count/set is rejected downstream), no worse than the previous unbounded boundary vectors — noting for completeness.
  • The non-preprocessed OFFSET column is prover-controlled but bus-pinned (same as monolithic private PAGE); not a new concern, but keep in mind if the GlobalMemory bus token layout ever changes.

Docs (continuations_design.md 3.6) are thorough and accurately match the code, including the explicit not-zero-knowledge scoping.

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

AI Review

PR #758 · 8 changed files

Findings

Status Sev Location Finding Found by
candidate low executor/programs/asm/test_private_input_multipage.s:13 test_private_input_multipage.s loads length into unused register minimax
minimax/MiniMax-M3
candidate low executor/src/elf.rs:317 ELF reservation criterion is broader than necessary minimax
minimax/MiniMax-M3
candidate low prover/src/continuation.rs:1527 Multipage test hard-codes the 4-byte length-prefix assumption minimax
minimax/MiniMax-M3
candidate low prover/src/tables/local_to_global.rs:87 InitClaim.timestamp is dead data (tracked but never read) minimax
minimax/MiniMax-M3
candidate low prover/src/tables/page.rs:141 with_private_input doc comment is misleading on the verifier side minimax
minimax/MiniMax-M3

Status column reflects the verdict from the verifier: deepseek-verifier (openrouter/deepseek/deepseek-v4-pro).

AI-001: test_private_input_multipage.s loads length into unused register
  • Status: candidate
  • Severity: low
  • Location: executor/programs/asm/test_private_input_multipage.s:13
  • Found by: minimax:minimax/MiniMax-M3
  • Verified by: -
  • Rejected by: -

Claim

lw t3, 0(t0) reads the length prefix into t3, but t3 is never subsequently referenced. The fixture only needs to touch page 0 (so it appears in touched_page_bases); the actual length value does not affect program behavior or verification.

Evidence

Lines 13-27 of the .s file load t3 then never use it. The test (test_continuation_multipage_private_input in continuation.rs:1517-1554) constructs a valid input, so the program always behaves correctly regardless of the read value. The unused register is a minor readability wart but not a bug.

Suggested fix

Either drop the lw t3, 0(t0) (touch page 0 some other way, or via the ld at page 1's base which already spans the read), or use t3 so the length is actually consumed.

AI-002: ELF reservation criterion is broader than necessary
  • Status: candidate
  • Severity: low
  • Location: executor/src/elf.rs:317
  • Found by: minimax:minimax/MiniMax-M3
  • Verified by: -
  • Rejected by: -

Claim

The check if seg_end &gt; PRIVATE_INPUT_START_INDEX rejects ANY PT_LOAD segment reaching at or above PRIVATE_INPUT_START_INDEX = 0xFF000000, which is a much broader span than the actual reserved area (private input only spans up to roughly PRIVATE_INPUT_START_INDEX + 257 * page_size ≈ 0xFF000000 + 0x4200000). The intent is to reserve the high memory for private input, but the rejection is broader than the actual reserved span.

Evidence

continuation.rs:316-322 documents the intent: nothing legitimate loads there because the stack is at STACK_TOP=0xFFFFFFFFFFFFFFF0 (registers.rs:3) and is initialized outside load_program, while private input is at 0xFF000000. So functionally fine, but the rejection criterion rejects any segment reaching into [0xFF000000, 0xFFFFFFFFFFFFFFF0), including areas that are not actually used for private input.

Suggested fix

This is a deliberate design choice (the comment explicitly says "we reserve the whole high area"). Add a brief code comment near the check (not just the test docstring) explaining that the broader reservation is intentional, so a future contributor doesn't tighten it and accidentally break a legitimate use.

AI-003: Multipage test hard-codes the 4-byte length-prefix assumption
  • Status: candidate
  • Severity: low
  • Location: prover/src/continuation.rs:1527
  • Found by: minimax:minimax/MiniMax-M3
  • Verified by: -
  • Rejected by: -

Claim

let commit_off = page_size - 4; assumes the private-input wire format uses exactly a 4-byte LE length prefix. The constant 4 is duplicated here rather than imported from the single source of truth. If memory.rs ever changes the prefix size, this test breaks silently.

Evidence

continuation.rs:1527 computes commit_off = page_size - 4 and uses it as both the byte index into input for expected and implicitly as the data offset of page 1's start. memory.rs:51 documents the layout as [len:u32 LE], but the value 4 is hardcoded in the test instead of being derived from size_of::&lt;u32&gt;() or a named constant in memory.rs.

Suggested fix

Export a PRIVATE_INPUT_LENGTH_PREFIX_BYTES: usize = 4 constant from memory.rs (or similar) and use it both in private_input_page_count (which does 4 + private_inputs.len()) and in this test.

AI-004: InitClaim.timestamp is dead data (tracked but never read)
  • Status: candidate
  • Severity: low
  • Location: prover/src/tables/local_to_global.rs:87
  • Found by: minimax:minimax/MiniMax-M3
  • Verified by: -
  • Rejected by: -

Claim

InitClaim.timestamp is populated in epoch_boundary and propagated via provenance, but is never read by any bus interaction or prover/verifier code.

Evidence

The L2G memory bus interactions (local_to_global.rs:367-396) hard-code ts=0 as BusValue::constant(0) for both timestamp halves; the GlobalMemory bus carries no timestamp at all (global_memory.rs:163-170, comment "no timestamp — the cross-epoch chain is ordered by epoch"). The only readers of b.init.timestamp are unit tests at lines 531, 541, 589, 621, 770 of local_to_global.rs. The per-epoch memory_state is also reset to timestamp=0 by MemoryState::from_image (trace_builder.rs:108), so MEMW's first-access old_timestamp is always 0 regardless of any prior epoch's end_timestamp.

Suggested fix

Drop the timestamp: u64 field from InitClaim and stop carrying it through provenance; or add a comment marking it explicitly as reserved/dead. The simpler change is to remove it.

AI-005: with_private_input doc comment is misleading on the verifier side
  • Status: candidate
  • Severity: low
  • Location: prover/src/tables/page.rs:141
  • Found by: minimax:minimax/MiniMax-M3
  • Verified by: -
  • Rejected by: -

Claim

The doc comment on with_private_input says "the verifier never sees the init values", but on the verifier side (continuation.rs:299-300) the constructor is still called with init_values: Some(vec![]) rather than None. The AIR shape ends up identical because private-input pages are built non-preprocessed and global_memory_air early-returns on is_private_input (continuation.rs:223-225) before consulting init_values, so this is harmless — but the field shape (Some(empty) vs None) is inconsistent with the comment.

Evidence

continuation.rs:299-300 calls PageConfig::with_private_input(page_base, data) where data = init_page_data.get(&amp;page_base).cloned().unwrap_or_default() — i.e. Some(empty_vec) on the verifier side because the verifier builds init_page_data from the ELF alone. The init_values field is never actually consulted for private-input AIRs (only is_private_input matters for the AIR shape in global_memory_air).

Suggested fix

Either accept Option&lt;Vec&lt;u8&gt;&gt; in with_private_input and let the verifier pass None, or update the comment to clarify that the AIR never consults init_values for private-input pages.

Reviewer Lanes

Lane Model Prompt Status Findings
glm openrouter/z-ai/glm-5.2 general error: opencode failed (provider/auth/runtime error) and no findings were submitted 0
kimi openrouter/moonshotai/kimi-k2.7-code general error: opencode failed (provider/auth/runtime error) and no findings were submitted 0
minimax minimax/MiniMax-M3 general success 5
moonmath zro/minimax-m3 general error: agentic lane timed out after 1800s 0
nemotron openrouter/nvidia/nemotron-3-ultra-550b-a55b general error: opencode failed (provider/auth/runtime error) and no findings were submitted 0

Verification Lanes

Lane Model Status Confirmed Rejected Uncertain
deepseek-verifier openrouter/deepseek/deepseek-v4-pro error: opencode failed (provider/auth/runtime error) and no verifications were submitted 0 0 0

Native Codex and Claude reviews run separately and post their own comments. They are not included in this structured provenance report.

Raw lane outputs, candidates, final issues, and model metrics are uploaded as workflow artifacts.

…erde derives, fix stale docs

- Extract private_input_page_count / is_private_input_page /
  private_input_page_bases / max_private_input_pages into tables::page as the
  single source of truth; the monolithic trace builder, monolithic verifier
  bound, continuation prover, and continuation verifier all previously
  re-derived the same wire-format math independently and could drift.
- Add PRIVATE_INPUT_LENGTH_PREFIX_BYTES to executor::vm::memory next to the
  wire-format writer and use it everywhere instead of a bare 4.
- Remove the now-unused serde derives from InitClaim/FiniClaim/CellBoundary:
  nothing serializes them since the bundle dropped EpochProof.boundary, and
  keeping them off makes re-introducing the value leak a compile error.
- Make the verifier-side private-page config explicitly data-free
  (include_private_genesis flag) instead of a dead init_page_data lookup.
- Reject a non-page-aligned touched_page_bases entry as a malformed bundle
  (new Error::MalformedContinuationBundle) instead of Ok(None), matching the
  count bound's Err semantics for structural validation of untrusted fields.
- Fix bin/cli/README.md, which still claimed the continuation bundle ships the
  raw private input bytes.
MauroToscano and others added 3 commits July 3, 2026 15:19
Review fixes for #758: dedupe private-input page math, drop leaky serde derives, fix stale CLI README
@MauroToscano MauroToscano enabled auto-merge July 3, 2026 20:29
@MauroToscano MauroToscano added this pull request to the merge queue Jul 3, 2026
Merged via the queue into main with commit 2aafbc5 Jul 3, 2026
19 checks passed
@MauroToscano MauroToscano deleted the fix-continuation-private-input-main branch July 3, 2026 20:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants