From 2ad806de1e225a8ace9059af1cf9f6430341e960 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 23 May 2026 19:52:01 -0600 Subject: [PATCH 1/2] Reject trailing Halo2 proof bytes --- src/delegation/prove.rs | 14 ++++----- src/prove_error.rs | 65 +++++++++++++++++++++++++++++++++++++-- src/share_reveal/prove.rs | 14 ++++----- src/vote_proof/prove.rs | 14 ++++----- 4 files changed, 81 insertions(+), 26 deletions(-) diff --git a/src/delegation/prove.rs b/src/delegation/prove.rs index df2fcdd9..53eec2ba 100644 --- a/src/delegation/prove.rs +++ b/src/delegation/prove.rs @@ -7,13 +7,15 @@ use std::{string::String, vec::Vec}; use halo2_proofs::{ pasta::EqAffine, - plonk::{self, keygen_pk, keygen_vk, verify_proof, SingleVerifier}, + plonk::{self, keygen_pk, keygen_vk}, poly::commitment::Params, - transcript::{Blake2bRead, Challenge255}, }; use super::circuit::{Circuit, Instance, K}; -use crate::{prove_error::create_proof_bytes, ProveError}; +use crate::{ + prove_error::{create_proof_bytes, verify_proof_bytes}, + ProveError, +}; // ================================================================ // Params / key generation @@ -156,11 +158,7 @@ pub fn verify_delegation_proof(proof: &[u8], instance: &Instance) -> Result<(), let public_inputs = instance.to_halo2_instance(); - let strategy = SingleVerifier::new(params); - let mut transcript = Blake2bRead::<_, EqAffine, Challenge255<_>>::init(proof); - - verify_proof(params, vk, strategy, &[&[&public_inputs]], &mut transcript) - .map_err(|e| format!("delegation verification failed: {:?}", e)) + verify_proof_bytes("delegation", params, vk, proof, &public_inputs) } #[cfg(test)] diff --git a/src/prove_error.rs b/src/prove_error.rs index 315001f0..6b795268 100644 --- a/src/prove_error.rs +++ b/src/prove_error.rs @@ -1,8 +1,8 @@ use halo2_proofs::{ pasta::EqAffine, - plonk::{self, create_proof}, + plonk::{self, create_proof, verify_proof, SingleVerifier}, poly::commitment::Params, - transcript::{Blake2bWrite, Challenge255}, + transcript::{Blake2bRead, Blake2bWrite, Challenge255}, }; use pasta_curves::vesta; use rand::rngs::OsRng; @@ -29,6 +29,30 @@ where Ok(transcript.finalize()) } +pub(crate) fn verify_proof_bytes( + label: &str, + params: &Params, + vk: &plonk::VerifyingKey, + proof: &[u8], + public_inputs: &[vesta::Scalar], +) -> Result<(), String> { + let strategy = SingleVerifier::new(params); + let mut proof_reader = proof; + let mut transcript = Blake2bRead::<_, EqAffine, Challenge255<_>>::init(&mut proof_reader); + + verify_proof(params, vk, strategy, &[&[public_inputs]], &mut transcript) + .map_err(|e| format!("{label} verification failed: {:?}", e))?; + + if !proof_reader.is_empty() { + return Err(format!( + "{label} verification failed: proof has {} trailing unread bytes", + proof_reader.len() + )); + } + + Ok(()) +} + /// Error returned when Halo2 proof creation fails. #[derive(Debug)] #[non_exhaustive] @@ -141,4 +165,41 @@ mod tests { assert!(matches!(err, ProveError::Halo2(plonk::Error::Synthesis))); } + + #[test] + fn verify_proof_bytes_rejects_trailing_unread_bytes() { + let params = Params::::new(4); + let empty_circuit = TinyCircuit { + witness: Value::unknown(), + }; + let vk = plonk::keygen_vk(¶ms, &empty_circuit).expect("tiny keygen_vk should succeed"); + let pk = plonk::keygen_pk(¶ms, vk.clone(), &empty_circuit) + .expect("tiny keygen_pk should succeed"); + let public_inputs = [vesta::Scalar::from(1)]; + let circuit = TinyCircuit { + witness: Value::known(public_inputs[0]), + }; + let proof = create_proof_bytes(¶ms, &pk, circuit, &public_inputs) + .expect("tiny proof should succeed"); + + verify_proof_bytes("tiny", ¶ms, &vk, &proof, &public_inputs) + .expect("canonical proof should verify"); + + let mut proof_with_trailing_bytes = proof; + proof_with_trailing_bytes.extend_from_slice(b"junk"); + + let err = verify_proof_bytes( + "tiny", + ¶ms, + &vk, + &proof_with_trailing_bytes, + &public_inputs, + ) + .expect_err("proof with trailing bytes must be rejected"); + + assert!( + err.contains("4 trailing unread bytes"), + "unexpected error: {err}" + ); + } } diff --git a/src/share_reveal/prove.rs b/src/share_reveal/prove.rs index 75aca424..cb994c60 100644 --- a/src/share_reveal/prove.rs +++ b/src/share_reveal/prove.rs @@ -7,13 +7,15 @@ use std::{string::String, vec::Vec}; use halo2_proofs::{ pasta::EqAffine, - plonk::{self, keygen_pk, keygen_vk, verify_proof, SingleVerifier}, + plonk::{self, keygen_pk, keygen_vk}, poly::commitment::Params, - transcript::{Blake2bRead, Challenge255}, }; use super::circuit::{Circuit, Instance, K}; -use crate::{prove_error::create_proof_bytes, ProveError}; +use crate::{ + prove_error::{create_proof_bytes, verify_proof_bytes}, + ProveError, +}; // ================================================================ // Params / key generation @@ -166,11 +168,7 @@ pub fn verify_share_reveal_proof(proof: &[u8], instance: &Instance) -> Result<() let public_inputs = instance.to_halo2_instance(); - let strategy = SingleVerifier::new(params); - let mut transcript = Blake2bRead::<_, EqAffine, Challenge255<_>>::init(proof); - - verify_proof(params, vk, strategy, &[&[&public_inputs]], &mut transcript) - .map_err(|e| format!("share_reveal verification failed: {:?}", e)) + verify_proof_bytes("share_reveal", params, vk, proof, &public_inputs) } #[cfg(test)] diff --git a/src/vote_proof/prove.rs b/src/vote_proof/prove.rs index 15c51fe3..6d650a80 100644 --- a/src/vote_proof/prove.rs +++ b/src/vote_proof/prove.rs @@ -7,13 +7,15 @@ use std::{string::String, vec::Vec}; use halo2_proofs::{ pasta::EqAffine, - plonk::{self, keygen_pk, keygen_vk, verify_proof, SingleVerifier}, + plonk::{self, keygen_pk, keygen_vk}, poly::commitment::Params, - transcript::{Blake2bRead, Challenge255}, }; use super::circuit::{Circuit, Instance, K}; -use crate::{prove_error::create_proof_bytes, ProveError}; +use crate::{ + prove_error::{create_proof_bytes, verify_proof_bytes}, + ProveError, +}; // ================================================================ // Cached params + keys @@ -164,11 +166,7 @@ pub fn verify_vote_proof(proof: &[u8], instance: &Instance) -> Result<(), String let public_inputs = instance.to_halo2_instance(); - let strategy = SingleVerifier::new(params); - let mut transcript = Blake2bRead::<_, EqAffine, Challenge255<_>>::init(proof); - - verify_proof(params, vk, strategy, &[&[&public_inputs]], &mut transcript) - .map_err(|e| format!("vote proof verification failed: {:?}", e)) + verify_proof_bytes("vote proof", params, vk, proof, &public_inputs) } #[cfg(test)] From 72415c7b9e486464291904195b801a18a3ab4ee1 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Tue, 2 Jun 2026 19:32:44 -0600 Subject: [PATCH 2/2] Prepare voting-circuits v0.7.0 --- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 680991b6..06548f39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## v0.7.0 + ### Documented - Clarified that the delegation circuit's condition 8 ("Ballot Scaling") @@ -68,6 +70,11 @@ reachable under the `unstable-internal-api` Cargo feature for the in-tree integration test). +### Security + +- Reject Halo2 proofs that verify but leave trailing unread transcript bytes + after delegation, vote-proof, or share-reveal verification. + ### Migration - Drop named imports of removed identifiers diff --git a/Cargo.lock b/Cargo.lock index 5bba2a52..ee3c400a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1177,7 +1177,7 @@ dependencies = [ [[package]] name = "voting-circuits" -version = "0.6.0" +version = "0.7.0" dependencies = [ "blake2b_simd", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 1e8669a0..3c298a07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "voting-circuits" -version = "0.6.0" +version = "0.7.0" edition = "2021" rust-version = "1.86.0" description = "Governance ZKP circuits (delegation, vote proof, share reveal) for the Zcash shielded-voting protocol."