diff --git a/CHANGELOG.md b/CHANGELOG.md index 6033d9df..34895a65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ 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`, `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. + # 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" 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()); + } +} diff --git a/zcash_voting/src/storage/migrations.rs b/zcash_voting/src/storage/migrations.rs index 85baf8cd..24139bc6 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 { @@ -212,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() { @@ -236,6 +293,28 @@ 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(&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(); + + 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..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,10 +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() { - 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(&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)) } @@ -320,6 +325,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, @@ -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)?; @@ -615,13 +625,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, &[])?; @@ -729,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, @@ -744,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!( @@ -1295,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(); @@ -1353,6 +1378,303 @@ 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}; + 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_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(); @@ -1466,8 +1788,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(); @@ -1493,7 +1814,7 @@ mod tests { &sighash, ) .unwrap(); - queries::store_proof_result_fields( + queries::store_proof_result_fields_with_van_comm( &conn, ROUND_ID, W, @@ -1502,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(); @@ -1567,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, @@ -1576,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 ffa69332..13cc0790 100644 --- a/zcash_voting/src/storage/queries.rs +++ b/zcash_voting/src/storage/queries.rs @@ -4,7 +4,66 @@ 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(); + // 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()); + 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() +} + +fn encode_gov_nullifiers_blob(gov_nullifiers: &[Vec]) -> Vec { + gov_nullifiers + .iter() + .flat_map(|n| n.iter().copied()) + .collect() +} // --- Rounds --- @@ -200,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, @@ -208,10 +272,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 +281,36 @@ 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 { + message: format!("failed to insert bundle: {}", e), + })?; + + 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 { @@ -230,6 +320,22 @@ pub fn insert_bundle( 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 +373,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 --- @@ -313,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). @@ -338,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, @@ -355,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, @@ -741,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, @@ -753,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(