exo_proofs/envelope.rs
1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! Versioned proof statement registry and proof envelope.
18//!
19//! Lane VCG-001a (see `GAP-REGISTRY.md` "VCG-001 - Production ZK Proof Backend
20//! Absent", remediation track: "Define a versioned proof statement registry
21//! covering governance compliance, DAG inclusion, execution receipt, model
22//! inference, and compatibility-only pedagogical proofs.").
23//!
24//! [`ProofEnvelope`] binds together everything a verifier needs to know
25//! *about* a proof before it ever looks at proof bytes: which kind of
26//! statement is being proven ([`ProofStatementKind`]), which backend
27//! produced it ([`BackendId`]), an envelope format version, the public
28//! inputs, commitment roots, the verifier key or image id, and a domain
29//! separator binding the proof to its intended context.
30//!
31//! ## Fail-closed backend registry
32//!
33//! [`BackendId`] is a closed set of *known* backends plus an
34//! [`BackendId::Unknown`] catch-all for any numeric id that does not match a
35//! known variant. [`ProofEnvelope::validate_backend`] refuses
36//! `BackendId::Unknown` unconditionally — an envelope naming an
37//! unrecognized or future backend id can never validate. This is the same
38//! "never stub, fail loudly" doctrine that governs the rest of this crate
39//! (see the crate-root docs and [`crate::guard_unaudited`]).
40//!
41//! ## Unaudited backend gating
42//!
43//! Two backends are currently registered (see [`default_registry`]): the
44//! blake3 "stand-in" cryptography ([`BackendId::UnauditedBlake3Standin`],
45//! marked [`AuditStatus::Pedagogical`]) described in the crate-root docs, and
46//! the RISC Zero integration seam ([`BackendId::RiscZero`], marked
47//! [`AuditStatus::PendingExternalReview`] — wired but not yet cryptographically
48//! reviewed). Neither is exempt from the crate's unaudited-refusal doctrine:
49//! [`ProofEnvelope::verify`] refuses the blake3 stand-in with
50//! [`crate::error::ProofError::UnauditedImplementation`] unless the opt-in
51//! `unaudited-pedagogical-proofs` Cargo feature is enabled (mirroring the
52//! [`crate::guard_unaudited`] pattern used by `snark`, `stark`, and `zkml`),
53//! and fails the RISC Zero seam closed unconditionally until its external
54//! review lands.
55//!
56//! ## Wire format
57//!
58//! Per the crate's canonical-CBOR-not-JSON convention (see
59//! `src/verifier.rs`), [`ProofEnvelope`] is (de)serialized with
60//! [`ciborium`]'s canonical CBOR encoding, never JSON.
61
62use exo_core::types::Hash256;
63use serde::{Deserialize, Serialize};
64
65use crate::error::{ProofError, Result};
66
67// ---------------------------------------------------------------------------
68// ProofStatementKind
69// ---------------------------------------------------------------------------
70
71/// The kind of statement a [`ProofEnvelope`] attests to.
72///
73/// This is the versioned proof statement registry named in the VCG-001
74/// remediation track. Adding a new kind is additive (append a variant);
75/// removing or renumbering an existing kind is a breaking wire-format
76/// change and must not be done silently.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78pub enum ProofStatementKind {
79 /// The statement attests to compliance with a governance rule or
80 /// constitutional constraint.
81 GovernanceCompliance,
82 /// The statement attests that a value is included in a DAG at a given
83 /// position/commitment.
84 DagInclusion,
85 /// The statement attests to the authenticity of an execution receipt
86 /// (e.g. a computation actually ran and produced a given result).
87 ExecutionReceipt,
88 /// The statement attests to properties of a model inference (e.g. that
89 /// a committed model produced a committed output from a committed
90 /// input).
91 ModelInference,
92 /// The statement attests only to structural/shape compatibility of a
93 /// pedagogical proof — not a production cryptographic claim. Used by
94 /// the unaudited blake3 stand-in backend.
95 PedagogicalCompatibility,
96}
97
98// ---------------------------------------------------------------------------
99// BackendId
100// ---------------------------------------------------------------------------
101
102/// Named alias for the crate's own unaudited pedagogical stand-in backend,
103/// [`BackendId::UnauditedBlake3Standin`].
104///
105/// Exposed as a named constant (rather than only the enum variant) so that
106/// callers and tests can construct envelopes that name this backend through
107/// a stable public path.
108pub const UNAUDITED_BLAKE3_STANDIN_BACKEND_ID: BackendId = BackendId::UnauditedBlake3Standin;
109
110/// Identifies which proof backend produced (and must verify) a
111/// [`ProofEnvelope`].
112///
113/// This is a closed registry: [`BackendId::Unknown`] is the only variant
114/// that accepts arbitrary numeric ids, and it is the *only* variant that
115/// [`ProofEnvelope::validate_backend`] ever refuses. Every other variant is
116/// a backend this crate knows about by construction. Registering a new
117/// backend means adding a new named variant here — not widening what
118/// `Unknown` accepts.
119///
120/// Ratified decision D1 (2026-07-02, see `GAP-REGISTRY.md` VCG-001) selects
121/// RISC Zero as the production backend family. Lane VCG-001b registers the
122/// [`BackendId::RiscZero`] variant and a fail-closed verifier *seam* for it
123/// (see [`RiscZeroReceiptVerifier`] and [`ProofEnvelope::verify`]) — but does
124/// **not** vendor the `risc0-zkvm` crate or wire an actual cryptographic
125/// verify call. Per D1, the audited risc0 proving/verification toolchain is
126/// itself a reviewed-dependency supply-chain event that "carries the
127/// external audit budget"; adding it is out of scope until that review
128/// happens. Until then, [`BackendId::RiscZero`] is registered as
129/// [`AuditStatus::PendingExternalReview`] and always fails closed at
130/// verify-time.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132pub enum BackendId {
133 /// The crate's existing unaudited blake3 "stand-in" cryptography
134 /// (`circuit.rs` / `snark.rs` / `stark.rs` / `zkml.rs`). Gated behind
135 /// the `unaudited-pedagogical-proofs` feature at verification time.
136 UnauditedBlake3Standin,
137 /// RISC Zero zkVM execution-receipt backend (ratified decision D1,
138 /// 2026-07-02, `GAP-REGISTRY.md` VCG-001). This variant exists so
139 /// envelopes can *name* the selected production backend family — it is
140 /// the integration seam, not a working verifier. No external
141 /// cryptographic review of the risc0 verify path has happened yet, so
142 /// this backend is registered under [`AuditStatus::PendingExternalReview`]
143 /// (never [`AuditStatus::ProductionReviewed`]) and
144 /// [`ProofEnvelope::verify`] always fails closed for it. See
145 /// [`RiscZeroReceiptVerifier`] for exactly where the audited risc0
146 /// verify call plugs in once review lands.
147 RiscZero,
148 /// An unrecognized or future backend id. Always fails closed in
149 /// [`ProofEnvelope::validate_backend`] — this crate refuses to treat an
150 /// id it does not recognize as valid, regardless of the numeric value.
151 Unknown(u32),
152}
153
154impl BackendId {
155 /// Returns `true` for any of the crate's registered *known* backend
156 /// variants (i.e. not [`BackendId::Unknown`]).
157 #[must_use]
158 pub const fn is_registered(&self) -> bool {
159 !matches!(self, BackendId::Unknown(_))
160 }
161}
162
163// ---------------------------------------------------------------------------
164// AuditStatus / backend descriptor registry
165// ---------------------------------------------------------------------------
166
167/// The audit/review status carried by each entry in [`default_registry`].
168///
169/// This is a minimal accessor, not a verification mechanism: it exists so
170/// tests (and future callers) can ask "does a production-reviewed backend
171/// exist yet?" without hardcoding backend ids. Today every registered
172/// backend is either [`AuditStatus::Pedagogical`] or
173/// [`AuditStatus::PendingExternalReview`] — no [`AuditStatus::ProductionReviewed`]
174/// entry exists yet. Registering one is itself the claim that external
175/// cryptographic review has happened; see `tests/refusal.rs`'s standing red
176/// and `tests/riscz_verifier_scaffold.rs`'s anti-overclaim regression locks,
177/// both of which must keep failing/holding until that review actually lands.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum AuditStatus {
180 /// Structural/pedagogical stand-in only — not a production trust claim.
181 /// Gated behind the `unaudited-pedagogical-proofs` feature at
182 /// verification time (see [`crate::guard_unaudited`]).
183 Pedagogical,
184 /// Integration wired but **not yet cryptographically reviewed**; not a
185 /// production trust claim. Used for backends (e.g.
186 /// [`BackendId::RiscZero`]) whose envelope shape and verifier *seam*
187 /// exist in-tree, but whose actual verify path has not undergone
188 /// external cryptographic review. [`ProofEnvelope::verify`] always fails
189 /// closed for backends in this status — the seam exists so that
190 /// wiring in the audited verifier later is a small, localized change,
191 /// not a claim that it is safe to trust today.
192 PendingExternalReview,
193 /// A production backend that has undergone cryptographic review and
194 /// carries its own audit evidence. Exempt from the pedagogical
195 /// unaudited-refusal gate. No backend holds this status yet — it is
196 /// introduced here only so the registry has somewhere to record one
197 /// once external review of a production backend actually lands.
198 ProductionReviewed,
199}
200
201/// A single entry in [`default_registry`]: a known [`BackendId`] paired with
202/// its [`AuditStatus`].
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub struct BackendDescriptor {
205 /// The backend this descriptor describes.
206 pub backend_id: BackendId,
207 /// Whether this backend is pedagogical or has been production-reviewed.
208 pub audit_status: AuditStatus,
209}
210
211/// Returns the crate's default backend registry as descriptors.
212///
213/// This is the minimal audit-status accessor named in the VCG-001a
214/// hardening pass: it lets callers (and tests) inspect what backends are
215/// registered and whether any of them are [`AuditStatus::ProductionReviewed`]
216/// without reaching into [`ProofEnvelope::verify`] internals. Today this
217/// returns two entries:
218///
219/// - the unaudited blake3 stand-in, marked [`AuditStatus::Pedagogical`];
220/// - the RISC Zero integration seam (VCG-001b, ratified decision D1),
221/// marked [`AuditStatus::PendingExternalReview`] — wired but not yet
222/// cryptographically reviewed.
223///
224/// Neither entry is [`AuditStatus::ProductionReviewed`]: no backend has
225/// undergone external cryptographic review yet. See
226/// `tests/riscz_verifier_scaffold.rs`'s anti-overclaim regression locks.
227#[must_use]
228pub fn default_registry() -> Vec<BackendDescriptor> {
229 vec![
230 BackendDescriptor {
231 backend_id: BackendId::UnauditedBlake3Standin,
232 audit_status: AuditStatus::Pedagogical,
233 },
234 BackendDescriptor {
235 backend_id: BackendId::RiscZero,
236 audit_status: AuditStatus::PendingExternalReview,
237 },
238 ]
239}
240
241// ---------------------------------------------------------------------------
242// RiscZero verifier-integration seam (VCG-001b)
243// ---------------------------------------------------------------------------
244
245/// Integration seam for the audited RISC Zero receipt verifier.
246///
247/// Ratified decision D1 (2026-07-02, `GAP-REGISTRY.md` VCG-001) selects RISC
248/// Zero as the production backend family, with Groth16 wrapping for receipt
249/// compression, server-side-only proving, and a verifier that "stays small,
250/// in-workspace, and pinned, and carries the external audit budget." That
251/// audit has not happened yet, and the `risc0-zkvm` crate itself is not a
252/// workspace dependency — vendoring it is precisely the reviewed-dependency
253/// supply-chain event D1 defers until review lands.
254///
255/// This trait exists so that event has exactly one, small, localized
256/// plug-in point: **this is where the audited risc0 `Receipt::verify` (or
257/// equivalent image-id-bound verification) call goes.** Implementing this
258/// trait against the real `risc0-zkvm` verifier — and swapping
259/// [`ProofEnvelope::verify`]'s `BackendId::RiscZero` arm to call it instead
260/// of failing closed — is the entire VCG-001c (or later) green-stage change.
261/// Until then, [`FailClosedRiscZeroVerifier`] is the only implementation,
262/// and it never returns `Ok(true)`.
263pub trait RiscZeroReceiptVerifier {
264 /// Verifies a RISC Zero execution receipt against the given image id (or
265 /// verifier key bytes) and the envelope's journal-binding digest.
266 ///
267 /// `receipt_bytes` are the serialized risc0 `Receipt`/proof bytes the
268 /// audited verifier will decode and check (objective O-1.2): an audited
269 /// implementation deserializes them into a risc0 `Receipt` and runs its
270 /// verify path. The [`FailClosedRiscZeroVerifier`] default ignores them and
271 /// refuses unconditionally — threading the bytes here now means wiring the
272 /// audited verifier later is a small, localized change rather than a new
273 /// signature break.
274 ///
275 /// `journal_digest` is [`ProofEnvelope::binding_digest`] — the canonical
276 /// digest over the full envelope context (`statement_kind`,
277 /// `commitment_roots`, `domain_separator`, `public_inputs`, ...). An audited
278 /// implementation MUST check both that the receipt verifies against the
279 /// image id AND that the receipt's journal commits to exactly this digest
280 /// (objective O-1.1), so a receipt proven for one context can never be
281 /// replayed under another.
282 ///
283 /// # Errors
284 ///
285 /// Real implementations return `Err` for any receipt that does not
286 /// verify. The [`FailClosedRiscZeroVerifier`] default always returns
287 /// `Err` — see its docs.
288 fn verify_receipt(
289 &self,
290 receipt_bytes: &[u8],
291 image_id_or_verifier_key: &[u8],
292 journal_digest: &Hash256,
293 ) -> Result<bool>;
294}
295
296/// Fail-closed default [`RiscZeroReceiptVerifier`]: refuses every receipt.
297///
298/// This is the only [`RiscZeroReceiptVerifier`] implementation in this
299/// crate today. It exists so [`ProofEnvelope::verify`] has a concrete seam
300/// to call for `BackendId::RiscZero` rather than inlining the refusal —
301/// swapping this type out for one backed by the audited `risc0-zkvm`
302/// verifier (once external cryptographic review lands) is the intended,
303/// localized future change. It must never be changed to return `Ok(true)`
304/// without that review having actually happened; doing so would be exactly
305/// the false soundness claim ratified decision D1 and the VCG-001b
306/// anti-overclaim regression locks (`tests/riscz_verifier_scaffold.rs`)
307/// exist to prevent.
308#[derive(Debug, Clone, Copy, Default)]
309pub struct FailClosedRiscZeroVerifier;
310
311impl RiscZeroReceiptVerifier for FailClosedRiscZeroVerifier {
312 fn verify_receipt(
313 &self,
314 _receipt_bytes: &[u8],
315 _image_id_or_verifier_key: &[u8],
316 _journal_digest: &Hash256,
317 ) -> Result<bool> {
318 Err(ProofError::VerificationFailed(
319 "BackendId::RiscZero verifier is a pending-review scaffold: no external \
320 cryptographic review of the risc0 verify path has landed yet, so this refuses \
321 closed rather than trust an unaudited verifier. See \
322 RiscZeroReceiptVerifier for exactly where the audited risc0 verify call plugs \
323 in once review lands."
324 .to_string(),
325 ))
326 }
327}
328
329// ---------------------------------------------------------------------------
330// ProofEnvelope
331// ---------------------------------------------------------------------------
332
333/// A versioned envelope binding everything a verifier needs to know about a
334/// proof, independent of the proof bytes themselves.
335///
336/// Field order mirrors the VCG-001 "Next red test" bullet: "statement kind,
337/// backend id, version, public inputs, commitment roots, verifier key or
338/// image id, and domain separator."
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340pub struct ProofEnvelope {
341 /// Which kind of statement this envelope attests to.
342 pub statement_kind: ProofStatementKind,
343 /// Which backend produced (and must verify) the wrapped proof.
344 pub backend_id: BackendId,
345 /// Envelope format version. Independent of the crate/package version;
346 /// bump when the envelope's own field shape changes.
347 pub version: u32,
348 /// Public inputs to the statement, as opaque byte strings. Semantics
349 /// are defined per [`ProofStatementKind`] / backend.
350 pub public_inputs: Vec<Vec<u8>>,
351 /// Commitment roots (e.g. DAG roots, state roots) the statement is
352 /// anchored to.
353 pub commitment_roots: Vec<Hash256>,
354 /// The verifier key (SNARK/STARK) or image id (zkVM-style backends)
355 /// needed to verify the wrapped proof, as opaque bytes.
356 pub verifier_key_or_image_id: Vec<u8>,
357 /// Domain separator binding this envelope to its intended context, so
358 /// a valid proof for one domain cannot be replayed as valid for
359 /// another.
360 pub domain_separator: Vec<u8>,
361}
362
363/// Domain-separation tag for [`ProofEnvelope::binding_digest`]. Bump only if
364/// the binding-tuple shape changes.
365const ENVELOPE_BINDING_DOMAIN: &str = "exo-proofs:envelope-binding:v1";
366
367/// The canonical, field-named binding tuple hashed by
368/// [`ProofEnvelope::binding_digest`].
369///
370/// Serialized (canonical CBOR) instead of the [`ProofEnvelope`] struct itself
371/// so the digest pre-image is an explicit, domain-tagged shape, and so
372/// `backend_id`/`version` are bound alongside the statement context.
373#[derive(Serialize)]
374struct EnvelopeBinding<'a> {
375 domain: &'a str,
376 statement_kind: &'a ProofStatementKind,
377 backend_id: &'a BackendId,
378 version: u32,
379 public_inputs: &'a [Vec<u8>],
380 commitment_roots: &'a [Hash256],
381 verifier_key_or_image_id: &'a [u8],
382 domain_separator: &'a [u8],
383}
384
385impl ProofEnvelope {
386 /// Validates that [`Self::backend_id`] names a known, registered
387 /// backend.
388 ///
389 /// Fails closed: any [`BackendId::Unknown`] value — including ids that
390 /// happen to coincide with a future backend not yet registered here —
391 /// is refused. This must be called (directly, or transitively via
392 /// [`Self::verify`]) before any proof bytes wrapped by this envelope
393 /// are trusted.
394 pub fn validate_backend(&self) -> Result<()> {
395 if self.backend_id.is_registered() {
396 Ok(())
397 } else {
398 Err(ProofError::InvalidProofFormat(format!(
399 "proof envelope names unknown/unregistered backend id: {:?}",
400 self.backend_id
401 )))
402 }
403 }
404
405 /// Canonical journal-binding digest for a RISC Zero receipt (objective
406 /// O-1.1, 2026-07-04 ratification slate).
407 ///
408 /// Folds the ENTIRE envelope context — `statement_kind`, `backend_id`,
409 /// `version`, `public_inputs`, `commitment_roots`,
410 /// `verifier_key_or_image_id`, and `domain_separator` — into a single
411 /// BLAKE3 digest over canonical CBOR, under a fixed domain tag. This is the
412 /// value a RISC Zero receipt's journal must commit to: the audited
413 /// [`RiscZeroReceiptVerifier`] checks the receipt verifies against the image
414 /// id AND that its journal equals this digest, so a receipt proven for one
415 /// (statement, roots, domain) context can never be replayed as valid under
416 /// another. Before O-1.1 the seam received only the image id and the raw
417 /// public inputs, dropping `statement_kind`, `commitment_roots`, and
418 /// `domain_separator` — an unbound statement.
419 ///
420 /// # Errors
421 ///
422 /// Returns [`ProofError::InvalidProofFormat`] if canonical CBOR encoding of
423 /// the binding tuple fails.
424 /// Canonical CBOR bytes hashed by [`Self::binding_digest`].
425 ///
426 /// A RISC Zero guest must commit exactly these bytes so
427 /// `blake3(journal.bytes)` equals the envelope binding digest.
428 pub fn binding_payload(&self) -> Result<Vec<u8>> {
429 let binding = EnvelopeBinding {
430 domain: ENVELOPE_BINDING_DOMAIN,
431 statement_kind: &self.statement_kind,
432 backend_id: &self.backend_id,
433 version: self.version,
434 public_inputs: &self.public_inputs,
435 commitment_roots: &self.commitment_roots,
436 verifier_key_or_image_id: &self.verifier_key_or_image_id,
437 domain_separator: &self.domain_separator,
438 };
439 let mut encoded = Vec::new();
440 ciborium::into_writer(&binding, &mut encoded).map_err(|err| {
441 ProofError::InvalidProofFormat(format!(
442 "failed to canonical-CBOR encode envelope binding for digest: {err}"
443 ))
444 })?;
445 Ok(encoded)
446 }
447
448 pub fn binding_digest(&self) -> Result<Hash256> {
449 Ok(Hash256(*blake3::hash(&self.binding_payload()?).as_bytes()))
450 }
451
452 /// Runs the RISC Zero seam against `verifier`, threading the serialized
453 /// `receipt_bytes` (objective O-1.2) and binding the full envelope context
454 /// via [`Self::binding_digest`] (objective O-1.1). Production
455 /// [`Self::verify`] passes [`FailClosedRiscZeroVerifier`]; tests inject a
456 /// spy to assert the receipt bytes and the binding digest actually reach
457 /// the verifier.
458 fn verify_riscz(
459 &self,
460 receipt_bytes: &[u8],
461 verifier: &dyn RiscZeroReceiptVerifier,
462 ) -> Result<bool> {
463 let journal_digest = self.binding_digest()?;
464 verifier.verify_receipt(
465 receipt_bytes,
466 &self.verifier_key_or_image_id,
467 &journal_digest,
468 )
469 }
470
471 /// Verifies the envelope's named backend is both registered and, if
472 /// unaudited, explicitly opted into.
473 ///
474 /// `receipt_bytes` are the serialized risc0 `Receipt`/proof bytes to check
475 /// (objective O-1.2). They are threaded down to the
476 /// [`RiscZeroReceiptVerifier`] seam for the [`BackendId::RiscZero`] arm so
477 /// the future audited verifier can decode and check them; the
478 /// [`FailClosedRiscZeroVerifier`] default ignores them and still refuses.
479 /// The `UnauditedBlake3Standin` and `Unknown` arms never inspect
480 /// `receipt_bytes` — for them it is simply an unused parameter — so passing
481 /// any value (including `&[]`) cannot change their fail-closed outcomes.
482 ///
483 /// No backend has a working, externally-reviewed verifier wired yet:
484 /// the unaudited blake3 stand-in is feature-gated and still a fail-closed
485 /// stub even when opted into (VCG-001a), and the RISC Zero seam
486 /// (VCG-001b, ratified decision D1) is wired but pending external
487 /// cryptographic review (see [`RiscZeroReceiptVerifier`]). Fail-closed:
488 /// **every** backend currently registered returns a typed error here —
489 /// there is no verifier wired for any backend at this stage. This is a
490 /// deliberate success-shaped surface trap avoidance: `verify()` must
491 /// never report `Ok(true)` unless it actually verified something.
492 ///
493 /// Behavior:
494 /// - Unknown/unregistered backend id → `Err(ProofError::InvalidProofFormat)`
495 /// (fail-closed registry, checked first via [`Self::validate_backend`]).
496 /// - [`UNAUDITED_BLAKE3_STANDIN_BACKEND_ID`] → first refuses with
497 /// `Err(ProofError::UnauditedImplementation)` unless the
498 /// `unaudited-pedagogical-proofs` feature is enabled (mirroring
499 /// [`crate::guard_unaudited`]); if that guard passes, still refuses
500 /// with `Err(ProofError::VerificationFailed)` because no verifier is
501 /// wired for this backend yet — construction/wrapping of an envelope
502 /// for this backend is feature-gated as above, but *verification* is
503 /// not implemented at all.
504 /// - [`BackendId::RiscZero`] → always refuses with
505 /// `Err(ProofError::VerificationFailed)` via
506 /// [`FailClosedRiscZeroVerifier`], independent of the
507 /// `unaudited-pedagogical-proofs` feature flag (that flag only gates
508 /// this crate's own blake3 stand-in, not the RiscZero seam). The
509 /// refusal reason names pending external review, not a missing
510 /// feature opt-in.
511 pub fn verify(&self, receipt_bytes: &[u8]) -> Result<bool> {
512 self.validate_backend()?;
513
514 match self.backend_id {
515 BackendId::UnauditedBlake3Standin => {
516 crate::guard_unaudited("envelope::ProofEnvelope::verify")?;
517 // Even once opted into the unaudited pedagogical stand-in,
518 // no verifier is wired for it yet at this stage: this lane
519 // (VCG-001a) only establishes the envelope/registry shape.
520 // Returning `Ok(true)` here would be a success-shaped
521 // surface that verifies nothing. Fail closed instead.
522 Err(ProofError::VerificationFailed(format!(
523 "no verifier is wired for backend {:?} yet; \
524 ProofEnvelope::verify is a fail-closed stub until \
525 VCG-001b lands real per-backend verification",
526 self.backend_id
527 )))
528 }
529 BackendId::RiscZero => {
530 #[cfg(feature = "risc0-verifier")]
531 {
532 self.verify_riscz(receipt_bytes, &crate::riscz::Risc0Groth16Verifier)
533 }
534 #[cfg(not(feature = "risc0-verifier"))]
535 {
536 let _ = receipt_bytes;
537 Err(ProofError::VerificationFailed(
538 "risc0-verifier feature is disabled; cannot verify RISC Zero receipts"
539 .into(),
540 ))
541 }
542 }
543 BackendId::Unknown(_) => unreachable!(
544 "validate_backend() above must have already refused an unregistered backend id"
545 ),
546 }
547 }
548}
549
550/// Public inputs a CGR RISC Zero guest must reproduce from combinator replay.
551#[derive(Debug, Clone, PartialEq, Eq)]
552pub struct CgrZkPublicInputs {
553 pub combinator_hash: Hash256,
554 pub input_hash: Hash256,
555 pub output_hash: Hash256,
556 pub trace_hash: Hash256,
557}
558
559impl CgrZkPublicInputs {
560 #[must_use]
561 pub fn to_public_inputs(&self) -> Vec<Vec<u8>> {
562 vec![
563 self.combinator_hash.as_bytes().to_vec(),
564 self.input_hash.as_bytes().to_vec(),
565 self.output_hash.as_bytes().to_vec(),
566 self.trace_hash.as_bytes().to_vec(),
567 ]
568 }
569
570 pub fn from_public_inputs(inputs: &[Vec<u8>]) -> Result<Self> {
571 if inputs.len() != 4 {
572 return Err(ProofError::InvalidProofFormat(format!(
573 "CGR zk public inputs must be 4 hashes, got {}",
574 inputs.len()
575 )));
576 }
577 Ok(Self {
578 combinator_hash: hash_from_bytes(&inputs[0], "combinator_hash")?,
579 input_hash: hash_from_bytes(&inputs[1], "input_hash")?,
580 output_hash: hash_from_bytes(&inputs[2], "output_hash")?,
581 trace_hash: hash_from_bytes(&inputs[3], "trace_hash")?,
582 })
583 }
584}
585
586fn hash_from_bytes(bytes: &[u8], name: &str) -> Result<Hash256> {
587 let arr: [u8; 32] = bytes
588 .try_into()
589 .map_err(|_| ProofError::InvalidProofFormat(format!("{name} must be exactly 32 bytes")))?;
590 Ok(Hash256(arr))
591}
592
593// ---------------------------------------------------------------------------
594// Tests
595// ---------------------------------------------------------------------------
596
597#[cfg(test)]
598mod canonical_encoding_contract_tests {
599 #[test]
600 fn envelope_module_uses_canonical_cbor_not_json() {
601 // Mirrors verifier.rs's `verify_any_uses_canonical_cbor_not_json`
602 // source-grep guard: the envelope module itself must never reach
603 // for JSON as a wire format for proof-adjacent data.
604 let source = include_str!("envelope.rs");
605 let production = source
606 .split("// ---------------------------------------------------------------------------\n// Tests")
607 .next()
608 .expect("production section exists");
609
610 assert!(
611 !production.contains("serde_json"),
612 "envelope module must not use serde_json anywhere in its production code path"
613 );
614 }
615
616 #[test]
617 fn backend_id_unknown_variant_is_the_only_unregistered_case() {
618 use super::BackendId;
619
620 assert!(BackendId::UnauditedBlake3Standin.is_registered());
621 assert!(!BackendId::Unknown(0).is_registered());
622 assert!(!BackendId::Unknown(u32::MAX).is_registered());
623 }
624}
625
626#[cfg(test)]
627#[allow(clippy::expect_used, clippy::unwrap_used)]
628mod riscz_seam_binding_unit {
629 //! O-1.1 (2026-07-04 slate): prove the RiscZero seam threads the envelope's
630 //! binding digest to the verifier, so the audited verifier can bind the
631 //! receipt to the full statement context rather than certify an unbound
632 //! statement.
633 use std::cell::RefCell;
634
635 use super::*;
636
637 /// Test-only verifier that records the journal digest it is handed, then
638 /// still refuses — a spy must never manufacture a success-shaped result.
639 struct SpyVerifier {
640 seen_digest: RefCell<Option<Hash256>>,
641 }
642
643 impl RiscZeroReceiptVerifier for SpyVerifier {
644 fn verify_receipt(
645 &self,
646 _receipt_bytes: &[u8],
647 _image_id_or_verifier_key: &[u8],
648 journal_digest: &Hash256,
649 ) -> Result<bool> {
650 *self.seen_digest.borrow_mut() = Some(*journal_digest);
651 Err(ProofError::VerificationFailed(
652 "spy verifier records the digest only; it never manufactures success".to_string(),
653 ))
654 }
655 }
656
657 fn riscz_env() -> ProofEnvelope {
658 ProofEnvelope {
659 statement_kind: ProofStatementKind::ExecutionReceipt,
660 backend_id: BackendId::RiscZero,
661 version: 1,
662 public_inputs: vec![b"pi".to_vec()],
663 commitment_roots: vec![Hash256([3u8; 32])],
664 verifier_key_or_image_id: b"img".to_vec(),
665 domain_separator: b"dom".to_vec(),
666 }
667 }
668
669 #[test]
670 fn seam_passes_binding_digest_to_verifier() {
671 let env = riscz_env();
672 let spy = SpyVerifier {
673 seen_digest: RefCell::new(None),
674 };
675 // Refuses (fail-closed spy), but must have received the digest.
676 let _ = env.verify_riscz(b"some-bytes", &spy);
677 assert_eq!(
678 *spy.seen_digest.borrow(),
679 Some(env.binding_digest().expect("binding digest")),
680 "the seam must hand the verifier exactly the envelope's binding digest"
681 );
682 }
683
684 #[test]
685 fn seam_binding_digest_reflects_domain_separator_change() {
686 let mut env = riscz_env();
687 let spy1 = SpyVerifier {
688 seen_digest: RefCell::new(None),
689 };
690 let _ = env.verify_riscz(b"receipt-bytes-a", &spy1);
691
692 env.domain_separator = b"dom-CHANGED".to_vec();
693 let spy2 = SpyVerifier {
694 seen_digest: RefCell::new(None),
695 };
696 let _ = env.verify_riscz(b"receipt-bytes-a", &spy2);
697
698 assert!(
699 spy1.seen_digest.borrow().is_some(),
700 "spy1 verifier must have been called"
701 );
702 assert_ne!(
703 *spy1.seen_digest.borrow(),
704 *spy2.seen_digest.borrow(),
705 "the digest reaching the verifier must change with domain_separator"
706 );
707 }
708
709 /// O-1.2(A): test-only verifier that records the `receipt_bytes` it is
710 /// handed, then STILL refuses — mirrors [`SpyVerifier`]'s discipline: a spy
711 /// must never manufacture a success-shaped result.
712 struct ReceiptBytesSpyVerifier {
713 seen_receipt_bytes: RefCell<Option<Vec<u8>>>,
714 }
715
716 impl RiscZeroReceiptVerifier for ReceiptBytesSpyVerifier {
717 fn verify_receipt(
718 &self,
719 receipt_bytes: &[u8],
720 _image_id_or_verifier_key: &[u8],
721 _journal_digest: &Hash256,
722 ) -> Result<bool> {
723 *self.seen_receipt_bytes.borrow_mut() = Some(receipt_bytes.to_vec());
724 Err(ProofError::VerificationFailed(
725 "spy verifier records the receipt bytes only; it never manufactures success"
726 .to_string(),
727 ))
728 }
729 }
730
731 #[test]
732 fn verify_riscz_passes_receipt_bytes_to_seam() {
733 let env = riscz_env();
734 let spy = ReceiptBytesSpyVerifier {
735 seen_receipt_bytes: RefCell::new(None),
736 };
737 // Refuses (fail-closed spy), but must have received the exact bytes.
738 let _ = env.verify_riscz(b"distinct-receipt-bytes", &spy);
739 assert_eq!(
740 spy.seen_receipt_bytes.borrow().as_deref(),
741 Some(b"distinct-receipt-bytes".as_slice()),
742 "the seam must hand the verifier exactly the receipt bytes it was called with"
743 );
744 }
745
746 #[test]
747 fn verify_riscz_receipt_bytes_reflect_the_argument_change() {
748 // Proves live plumbing, not a hardcoded constant: changing the bytes
749 // passed in changes what the spy records.
750 let env = riscz_env();
751
752 let spy1 = ReceiptBytesSpyVerifier {
753 seen_receipt_bytes: RefCell::new(None),
754 };
755 let _ = env.verify_riscz(b"receipt-bytes-one", &spy1);
756
757 let spy2 = ReceiptBytesSpyVerifier {
758 seen_receipt_bytes: RefCell::new(None),
759 };
760 let _ = env.verify_riscz(b"receipt-bytes-TWO", &spy2);
761
762 assert!(
763 spy1.seen_receipt_bytes.borrow().is_some(),
764 "spy1 verifier must have been called"
765 );
766 assert_ne!(
767 *spy1.seen_receipt_bytes.borrow(),
768 *spy2.seen_receipt_bytes.borrow(),
769 "the receipt bytes reaching the verifier must change with the argument passed"
770 );
771 }
772}