From 27e0a5c40eceaae9932db42b1997cba1b0ac9c2e Mon Sep 17 00:00:00 2001 From: Greg Nagy Date: Thu, 7 May 2026 14:08:54 +0200 Subject: [PATCH 1/4] Avoid Tokio runtime drop panic in HTTP transport --- zcash_voting/src/http_transport.rs | 63 +++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/zcash_voting/src/http_transport.rs b/zcash_voting/src/http_transport.rs index 0f47ef0b..f03b95fc 100644 --- a/zcash_voting/src/http_transport.rs +++ b/zcash_voting/src/http_transport.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "client-tree-sync")] +use std::future::Future; + use anyhow::{Context, Result}; use bytes::Bytes; use http::{Method, Request}; @@ -26,16 +29,13 @@ struct HyperResponse { pub struct HyperTransport { client: HyperClient, #[cfg(feature = "client-tree-sync")] - runtime: tokio::runtime::Runtime, + runtime: BlockingRuntime, } impl HyperTransport { pub fn new() -> Self { #[cfg(feature = "client-tree-sync")] - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("create tree-sync HTTP runtime"); + let runtime = BlockingRuntime::new(); let mut connector = HttpConnector::new(); connector.enforce_http(false); let https = hyper_rustls::HttpsConnectorBuilder::new() @@ -99,6 +99,41 @@ impl Default for HyperTransport { } } +#[cfg(feature = "client-tree-sync")] +struct BlockingRuntime { + inner: Option, +} + +#[cfg(feature = "client-tree-sync")] +impl BlockingRuntime { + fn new() -> Self { + Self { + inner: Some( + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("create tree-sync HTTP runtime"), + ), + } + } + + fn block_on(&self, future: F) -> F::Output { + self.inner + .as_ref() + .expect("tree-sync HTTP runtime is unavailable") + .block_on(future) + } +} + +#[cfg(feature = "client-tree-sync")] +impl Drop for BlockingRuntime { + fn drop(&mut self) { + if let Some(runtime) = self.inner.take() { + runtime.shutdown_background(); + } + } +} + #[cfg(feature = "client-pir")] impl pir_client::Transport for HyperTransport { fn get<'a>(&'a self, url: &'a str) -> pir_client::TransportFuture<'a> { @@ -148,3 +183,21 @@ impl vote_commitment_tree_client::transport::Transport for HyperTransport { }) } } + +#[cfg(all(test, feature = "client-tree-sync"))] +mod tests { + use super::BlockingRuntime; + + #[test] + fn blocking_runtime_drop_does_not_panic_inside_tokio_context() { + let outer = tokio::runtime::Runtime::new().unwrap(); + let result = std::panic::catch_unwind(|| { + outer.block_on(async { + let runtime = BlockingRuntime::new(); + drop(runtime); + }); + }); + + assert!(result.is_ok()); + } +} From 8beda2842473615d4287339ea2af3145f7b2cc9c Mon Sep 17 00:00:00 2001 From: Greg Nagy Date: Mon, 11 May 2026 14:56:37 +0200 Subject: [PATCH 2/4] Persist bundle note identities --- zcash_voting/src/storage/migrations.rs | 74 ++++++- .../src/storage/migrations/001_init.sql | 1 + zcash_voting/src/storage/mod.rs | 2 +- zcash_voting/src/storage/operations.rs | 69 ++++++- zcash_voting/src/storage/queries.rs | 189 ++++++++++++++++-- 5 files changed, 309 insertions(+), 26 deletions(-) diff --git a/zcash_voting/src/storage/migrations.rs b/zcash_voting/src/storage/migrations.rs index 85baf8cd..e09c229f 100644 --- a/zcash_voting/src/storage/migrations.rs +++ b/zcash_voting/src/storage/migrations.rs @@ -2,7 +2,31 @@ use rusqlite::Connection; use crate::VotingError; -const CURRENT_VERSION: u32 = 7; +const CURRENT_VERSION: u32 = 8; + +fn column_exists(conn: &Connection, table: &str, column: &str) -> Result { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(|e| VotingError::Internal { + message: format!("failed to inspect {table} columns: {e}"), + })?; + let columns = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| VotingError::Internal { + message: format!("failed to query {table} columns: {e}"), + })?; + + for name in columns { + let name = name.map_err(|e| VotingError::Internal { + message: format!("failed to read {table} column name: {e}"), + })?; + if name == column { + return Ok(true); + } + } + + Ok(false) +} pub fn migrate(conn: &Connection) -> Result<(), VotingError> { let version: u32 = conn @@ -191,6 +215,23 @@ pub fn migrate(conn: &Connection) -> Result<(), VotingError> { })?; } + if version < 8 { + // v8: persist full bundle note identity hashes so later workflow steps + // can reject same-position note substitutions. Fresh DBs and drop-all + // migrations already get the column from 001_init.sql, so guard the + // ALTER for those paths. + if !column_exists(conn, "bundles", "note_identity_hashes_blob")? { + conn.execute_batch("ALTER TABLE bundles ADD COLUMN note_identity_hashes_blob BLOB;") + .map_err(|e| VotingError::Internal { + message: format!("migration to version 8 failed: {}", e), + })?; + } + conn.pragma_update(None, "user_version", 8) + .map_err(|e| VotingError::Internal { + message: format!("failed to update database version: {}", e), + })?; + } + let final_version: u32 = conn .pragma_query_value(None, "user_version", |r| r.get(0)) .map_err(|e| VotingError::Internal { @@ -236,6 +277,37 @@ mod tests { assert_eq!(version, CURRENT_VERSION); } + #[test] + fn test_migrate_from_v7_preserves_existing_bundles() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE bundles ( + round_id TEXT NOT NULL, + wallet_id TEXT NOT NULL DEFAULT '', + bundle_index INTEGER NOT NULL, + note_positions_blob BLOB, + PRIMARY KEY (round_id, wallet_id, bundle_index) + ); + INSERT INTO bundles (round_id, wallet_id, bundle_index, note_positions_blob) + VALUES ('test-round', 'wallet', 0, X'0100000000000000'); + PRAGMA user_version = 7;", + ) + .unwrap(); + + migrate(&conn).unwrap(); + + let (positions, identity_hashes): (Vec, Option>) = conn + .query_row( + "SELECT note_positions_blob, note_identity_hashes_blob FROM bundles + WHERE round_id = 'test-round' AND wallet_id = 'wallet' AND bundle_index = 0", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(positions, 1u64.to_le_bytes().to_vec()); + assert_eq!(identity_hashes, None); + } + #[test] fn test_tables_created() { let conn = Connection::open_in_memory().unwrap(); diff --git a/zcash_voting/src/storage/migrations/001_init.sql b/zcash_voting/src/storage/migrations/001_init.sql index aa37074f..55545027 100644 --- a/zcash_voting/src/storage/migrations/001_init.sql +++ b/zcash_voting/src/storage/migrations/001_init.sql @@ -16,6 +16,7 @@ CREATE TABLE bundles ( wallet_id TEXT NOT NULL DEFAULT '', bundle_index INTEGER NOT NULL, note_positions_blob BLOB, + note_identity_hashes_blob BLOB, van_comm_rand BLOB, dummy_nullifiers BLOB, rho_signed BLOB, diff --git a/zcash_voting/src/storage/mod.rs b/zcash_voting/src/storage/mod.rs index ca837aac..0d64a86f 100644 --- a/zcash_voting/src/storage/mod.rs +++ b/zcash_voting/src/storage/mod.rs @@ -158,7 +158,7 @@ mod tests { let version: u32 = conn .pragma_query_value(None, "user_version", |r| r.get(0)) .unwrap(); - assert_eq!(version, 7); + assert_eq!(version, 8); } #[test] diff --git a/zcash_voting/src/storage/operations.rs b/zcash_voting/src/storage/operations.rs index 1a7704ee..73a32d1f 100644 --- a/zcash_voting/src/storage/operations.rs +++ b/zcash_voting/src/storage/operations.rs @@ -267,8 +267,7 @@ impl VotingDb { ); } for (i, chunk) in result.bundles.iter().enumerate() { - let positions: Vec = chunk.iter().map(|n| n.position).collect(); - queries::insert_bundle(&conn, round_id, &wallet_id, i as u32, &positions)?; + queries::insert_bundle_notes(&conn, round_id, &wallet_id, i as u32, chunk)?; } Ok((result.bundles.len() as u32, result.eligible_weight)) } @@ -320,6 +319,7 @@ impl VotingDb { let conn = self.conn(); let wallet_id = self.wallet_id(); let params = queries::load_round_params(&conn, round_id, &wallet_id)?; + queries::require_bundle_notes(&conn, round_id, &wallet_id, bundle_index, notes)?; let result = crate::action::build_governance_pczt( notes, ¶ms, @@ -615,13 +615,8 @@ impl VotingDb { // Phase 2: Load/fetch IMT exclusion proofs via PIR. let pir_start = std::time::Instant::now(); - let precompute = self.precompute_delegation_pir( - round_id, - bundle_index, - notes, - pir_client, - network_id, - )?; + let precompute = + self.precompute_delegation_pir(round_id, bundle_index, notes, pir_client, network_id)?; let conn = self.conn(); let real_targets = delegation_nullifier_targets(notes, &[])?; @@ -1353,6 +1348,59 @@ mod tests { assert_eq!(db.get_bundle_count(ROUND_ID).unwrap(), 1); } + #[test] + fn test_build_governance_pczt_rejects_same_position_note_substitution() { + use orchard::keys::{FullViewingKey, SpendingKey}; + use zip32::Scope; + + let db = test_db(); + db.init_round(&test_params(), None).unwrap(); + + let notes = vec![NoteInfo { + commitment: vec![0x01; 32], + nullifier: vec![0x02; 32], + value: 13_000_000, + position: 0, + diversifier: vec![0; 11], + rho: vec![0; 32], + rseed: vec![0; 32], + scope: 0, + ufvk_str: String::new(), + }]; + db.setup_bundles(ROUND_ID, ¬es).unwrap(); + + let mut substituted_notes = notes.clone(); + substituted_notes[0].nullifier = vec![0x03; 32]; + + let sk = SpendingKey::from_bytes([0x42; 32]).expect("valid spending key"); + let fvk = FullViewingKey::from(&sk); + let hotkey_sk = SpendingKey::from_bytes([0x43; 32]).expect("valid spending key"); + let hotkey_fvk = FullViewingKey::from(&hotkey_sk); + let hotkey_raw_address = hotkey_fvk + .address_at(0u32, Scope::External) + .to_raw_address_bytes() + .to_vec(); + let seed_fingerprint = [0x42u8; 32]; + + let err = db + .build_governance_pczt( + ROUND_ID, + 0, + &substituted_notes, + &fvk.to_bytes().to_vec(), + &hotkey_raw_address, + 0xC8E71055, + 1, + &seed_fingerprint, + 0, + "test-round", + 0, + ) + .unwrap_err(); + + assert!(err.to_string().contains("note identity mismatch")); + } + #[test] fn test_store_and_load_tree_state() { let db = test_db(); @@ -1466,8 +1514,7 @@ mod tests { let alpha = pallas::Scalar::from(7); let alpha_bytes = alpha.to_repr(); let sighash = [0x99; 32]; - let account_1_rk: [u8; 32] = - (&randomized_verification_key(&sender_seed, 1, &alpha)).into(); + let account_1_rk: [u8; 32] = (&randomized_verification_key(&sender_seed, 1, &alpha)).into(); { let conn = db.conn(); diff --git a/zcash_voting/src/storage/queries.rs b/zcash_voting/src/storage/queries.rs index ffa69332..55b2a12b 100644 --- a/zcash_voting/src/storage/queries.rs +++ b/zcash_voting/src/storage/queries.rs @@ -4,7 +4,58 @@ use rusqlite::{named_params, Connection, OptionalExtension}; use voting_circuits::delegation::imt::ImtProofData; use crate::storage::{KeystoneSignatureRecord, RoundPhase, RoundState, RoundSummary, VoteRecord}; -use crate::types::{ShareDelegationRecord, VotingError, VotingRoundParams}; +use crate::types::{NoteInfo, ShareDelegationRecord, VotingError, VotingRoundParams}; + +const NOTE_IDENTITY_HASH_BYTES: usize = 32; +const NOTE_IDENTITY_DOMAIN: &[u8] = b"zcash-voting-note-identity-v1"; + +fn update_hash_with_len_prefixed_bytes(state: &mut blake2b_simd::State, value: &[u8]) { + state.update(&(value.len() as u64).to_le_bytes()); + state.update(value); +} + +fn note_identity_hash(note: &NoteInfo) -> [u8; NOTE_IDENTITY_HASH_BYTES] { + let mut state = blake2b_simd::Params::new() + .hash_length(NOTE_IDENTITY_HASH_BYTES) + .to_state(); + state.update(NOTE_IDENTITY_DOMAIN); + state.update(¬e.position.to_le_bytes()); + state.update(¬e.value.to_le_bytes()); + state.update(¬e.scope.to_le_bytes()); + update_hash_with_len_prefixed_bytes(&mut state, ¬e.commitment); + update_hash_with_len_prefixed_bytes(&mut state, ¬e.nullifier); + update_hash_with_len_prefixed_bytes(&mut state, ¬e.diversifier); + update_hash_with_len_prefixed_bytes(&mut state, ¬e.rho); + update_hash_with_len_prefixed_bytes(&mut state, ¬e.rseed); + update_hash_with_len_prefixed_bytes(&mut state, note.ufvk_str.as_bytes()); + + let hash = state.finalize(); + let mut out = [0u8; NOTE_IDENTITY_HASH_BYTES]; + out.copy_from_slice(hash.as_bytes()); + out +} + +fn note_positions_blob(note_positions: &[u64]) -> Vec { + note_positions + .iter() + .flat_map(|position| position.to_le_bytes()) + .collect() +} + +fn note_positions_blob_for_notes(notes: &[NoteInfo]) -> Vec { + notes + .iter() + .map(|note| note.position) + .flat_map(|position| position.to_le_bytes()) + .collect() +} + +fn note_identity_hashes_blob(notes: &[NoteInfo]) -> Vec { + notes + .iter() + .flat_map(|note| note_identity_hash(note)) + .collect() +} // --- Rounds --- @@ -208,10 +259,7 @@ pub fn insert_bundle( bundle_index: u32, note_positions: &[u64], ) -> Result<(), VotingError> { - let blob: Vec = note_positions - .iter() - .flat_map(|p| p.to_le_bytes()) - .collect(); + let positions_blob = note_positions_blob(note_positions); conn.execute( "INSERT INTO bundles (round_id, wallet_id, bundle_index, note_positions_blob) @@ -220,7 +268,7 @@ pub fn insert_bundle( ":round_id": round_id, ":wallet_id": wallet_id, ":bundle_index": bundle_index as i64, - ":note_positions_blob": blob, + ":note_positions_blob": positions_blob, }, ) .map_err(|e| VotingError::Internal { @@ -230,6 +278,51 @@ pub fn insert_bundle( Ok(()) } +/// Insert a bundle row from full notes, persisting both positions and note identity hashes. +pub fn insert_bundle_notes( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + notes: &[NoteInfo], +) -> Result<(), VotingError> { + let positions_blob = note_positions_blob_for_notes(notes); + let identity_hashes_blob = note_identity_hashes_blob(notes); + + conn.execute( + "INSERT INTO bundles (round_id, wallet_id, bundle_index, note_positions_blob, note_identity_hashes_blob) + VALUES (:round_id, :wallet_id, :bundle_index, :note_positions_blob, :note_identity_hashes_blob)", + named_params! { + ":round_id": round_id, + ":wallet_id": wallet_id, + ":bundle_index": bundle_index as i64, + ":note_positions_blob": positions_blob, + ":note_identity_hashes_blob": identity_hashes_blob, + }, + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to insert bundle: {}", e), + })?; + + Ok(()) +} + +fn decode_note_positions_blob(blob: &[u8]) -> Result, VotingError> { + if blob.len() % 8 != 0 { + return Err(VotingError::Internal { + message: format!( + "corrupt note_positions_blob: length {} is not a multiple of 8", + blob.len() + ), + }); + } + + Ok(blob + .chunks_exact(8) + .map(|c| u64::from_le_bytes(c.try_into().expect("chunks_exact(8) guarantees 8 bytes"))) + .collect()) +} + /// Get the number of bundles for a round. pub fn get_bundle_count( conn: &Connection, @@ -267,18 +360,88 @@ pub fn load_bundle_note_positions( message: format!("bundle not found: round={}, bundle={} ({})", round_id, bundle_index, e), })?; - if blob.len() % 8 != 0 { + decode_note_positions_blob(&blob) +} + +pub fn require_bundle_notes( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + notes: &[NoteInfo], +) -> Result<(), VotingError> { + let (positions_blob, identity_hashes_blob): (Vec, Option>) = conn + .query_row( + "SELECT note_positions_blob, note_identity_hashes_blob FROM bundles WHERE round_id = :round_id AND wallet_id = :wallet_id AND bundle_index = :bundle_index", + named_params! { + ":round_id": round_id, + ":wallet_id": wallet_id, + ":bundle_index": bundle_index as i64, + }, + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|e| VotingError::InvalidInput { + message: format!( + "bundle not found: round={}, bundle={} ({})", + round_id, bundle_index, e + ), + })?; + + let stored_positions = decode_note_positions_blob(&positions_blob)?; + let requested_positions = notes.iter().map(|note| note.position).collect::>(); + if stored_positions != requested_positions { + return Err(VotingError::InvalidInput { + message: format!( + "bundle_index {bundle_index} notes do not match persisted setup: stored positions {:?}, requested positions {:?}", + stored_positions, requested_positions + ), + }); + } + + // Legacy carve-out: bundles persisted before 0.5.8 were ALTER-migrated to + // v8 with a NULL `note_identity_hashes_blob` because the original + // `NoteInfo` payloads cannot be backfilled. For those rows we fall back to + // the position-only check above; identity verification only applies to + // bundles set up under 0.5.8 or later. + let Some(identity_hashes_blob) = identity_hashes_blob else { + return Ok(()); + }; + + if identity_hashes_blob.len() % NOTE_IDENTITY_HASH_BYTES != 0 { return Err(VotingError::Internal { message: format!( - "corrupt note_positions_blob: length {} is not a multiple of 8", - blob.len() + "corrupt note_identity_hashes_blob: length {} is not a multiple of {}", + identity_hashes_blob.len(), + NOTE_IDENTITY_HASH_BYTES ), }); } - Ok(blob - .chunks_exact(8) - .map(|c| u64::from_le_bytes(c.try_into().expect("chunks_exact(8) guarantees 8 bytes"))) - .collect()) + + let stored_hashes = identity_hashes_blob + .chunks_exact(NOTE_IDENTITY_HASH_BYTES) + .collect::>(); + if stored_hashes.len() != notes.len() { + return Err(VotingError::InvalidInput { + message: format!( + "bundle_index {bundle_index} note identity count mismatch: stored {}, requested {}", + stored_hashes.len(), + notes.len() + ), + }); + } + + for (index, (stored_hash, note)) in stored_hashes.iter().zip(notes.iter()).enumerate() { + let requested_hash = note_identity_hash(note); + if *stored_hash != requested_hash { + return Err(VotingError::InvalidInput { + message: format!( + "bundle_index {bundle_index} note identity mismatch at index {index}" + ), + }); + } + } + + Ok(()) } // --- Delegation Secrets --- From facc4d6cdb0bb751d06fca1841f4f64a8bd8af2c Mon Sep 17 00:00:00 2001 From: Greg Nagy Date: Mon, 11 May 2026 14:57:52 +0200 Subject: [PATCH 3/4] Bump zcash_voting to 0.5.8 --- CHANGELOG.md | 13 +++++++++++++ Cargo.lock | 2 +- zcash_voting/Cargo.toml | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6033d9df..ef41f3df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this workspace will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this workspace adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +# 0.5.8 + +## Added +- `VotingDb::setup_bundles` now persists bundle note identity hashes, and + `VotingDb::build_governance_pczt` rejects same-position note substitutions + for bundles set up under 0.5.8 or later before constructing a governance + PCZT. Bundles persisted by earlier releases retain the prior position-only + check until they are re-setup. + +## Fixed +- Avoided dropping the Hyper/Tokio transport runtime from inside an active Tokio + context. + # 0.5.7 ## Fixed diff --git a/Cargo.lock b/Cargo.lock index 9368c25b..a8efa807 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2481,7 +2481,7 @@ dependencies = [ [[package]] name = "zcash_voting" -version = "0.5.7" +version = "0.5.8" dependencies = [ "anyhow", "blake2b_simd", diff --git a/zcash_voting/Cargo.toml b/zcash_voting/Cargo.toml index bffecf91..44623c11 100644 --- a/zcash_voting/Cargo.toml +++ b/zcash_voting/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zcash_voting" -version = "0.5.7" +version = "0.5.8" edition = "2021" description = "Client-side library for Zcash shielded voting: ZKP delegation and vote-commitment proofs (Halo 2), ElGamal encryption, governance PCZT construction, Merkle witness generation, and SQLite round-state persistence." license = "MIT OR Apache-2.0" From 6654557283a55fbcde55f940f22c148c10a2ce76 Mon Sep 17 00:00:00 2001 From: Greg Nagy Date: Mon, 11 May 2026 16:41:41 +0200 Subject: [PATCH 4/4] Strengthen bundle identity enforcement Persist bundle note identities as the core security boundary for Swift and Android consumers, and reject substituted notes across PCZT, PIR, and proof entry points. Keep legacy position-only rows compatible while making setup/proof persistence atomic so SDK wrappers do not need sidecar transaction logic. --- CHANGELOG.md | 14 +- zcash_voting/src/storage/migrations.rs | 33 +-- zcash_voting/src/storage/operations.rs | 304 +++++++++++++++++++++++-- zcash_voting/src/storage/queries.rs | 230 ++++++++++++++++++- 4 files changed, 541 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef41f3df..34895a65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,18 @@ and this workspace adheres to [Semantic Versioning](https://semver.org/spec/v2.0 ## Added - `VotingDb::setup_bundles` now persists bundle note identity hashes, and - `VotingDb::build_governance_pczt` rejects same-position note substitutions - for bundles set up under 0.5.8 or later before constructing a governance - PCZT. Bundles persisted by earlier releases retain the prior position-only - check until they are re-setup. + `VotingDb::build_governance_pczt`, `VotingDb::precompute_delegation_pir`, + and `VotingDb::build_and_prove_delegation` reject same-position note + substitutions for bundles set up under 0.5.8 or later. Bundles persisted by + earlier releases retain the prior position-only check until they are + re-setup. ## Fixed +- Delegation proof storage now checks proof-derived public inputs against the + PCZT-derived values stored during `VotingDb::build_governance_pczt`, and + stores the proof, public inputs, and round phase atomically. +- `VotingDb::setup_bundles` now persists all bundle rows in a single + transaction. - Avoided dropping the Hyper/Tokio transport runtime from inside an active Tokio context. diff --git a/zcash_voting/src/storage/migrations.rs b/zcash_voting/src/storage/migrations.rs index e09c229f..24139bc6 100644 --- a/zcash_voting/src/storage/migrations.rs +++ b/zcash_voting/src/storage/migrations.rs @@ -253,6 +253,22 @@ pub fn migrate(conn: &Connection) -> Result<(), VotingError> { #[cfg(test)] mod tests { use super::*; + use crate::storage::queries; + use crate::VotingRoundParams; + + fn v7_schema() -> String { + include_str!("migrations/001_init.sql").replace(" note_identity_hashes_blob BLOB,\n", "") + } + + fn test_params() -> VotingRoundParams { + VotingRoundParams { + vote_round_id: "test-round".to_string(), + snapshot_height: 1000, + ea_pk: vec![0xEA; 32], + nc_root: vec![0xAA; 32], + nullifier_imt_root: vec![0xBB; 32], + } + } #[test] fn test_migrate_fresh_database() { @@ -280,19 +296,10 @@ mod tests { #[test] fn test_migrate_from_v7_preserves_existing_bundles() { let conn = Connection::open_in_memory().unwrap(); - conn.execute_batch( - "CREATE TABLE bundles ( - round_id TEXT NOT NULL, - wallet_id TEXT NOT NULL DEFAULT '', - bundle_index INTEGER NOT NULL, - note_positions_blob BLOB, - PRIMARY KEY (round_id, wallet_id, bundle_index) - ); - INSERT INTO bundles (round_id, wallet_id, bundle_index, note_positions_blob) - VALUES ('test-round', 'wallet', 0, X'0100000000000000'); - PRAGMA user_version = 7;", - ) - .unwrap(); + conn.execute_batch(&v7_schema()).unwrap(); + queries::insert_round(&conn, "wallet", &test_params(), None).unwrap(); + queries::insert_bundle(&conn, "test-round", "wallet", 0, &[1]).unwrap(); + conn.pragma_update(None, "user_version", 7).unwrap(); migrate(&conn).unwrap(); diff --git a/zcash_voting/src/storage/operations.rs b/zcash_voting/src/storage/operations.rs index 73a32d1f..0b8db4bc 100644 --- a/zcash_voting/src/storage/operations.rs +++ b/zcash_voting/src/storage/operations.rs @@ -255,7 +255,7 @@ impl VotingDb { round_id: &str, notes: &[NoteInfo], ) -> Result<(u32, u64), VotingError> { - let conn = self.conn(); + let mut conn = self.conn(); let wallet_id = self.wallet_id(); let result = crate::types::chunk_notes(notes); if result.dropped_count > 0 { @@ -266,9 +266,15 @@ impl VotingDb { notes.len() ); } + let tx = conn.transaction().map_err(|e| VotingError::Internal { + message: format!("failed to begin bundle setup transaction: {e}"), + })?; for (i, chunk) in result.bundles.iter().enumerate() { - queries::insert_bundle_notes(&conn, round_id, &wallet_id, i as u32, chunk)?; + queries::insert_bundle_notes(&tx, round_id, &wallet_id, i as u32, chunk)?; } + tx.commit().map_err(|e| VotingError::Internal { + message: format!("failed to commit bundle setup transaction: {e}"), + })?; Ok((result.bundles.len() as u32, result.eligible_weight)) } @@ -340,7 +346,7 @@ impl VotingDb { })?; // Persist delegation data fields // plus padded_note_secrets and pczt_sighash for ZCA-74 randomness threading. - queries::store_delegation_data( + queries::store_delegation_data_with_pczt_fields( &conn, round_id, &wallet_id, @@ -359,6 +365,8 @@ impl VotingDb { address_index, &result.padded_note_secrets, &result.pczt_sighash, + &result.rk, + &result.gov_nullifiers, )?; Ok(result) } @@ -439,6 +447,7 @@ impl VotingDb { let conn = self.conn(); let wallet_id = self.wallet_id(); let params = queries::load_round_params(&conn, round_id, &wallet_id)?; + queries::require_bundle_notes(&conn, round_id, &wallet_id, bundle_index, notes)?; let padded_secrets = queries::load_padded_note_secrets(&conn, round_id, &wallet_id, bundle_index)?; let padded_nullifiers = padded_nullifiers_for_circuit(notes, &padded_secrets, network_id)?; @@ -545,6 +554,7 @@ impl VotingDb { let conn = self.conn(); let wallet_id = self.wallet_id(); let params = queries::load_round_params(&conn, round_id, &wallet_id)?; + queries::require_bundle_notes(&conn, round_id, &wallet_id, bundle_index, notes)?; let alpha = queries::load_alpha(&conn, round_id, &wallet_id, bundle_index)?; let van_comm_rand = queries::load_van_comm_rand(&conn, round_id, &wallet_id, bundle_index)?; let witnesses = queries::load_witnesses(&conn, round_id, &wallet_id, bundle_index)?; @@ -724,14 +734,16 @@ impl VotingDb { prove_elapsed.as_secs_f64() ); - // Store proof bytes for debugging/recovery - let conn = self.conn(); - queries::store_proof(&conn, round_id, &wallet_id, bundle_index, &result.proof)?; - // Persist prover's public inputs — needed later for delegation TX submission. - // With PrecomputedRandomness (ZCA-74 fix), nf_signed/cmx_new should match - // Phase 1 values. We still store them to be explicit and support the legacy path. - queries::store_proof_result_fields( - &conn, + // Persist proof bytes, public inputs, and phase together. The public + // inputs are checked against the PCZT fields before any partial proof + // success state is committed. + let mut conn = self.conn(); + let tx = conn.transaction().map_err(|e| VotingError::Internal { + message: format!("failed to begin proof result transaction: {e}"), + })?; + queries::store_proof(&tx, round_id, &wallet_id, bundle_index, &result.proof)?; + queries::store_proof_result_fields_with_van_comm( + &tx, round_id, &wallet_id, bundle_index, @@ -739,8 +751,12 @@ impl VotingDb { &result.gov_nullifiers, &result.nf_signed, &result.cmx_new, + &result.van_comm, )?; - queries::update_round_phase(&conn, round_id, &wallet_id, RoundPhase::DelegationProved)?; + queries::update_round_phase(&tx, round_id, &wallet_id, RoundPhase::DelegationProved)?; + tx.commit().map_err(|e| VotingError::Internal { + message: format!("failed to commit proof result transaction: {e}"), + })?; let total_elapsed = total_start.elapsed(); eprintln!( @@ -1290,6 +1306,20 @@ mod tests { } } + fn identity_test_note() -> NoteInfo { + NoteInfo { + commitment: vec![0x01; 32], + nullifier: vec![0x02; 32], + value: 13_000_000, + position: 7, + diversifier: vec![0x03; 11], + rho: vec![0x04; 32], + rseed: vec![0x05; 32], + scope: 0, + ufvk_str: "uview1test".to_string(), + } + } + #[test] fn test_init_and_get_round() { let db = test_db(); @@ -1348,6 +1378,122 @@ mod tests { assert_eq!(db.get_bundle_count(ROUND_ID).unwrap(), 1); } + #[test] + fn test_setup_bundles_rolls_back_partial_insert_on_error() { + let db = test_db(); + db.init_round(&test_params(), None).unwrap(); + + let notes: Vec = (0..6) + .map(|i| NoteInfo { + commitment: vec![i as u8; 32], + nullifier: vec![i as u8 + 1; 32], + value: 13_000_000, + position: i as u64, + diversifier: vec![0; 11], + rho: vec![0; 32], + rseed: vec![0; 32], + scope: 0, + ufvk_str: String::new(), + }) + .collect(); + + { + let conn = db.conn(); + queries::insert_bundle(&conn, ROUND_ID, W, 1, &[99]).unwrap(); + } + + let err = db + .setup_bundles(ROUND_ID, ¬es) + .expect_err("bundle index conflict should fail setup"); + assert!(err.to_string().contains("failed to insert bundle")); + + let conn = db.conn(); + let bundle_zero_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM bundles + WHERE round_id = ?1 AND wallet_id = ?2 AND bundle_index = 0", + rusqlite::params![ROUND_ID, W], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(bundle_zero_count, 0); + assert_eq!(queries::get_bundle_count(&conn, ROUND_ID, W).unwrap(), 1); + } + + #[test] + fn test_require_bundle_notes_rejects_each_identity_field_substitution() { + fn mutate_commitment(note: &mut NoteInfo) { + note.commitment[0] ^= 0x01; + } + fn mutate_nullifier(note: &mut NoteInfo) { + note.nullifier[0] ^= 0x01; + } + fn mutate_value(note: &mut NoteInfo) { + note.value += 1; + } + fn mutate_position(note: &mut NoteInfo) { + note.position += 1; + } + fn mutate_diversifier(note: &mut NoteInfo) { + note.diversifier[0] ^= 0x01; + } + fn mutate_rho(note: &mut NoteInfo) { + note.rho[0] ^= 0x01; + } + fn mutate_rseed(note: &mut NoteInfo) { + note.rseed[0] ^= 0x01; + } + fn mutate_scope(note: &mut NoteInfo) { + note.scope += 1; + } + fn mutate_ufvk(note: &mut NoteInfo) { + note.ufvk_str.push_str("-substituted"); + } + + let db = test_db(); + db.init_round(&test_params(), None).unwrap(); + let note = identity_test_note(); + let conn = db.conn(); + queries::insert_bundle_notes(&conn, ROUND_ID, W, 0, &[note.clone()]).unwrap(); + + let cases: [(&str, fn(&mut NoteInfo)); 9] = [ + ("commitment", mutate_commitment), + ("nullifier", mutate_nullifier), + ("value", mutate_value), + ("position", mutate_position), + ("diversifier", mutate_diversifier), + ("rho", mutate_rho), + ("rseed", mutate_rseed), + ("scope", mutate_scope), + ("ufvk_str", mutate_ufvk), + ]; + + for (field, mutate) in cases { + let mut substituted = note.clone(); + mutate(&mut substituted); + + let err = queries::require_bundle_notes(&conn, ROUND_ID, W, 0, &[substituted]) + .expect_err(field); + assert!(err.to_string().contains("bundle_index 0"), "{field}: {err}"); + } + } + + #[test] + fn test_require_bundle_notes_allows_legacy_position_only_rows() { + let db = test_db(); + db.init_round(&test_params(), None).unwrap(); + let note = identity_test_note(); + let conn = db.conn(); + queries::insert_bundle(&conn, ROUND_ID, W, 0, &[note.position]).unwrap(); + + let mut substituted = note; + substituted.nullifier[0] ^= 0x01; + substituted.rseed[0] ^= 0x01; + substituted.ufvk_str.push_str("-substituted"); + + queries::require_bundle_notes(&conn, ROUND_ID, W, 0, &[substituted]).unwrap(); + } + #[test] fn test_build_governance_pczt_rejects_same_position_note_substitution() { use orchard::keys::{FullViewingKey, SpendingKey}; @@ -1401,6 +1547,134 @@ mod tests { assert!(err.to_string().contains("note identity mismatch")); } + #[test] + fn test_store_proof_result_fields_rejects_pczt_mismatch() { + let db = test_db(); + db.init_round(&test_params(), None).unwrap(); + + let rk = [0x10; 32]; + let wrong_rk = [0x11; 32]; + let gov_nullifiers = vec![vec![0x20; 32]; 5]; + let nf_signed = [0x30; 32]; + let cmx_new = [0x40; 32]; + let van_comm = [0x50; 32]; + + let mut conn = db.conn(); + queries::insert_bundle(&conn, ROUND_ID, W, 0, &[0]).unwrap(); + queries::store_delegation_data_with_pczt_fields( + &conn, + ROUND_ID, + W, + 0, + &[0x01; 32], + &[], + &[0x02; 32], + &[], + &nf_signed, + &cmx_new, + &[0x03; 32], + &[0x04; 32], + &[0x05; 32], + &van_comm, + 1, + 0, + &[], + &[0x06; 32], + &rk, + &gov_nullifiers, + ) + .unwrap(); + + let tx = conn.transaction().unwrap(); + queries::store_proof(&tx, ROUND_ID, W, 0, &[0xAB; 96]).unwrap(); + let err = queries::store_proof_result_fields_with_van_comm( + &tx, + ROUND_ID, + W, + 0, + &wrong_rk, + &gov_nullifiers, + &nf_signed, + &cmx_new, + &van_comm, + ) + .expect_err("proof rk must match PCZT rk"); + assert!(err.to_string().contains("rk")); + drop(tx); + + let proof_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM proofs + WHERE round_id = ?1 AND wallet_id = ?2 AND bundle_index = ?3", + rusqlite::params![ROUND_ID, W, 0], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(proof_count, 0); + + queries::store_proof_result_fields_with_van_comm( + &conn, + ROUND_ID, + W, + 0, + &rk, + &gov_nullifiers, + &nf_signed, + &cmx_new, + &van_comm, + ) + .unwrap(); + } + + #[test] + fn test_store_proof_result_fields_allows_legacy_missing_pczt_fields() { + let db = test_db(); + db.init_round(&test_params(), None).unwrap(); + + let rk = [0x10; 32]; + let gov_nullifiers = vec![vec![0x20; 32]; 5]; + let nf_signed = [0x30; 32]; + let cmx_new = [0x40; 32]; + let van_comm = [0x50; 32]; + + let conn = db.conn(); + queries::insert_bundle(&conn, ROUND_ID, W, 0, &[0]).unwrap(); + queries::store_delegation_data( + &conn, + ROUND_ID, + W, + 0, + &[0x01; 32], + &[], + &[0x02; 32], + &[], + &nf_signed, + &cmx_new, + &[0x03; 32], + &[0x04; 32], + &[0x05; 32], + &van_comm, + 1, + 0, + &[], + &[0x06; 32], + ) + .unwrap(); + + queries::store_proof_result_fields_with_van_comm( + &conn, + ROUND_ID, + W, + 0, + &rk, + &gov_nullifiers, + &nf_signed, + &cmx_new, + &van_comm, + ) + .unwrap(); + } + #[test] fn test_store_and_load_tree_state() { let db = test_db(); @@ -1540,7 +1814,7 @@ mod tests { &sighash, ) .unwrap(); - queries::store_proof_result_fields( + queries::store_proof_result_fields_with_van_comm( &conn, ROUND_ID, W, @@ -1549,6 +1823,7 @@ mod tests { &[vec![0x88; 32]], &[0x33; 32], &[0x44; 32], + &[0x77; 32], ) .unwrap(); queries::store_proof(&conn, ROUND_ID, W, 0, &[0xAB; 96]).unwrap(); @@ -1614,7 +1889,7 @@ mod tests { &stored_sighash, ) .unwrap(); - queries::store_proof_result_fields( + queries::store_proof_result_fields_with_van_comm( &conn, ROUND_ID, W, @@ -1623,6 +1898,7 @@ mod tests { &[vec![0x89; 32]], &[0x33; 32], &[0x44; 32], + &[0x88; 32], ) .unwrap(); queries::store_proof(&conn, ROUND_ID, W, 0, &[0xAC; 96]).unwrap(); diff --git a/zcash_voting/src/storage/queries.rs b/zcash_voting/src/storage/queries.rs index 55b2a12b..13cc0790 100644 --- a/zcash_voting/src/storage/queries.rs +++ b/zcash_voting/src/storage/queries.rs @@ -18,6 +18,7 @@ fn note_identity_hash(note: &NoteInfo) -> [u8; NOTE_IDENTITY_HASH_BYTES] { let mut state = blake2b_simd::Params::new() .hash_length(NOTE_IDENTITY_HASH_BYTES) .to_state(); + // The domain string is longer than BLAKE2b's 16-byte personalization field. state.update(NOTE_IDENTITY_DOMAIN); state.update(¬e.position.to_le_bytes()); state.update(¬e.value.to_le_bytes()); @@ -57,6 +58,13 @@ fn note_identity_hashes_blob(notes: &[NoteInfo]) -> Vec { .collect() } +fn encode_gov_nullifiers_blob(gov_nullifiers: &[Vec]) -> Vec { + gov_nullifiers + .iter() + .flat_map(|n| n.iter().copied()) + .collect() +} + // --- Rounds --- pub fn insert_round( @@ -251,7 +259,12 @@ pub fn clear_round(conn: &Connection, round_id: &str, wallet_id: &str) -> Result // --- Bundles --- -/// Insert a bundle row. `note_positions` is stored as a flat blob of u64 LE values. +/// Insert a bundle row from positions only. +/// +/// Retained for SDK/FFI compatibility with callers that cannot provide full +/// notes at insertion time. Rows written this way have a NULL +/// `note_identity_hashes_blob`, so `require_bundle_notes` can only enforce the +/// legacy position check until callers migrate to `insert_bundle_notes`. pub fn insert_bundle( conn: &Connection, round_id: &str, @@ -476,6 +489,101 @@ pub fn store_delegation_data( address_index: u32, padded_note_secrets: &[(Vec, Vec)], pczt_sighash: &[u8], +) -> Result<(), VotingError> { + store_delegation_data_inner( + conn, + round_id, + wallet_id, + bundle_index, + van_comm_rand, + dummy_nullifiers, + rho_signed, + padded_cmx, + nf_signed, + cmx_new, + alpha, + rseed_signed, + rseed_output, + gov_comm, + total_note_value, + address_index, + padded_note_secrets, + pczt_sighash, + None, + None, + ) +} + +/// Persist delegation action data plus PCZT-derived public inputs that the +/// later delegation proof must reproduce. +pub(crate) fn store_delegation_data_with_pczt_fields( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + van_comm_rand: &[u8], + dummy_nullifiers: &[Vec], + rho_signed: &[u8], + padded_cmx: &[Vec], + nf_signed: &[u8], + cmx_new: &[u8], + alpha: &[u8], + rseed_signed: &[u8], + rseed_output: &[u8], + gov_comm: &[u8], + total_note_value: u64, + address_index: u32, + padded_note_secrets: &[(Vec, Vec)], + pczt_sighash: &[u8], + rk: &[u8], + gov_nullifiers: &[Vec], +) -> Result<(), VotingError> { + let gov_nullifiers_blob = encode_gov_nullifiers_blob(gov_nullifiers); + store_delegation_data_inner( + conn, + round_id, + wallet_id, + bundle_index, + van_comm_rand, + dummy_nullifiers, + rho_signed, + padded_cmx, + nf_signed, + cmx_new, + alpha, + rseed_signed, + rseed_output, + gov_comm, + total_note_value, + address_index, + padded_note_secrets, + pczt_sighash, + Some(rk), + Some(gov_nullifiers_blob.as_slice()), + ) +} + +fn store_delegation_data_inner( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + van_comm_rand: &[u8], + dummy_nullifiers: &[Vec], + rho_signed: &[u8], + padded_cmx: &[Vec], + nf_signed: &[u8], + cmx_new: &[u8], + alpha: &[u8], + rseed_signed: &[u8], + rseed_output: &[u8], + gov_comm: &[u8], + total_note_value: u64, + address_index: u32, + padded_note_secrets: &[(Vec, Vec)], + pczt_sighash: &[u8], + rk: Option<&[u8]>, + gov_nullifiers_blob: Option<&[u8]>, ) -> Result<(), VotingError> { // Serialize padded-note nullifiers as a flat byte blob: [nf0 (32 bytes) | nf1 | nf2 | ...]. // Length 0 means no padding was needed (all 5 notes were real). @@ -501,7 +609,9 @@ pub fn store_delegation_data( cmx_new = :cmx_new, alpha = :alpha, rseed_signed = :rseed_signed, \ rseed_output = :rseed_output, gov_comm = :gov_comm, \ total_note_value = :total_note_value, address_index = :address_index, \ - padded_note_secrets = :secrets, pczt_sighash = :sighash \ + padded_note_secrets = :secrets, pczt_sighash = :sighash, \ + rk = COALESCE(:rk, rk), \ + gov_nullifiers_blob = COALESCE(:gov_nullifiers_blob, gov_nullifiers_blob) \ WHERE round_id = :round_id AND wallet_id = :wallet_id AND bundle_index = :bundle_index", named_params! { ":rand": van_comm_rand, @@ -518,6 +628,8 @@ pub fn store_delegation_data( ":address_index": address_index as i64, ":secrets": secrets_blob, ":sighash": pczt_sighash, + ":rk": rk, + ":gov_nullifiers_blob": gov_nullifiers_blob, ":round_id": round_id, ":wallet_id": wallet_id, ":bundle_index": bundle_index as i64, @@ -904,8 +1016,24 @@ pub fn load_van_position( // --- Delegation proof result fields --- -/// Persist rk and gov_nullifiers from DelegationProofResult after proof generation. -/// These survive the FFI boundary and are needed later for delegation TX submission. +fn require_matching_stored_field( + stored: Option<&[u8]>, + requested: &[u8], + field: &str, +) -> Result<(), VotingError> { + if let Some(stored) = stored { + if stored != requested { + return Err(VotingError::InvalidInput { + message: format!("delegation proof result {field} does not match stored PCZT data"), + }); + } + } + + Ok(()) +} + +/// Persist public inputs from DelegationProofResult after proof generation. +/// If PCZT-derived values already exist, the proof result must reproduce them. pub fn store_proof_result_fields( conn: &Connection, round_id: &str, @@ -916,11 +1044,95 @@ pub fn store_proof_result_fields( nf_signed: &[u8], cmx_new: &[u8], ) -> Result<(), VotingError> { - // Serialize gov_nullifiers as flat blob: [nf0 (32 bytes) | nf1 | nf2 | nf3] - let gov_nullifiers_blob: Vec = gov_nullifiers - .iter() - .flat_map(|n| n.iter().copied()) - .collect(); + store_proof_result_fields_inner( + conn, + round_id, + wallet_id, + bundle_index, + rk, + gov_nullifiers, + nf_signed, + cmx_new, + None, + ) +} + +/// Persist proof public inputs and compare the proof VAN against the stored PCZT VAN. +#[cfg_attr(not(feature = "client-pir"), allow(dead_code))] +pub(crate) fn store_proof_result_fields_with_van_comm( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + rk: &[u8], + gov_nullifiers: &[Vec], + nf_signed: &[u8], + cmx_new: &[u8], + van_comm: &[u8], +) -> Result<(), VotingError> { + store_proof_result_fields_inner( + conn, + round_id, + wallet_id, + bundle_index, + rk, + gov_nullifiers, + nf_signed, + cmx_new, + Some(van_comm), + ) +} + +fn store_proof_result_fields_inner( + conn: &Connection, + round_id: &str, + wallet_id: &str, + bundle_index: u32, + rk: &[u8], + gov_nullifiers: &[Vec], + nf_signed: &[u8], + cmx_new: &[u8], + van_comm: Option<&[u8]>, +) -> Result<(), VotingError> { + // Serialize gov_nullifiers as flat blob: [nf0 (32 bytes) | ... | nf4] + let gov_nullifiers_blob = encode_gov_nullifiers_blob(gov_nullifiers); + + let (stored_rk, stored_gov_nullifiers, stored_nf_signed, stored_cmx_new, stored_gov_comm): ( + Option>, + Option>, + Option>, + Option>, + Option>, + ) = conn + .query_row( + "SELECT rk, gov_nullifiers_blob, nf_signed, cmx_new, gov_comm \ + FROM bundles \ + WHERE round_id = :round_id AND wallet_id = :wallet_id AND bundle_index = :bundle_index", + named_params! { + ":round_id": round_id, + ":wallet_id": wallet_id, + ":bundle_index": bundle_index as i64, + }, + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), + ) + .map_err(|e| VotingError::InvalidInput { + message: format!( + "bundle not found: round={}, bundle={} ({})", + round_id, bundle_index, e + ), + })?; + + require_matching_stored_field(stored_rk.as_deref(), rk, "rk")?; + require_matching_stored_field( + stored_gov_nullifiers.as_deref(), + &gov_nullifiers_blob, + "gov_nullifiers", + )?; + require_matching_stored_field(stored_nf_signed.as_deref(), nf_signed, "nf_signed")?; + require_matching_stored_field(stored_cmx_new.as_deref(), cmx_new, "cmx_new")?; + if let Some(van_comm) = van_comm { + require_matching_stored_field(stored_gov_comm.as_deref(), van_comm, "van_comm")?; + } let rows = conn .execute(