From 7437b9c931d0f4258a3936527cd45320f3e8bea1 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Thu, 2 Jul 2026 20:45:36 -0600 Subject: [PATCH 1/6] Add Zcash compact batch signature result UR type Adds `zcash-batch-sig-result` (registry tag 49207), a compact device response to a Zcash signing batch. Instead of echoing full redacted PCZTs back (as `zcash-sign-result` does), the device returns only the produced 64-byte spend-authorization signatures, correlated to each request message by id and to each spend by pool and action index. The wallet re-applies them to the PCZTs it already holds, cutting the response to roughly an eighth of the redacted-PCZT size on the wire. Like the sibling Zcash registry types this is an egress-only container: decode enforces CBOR shape (definite-length maps/arrays only), required fields, duplicate CBOR map keys, and trailing data, then preserves values for the caller to police (version support, id correlation, pool validity, action-index bounds, signature length). Tests cover the encode/decode round trip, a byte-exact golden wire vector, empty results, unknown-field skipping, indefinite-length and duplicate-key rejection at every map level, missing-required-key rejection, and a full UR-level probe_encode/probe_decode round trip, matching the test matrix of the adjacent zcash-sign-result and zcash-sign-batch types. --- libs/ur-parse-lib/src/keystone_ur_encoder.rs | 45 ++ libs/ur-registry/src/registry_types.rs | 5 + libs/ur-registry/src/zcash/mod.rs | 1 + .../src/zcash/zcash_batch_sig_result.rs | 616 ++++++++++++++++++ 4 files changed, 667 insertions(+) create mode 100644 libs/ur-registry/src/zcash/zcash_batch_sig_result.rs diff --git a/libs/ur-parse-lib/src/keystone_ur_encoder.rs b/libs/ur-parse-lib/src/keystone_ur_encoder.rs index d66ef8f..2935ce9 100644 --- a/libs/ur-parse-lib/src/keystone_ur_encoder.rs +++ b/libs/ur-parse-lib/src/keystone_ur_encoder.rs @@ -108,6 +108,10 @@ mod tests { use ur_registry::crypto_psbt::CryptoPSBT; use ur_registry::extend::qr_hardware_call::QRHardwareCall; use ur_registry::traits::{RegistryItem, UR}; + use ur_registry::zcash::zcash_batch_sig_result::{ + ZcashActionSig, ZcashBatchSigResult, ZcashMsgSig, ZCASH_BATCH_SIG_RESULT_VERSION, + ZCASH_SIG_LEN, ZCASH_SIG_POOL_ORCHARD, + }; use ur_registry::zcash::zcash_sign_batch::{ ZcashSignBatch, ZcashSignMessage, ZCASH_SIGN_BATCH_NETWORK_MAINNET, ZCASH_SIGN_BATCH_VERSION, ZCASH_SIGN_MESSAGE_KIND_PCZT_V1, @@ -229,6 +233,47 @@ mod tests { ); } + #[test] + fn test_encode_decode_zcash_batch_sig_result_ur() { + let result = ZcashBatchSigResult::new( + ZCASH_BATCH_SIG_RESULT_VERSION, + vec![0xaa, 0xbb], + vec![ZcashMsgSig::new( + vec![0x01], + vec![ZcashActionSig::new( + ZCASH_SIG_POOL_ORCHARD, + 3, + vec![0x11; ZCASH_SIG_LEN], + )], + )], + ); + let cbor: Vec = result.try_into().unwrap(); + let encoded = + probe_encode(&cbor, 400, ZcashBatchSigResult::get_registry_type().get_type()).unwrap(); + + assert!(!encoded.is_multi_part); + + let decoded: URParseResult = probe_decode(encoded.data).unwrap(); + let decoded_result = decoded.data.unwrap(); + + assert_eq!( + decoded.ur_type.unwrap().get_type_str(), + "zcash-batch-sig-result" + ); + assert_eq!(decoded_result.get_version(), ZCASH_BATCH_SIG_RESULT_VERSION); + assert_eq!(decoded_result.get_request_id(), &vec![0xaa, 0xbb]); + assert_eq!(decoded_result.get_results().len(), 1); + assert_eq!( + decoded_result.get_results()[0].get_message_id(), + &vec![0x01] + ); + assert_eq!(decoded_result.get_results()[0].get_sigs().len(), 1); + let action = &decoded_result.get_results()[0].get_sigs()[0]; + assert_eq!(action.get_pool(), ZCASH_SIG_POOL_ORCHARD); + assert_eq!(action.get_action_index(), 3); + assert_eq!(action.get_sig(), &vec![0x11; ZCASH_SIG_LEN]); + } + #[test] fn test_encode_decode_multipart_zcash_sign_batch_ur() { let payload = vec![0x42; 1024]; diff --git a/libs/ur-registry/src/registry_types.rs b/libs/ur-registry/src/registry_types.rs index 1f690c6..800eb55 100644 --- a/libs/ur-registry/src/registry_types.rs +++ b/libs/ur-registry/src/registry_types.rs @@ -33,6 +33,7 @@ pub enum URType { ZcashPczt(String), ZcashSignBatch(String), ZcashSignResult(String), + ZcashBatchSigResult(String), XmrOutput(String), XmrTxUnsigned(String), AvaxSignRequest(String), @@ -86,6 +87,7 @@ impl URType { "zcash-pczt" => Ok(URType::ZcashPczt(type_str.to_string())), "zcash-sign-batch" => Ok(URType::ZcashSignBatch(type_str.to_string())), "zcash-sign-result" => Ok(URType::ZcashSignResult(type_str.to_string())), + "zcash-batch-sig-result" => Ok(URType::ZcashBatchSigResult(type_str.to_string())), "tron-sign-request" => Ok(URType::TronSignRequest(type_str.to_string())), "tron-signature" => Ok(URType::TronSignature(type_str.to_string())), "xmr-output" => Ok(URType::XmrOutput(type_str.to_string())), @@ -132,6 +134,7 @@ impl URType { URType::ZcashPczt(type_str) => type_str.to_string(), URType::ZcashSignBatch(type_str) => type_str.to_string(), URType::ZcashSignResult(type_str) => type_str.to_string(), + URType::ZcashBatchSigResult(type_str) => type_str.to_string(), URType::TronSignRequest(type_str) => type_str.to_string(), URType::TronSignature(type_str) => type_str.to_string(), URType::XmrOutput(type_str) => type_str.to_string(), @@ -278,3 +281,5 @@ pub const ZCASH_UNIFIED_FULL_VIEWING_KEY: RegistryType = pub const ZCASH_PCZT: RegistryType = RegistryType("zcash-pczt", Some(49204)); pub const ZCASH_SIGN_BATCH: RegistryType = RegistryType("zcash-sign-batch", Some(49205)); pub const ZCASH_SIGN_RESULT: RegistryType = RegistryType("zcash-sign-result", Some(49206)); +pub const ZCASH_BATCH_SIG_RESULT: RegistryType = + RegistryType("zcash-batch-sig-result", Some(49207)); diff --git a/libs/ur-registry/src/zcash/mod.rs b/libs/ur-registry/src/zcash/mod.rs index a1d4ca6..87c53ba 100644 --- a/libs/ur-registry/src/zcash/mod.rs +++ b/libs/ur-registry/src/zcash/mod.rs @@ -1,6 +1,7 @@ mod cbor_helpers; pub mod zcash_accounts; +pub mod zcash_batch_sig_result; pub mod zcash_pczt; pub mod zcash_sign_batch; pub mod zcash_sign_result; diff --git a/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs b/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs new file mode 100644 index 0000000..70eb3d3 --- /dev/null +++ b/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs @@ -0,0 +1,616 @@ +//! Zcash signatures-only signing result Registry Type. +//! +//! This module implements CBOR encoding and decoding for a compact device +//! response to a Zcash signing batch. Instead of returning full (redacted) +//! PCZTs like [`super::zcash_sign_result::ZcashSignResult`], the device returns +//! only the produced signatures, correlated back to each request message by id +//! and to each spend by its pool and action index. The wallet re-applies these +//! signatures to the PCZTs it already holds, which is roughly 8-9x smaller on +//! the wire than echoing redacted PCZTs back. +//! +//! This is a registry container, not a protocol policy validator. Decode checks +//! CBOR shape, required fields, duplicate CBOR map keys, and trailing data, then +//! preserves registry values as supplied. Callers enforce policy such as request +//! correlation, supported versions, unique ids, pool validity, action-index +//! bounds, and signature length. + +use super::cbor_helpers::{reject_duplicate_key, require_key}; +use crate::{ + registry_types::{RegistryType, ZCASH_BATCH_SIG_RESULT}, + traits::{MapSize, RegistryItem}, +}; +use alloc::string::ToString; +use alloc::vec; +use alloc::vec::Vec; +use minicbor::data::Int; +use minicbor::{Decoder, Encoder}; + +use crate::error::{URError, URResult}; + +/// Registered result version used by producers. Decode preserves any `u32` +/// version so callers can decide protocol policy. +pub const ZCASH_BATCH_SIG_RESULT_VERSION: u32 = 1; + +/// Pool discriminant carried on each action signature so the wallet knows which +/// bundle to re-apply it to. These are Keystone-side wire discriminants, not +/// consensus values; the SDK preserves any `u32` and callers decide policy. +/// Registered pool discriminant for an Orchard action signature. +pub const ZCASH_SIG_POOL_ORCHARD: u32 = 0; +/// Registered pool discriminant for an Ironwood action signature (the +/// Keystone-side Orchard-to-Ironwood migration pool). +pub const ZCASH_SIG_POOL_IRONWOOD: u32 = 1; + +/// A Zcash spend authorization signature is a 64-byte RedDSA signature. Decode +/// preserves any byte string so callers can decide policy. +pub const ZCASH_SIG_LEN: usize = 64; + +// Top-level `zcash-batch-sig-result` map keys. +const VERSION: u8 = 1; +const REQUEST_ID: u8 = 2; +const RESULTS: u8 = 3; + +// `msg-sig` map keys. +const MESSAGE_ID: u8 = 1; +const SIGS: u8 = 2; + +// `action-sig` map keys. +const POOL: u8 = 1; +const ACTION_INDEX: u8 = 2; +const SIG: u8 = 3; + +/// A signatures-only response to a Zcash signing batch: a version, the request +/// id it answers, and one [`ZcashMsgSig`] per signed message. +#[derive(Clone, Debug, Default)] +pub struct ZcashBatchSigResult { + version: u32, + request_id: Vec, + results: Vec, +} + +impl ZcashBatchSigResult { + /// Builds a signatures-only result container. The SDK does not validate + /// protocol policy such as supported version, expected result count, or + /// duplicate ids here. + pub fn new(version: u32, request_id: Vec, results: Vec) -> Self { + Self { + version, + request_id, + results, + } + } + + /// Returns the registered result version. + pub fn get_version(&self) -> u32 { + self.version + } + + /// Returns the request id this result answers. + pub fn get_request_id(&self) -> &Vec { + &self.request_id + } + + /// Returns the per-message signature results. + pub fn get_results(&self) -> &Vec { + &self.results + } +} + +impl RegistryItem for ZcashBatchSigResult { + fn get_registry_type() -> RegistryType<'static> { + ZCASH_BATCH_SIG_RESULT + } +} + +impl MapSize for ZcashBatchSigResult { + fn map_size(&self) -> u64 { + 3 + } +} + +/// The signatures produced for a single request message, correlated by id. +#[derive(Clone, Debug, Default)] +pub struct ZcashMsgSig { + message_id: Vec, + sigs: Vec, +} + +impl ZcashMsgSig { + /// Builds a per-message signature container. The SDK does not validate id + /// correlation or signature policy here. + pub fn new(message_id: Vec, sigs: Vec) -> Self { + Self { message_id, sigs } + } + + /// Returns the id of the request message these signatures answer. + pub fn get_message_id(&self) -> &Vec { + &self.message_id + } + + /// Returns the per-action signatures for this message. + pub fn get_sigs(&self) -> &Vec { + &self.sigs + } +} + +impl MapSize for ZcashMsgSig { + fn map_size(&self) -> u64 { + 2 + } +} + +/// A single spend-authorization signature, located by pool and action index. +#[derive(Clone, Debug, Default)] +pub struct ZcashActionSig { + pool: u32, + action_index: u32, + sig: Vec, +} + +impl ZcashActionSig { + /// Builds a single action signature container. The SDK does not validate + /// pool validity, action-index bounds, or signature length here. + pub fn new(pool: u32, action_index: u32, sig: Vec) -> Self { + Self { + pool, + action_index, + sig, + } + } + + /// Returns the pool discriminant (see [`ZCASH_SIG_POOL_ORCHARD`] / + /// [`ZCASH_SIG_POOL_IRONWOOD`]). + pub fn get_pool(&self) -> u32 { + self.pool + } + + /// Returns the index of the signed action within its pool's bundle. + pub fn get_action_index(&self) -> u32 { + self.action_index + } + + /// Returns the raw signature bytes (expected length [`ZCASH_SIG_LEN`]). + pub fn get_sig(&self) -> &Vec { + &self.sig + } +} + +impl MapSize for ZcashActionSig { + fn map_size(&self) -> u64 { + 3 + } +} + +impl TryFrom> for ZcashBatchSigResult { + type Error = URError; + + fn try_from(value: Vec) -> URResult { + let mut decoder = Decoder::new(&value); + let result = + >::decode(&mut decoder, &mut ()) + .map_err(|e| URError::CborDecodeError(e.to_string()))?; + if decoder.position() != value.len() { + return Err(URError::CborDecodeError( + "trailing data after zcash-batch-sig-result".to_string(), + )); + } + Ok(result) + } +} + +impl TryInto> for ZcashBatchSigResult { + type Error = URError; + + fn try_into(self) -> URResult> { + minicbor::to_vec(self).map_err(|e| URError::CborEncodeError(e.to_string())) + } +} + +impl minicbor::Encode for ZcashBatchSigResult { + fn encode( + &self, + e: &mut Encoder, + ctx: &mut C, + ) -> Result<(), minicbor::encode::Error> { + e.map(self.map_size())?; + e.int(Int::from(VERSION))?.u32(self.version)?; + e.int(Int::from(REQUEST_ID))?.bytes(&self.request_id)?; + e.int(Int::from(RESULTS))? + .array(self.results.len() as u64)?; + for result in &self.results { + result.encode(e, ctx)?; + } + Ok(()) + } +} + +impl minicbor::Encode for ZcashMsgSig { + fn encode( + &self, + e: &mut Encoder, + ctx: &mut C, + ) -> Result<(), minicbor::encode::Error> { + e.map(self.map_size())?; + e.int(Int::from(MESSAGE_ID))?.bytes(&self.message_id)?; + e.int(Int::from(SIGS))?.array(self.sigs.len() as u64)?; + for sig in &self.sigs { + sig.encode(e, ctx)?; + } + Ok(()) + } +} + +impl minicbor::Encode for ZcashActionSig { + fn encode( + &self, + e: &mut Encoder, + _ctx: &mut C, + ) -> Result<(), minicbor::encode::Error> { + e.map(self.map_size())?; + e.int(Int::from(POOL))?.u32(self.pool)?; + e.int(Int::from(ACTION_INDEX))?.u32(self.action_index)?; + e.int(Int::from(SIG))?.bytes(&self.sig)?; + Ok(()) + } +} + +impl<'b, C> minicbor::Decode<'b, C> for ZcashBatchSigResult { + fn decode(d: &mut Decoder<'b>, ctx: &mut C) -> Result { + let mut result = ZcashBatchSigResult::default(); + let len = d.map()?.ok_or_else(|| { + minicbor::decode::Error::message("indefinite zcash-batch-sig-result map is unsupported") + .at(d.position()) + })?; + let mut seen_keys = Vec::new(); + for _ in 0..len { + let key = d.u8()?; + reject_duplicate_key( + &mut seen_keys, + key, + d, + "duplicate key in zcash-batch-sig-result map", + )?; + match key { + VERSION => result.version = d.u32()?, + REQUEST_ID => result.request_id = d.bytes()?.to_vec(), + RESULTS => { + let mut results = vec![]; + let len = d.array()?.ok_or_else(|| { + minicbor::decode::Error::message( + "indefinite zcash-batch-sig-result results array is unsupported", + ) + .at(d.position()) + })?; + for _ in 0..len { + results.push(ZcashMsgSig::decode(d, ctx)?); + } + result.results = results; + } + _ => d.skip()?, + } + } + require_key( + &seen_keys, + VERSION, + d, + "missing zcash-batch-sig-result version", + )?; + require_key( + &seen_keys, + REQUEST_ID, + d, + "missing zcash-batch-sig-result request id", + )?; + require_key( + &seen_keys, + RESULTS, + d, + "missing zcash-batch-sig-result results", + )?; + Ok(result) + } +} + +impl<'b, C> minicbor::Decode<'b, C> for ZcashMsgSig { + fn decode(d: &mut Decoder<'b>, ctx: &mut C) -> Result { + let mut result = ZcashMsgSig::default(); + let len = d.map()?.ok_or_else(|| { + minicbor::decode::Error::message("indefinite zcash-msg-sig map is unsupported") + .at(d.position()) + })?; + let mut seen_keys = Vec::new(); + for _ in 0..len { + let key = d.u8()?; + reject_duplicate_key(&mut seen_keys, key, d, "duplicate key in zcash-msg-sig map")?; + match key { + MESSAGE_ID => result.message_id = d.bytes()?.to_vec(), + SIGS => { + let mut sigs = vec![]; + let len = d.array()?.ok_or_else(|| { + minicbor::decode::Error::message( + "indefinite zcash-msg-sig sigs array is unsupported", + ) + .at(d.position()) + })?; + for _ in 0..len { + sigs.push(ZcashActionSig::decode(d, ctx)?); + } + result.sigs = sigs; + } + _ => d.skip()?, + } + } + require_key( + &seen_keys, + MESSAGE_ID, + d, + "missing zcash-msg-sig message id", + )?; + require_key(&seen_keys, SIGS, d, "missing zcash-msg-sig sigs")?; + Ok(result) + } +} + +impl<'b, C> minicbor::Decode<'b, C> for ZcashActionSig { + fn decode(d: &mut Decoder<'b>, _ctx: &mut C) -> Result { + let mut result = ZcashActionSig::default(); + let len = d.map()?.ok_or_else(|| { + minicbor::decode::Error::message("indefinite zcash-action-sig map is unsupported") + .at(d.position()) + })?; + let mut seen_keys = Vec::new(); + for _ in 0..len { + let key = d.u8()?; + reject_duplicate_key( + &mut seen_keys, + key, + d, + "duplicate key in zcash-action-sig map", + )?; + match key { + POOL => result.pool = d.u32()?, + ACTION_INDEX => result.action_index = d.u32()?, + SIG => result.sig = d.bytes()?.to_vec(), + _ => d.skip()?, + } + } + require_key(&seen_keys, POOL, d, "missing zcash-action-sig pool")?; + require_key( + &seen_keys, + ACTION_INDEX, + d, + "missing zcash-action-sig action index", + )?; + require_key(&seen_keys, SIG, d, "missing zcash-action-sig sig")?; + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + #[test] + fn test_zcash_batch_sig_result_encode_decode() { + let result = ZcashBatchSigResult::new( + ZCASH_BATCH_SIG_RESULT_VERSION, + vec![0xaa, 0xbb], + vec![ + ZcashMsgSig::new( + vec![0x01], + vec![ + ZcashActionSig::new(ZCASH_SIG_POOL_ORCHARD, 0, vec![0x11; ZCASH_SIG_LEN]), + ZcashActionSig::new(ZCASH_SIG_POOL_IRONWOOD, 3, vec![0x22; ZCASH_SIG_LEN]), + ], + ), + ZcashMsgSig::new( + vec![0x02, 0x03], + vec![ZcashActionSig::new( + ZCASH_SIG_POOL_ORCHARD, + 7, + vec![0x33; ZCASH_SIG_LEN], + )], + ), + ], + ); + + let encoded: Vec = result.clone().try_into().unwrap(); + let decoded = ZcashBatchSigResult::try_from(encoded).unwrap(); + + assert_eq!(decoded.get_version(), ZCASH_BATCH_SIG_RESULT_VERSION); + assert_eq!(decoded.get_request_id(), &vec![0xaa, 0xbb]); + assert_eq!(decoded.get_results().len(), 2); + + let first = &decoded.get_results()[0]; + assert_eq!(first.get_message_id(), &vec![0x01]); + assert_eq!(first.get_sigs().len(), 2); + assert_eq!(first.get_sigs()[0].get_pool(), ZCASH_SIG_POOL_ORCHARD); + assert_eq!(first.get_sigs()[0].get_action_index(), 0); + assert_eq!(first.get_sigs()[0].get_sig(), &vec![0x11; ZCASH_SIG_LEN]); + assert_eq!(first.get_sigs()[1].get_pool(), ZCASH_SIG_POOL_IRONWOOD); + assert_eq!(first.get_sigs()[1].get_action_index(), 3); + assert_eq!(first.get_sigs()[1].get_sig(), &vec![0x22; ZCASH_SIG_LEN]); + + let second = &decoded.get_results()[1]; + assert_eq!(second.get_message_id(), &vec![0x02, 0x03]); + assert_eq!(second.get_sigs().len(), 1); + assert_eq!(second.get_sigs()[0].get_pool(), ZCASH_SIG_POOL_ORCHARD); + assert_eq!(second.get_sigs()[0].get_action_index(), 7); + assert_eq!(second.get_sigs()[0].get_sig(), &vec![0x33; ZCASH_SIG_LEN]); + } + + #[test] + fn test_zcash_batch_sig_result_empty_results() { + let result = + ZcashBatchSigResult::new(ZCASH_BATCH_SIG_RESULT_VERSION, vec![0x01, 0x02], vec![]); + + let encoded: Vec = result.clone().try_into().unwrap(); + let decoded = ZcashBatchSigResult::try_from(encoded).unwrap(); + + assert_eq!(decoded.get_version(), ZCASH_BATCH_SIG_RESULT_VERSION); + assert_eq!(decoded.get_request_id(), &vec![0x01, 0x02]); + assert!(decoded.get_results().is_empty()); + } + + #[test] + fn test_zcash_batch_sig_result_rejects_duplicate_keys() { + let duplicate_version_keys = vec![0xa2, VERSION, 0x01, VERSION, 0x02]; + + let err = ZcashBatchSigResult::try_from(duplicate_version_keys).unwrap_err(); + + assert!(err.to_string().contains("duplicate key")); + } + + #[test] + fn test_zcash_batch_sig_result_rejects_trailing_data() { + let result = ZcashBatchSigResult::new(ZCASH_BATCH_SIG_RESULT_VERSION, vec![], vec![]); + let mut encoded: Vec = result.try_into().unwrap(); + encoded.push(0x00); + + let err = ZcashBatchSigResult::try_from(encoded).unwrap_err(); + + assert!(err.to_string().contains("trailing data")); + } + + #[test] + fn test_zcash_batch_sig_result_rejects_missing_required_top_level_key() { + // map(2) { VERSION: 1, REQUEST_ID: h'01' } — missing RESULTS. + let missing_results = vec![0xa2, VERSION, 0x01, REQUEST_ID, 0x41, 0x01]; + + let err = ZcashBatchSigResult::try_from(missing_results).unwrap_err(); + + assert!( + err.to_string() + .contains("missing zcash-batch-sig-result results") + ); + } + + #[test] + fn test_zcash_batch_sig_result_rejects_missing_required_action_key() { + // map(3) { + // VERSION: 1, + // REQUEST_ID: h'01', + // RESULTS: [ map(2) { MESSAGE_ID: h'02', SIGS: [ map(2) { POOL: 0, SIG: h'' } ] } ] + // } — the action map is missing ACTION_INDEX. + let missing_action_index = vec![ + 0xa3, VERSION, 0x01, REQUEST_ID, 0x41, 0x01, RESULTS, 0x81, 0xa2, MESSAGE_ID, 0x41, + 0x02, SIGS, 0x81, 0xa2, POOL, 0x00, SIG, 0x40, + ]; + + let err = ZcashBatchSigResult::try_from(missing_action_index).unwrap_err(); + + assert!( + err.to_string() + .contains("missing zcash-action-sig action index") + ); + } + + #[test] + fn test_zcash_batch_sig_result_decodes_literal_cbor_fixture() { + // { 1: 1, 2: h'aabb', 3: [ { 1: h'01', 2: [ { 1: 0, 2: 3, 3: h'11'*64 } ] } ] } + let mut cbor = hex::decode("a301010242aabb0381a20141010281a301000203035840").unwrap(); + cbor.extend(vec![0x11u8; ZCASH_SIG_LEN]); + + let decoded = ZcashBatchSigResult::try_from(cbor.clone()).unwrap(); + assert_eq!(decoded.get_version(), ZCASH_BATCH_SIG_RESULT_VERSION); + assert_eq!(decoded.get_request_id(), &vec![0xaa, 0xbb]); + assert_eq!(decoded.get_results().len(), 1); + assert_eq!(decoded.get_results()[0].get_message_id(), &vec![0x01]); + assert_eq!(decoded.get_results()[0].get_sigs().len(), 1); + let action = &decoded.get_results()[0].get_sigs()[0]; + assert_eq!(action.get_pool(), ZCASH_SIG_POOL_ORCHARD); + assert_eq!(action.get_action_index(), 3); + assert_eq!(action.get_sig(), &vec![0x11u8; ZCASH_SIG_LEN]); + + // An independent wallet implementation consumes this wire format, so pin + // it: re-encoding the equivalent value must reproduce these exact bytes. + let reencoded: Vec = ZcashBatchSigResult::new( + ZCASH_BATCH_SIG_RESULT_VERSION, + vec![0xaa, 0xbb], + vec![ZcashMsgSig::new( + vec![0x01], + vec![ZcashActionSig::new( + ZCASH_SIG_POOL_ORCHARD, + 3, + vec![0x11u8; ZCASH_SIG_LEN], + )], + )], + ) + .try_into() + .unwrap(); + assert_eq!(reencoded, cbor); + } + + #[test] + fn test_zcash_batch_sig_result_skips_unknown_fields() { + // Base value { 1:1, 2:h'', 3:[ { 1:h'', 2:[ { 1:0, 2:0, 3:h'' } ] } ] } with an + // extra unknown key 9 -> [1, {"x": true}]: first at the top level (map a3->a4), + // then inside the action-sig map (map a3->a4). Both must decode the known fields. + let fixtures = [ + "a4010102400381a201400281a3010002000340098201a16178f5", + "a3010102400381a201400281a4010002000340098201a16178f5", + ]; + for cbor_hex in fixtures { + let decoded = ZcashBatchSigResult::try_from(hex::decode(cbor_hex).unwrap()).unwrap(); + assert_eq!(decoded.get_version(), ZCASH_BATCH_SIG_RESULT_VERSION); + assert_eq!(decoded.get_results().len(), 1); + assert_eq!(decoded.get_results()[0].get_sigs().len(), 1); + let action = &decoded.get_results()[0].get_sigs()[0]; + assert_eq!(action.get_pool(), ZCASH_SIG_POOL_ORCHARD); + assert_eq!(action.get_action_index(), 0); + } + } + + #[test] + fn test_zcash_batch_sig_result_rejects_indefinite_top_level_map() { + let err = ZcashBatchSigResult::try_from(vec![0xbf, 0xff]).unwrap_err(); + + assert!( + err.to_string() + .contains("indefinite zcash-batch-sig-result map") + ); + } + + #[test] + fn test_zcash_batch_sig_result_rejects_indefinite_results_array() { + let indefinite_results = vec![0xa3, VERSION, 0x01, REQUEST_ID, 0x40, RESULTS, 0x9f, 0xff]; + + let err = ZcashBatchSigResult::try_from(indefinite_results).unwrap_err(); + + assert!(err.to_string().contains("results array")); + } + + #[test] + fn test_zcash_batch_sig_result_rejects_indefinite_msg_sig_map() { + let indefinite_msg_sig = + vec![0xa3, VERSION, 0x01, REQUEST_ID, 0x40, RESULTS, 0x81, 0xbf, 0xff]; + + let err = ZcashBatchSigResult::try_from(indefinite_msg_sig).unwrap_err(); + + assert!(err.to_string().contains("indefinite zcash-msg-sig map")); + } + + #[test] + fn test_zcash_batch_sig_result_rejects_duplicate_msg_sig_keys() { + // A msg-sig map carrying MESSAGE_ID twice. + let duplicate_message_id = vec![ + 0xa3, VERSION, 0x01, REQUEST_ID, 0x40, RESULTS, 0x81, 0xa2, MESSAGE_ID, 0x40, + MESSAGE_ID, 0x40, + ]; + + let err = ZcashBatchSigResult::try_from(duplicate_message_id).unwrap_err(); + + assert!( + err.to_string() + .contains("duplicate key in zcash-msg-sig map") + ); + } + + #[test] + fn test_registry_type() { + assert_eq!( + ZcashBatchSigResult::get_registry_type().get_type(), + "zcash-batch-sig-result" + ); + } +} From 2f56492a515facc095d54fadc34faa1e4a1a8e75 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Fri, 10 Jul 2026 13:38:38 +0200 Subject: [PATCH 2/6] Use serialized PCZT batch responses --- libs/ur-parse-lib/src/keystone_ur_encoder.rs | 34 +- .../src/zcash/zcash_batch_sig_result.rs | 606 +++--------------- 2 files changed, 95 insertions(+), 545 deletions(-) diff --git a/libs/ur-parse-lib/src/keystone_ur_encoder.rs b/libs/ur-parse-lib/src/keystone_ur_encoder.rs index 2935ce9..6a58d5a 100644 --- a/libs/ur-parse-lib/src/keystone_ur_encoder.rs +++ b/libs/ur-parse-lib/src/keystone_ur_encoder.rs @@ -108,10 +108,7 @@ mod tests { use ur_registry::crypto_psbt::CryptoPSBT; use ur_registry::extend::qr_hardware_call::QRHardwareCall; use ur_registry::traits::{RegistryItem, UR}; - use ur_registry::zcash::zcash_batch_sig_result::{ - ZcashActionSig, ZcashBatchSigResult, ZcashMsgSig, ZCASH_BATCH_SIG_RESULT_VERSION, - ZCASH_SIG_LEN, ZCASH_SIG_POOL_ORCHARD, - }; + use ur_registry::zcash::zcash_batch_sig_result::ZcashBatchSigResult; use ur_registry::zcash::zcash_sign_batch::{ ZcashSignBatch, ZcashSignMessage, ZCASH_SIGN_BATCH_NETWORK_MAINNET, ZCASH_SIGN_BATCH_VERSION, ZCASH_SIGN_MESSAGE_KIND_PCZT_V1, @@ -235,18 +232,10 @@ mod tests { #[test] fn test_encode_decode_zcash_batch_sig_result_ur() { - let result = ZcashBatchSigResult::new( - ZCASH_BATCH_SIG_RESULT_VERSION, - vec![0xaa, 0xbb], - vec![ZcashMsgSig::new( - vec![0x01], - vec![ZcashActionSig::new( - ZCASH_SIG_POOL_ORCHARD, - 3, - vec![0x11; ZCASH_SIG_LEN], - )], - )], - ); + // Serialization of an empty PCZT BatchSignResponse: + // "PCZS" || batch_version_le || Postcard body. + let response = vec![b'P', b'C', b'Z', b'S', 1, 0, 0, 0, 0]; + let result = ZcashBatchSigResult::new(response.clone()); let cbor: Vec = result.try_into().unwrap(); let encoded = probe_encode(&cbor, 400, ZcashBatchSigResult::get_registry_type().get_type()).unwrap(); @@ -260,18 +249,7 @@ mod tests { decoded.ur_type.unwrap().get_type_str(), "zcash-batch-sig-result" ); - assert_eq!(decoded_result.get_version(), ZCASH_BATCH_SIG_RESULT_VERSION); - assert_eq!(decoded_result.get_request_id(), &vec![0xaa, 0xbb]); - assert_eq!(decoded_result.get_results().len(), 1); - assert_eq!( - decoded_result.get_results()[0].get_message_id(), - &vec![0x01] - ); - assert_eq!(decoded_result.get_results()[0].get_sigs().len(), 1); - let action = &decoded_result.get_results()[0].get_sigs()[0]; - assert_eq!(action.get_pool(), ZCASH_SIG_POOL_ORCHARD); - assert_eq!(action.get_action_index(), 3); - assert_eq!(action.get_sig(), &vec![0x11; ZCASH_SIG_LEN]); + assert_eq!(decoded_result.get_data(), response); } #[test] diff --git a/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs b/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs index 70eb3d3..b3ab840 100644 --- a/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs +++ b/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs @@ -1,97 +1,32 @@ -//! Zcash signatures-only signing result Registry Type. +//! Zcash PCZT batch signature result Registry Type. //! -//! This module implements CBOR encoding and decoding for a compact device -//! response to a Zcash signing batch. Instead of returning full (redacted) -//! PCZTs like [`super::zcash_sign_result::ZcashSignResult`], the device returns -//! only the produced signatures, correlated back to each request message by id -//! and to each spend by its pool and action index. The wallet re-applies these -//! signatures to the PCZTs it already holds, which is roughly 8-9x smaller on -//! the wire than echoing redacted PCZTs back. -//! -//! This is a registry container, not a protocol policy validator. Decode checks -//! CBOR shape, required fields, duplicate CBOR map keys, and trailing data, then -//! preserves registry values as supplied. Callers enforce policy such as request -//! correlation, supported versions, unique ids, pool validity, action-index -//! bounds, and signature length. +//! The payload is the opaque output of +//! `pczt::roles::signer::batch::BatchSignResponse::serialize`. The PCZT crate +//! owns the versioned response encoding and its semantic validation; this +//! registry type only provides the outer UR/CBOR envelope. + +use alloc::{string::ToString, vec::Vec}; + +use minicbor::data::Int; -use super::cbor_helpers::{reject_duplicate_key, require_key}; use crate::{ + cbor::cbor_map, + error::{URError, URResult}, + impl_template_struct, registry_types::{RegistryType, ZCASH_BATCH_SIG_RESULT}, traits::{MapSize, RegistryItem}, + types::Bytes, }; -use alloc::string::ToString; -use alloc::vec; -use alloc::vec::Vec; -use minicbor::data::Int; -use minicbor::{Decoder, Encoder}; - -use crate::error::{URError, URResult}; - -/// Registered result version used by producers. Decode preserves any `u32` -/// version so callers can decide protocol policy. -pub const ZCASH_BATCH_SIG_RESULT_VERSION: u32 = 1; - -/// Pool discriminant carried on each action signature so the wallet knows which -/// bundle to re-apply it to. These are Keystone-side wire discriminants, not -/// consensus values; the SDK preserves any `u32` and callers decide policy. -/// Registered pool discriminant for an Orchard action signature. -pub const ZCASH_SIG_POOL_ORCHARD: u32 = 0; -/// Registered pool discriminant for an Ironwood action signature (the -/// Keystone-side Orchard-to-Ironwood migration pool). -pub const ZCASH_SIG_POOL_IRONWOOD: u32 = 1; - -/// A Zcash spend authorization signature is a 64-byte RedDSA signature. Decode -/// preserves any byte string so callers can decide policy. -pub const ZCASH_SIG_LEN: usize = 64; - -// Top-level `zcash-batch-sig-result` map keys. -const VERSION: u8 = 1; -const REQUEST_ID: u8 = 2; -const RESULTS: u8 = 3; - -// `msg-sig` map keys. -const MESSAGE_ID: u8 = 1; -const SIGS: u8 = 2; - -// `action-sig` map keys. -const POOL: u8 = 1; -const ACTION_INDEX: u8 = 2; -const SIG: u8 = 3; - -/// A signatures-only response to a Zcash signing batch: a version, the request -/// id it answers, and one [`ZcashMsgSig`] per signed message. -#[derive(Clone, Debug, Default)] -pub struct ZcashBatchSigResult { - version: u32, - request_id: Vec, - results: Vec, -} -impl ZcashBatchSigResult { - /// Builds a signatures-only result container. The SDK does not validate - /// protocol policy such as supported version, expected result count, or - /// duplicate ids here. - pub fn new(version: u32, request_id: Vec, results: Vec) -> Self { - Self { - version, - request_id, - results, - } - } +use super::cbor_helpers::{reject_duplicate_key, require_key}; - /// Returns the registered result version. - pub fn get_version(&self) -> u32 { - self.version - } +const DATA: u8 = 1; - /// Returns the request id this result answers. - pub fn get_request_id(&self) -> &Vec { - &self.request_id - } +impl_template_struct!(ZcashBatchSigResult { data: Bytes }); - /// Returns the per-message signature results. - pub fn get_results(&self) -> &Vec { - &self.results +impl MapSize for ZcashBatchSigResult { + fn map_size(&self) -> u64 { + 1 } } @@ -101,168 +36,28 @@ impl RegistryItem for ZcashBatchSigResult { } } -impl MapSize for ZcashBatchSigResult { - fn map_size(&self) -> u64 { - 3 - } -} - -/// The signatures produced for a single request message, correlated by id. -#[derive(Clone, Debug, Default)] -pub struct ZcashMsgSig { - message_id: Vec, - sigs: Vec, -} - -impl ZcashMsgSig { - /// Builds a per-message signature container. The SDK does not validate id - /// correlation or signature policy here. - pub fn new(message_id: Vec, sigs: Vec) -> Self { - Self { message_id, sigs } - } - - /// Returns the id of the request message these signatures answer. - pub fn get_message_id(&self) -> &Vec { - &self.message_id - } - - /// Returns the per-action signatures for this message. - pub fn get_sigs(&self) -> &Vec { - &self.sigs - } -} - -impl MapSize for ZcashMsgSig { - fn map_size(&self) -> u64 { - 2 - } -} - -/// A single spend-authorization signature, located by pool and action index. -#[derive(Clone, Debug, Default)] -pub struct ZcashActionSig { - pool: u32, - action_index: u32, - sig: Vec, -} - -impl ZcashActionSig { - /// Builds a single action signature container. The SDK does not validate - /// pool validity, action-index bounds, or signature length here. - pub fn new(pool: u32, action_index: u32, sig: Vec) -> Self { - Self { - pool, - action_index, - sig, - } - } - - /// Returns the pool discriminant (see [`ZCASH_SIG_POOL_ORCHARD`] / - /// [`ZCASH_SIG_POOL_IRONWOOD`]). - pub fn get_pool(&self) -> u32 { - self.pool - } - - /// Returns the index of the signed action within its pool's bundle. - pub fn get_action_index(&self) -> u32 { - self.action_index - } - - /// Returns the raw signature bytes (expected length [`ZCASH_SIG_LEN`]). - pub fn get_sig(&self) -> &Vec { - &self.sig - } -} - -impl MapSize for ZcashActionSig { - fn map_size(&self) -> u64 { - 3 - } -} - -impl TryFrom> for ZcashBatchSigResult { - type Error = URError; - - fn try_from(value: Vec) -> URResult { - let mut decoder = Decoder::new(&value); - let result = - >::decode(&mut decoder, &mut ()) - .map_err(|e| URError::CborDecodeError(e.to_string()))?; - if decoder.position() != value.len() { - return Err(URError::CborDecodeError( - "trailing data after zcash-batch-sig-result".to_string(), - )); - } - Ok(result) - } -} - -impl TryInto> for ZcashBatchSigResult { - type Error = URError; - - fn try_into(self) -> URResult> { - minicbor::to_vec(self).map_err(|e| URError::CborEncodeError(e.to_string())) - } -} - impl minicbor::Encode for ZcashBatchSigResult { fn encode( &self, - e: &mut Encoder, - ctx: &mut C, - ) -> Result<(), minicbor::encode::Error> { - e.map(self.map_size())?; - e.int(Int::from(VERSION))?.u32(self.version)?; - e.int(Int::from(REQUEST_ID))?.bytes(&self.request_id)?; - e.int(Int::from(RESULTS))? - .array(self.results.len() as u64)?; - for result in &self.results { - result.encode(e, ctx)?; - } - Ok(()) - } -} - -impl minicbor::Encode for ZcashMsgSig { - fn encode( - &self, - e: &mut Encoder, - ctx: &mut C, - ) -> Result<(), minicbor::encode::Error> { - e.map(self.map_size())?; - e.int(Int::from(MESSAGE_ID))?.bytes(&self.message_id)?; - e.int(Int::from(SIGS))?.array(self.sigs.len() as u64)?; - for sig in &self.sigs { - sig.encode(e, ctx)?; - } - Ok(()) - } -} - -impl minicbor::Encode for ZcashActionSig { - fn encode( - &self, - e: &mut Encoder, + e: &mut minicbor::Encoder, _ctx: &mut C, ) -> Result<(), minicbor::encode::Error> { e.map(self.map_size())?; - e.int(Int::from(POOL))?.u32(self.pool)?; - e.int(Int::from(ACTION_INDEX))?.u32(self.action_index)?; - e.int(Int::from(SIG))?.bytes(&self.sig)?; + e.int(Int::from(DATA))?.bytes(&self.data)?; Ok(()) } } impl<'b, C> minicbor::Decode<'b, C> for ZcashBatchSigResult { - fn decode(d: &mut Decoder<'b>, ctx: &mut C) -> Result { + fn decode( + d: &mut minicbor::Decoder<'b>, + _ctx: &mut C, + ) -> Result { let mut result = ZcashBatchSigResult::default(); - let len = d.map()?.ok_or_else(|| { - minicbor::decode::Error::message("indefinite zcash-batch-sig-result map is unsupported") - .at(d.position()) - })?; let mut seen_keys = Vec::new(); - for _ in 0..len { - let key = d.u8()?; + cbor_map(d, &mut result, |key, obj, d| { + let key = + u8::try_from(key).map_err(|e| minicbor::decode::Error::message(e.to_string()))?; reject_duplicate_key( &mut seen_keys, key, @@ -270,118 +65,38 @@ impl<'b, C> minicbor::Decode<'b, C> for ZcashBatchSigResult { "duplicate key in zcash-batch-sig-result map", )?; match key { - VERSION => result.version = d.u32()?, - REQUEST_ID => result.request_id = d.bytes()?.to_vec(), - RESULTS => { - let mut results = vec![]; - let len = d.array()?.ok_or_else(|| { - minicbor::decode::Error::message( - "indefinite zcash-batch-sig-result results array is unsupported", - ) - .at(d.position()) - })?; - for _ in 0..len { - results.push(ZcashMsgSig::decode(d, ctx)?); - } - result.results = results; - } + DATA => obj.data = d.bytes()?.to_vec(), _ => d.skip()?, } - } - require_key( - &seen_keys, - VERSION, - d, - "missing zcash-batch-sig-result version", - )?; - require_key( - &seen_keys, - REQUEST_ID, - d, - "missing zcash-batch-sig-result request id", - )?; - require_key( - &seen_keys, - RESULTS, - d, - "missing zcash-batch-sig-result results", - )?; + Ok(()) + })?; + require_key(&seen_keys, DATA, d, "missing zcash-batch-sig-result data")?; Ok(result) } } -impl<'b, C> minicbor::Decode<'b, C> for ZcashMsgSig { - fn decode(d: &mut Decoder<'b>, ctx: &mut C) -> Result { - let mut result = ZcashMsgSig::default(); - let len = d.map()?.ok_or_else(|| { - minicbor::decode::Error::message("indefinite zcash-msg-sig map is unsupported") - .at(d.position()) - })?; - let mut seen_keys = Vec::new(); - for _ in 0..len { - let key = d.u8()?; - reject_duplicate_key(&mut seen_keys, key, d, "duplicate key in zcash-msg-sig map")?; - match key { - MESSAGE_ID => result.message_id = d.bytes()?.to_vec(), - SIGS => { - let mut sigs = vec![]; - let len = d.array()?.ok_or_else(|| { - minicbor::decode::Error::message( - "indefinite zcash-msg-sig sigs array is unsupported", - ) - .at(d.position()) - })?; - for _ in 0..len { - sigs.push(ZcashActionSig::decode(d, ctx)?); - } - result.sigs = sigs; - } - _ => d.skip()?, - } +impl TryFrom> for ZcashBatchSigResult { + type Error = URError; + + fn try_from(value: Vec) -> URResult { + let mut decoder = minicbor::Decoder::new(&value); + let result = + >::decode(&mut decoder, &mut ()) + .map_err(|e| URError::CborDecodeError(e.to_string()))?; + if decoder.position() != value.len() { + return Err(URError::CborDecodeError( + "trailing data after zcash-batch-sig-result".to_string(), + )); } - require_key( - &seen_keys, - MESSAGE_ID, - d, - "missing zcash-msg-sig message id", - )?; - require_key(&seen_keys, SIGS, d, "missing zcash-msg-sig sigs")?; Ok(result) } } -impl<'b, C> minicbor::Decode<'b, C> for ZcashActionSig { - fn decode(d: &mut Decoder<'b>, _ctx: &mut C) -> Result { - let mut result = ZcashActionSig::default(); - let len = d.map()?.ok_or_else(|| { - minicbor::decode::Error::message("indefinite zcash-action-sig map is unsupported") - .at(d.position()) - })?; - let mut seen_keys = Vec::new(); - for _ in 0..len { - let key = d.u8()?; - reject_duplicate_key( - &mut seen_keys, - key, - d, - "duplicate key in zcash-action-sig map", - )?; - match key { - POOL => result.pool = d.u32()?, - ACTION_INDEX => result.action_index = d.u32()?, - SIG => result.sig = d.bytes()?.to_vec(), - _ => d.skip()?, - } - } - require_key(&seen_keys, POOL, d, "missing zcash-action-sig pool")?; - require_key( - &seen_keys, - ACTION_INDEX, - d, - "missing zcash-action-sig action index", - )?; - require_key(&seen_keys, SIG, d, "missing zcash-action-sig sig")?; - Ok(result) +impl TryInto> for ZcashBatchSigResult { + type Error = URError; + + fn try_into(self) -> URResult> { + minicbor::to_vec(self).map_err(|e| URError::CborEncodeError(e.to_string())) } } @@ -390,224 +105,81 @@ mod tests { use super::*; use alloc::vec; - #[test] - fn test_zcash_batch_sig_result_encode_decode() { - let result = ZcashBatchSigResult::new( - ZCASH_BATCH_SIG_RESULT_VERSION, - vec![0xaa, 0xbb], - vec![ - ZcashMsgSig::new( - vec![0x01], - vec![ - ZcashActionSig::new(ZCASH_SIG_POOL_ORCHARD, 0, vec![0x11; ZCASH_SIG_LEN]), - ZcashActionSig::new(ZCASH_SIG_POOL_IRONWOOD, 3, vec![0x22; ZCASH_SIG_LEN]), - ], - ), - ZcashMsgSig::new( - vec![0x02, 0x03], - vec![ZcashActionSig::new( - ZCASH_SIG_POOL_ORCHARD, - 7, - vec![0x33; ZCASH_SIG_LEN], - )], - ), - ], - ); - - let encoded: Vec = result.clone().try_into().unwrap(); - let decoded = ZcashBatchSigResult::try_from(encoded).unwrap(); - - assert_eq!(decoded.get_version(), ZCASH_BATCH_SIG_RESULT_VERSION); - assert_eq!(decoded.get_request_id(), &vec![0xaa, 0xbb]); - assert_eq!(decoded.get_results().len(), 2); - - let first = &decoded.get_results()[0]; - assert_eq!(first.get_message_id(), &vec![0x01]); - assert_eq!(first.get_sigs().len(), 2); - assert_eq!(first.get_sigs()[0].get_pool(), ZCASH_SIG_POOL_ORCHARD); - assert_eq!(first.get_sigs()[0].get_action_index(), 0); - assert_eq!(first.get_sigs()[0].get_sig(), &vec![0x11; ZCASH_SIG_LEN]); - assert_eq!(first.get_sigs()[1].get_pool(), ZCASH_SIG_POOL_IRONWOOD); - assert_eq!(first.get_sigs()[1].get_action_index(), 3); - assert_eq!(first.get_sigs()[1].get_sig(), &vec![0x22; ZCASH_SIG_LEN]); - - let second = &decoded.get_results()[1]; - assert_eq!(second.get_message_id(), &vec![0x02, 0x03]); - assert_eq!(second.get_sigs().len(), 1); - assert_eq!(second.get_sigs()[0].get_pool(), ZCASH_SIG_POOL_ORCHARD); - assert_eq!(second.get_sigs()[0].get_action_index(), 7); - assert_eq!(second.get_sigs()[0].get_sig(), &vec![0x33; ZCASH_SIG_LEN]); + // Serialization of an empty PCZT BatchSignResponse. The response format is + // "PCZS" || batch_version_le || Postcard body. + fn empty_batch_response() -> Vec { + vec![b'P', b'C', b'Z', b'S', 1, 0, 0, 0, 0] } #[test] - fn test_zcash_batch_sig_result_empty_results() { - let result = - ZcashBatchSigResult::new(ZCASH_BATCH_SIG_RESULT_VERSION, vec![0x01, 0x02], vec![]); + fn round_trip() { + let data = empty_batch_response(); + let result = ZcashBatchSigResult::new(data.clone()); - let encoded: Vec = result.clone().try_into().unwrap(); + let encoded: Vec = result.try_into().unwrap(); let decoded = ZcashBatchSigResult::try_from(encoded).unwrap(); - assert_eq!(decoded.get_version(), ZCASH_BATCH_SIG_RESULT_VERSION); - assert_eq!(decoded.get_request_id(), &vec![0x01, 0x02]); - assert!(decoded.get_results().is_empty()); + assert_eq!(decoded.get_data(), data); } #[test] - fn test_zcash_batch_sig_result_rejects_duplicate_keys() { - let duplicate_version_keys = vec![0xa2, VERSION, 0x01, VERSION, 0x02]; + fn wire_encoding_is_stable() { + let encoded: Vec = ZcashBatchSigResult::new(empty_batch_response()) + .try_into() + .unwrap(); - let err = ZcashBatchSigResult::try_from(duplicate_version_keys).unwrap_err(); - - assert!(err.to_string().contains("duplicate key")); + assert_eq!(hex::encode(encoded), "a1014950435a530100000000"); } #[test] - fn test_zcash_batch_sig_result_rejects_trailing_data() { - let result = ZcashBatchSigResult::new(ZCASH_BATCH_SIG_RESULT_VERSION, vec![], vec![]); - let mut encoded: Vec = result.try_into().unwrap(); - encoded.push(0x00); - - let err = ZcashBatchSigResult::try_from(encoded).unwrap_err(); - - assert!(err.to_string().contains("trailing data")); - } - - #[test] - fn test_zcash_batch_sig_result_rejects_missing_required_top_level_key() { - // map(2) { VERSION: 1, REQUEST_ID: h'01' } — missing RESULTS. - let missing_results = vec![0xa2, VERSION, 0x01, REQUEST_ID, 0x41, 0x01]; - - let err = ZcashBatchSigResult::try_from(missing_results).unwrap_err(); + fn preserves_empty_data() { + let encoded: Vec = ZcashBatchSigResult::new(vec![]).try_into().unwrap(); + let decoded = ZcashBatchSigResult::try_from(encoded).unwrap(); - assert!( - err.to_string() - .contains("missing zcash-batch-sig-result results") - ); + assert!(decoded.get_data().is_empty()); } #[test] - fn test_zcash_batch_sig_result_rejects_missing_required_action_key() { - // map(3) { - // VERSION: 1, - // REQUEST_ID: h'01', - // RESULTS: [ map(2) { MESSAGE_ID: h'02', SIGS: [ map(2) { POOL: 0, SIG: h'' } ] } ] - // } — the action map is missing ACTION_INDEX. - let missing_action_index = vec![ - 0xa3, VERSION, 0x01, REQUEST_ID, 0x41, 0x01, RESULTS, 0x81, 0xa2, MESSAGE_ID, 0x41, - 0x02, SIGS, 0x81, 0xa2, POOL, 0x00, SIG, 0x40, - ]; - - let err = ZcashBatchSigResult::try_from(missing_action_index).unwrap_err(); - - assert!( - err.to_string() - .contains("missing zcash-action-sig action index") - ); - } + fn rejects_missing_data() { + let err = ZcashBatchSigResult::try_from(vec![0xa1, 0x09, 0x00]).unwrap_err(); - #[test] - fn test_zcash_batch_sig_result_decodes_literal_cbor_fixture() { - // { 1: 1, 2: h'aabb', 3: [ { 1: h'01', 2: [ { 1: 0, 2: 3, 3: h'11'*64 } ] } ] } - let mut cbor = hex::decode("a301010242aabb0381a20141010281a301000203035840").unwrap(); - cbor.extend(vec![0x11u8; ZCASH_SIG_LEN]); - - let decoded = ZcashBatchSigResult::try_from(cbor.clone()).unwrap(); - assert_eq!(decoded.get_version(), ZCASH_BATCH_SIG_RESULT_VERSION); - assert_eq!(decoded.get_request_id(), &vec![0xaa, 0xbb]); - assert_eq!(decoded.get_results().len(), 1); - assert_eq!(decoded.get_results()[0].get_message_id(), &vec![0x01]); - assert_eq!(decoded.get_results()[0].get_sigs().len(), 1); - let action = &decoded.get_results()[0].get_sigs()[0]; - assert_eq!(action.get_pool(), ZCASH_SIG_POOL_ORCHARD); - assert_eq!(action.get_action_index(), 3); - assert_eq!(action.get_sig(), &vec![0x11u8; ZCASH_SIG_LEN]); - - // An independent wallet implementation consumes this wire format, so pin - // it: re-encoding the equivalent value must reproduce these exact bytes. - let reencoded: Vec = ZcashBatchSigResult::new( - ZCASH_BATCH_SIG_RESULT_VERSION, - vec![0xaa, 0xbb], - vec![ZcashMsgSig::new( - vec![0x01], - vec![ZcashActionSig::new( - ZCASH_SIG_POOL_ORCHARD, - 3, - vec![0x11u8; ZCASH_SIG_LEN], - )], - )], - ) - .try_into() - .unwrap(); - assert_eq!(reencoded, cbor); + assert!(err + .to_string() + .contains("missing zcash-batch-sig-result data")); } #[test] - fn test_zcash_batch_sig_result_skips_unknown_fields() { - // Base value { 1:1, 2:h'', 3:[ { 1:h'', 2:[ { 1:0, 2:0, 3:h'' } ] } ] } with an - // extra unknown key 9 -> [1, {"x": true}]: first at the top level (map a3->a4), - // then inside the action-sig map (map a3->a4). Both must decode the known fields. - let fixtures = [ - "a4010102400381a201400281a3010002000340098201a16178f5", - "a3010102400381a201400281a4010002000340098201a16178f5", - ]; - for cbor_hex in fixtures { - let decoded = ZcashBatchSigResult::try_from(hex::decode(cbor_hex).unwrap()).unwrap(); - assert_eq!(decoded.get_version(), ZCASH_BATCH_SIG_RESULT_VERSION); - assert_eq!(decoded.get_results().len(), 1); - assert_eq!(decoded.get_results()[0].get_sigs().len(), 1); - let action = &decoded.get_results()[0].get_sigs()[0]; - assert_eq!(action.get_pool(), ZCASH_SIG_POOL_ORCHARD); - assert_eq!(action.get_action_index(), 0); - } - } + fn rejects_duplicate_keys() { + let err = ZcashBatchSigResult::try_from(vec![0xa2, 0x01, 0x40, 0x01, 0x40]).unwrap_err(); - #[test] - fn test_zcash_batch_sig_result_rejects_indefinite_top_level_map() { - let err = ZcashBatchSigResult::try_from(vec![0xbf, 0xff]).unwrap_err(); - - assert!( - err.to_string() - .contains("indefinite zcash-batch-sig-result map") - ); + assert!(err + .to_string() + .contains("duplicate key in zcash-batch-sig-result map")); } #[test] - fn test_zcash_batch_sig_result_rejects_indefinite_results_array() { - let indefinite_results = vec![0xa3, VERSION, 0x01, REQUEST_ID, 0x40, RESULTS, 0x9f, 0xff]; + fn rejects_trailing_data() { + let mut encoded: Vec = ZcashBatchSigResult::new(empty_batch_response()) + .try_into() + .unwrap(); + encoded.push(0); - let err = ZcashBatchSigResult::try_from(indefinite_results).unwrap_err(); + let err = ZcashBatchSigResult::try_from(encoded).unwrap_err(); - assert!(err.to_string().contains("results array")); + assert!(err.to_string().contains("trailing data")); } #[test] - fn test_zcash_batch_sig_result_rejects_indefinite_msg_sig_map() { - let indefinite_msg_sig = - vec![0xa3, VERSION, 0x01, REQUEST_ID, 0x40, RESULTS, 0x81, 0xbf, 0xff]; - - let err = ZcashBatchSigResult::try_from(indefinite_msg_sig).unwrap_err(); + fn skips_unknown_keys() { + let encoded = hex::decode("a2014950435a5301000000000982010a").unwrap(); - assert!(err.to_string().contains("indefinite zcash-msg-sig map")); - } + let decoded = ZcashBatchSigResult::try_from(encoded).unwrap(); - #[test] - fn test_zcash_batch_sig_result_rejects_duplicate_msg_sig_keys() { - // A msg-sig map carrying MESSAGE_ID twice. - let duplicate_message_id = vec![ - 0xa3, VERSION, 0x01, REQUEST_ID, 0x40, RESULTS, 0x81, 0xa2, MESSAGE_ID, 0x40, - MESSAGE_ID, 0x40, - ]; - - let err = ZcashBatchSigResult::try_from(duplicate_message_id).unwrap_err(); - - assert!( - err.to_string() - .contains("duplicate key in zcash-msg-sig map") - ); + assert_eq!(decoded.get_data(), empty_batch_response()); } #[test] - fn test_registry_type() { + fn registry_type() { assert_eq!( ZcashBatchSigResult::get_registry_type().get_type(), "zcash-batch-sig-result" From 6aa779462cd8ed881b621252d789623b15348911 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Fri, 10 Jul 2026 13:42:37 +0200 Subject: [PATCH 3/6] Use serialized PCZT batch requests --- libs/ur-parse-lib/src/keystone_ur_encoder.rs | 84 +-- .../ur-registry/src/zcash/zcash_sign_batch.rs | 646 +++--------------- .../src/zcash/zcash_sign_result.rs | 7 +- 3 files changed, 95 insertions(+), 642 deletions(-) diff --git a/libs/ur-parse-lib/src/keystone_ur_encoder.rs b/libs/ur-parse-lib/src/keystone_ur_encoder.rs index 6a58d5a..bc79e3c 100644 --- a/libs/ur-parse-lib/src/keystone_ur_encoder.rs +++ b/libs/ur-parse-lib/src/keystone_ur_encoder.rs @@ -109,10 +109,7 @@ mod tests { use ur_registry::extend::qr_hardware_call::QRHardwareCall; use ur_registry::traits::{RegistryItem, UR}; use ur_registry::zcash::zcash_batch_sig_result::ZcashBatchSigResult; - use ur_registry::zcash::zcash_sign_batch::{ - ZcashSignBatch, ZcashSignMessage, ZCASH_SIGN_BATCH_NETWORK_MAINNET, - ZCASH_SIGN_BATCH_VERSION, ZCASH_SIGN_MESSAGE_KIND_PCZT_V1, - }; + use ur_registry::zcash::zcash_sign_batch::ZcashSignBatch; use ur_registry::zcash::zcash_sign_result::{ ZcashSignMessageResult, ZcashSignResult, ZCASH_SIGN_RESULT_KIND_PCZT_V1, ZCASH_SIGN_RESULT_VERSION, ZCASH_SIGN_STATUS_SIGNED, @@ -120,54 +117,21 @@ mod tests { #[test] fn test_encode_decode_zcash_sign_batch_ur() { - let payload_digest = - Vec::from_hex("7a66e6be087afee0665161828bd11dc1e201fdb8c2d72786ee485e29897c8da4") - .unwrap(); - let batch = ZcashSignBatch::new( - ZCASH_SIGN_BATCH_VERSION, - vec![0xaa, 0xbb], - ZCASH_SIGN_BATCH_NETWORK_MAINNET, - vec![ZcashSignMessage::new( - vec![0x01], - ZCASH_SIGN_MESSAGE_KIND_PCZT_V1, - b"pczt-request".to_vec(), - Some(payload_digest.clone()), - )], - Some(false), - ); + // Serialization of an empty PCZT BatchSignRequest: + // "PCZB" || batch_version_le || pczt_version_le || Postcard body. + let request = vec![b'P', b'C', b'Z', b'B', 1, 0, 0, 0, 2, 0, 0, 0, 0]; + let batch = ZcashSignBatch::new(request.clone()); let cbor: Vec = batch.try_into().unwrap(); let encoded = probe_encode(&cbor, 400, ZcashSignBatch::get_registry_type().get_type()).unwrap(); - let literal_ur = "ur:zcash-sign-batch/onadadaofwpkrkaxadaalyoxadfpadaoadaxgsjoiaknjydpjpihjskpihjkjyamhdcxkniyvarnayknzevtiygyhslfluttcasevoadzcrosatsdilnwyfdhydtldkelgoxbdwkeccarlis"; assert!(!encoded.is_multi_part); - assert_eq!(encoded.data, literal_ur); - let decoded: URParseResult = probe_decode(literal_ur.to_string()).unwrap(); + let decoded: URParseResult = probe_decode(encoded.data).unwrap(); let decoded_batch = decoded.data.unwrap(); assert_eq!(decoded.ur_type.unwrap().get_type_str(), "zcash-sign-batch"); - assert_eq!(decoded_batch.get_version(), ZCASH_SIGN_BATCH_VERSION); - assert_eq!(decoded_batch.get_request_id(), &vec![0xaa, 0xbb]); - assert_eq!( - decoded_batch.get_network(), - ZCASH_SIGN_BATCH_NETWORK_MAINNET - ); - assert!(!decoded_batch.get_atomic()); - assert_eq!(decoded_batch.get_messages().len(), 1); - assert_eq!(decoded_batch.get_messages()[0].get_id(), &vec![0x01]); - assert_eq!( - decoded_batch.get_messages()[0].get_kind(), - ZCASH_SIGN_MESSAGE_KIND_PCZT_V1 - ); - assert_eq!( - decoded_batch.get_messages()[0].get_payload(), - &b"pczt-request".to_vec() - ); - assert_eq!( - decoded_batch.get_messages()[0].get_payload_digest(), - Some(&payload_digest) - ); + assert_eq!(decoded_batch.get_data(), request); } #[test] @@ -255,21 +219,7 @@ mod tests { #[test] fn test_encode_decode_multipart_zcash_sign_batch_ur() { let payload = vec![0x42; 1024]; - let payload_digest = - Vec::from_hex("7a66e6be087afee0665161828bd11dc1e201fdb8c2d72786ee485e29897c8da4") - .unwrap(); - let batch = ZcashSignBatch::new( - ZCASH_SIGN_BATCH_VERSION, - vec![0xaa, 0xbb], - ZCASH_SIGN_BATCH_NETWORK_MAINNET, - vec![ZcashSignMessage::new( - vec![0x01], - ZCASH_SIGN_MESSAGE_KIND_PCZT_V1, - payload.clone(), - Some(payload_digest.clone()), - )], - Some(true), - ); + let batch = ZcashSignBatch::new(payload.clone()); let cbor: Vec = batch.try_into().unwrap(); let encoded = probe_encode(&cbor, 100, ZcashSignBatch::get_registry_type().get_type()).unwrap(); @@ -293,23 +243,7 @@ mod tests { } let decoded_batch = decoded_batch.unwrap(); - assert_eq!(decoded_batch.get_version(), ZCASH_SIGN_BATCH_VERSION); - assert_eq!(decoded_batch.get_request_id(), &vec![0xaa, 0xbb]); - assert_eq!( - decoded_batch.get_network(), - ZCASH_SIGN_BATCH_NETWORK_MAINNET - ); - assert!(decoded_batch.get_atomic()); - assert_eq!(decoded_batch.get_messages().len(), 1); - assert_eq!( - decoded_batch.get_messages()[0].get_kind(), - ZCASH_SIGN_MESSAGE_KIND_PCZT_V1 - ); - assert_eq!(decoded_batch.get_messages()[0].get_payload(), &payload); - assert_eq!( - decoded_batch.get_messages()[0].get_payload_digest(), - Some(&payload_digest) - ); + assert_eq!(decoded_batch.get_data(), payload); } #[test] diff --git a/libs/ur-registry/src/zcash/zcash_sign_batch.rs b/libs/ur-registry/src/zcash/zcash_sign_batch.rs index 2d0006c..16e996c 100644 --- a/libs/ur-registry/src/zcash/zcash_sign_batch.rs +++ b/libs/ur-registry/src/zcash/zcash_sign_batch.rs @@ -1,103 +1,32 @@ -//! Zcash signing batch Registry Type. +//! Zcash PCZT batch signing request Registry Type. //! -//! This module implements CBOR encoding and decoding for batches of Zcash -//! signing messages. Each message carries a stable message id, a kind, and the -//! raw payload that should be signed. -//! -//! This is a registry container, not a protocol policy validator. Decode checks -//! CBOR shape, required fields, duplicate CBOR map keys, and trailing data, then -//! preserves registry values as supplied. Callers enforce policy such as -//! supported versions, networks, message kinds, unique message ids, digest -//! validity, and batch signing semantics. +//! The payload is the opaque output of +//! `pczt::roles::signer::batch::BatchSignRequest::serialize`. The PCZT crate +//! owns the versioned request encoding and its semantic validation; this +//! registry type only provides the outer UR/CBOR envelope. + +use alloc::{string::ToString, vec::Vec}; + +use minicbor::data::Int; -use super::cbor_helpers::{reject_duplicate_key, require_key}; use crate::{ + cbor::cbor_map, + error::{URError, URResult}, + impl_template_struct, registry_types::{RegistryType, ZCASH_SIGN_BATCH}, traits::{MapSize, RegistryItem}, + types::Bytes, }; -use alloc::string::ToString; -use alloc::vec; -use alloc::vec::Vec; -use minicbor::data::Int; -use minicbor::{Decoder, Encoder}; - -use crate::error::{URError, URResult}; - -/// Registered batch version used by producers. Decode preserves any `u32` -/// version so callers can decide protocol policy. -pub const ZCASH_SIGN_BATCH_VERSION: u32 = 1; -/// Registered mainnet network value used by producers. Decode preserves any -/// `u32` network so callers can decide protocol policy. -pub const ZCASH_SIGN_BATCH_NETWORK_MAINNET: u32 = 1; -/// Registered PCZT v1 message kind used by producers. Decode preserves any -/// `u32` kind so callers can decide protocol policy. -pub const ZCASH_SIGN_MESSAGE_KIND_PCZT_V1: u32 = 1; - -const VERSION: u8 = 1; -const REQUEST_ID: u8 = 2; -const NETWORK: u8 = 3; -const MESSAGES: u8 = 4; -const ATOMIC: u8 = 11; - -const MESSAGE_ID: u8 = 1; -const MESSAGE_KIND: u8 = 2; -const MESSAGE_PAYLOAD: u8 = 3; -const MESSAGE_PAYLOAD_DIGEST: u8 = 6; - -#[derive(Clone, Debug, Default)] -pub struct ZcashSignBatch { - version: u32, - request_id: Vec, - network: u32, - messages: Vec, - atomic: Option, -} - -impl ZcashSignBatch { - /// Builds a signing batch container. The SDK does not validate protocol - /// policy such as supported version, network, or duplicate message ids here. - pub fn new( - version: u32, - request_id: Vec, - network: u32, - messages: Vec, - atomic: Option, - ) -> Self { - Self { - version, - request_id, - network, - messages, - atomic, - } - } - - pub fn get_version(&self) -> u32 { - self.version - } - pub fn get_request_id(&self) -> &Vec { - &self.request_id - } +use super::cbor_helpers::{reject_duplicate_key, require_key}; - pub fn get_network(&self) -> u32 { - self.network - } +const DATA: u8 = 1; - pub fn get_messages(&self) -> &Vec { - &self.messages - } +impl_template_struct!(ZcashSignBatch { data: Bytes }); - /// Returns the optional `atomic` field exactly as it appeared in the - /// registry container. Use `get_atomic` for the effective default. - pub fn get_atomic_field(&self) -> Option { - self.atomic - } - - /// Returns the effective batch semantics. An omitted `atomic` field - /// defaults to `true`. - pub fn get_atomic(&self) -> bool { - self.atomic.unwrap_or(true) +impl MapSize for ZcashSignBatch { + fn map_size(&self) -> u64 { + 1 } } @@ -107,137 +36,28 @@ impl RegistryItem for ZcashSignBatch { } } -impl MapSize for ZcashSignBatch { - fn map_size(&self) -> u64 { - if self.atomic.is_some() { - 5 - } else { - 4 - } - } -} - -#[derive(Clone, Debug, Default)] -pub struct ZcashSignMessage { - id: Vec, - kind: u32, - payload: Vec, - payload_digest: Option>, -} - -impl ZcashSignMessage { - /// Builds a signing message container. The SDK does not validate protocol - /// policy such as supported kind, id uniqueness, or digest length here. - pub fn new(id: Vec, kind: u32, payload: Vec, payload_digest: Option>) -> Self { - Self { - id, - kind, - payload, - payload_digest, - } - } - - pub fn get_id(&self) -> &Vec { - &self.id - } - - pub fn get_kind(&self) -> u32 { - self.kind - } - - pub fn get_payload(&self) -> &Vec { - &self.payload - } - - pub fn get_payload_digest(&self) -> Option<&Vec> { - self.payload_digest.as_ref() - } -} - -impl MapSize for ZcashSignMessage { - fn map_size(&self) -> u64 { - if self.payload_digest.is_some() { - 4 - } else { - 3 - } - } -} - -impl TryFrom> for ZcashSignBatch { - type Error = URError; - - fn try_from(value: Vec) -> URResult { - let mut decoder = Decoder::new(&value); - let batch = >::decode(&mut decoder, &mut ()) - .map_err(|e| URError::CborDecodeError(e.to_string()))?; - if decoder.position() != value.len() { - return Err(URError::CborDecodeError( - "trailing data after zcash-sign-batch".to_string(), - )); - } - Ok(batch) - } -} - -impl TryInto> for ZcashSignBatch { - type Error = URError; - - fn try_into(self) -> URResult> { - minicbor::to_vec(self).map_err(|e| URError::CborEncodeError(e.to_string())) - } -} - impl minicbor::Encode for ZcashSignBatch { fn encode( &self, - e: &mut Encoder, - ctx: &mut C, - ) -> Result<(), minicbor::encode::Error> { - e.map(self.map_size())?; - e.int(Int::from(VERSION))?.u32(self.version)?; - e.int(Int::from(REQUEST_ID))?.bytes(&self.request_id)?; - e.int(Int::from(NETWORK))?.u32(self.network)?; - e.int(Int::from(MESSAGES))? - .array(self.messages.len() as u64)?; - for message in &self.messages { - message.encode(e, ctx)?; - } - if let Some(atomic) = self.atomic { - e.int(Int::from(ATOMIC))?.bool(atomic)?; - } - Ok(()) - } -} - -impl minicbor::Encode for ZcashSignMessage { - fn encode( - &self, - e: &mut Encoder, + e: &mut minicbor::Encoder, _ctx: &mut C, ) -> Result<(), minicbor::encode::Error> { e.map(self.map_size())?; - e.int(Int::from(MESSAGE_ID))?.bytes(&self.id)?; - e.int(Int::from(MESSAGE_KIND))?.u32(self.kind)?; - e.int(Int::from(MESSAGE_PAYLOAD))?.bytes(&self.payload)?; - if let Some(payload_digest) = self.payload_digest.as_ref() { - e.int(Int::from(MESSAGE_PAYLOAD_DIGEST))? - .bytes(payload_digest)?; - } + e.int(Int::from(DATA))?.bytes(&self.data)?; Ok(()) } } impl<'b, C> minicbor::Decode<'b, C> for ZcashSignBatch { - fn decode(d: &mut Decoder<'b>, ctx: &mut C) -> Result { + fn decode( + d: &mut minicbor::Decoder<'b>, + _ctx: &mut C, + ) -> Result { let mut result = ZcashSignBatch::default(); - let len = d.map()?.ok_or_else(|| { - minicbor::decode::Error::message("indefinite zcash-sign-batch map is unsupported") - .at(d.position()) - })?; let mut seen_keys = Vec::new(); - for _ in 0..len { - let key = d.u8()?; + cbor_map(d, &mut result, |key, obj, d| { + let key = + u8::try_from(key).map_err(|e| minicbor::decode::Error::message(e.to_string()))?; reject_duplicate_key( &mut seen_keys, key, @@ -245,77 +65,37 @@ impl<'b, C> minicbor::Decode<'b, C> for ZcashSignBatch { "duplicate key in zcash-sign-batch map", )?; match key { - VERSION => result.version = d.u32()?, - REQUEST_ID => result.request_id = d.bytes()?.to_vec(), - NETWORK => result.network = d.u32()?, - MESSAGES => { - let mut messages = vec![]; - let len = d.array()?.ok_or_else(|| { - minicbor::decode::Error::message( - "indefinite zcash-sign-batch messages array is unsupported", - ) - .at(d.position()) - })?; - for _ in 0..len { - messages.push(ZcashSignMessage::decode(d, ctx)?); - } - result.messages = messages; - } - ATOMIC => result.atomic = Some(d.bool()?), + DATA => obj.data = d.bytes()?.to_vec(), _ => d.skip()?, } - } - require_key(&seen_keys, VERSION, d, "missing zcash-sign-batch version")?; - require_key( - &seen_keys, - REQUEST_ID, - d, - "missing zcash-sign-batch request id", - )?; - require_key(&seen_keys, NETWORK, d, "missing zcash-sign-batch network")?; - require_key(&seen_keys, MESSAGES, d, "missing zcash-sign-batch messages")?; + Ok(()) + })?; + require_key(&seen_keys, DATA, d, "missing zcash-sign-batch data")?; Ok(result) } } -impl<'b, C> minicbor::Decode<'b, C> for ZcashSignMessage { - fn decode(d: &mut Decoder<'b>, _ctx: &mut C) -> Result { - let mut result = ZcashSignMessage::default(); - let len = d.map()?.ok_or_else(|| { - minicbor::decode::Error::message("indefinite zcash-sign-message map is unsupported") - .at(d.position()) - })?; - let mut seen_keys = Vec::new(); - for _ in 0..len { - let key = d.u8()?; - reject_duplicate_key( - &mut seen_keys, - key, - d, - "duplicate key in zcash-sign-message map", - )?; - match key { - MESSAGE_ID => result.id = d.bytes()?.to_vec(), - MESSAGE_KIND => result.kind = d.u32()?, - MESSAGE_PAYLOAD => result.payload = d.bytes()?.to_vec(), - MESSAGE_PAYLOAD_DIGEST => result.payload_digest = Some(d.bytes()?.to_vec()), - _ => d.skip()?, - } +impl TryFrom> for ZcashSignBatch { + type Error = URError; + + fn try_from(value: Vec) -> URResult { + let mut decoder = minicbor::Decoder::new(&value); + let batch = >::decode(&mut decoder, &mut ()) + .map_err(|e| URError::CborDecodeError(e.to_string()))?; + if decoder.position() != value.len() { + return Err(URError::CborDecodeError( + "trailing data after zcash-sign-batch".to_string(), + )); } - require_key(&seen_keys, MESSAGE_ID, d, "missing zcash-sign-message id")?; - require_key( - &seen_keys, - MESSAGE_KIND, - d, - "missing zcash-sign-message kind", - )?; - require_key( - &seen_keys, - MESSAGE_PAYLOAD, - d, - "missing zcash-sign-message payload", - )?; - Ok(result) + Ok(batch) + } +} + +impl TryInto> for ZcashSignBatch { + type Error = URError; + + fn try_into(self) -> URResult> { + minicbor::to_vec(self).map_err(|e| URError::CborEncodeError(e.to_string())) } } @@ -324,256 +104,62 @@ mod tests { use super::*; use alloc::vec; - #[test] - fn test_zcash_sign_batch_encode_decode() { - let payload_digest = - hex::decode("ee9040f65c341855e070ff438eb0ea9d5b831b2a2c270fb7ef592d750408e3b3") - .unwrap(); - let batch = ZcashSignBatch::new( - ZCASH_SIGN_BATCH_VERSION, - vec![0xaa, 0xbb], - ZCASH_SIGN_BATCH_NETWORK_MAINNET, - vec![ZcashSignMessage::new( - vec![0x01], - ZCASH_SIGN_MESSAGE_KIND_PCZT_V1, - vec![0x02, 0x03], - Some(payload_digest.clone()), - )], - Some(false), - ); - - let encoded: Vec = batch.clone().try_into().unwrap(); - let decoded = ZcashSignBatch::try_from(encoded).unwrap(); - - assert_eq!(decoded.get_version(), ZCASH_SIGN_BATCH_VERSION); - assert_eq!(decoded.get_request_id(), &vec![0xaa, 0xbb]); - assert_eq!(decoded.get_network(), ZCASH_SIGN_BATCH_NETWORK_MAINNET); - assert_eq!(decoded.get_atomic_field(), Some(false)); - assert!(!decoded.get_atomic()); - assert_eq!(decoded.get_messages().len(), 1); - assert_eq!(decoded.get_messages()[0].get_id(), &vec![0x01]); - assert_eq!( - decoded.get_messages()[0].get_kind(), - ZCASH_SIGN_MESSAGE_KIND_PCZT_V1 - ); - assert_eq!(decoded.get_messages()[0].get_payload(), &vec![0x02, 0x03]); - assert_eq!( - decoded.get_messages()[0].get_payload_digest(), - Some(&payload_digest) - ); + // Serialization of an empty PCZT BatchSignRequest. The request format is + // "PCZB" || batch_version_le || pczt_version_le || Postcard body. + fn empty_batch_request() -> Vec { + vec![b'P', b'C', b'Z', b'B', 1, 0, 0, 0, 2, 0, 0, 0, 0] } #[test] - fn test_zcash_sign_batch_decodes_literal_cbor_fixture() { - let cbor = hex::decode( - "a501010242aabb03010481a40141010201034c70637a742d726571756573740658207a66e6be087afee0665161828bd11dc1e201fdb8c2d72786ee485e29897c8da40bf4", - ) - .unwrap(); - let payload_digest = - hex::decode("7a66e6be087afee0665161828bd11dc1e201fdb8c2d72786ee485e29897c8da4") - .unwrap(); - - let decoded = ZcashSignBatch::try_from(cbor.clone()).unwrap(); - - assert_eq!(decoded.get_version(), ZCASH_SIGN_BATCH_VERSION); - assert_eq!(decoded.get_request_id(), &vec![0xaa, 0xbb]); - assert_eq!(decoded.get_network(), ZCASH_SIGN_BATCH_NETWORK_MAINNET); - assert_eq!(decoded.get_atomic_field(), Some(false)); - assert!(!decoded.get_atomic()); - assert_eq!(decoded.get_messages().len(), 1); - assert_eq!(decoded.get_messages()[0].get_id(), &vec![0x01]); - assert_eq!( - decoded.get_messages()[0].get_kind(), - ZCASH_SIGN_MESSAGE_KIND_PCZT_V1 - ); - assert_eq!( - decoded.get_messages()[0].get_payload(), - &b"pczt-request".to_vec() - ); - assert_eq!( - decoded.get_messages()[0].get_payload_digest(), - Some(&payload_digest) - ); - assert_eq!( - decoded.get_messages()[0] - .get_payload_digest() - .unwrap() - .len(), - 32 - ); + fn round_trip() { + let data = empty_batch_request(); + let batch = ZcashSignBatch::new(data.clone()); - let batch = ZcashSignBatch::new( - ZCASH_SIGN_BATCH_VERSION, - vec![0xaa, 0xbb], - ZCASH_SIGN_BATCH_NETWORK_MAINNET, - vec![ZcashSignMessage::new( - vec![0x01], - ZCASH_SIGN_MESSAGE_KIND_PCZT_V1, - b"pczt-request".to_vec(), - Some(payload_digest), - )], - Some(false), - ); let encoded: Vec = batch.try_into().unwrap(); - - assert_eq!(encoded, cbor); - } - - #[test] - fn test_zcash_sign_batch_skips_unknown_fields() { - let fixtures = [ - // Unknown top level key 9 with value [1, {"x": true}]. - "a601010242aabb03010481a40141010201034c70637a742d726571756573740658207a66e6be087afee0665161828bd11dc1e201fdb8c2d72786ee485e29897c8da4098201a16178f50bf4", - // Unknown nested message key 9 with value [1, {"x": true}]. - "a501010242aabb03010481a50141010201034c70637a742d72657175657374098201a16178f50658207a66e6be087afee0665161828bd11dc1e201fdb8c2d72786ee485e29897c8da40bf4", - ]; - let payload_digest = - hex::decode("7a66e6be087afee0665161828bd11dc1e201fdb8c2d72786ee485e29897c8da4") - .unwrap(); - - for cbor_hex in fixtures { - let decoded = ZcashSignBatch::try_from(hex::decode(cbor_hex).unwrap()).unwrap(); - - assert_eq!(decoded.get_version(), ZCASH_SIGN_BATCH_VERSION); - assert_eq!(decoded.get_request_id(), &vec![0xaa, 0xbb]); - assert_eq!(decoded.get_network(), ZCASH_SIGN_BATCH_NETWORK_MAINNET); - assert_eq!(decoded.get_atomic_field(), Some(false)); - assert_eq!(decoded.get_messages().len(), 1); - assert_eq!(decoded.get_messages()[0].get_id(), &vec![0x01]); - assert_eq!( - decoded.get_messages()[0].get_kind(), - ZCASH_SIGN_MESSAGE_KIND_PCZT_V1 - ); - assert_eq!( - decoded.get_messages()[0].get_payload(), - &b"pczt-request".to_vec() - ); - assert_eq!( - decoded.get_messages()[0].get_payload_digest(), - Some(&payload_digest) - ); - } - } - - #[test] - fn test_zcash_sign_batch_decodes_unknown_policy_values() { - let batch = ZcashSignBatch::new( - 99, - vec![0xaa, 0xbb], - 42, - vec![ZcashSignMessage::new( - vec![0x01], - 77, - b"policy-is-external".to_vec(), - None, - )], - Some(true), - ); - let encoded: Vec = batch.try_into().unwrap(); - let decoded = ZcashSignBatch::try_from(encoded).unwrap(); - assert_eq!(decoded.get_version(), 99); - assert_eq!(decoded.get_request_id(), &vec![0xaa, 0xbb]); - assert_eq!(decoded.get_network(), 42); - assert_eq!(decoded.get_atomic_field(), Some(true)); - assert!(decoded.get_atomic()); - assert_eq!(decoded.get_messages().len(), 1); - assert_eq!(decoded.get_messages()[0].get_id(), &vec![0x01]); - assert_eq!(decoded.get_messages()[0].get_kind(), 77); - assert_eq!( - decoded.get_messages()[0].get_payload(), - &b"policy-is-external".to_vec() - ); + assert_eq!(decoded.get_data(), data); } #[test] - fn test_zcash_sign_batch_decodes_duplicate_message_ids() { - let batch = ZcashSignBatch::new( - ZCASH_SIGN_BATCH_VERSION, - vec![0xaa, 0xbb], - ZCASH_SIGN_BATCH_NETWORK_MAINNET, - vec![ - ZcashSignMessage::new( - vec![0x01], - ZCASH_SIGN_MESSAGE_KIND_PCZT_V1, - b"first".to_vec(), - None, - ), - ZcashSignMessage::new( - vec![0x01], - ZCASH_SIGN_MESSAGE_KIND_PCZT_V1, - b"second".to_vec(), - None, - ), - ], - Some(true), - ); - let encoded: Vec = batch.try_into().unwrap(); - - let decoded = ZcashSignBatch::try_from(encoded).unwrap(); + fn wire_encoding_is_stable() { + let encoded: Vec = ZcashSignBatch::new(empty_batch_request()) + .try_into() + .unwrap(); - assert_eq!(decoded.get_messages().len(), 2); - assert_eq!(decoded.get_messages()[0].get_id(), &vec![0x01]); - assert_eq!(decoded.get_messages()[1].get_id(), &vec![0x01]); - assert_eq!(decoded.get_messages()[0].get_payload(), &b"first".to_vec()); - assert_eq!(decoded.get_messages()[1].get_payload(), &b"second".to_vec()); + assert_eq!(hex::encode(encoded), "a1014d50435a42010000000200000000"); } #[test] - fn test_zcash_sign_batch_defaults_to_atomic() { - let batch = ZcashSignBatch::new(1, vec![], 1, vec![], None); - let encoded: Vec = batch.try_into().unwrap(); + fn preserves_empty_data() { + let encoded: Vec = ZcashSignBatch::new(vec![]).try_into().unwrap(); let decoded = ZcashSignBatch::try_from(encoded).unwrap(); - assert_eq!(decoded.get_atomic_field(), None); - assert!(decoded.get_atomic()); + assert!(decoded.get_data().is_empty()); } #[test] - fn test_zcash_sign_batch_rejects_duplicate_keys() { - let duplicate_version_keys = vec![0xa2, VERSION, 0x01, VERSION, 0x02]; - - let err = ZcashSignBatch::try_from(duplicate_version_keys).unwrap_err(); + fn rejects_missing_data() { + let err = ZcashSignBatch::try_from(vec![0xa1, 0x09, 0x00]).unwrap_err(); - assert!(err.to_string().contains("duplicate key")); + assert!(err.to_string().contains("missing zcash-sign-batch data")); } #[test] - fn test_zcash_sign_batch_rejects_duplicate_message_keys() { - let duplicate_message_id_keys = vec![ - 0xa4, - VERSION, - 0x01, - REQUEST_ID, - 0x40, - NETWORK, - 0x01, - MESSAGES, - 0x81, - 0xa4, - MESSAGE_ID, - 0x40, - MESSAGE_ID, - 0x40, - MESSAGE_KIND, - 0x01, - MESSAGE_PAYLOAD, - 0x40, - ]; - - let err = ZcashSignBatch::try_from(duplicate_message_id_keys).unwrap_err(); + fn rejects_duplicate_keys() { + let err = ZcashSignBatch::try_from(vec![0xa2, 0x01, 0x40, 0x01, 0x40]).unwrap_err(); assert!(err .to_string() - .contains("duplicate key in zcash-sign-message map")); + .contains("duplicate key in zcash-sign-batch map")); } #[test] - fn test_zcash_sign_batch_rejects_trailing_data() { - let batch = ZcashSignBatch::new(1, vec![], 1, vec![], None); - let mut encoded: Vec = batch.try_into().unwrap(); - encoded.push(0x00); + fn rejects_trailing_data() { + let mut encoded: Vec = ZcashSignBatch::new(empty_batch_request()) + .try_into() + .unwrap(); + encoded.push(0); let err = ZcashSignBatch::try_from(encoded).unwrap_err(); @@ -581,80 +167,16 @@ mod tests { } #[test] - fn test_zcash_sign_batch_rejects_indefinite_top_level_map() { - let indefinite_map = vec![0xbf, 0xff]; + fn skips_unknown_keys() { + let encoded = hex::decode("a2014d50435a420100000002000000000982010a").unwrap(); - let err = ZcashSignBatch::try_from(indefinite_map).unwrap_err(); - - assert!(err.to_string().contains("indefinite zcash-sign-batch map")); - } - - #[test] - fn test_zcash_sign_batch_rejects_indefinite_messages_array() { - let indefinite_messages = vec![ - 0xa4, VERSION, 0x01, REQUEST_ID, 0x40, NETWORK, 0x01, MESSAGES, 0x9f, 0xff, - ]; - - let err = ZcashSignBatch::try_from(indefinite_messages).unwrap_err(); - - assert!(err.to_string().contains("messages array")); - } - - #[test] - fn test_zcash_sign_batch_rejects_indefinite_message_map() { - let indefinite_message = vec![ - 0xa4, VERSION, 0x01, REQUEST_ID, 0x40, NETWORK, 0x01, MESSAGES, 0x81, 0xbf, 0xff, - ]; - - let err = ZcashSignBatch::try_from(indefinite_message).unwrap_err(); - - assert!(err - .to_string() - .contains("indefinite zcash-sign-message map")); - } - - #[test] - fn test_zcash_sign_batch_rejects_missing_required_top_level_key() { - for (cbor_hex, message) in [ - ("a3024003010480", "missing zcash-sign-batch version"), - ("a3010103010480", "missing zcash-sign-batch request id"), - ("a3010102400480", "missing zcash-sign-batch network"), - ("a3010102400301", "missing zcash-sign-batch messages"), - ] { - let cbor = hex::decode(cbor_hex).unwrap(); - - let err = ZcashSignBatch::try_from(cbor).unwrap_err(); - - assert!(err.to_string().contains(message)); - } - } + let decoded = ZcashSignBatch::try_from(encoded).unwrap(); - #[test] - fn test_zcash_sign_batch_rejects_missing_required_message_key() { - for (cbor_hex, message) in [ - ( - "a40101024003010481a202010340", - "missing zcash-sign-message id", - ), - ( - "a40101024003010481a201400340", - "missing zcash-sign-message kind", - ), - ( - "a40101024003010481a201400201", - "missing zcash-sign-message payload", - ), - ] { - let cbor = hex::decode(cbor_hex).unwrap(); - - let err = ZcashSignBatch::try_from(cbor).unwrap_err(); - - assert!(err.to_string().contains(message)); - } + assert_eq!(decoded.get_data(), empty_batch_request()); } #[test] - fn test_registry_type() { + fn registry_type() { assert_eq!( ZcashSignBatch::get_registry_type().get_type(), "zcash-sign-batch" diff --git a/libs/ur-registry/src/zcash/zcash_sign_result.rs b/libs/ur-registry/src/zcash/zcash_sign_result.rs index 3ab59d2..b207667 100644 --- a/libs/ur-registry/src/zcash/zcash_sign_result.rs +++ b/libs/ur-registry/src/zcash/zcash_sign_result.rs @@ -9,10 +9,7 @@ //! correlation, supported versions, result status, result kind, unique ids, //! digest validity, and expected result count. -use super::{ - cbor_helpers::{reject_duplicate_key, require_key}, - zcash_sign_batch::ZCASH_SIGN_MESSAGE_KIND_PCZT_V1, -}; +use super::cbor_helpers::{reject_duplicate_key, require_key}; use crate::{ registry_types::{RegistryType, ZCASH_SIGN_RESULT}, traits::{MapSize, RegistryItem}, @@ -30,7 +27,7 @@ use crate::error::{URError, URResult}; pub const ZCASH_SIGN_RESULT_VERSION: u32 = 1; /// Registered PCZT v1 result kind used by producers. Result kind mirrors the /// request message kind so callers can correlate request and response policy. -pub const ZCASH_SIGN_RESULT_KIND_PCZT_V1: u32 = ZCASH_SIGN_MESSAGE_KIND_PCZT_V1; +pub const ZCASH_SIGN_RESULT_KIND_PCZT_V1: u32 = 1; /// Registered status for a signed result. Decode preserves any `u32` status so /// callers can decide protocol policy. pub const ZCASH_SIGN_STATUS_SIGNED: u32 = 0; From 48a4f3879da351a8770bc1493189e9b505f28f9b Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Fri, 10 Jul 2026 13:45:42 +0200 Subject: [PATCH 4/6] Borrow Zcash batch payload bytes --- .../src/zcash/zcash_batch_sig_result.rs | 16 ++++++++++++++-- libs/ur-registry/src/zcash/zcash_sign_batch.rs | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs b/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs index b3ab840..3a5e915 100644 --- a/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs +++ b/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs @@ -12,7 +12,6 @@ use minicbor::data::Int; use crate::{ cbor::cbor_map, error::{URError, URResult}, - impl_template_struct, registry_types::{RegistryType, ZCASH_BATCH_SIG_RESULT}, traits::{MapSize, RegistryItem}, types::Bytes, @@ -22,7 +21,20 @@ use super::cbor_helpers::{reject_duplicate_key, require_key}; const DATA: u8 = 1; -impl_template_struct!(ZcashBatchSigResult { data: Bytes }); +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ZcashBatchSigResult { + data: Bytes, +} + +impl ZcashBatchSigResult { + pub fn new(data: Bytes) -> Self { + Self { data } + } + + pub fn get_data(&self) -> &[u8] { + &self.data + } +} impl MapSize for ZcashBatchSigResult { fn map_size(&self) -> u64 { diff --git a/libs/ur-registry/src/zcash/zcash_sign_batch.rs b/libs/ur-registry/src/zcash/zcash_sign_batch.rs index 16e996c..91457cc 100644 --- a/libs/ur-registry/src/zcash/zcash_sign_batch.rs +++ b/libs/ur-registry/src/zcash/zcash_sign_batch.rs @@ -12,7 +12,6 @@ use minicbor::data::Int; use crate::{ cbor::cbor_map, error::{URError, URResult}, - impl_template_struct, registry_types::{RegistryType, ZCASH_SIGN_BATCH}, traits::{MapSize, RegistryItem}, types::Bytes, @@ -22,7 +21,20 @@ use super::cbor_helpers::{reject_duplicate_key, require_key}; const DATA: u8 = 1; -impl_template_struct!(ZcashSignBatch { data: Bytes }); +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ZcashSignBatch { + data: Bytes, +} + +impl ZcashSignBatch { + pub fn new(data: Bytes) -> Self { + Self { data } + } + + pub fn get_data(&self) -> &[u8] { + &self.data + } +} impl MapSize for ZcashSignBatch { fn map_size(&self) -> u64 { From 7a6910511cb26ac7f18f924b281a0da15552ebb1 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Fri, 10 Jul 2026 14:50:21 +0200 Subject: [PATCH 5/6] Add request IDs to Zcash batch UR messages --- libs/ur-parse-lib/src/keystone_ur_encoder.rs | 20 ++++-- .../src/zcash/zcash_batch_sig_result.rs | 64 +++++++++++++++---- .../ur-registry/src/zcash/zcash_sign_batch.rs | 64 +++++++++++++++---- 3 files changed, 119 insertions(+), 29 deletions(-) diff --git a/libs/ur-parse-lib/src/keystone_ur_encoder.rs b/libs/ur-parse-lib/src/keystone_ur_encoder.rs index bc79e3c..b482c47 100644 --- a/libs/ur-parse-lib/src/keystone_ur_encoder.rs +++ b/libs/ur-parse-lib/src/keystone_ur_encoder.rs @@ -120,7 +120,8 @@ mod tests { // Serialization of an empty PCZT BatchSignRequest: // "PCZB" || batch_version_le || pczt_version_le || Postcard body. let request = vec![b'P', b'C', b'Z', b'B', 1, 0, 0, 0, 2, 0, 0, 0, 0]; - let batch = ZcashSignBatch::new(request.clone()); + let request_id = vec![0xaa, 0xbb]; + let batch = ZcashSignBatch::new(request_id.clone(), request.clone()); let cbor: Vec = batch.try_into().unwrap(); let encoded = probe_encode(&cbor, 400, ZcashSignBatch::get_registry_type().get_type()).unwrap(); @@ -132,6 +133,7 @@ mod tests { assert_eq!(decoded.ur_type.unwrap().get_type_str(), "zcash-sign-batch"); assert_eq!(decoded_batch.get_data(), request); + assert_eq!(decoded_batch.get_request_id(), request_id); } #[test] @@ -199,10 +201,15 @@ mod tests { // Serialization of an empty PCZT BatchSignResponse: // "PCZS" || batch_version_le || Postcard body. let response = vec![b'P', b'C', b'Z', b'S', 1, 0, 0, 0, 0]; - let result = ZcashBatchSigResult::new(response.clone()); + let request_id = vec![0xaa, 0xbb]; + let result = ZcashBatchSigResult::new(request_id.clone(), response.clone()); let cbor: Vec = result.try_into().unwrap(); - let encoded = - probe_encode(&cbor, 400, ZcashBatchSigResult::get_registry_type().get_type()).unwrap(); + let encoded = probe_encode( + &cbor, + 400, + ZcashBatchSigResult::get_registry_type().get_type(), + ) + .unwrap(); assert!(!encoded.is_multi_part); @@ -214,12 +221,14 @@ mod tests { "zcash-batch-sig-result" ); assert_eq!(decoded_result.get_data(), response); + assert_eq!(decoded_result.get_request_id(), request_id); } #[test] fn test_encode_decode_multipart_zcash_sign_batch_ur() { let payload = vec![0x42; 1024]; - let batch = ZcashSignBatch::new(payload.clone()); + let request_id = vec![0xaa, 0xbb]; + let batch = ZcashSignBatch::new(request_id.clone(), payload.clone()); let cbor: Vec = batch.try_into().unwrap(); let encoded = probe_encode(&cbor, 100, ZcashSignBatch::get_registry_type().get_type()).unwrap(); @@ -244,6 +253,7 @@ mod tests { let decoded_batch = decoded_batch.unwrap(); assert_eq!(decoded_batch.get_data(), payload); + assert_eq!(decoded_batch.get_request_id(), request_id); } #[test] diff --git a/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs b/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs index 3a5e915..ec12bd7 100644 --- a/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs +++ b/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs @@ -3,7 +3,8 @@ //! The payload is the opaque output of //! `pczt::roles::signer::batch::BatchSignResponse::serialize`. The PCZT crate //! owns the versioned response encoding and its semantic validation; this -//! registry type only provides the outer UR/CBOR envelope. +//! registry type only provides the outer UR/CBOR envelope and the request id +//! copied from the corresponding signing request. use alloc::{string::ToString, vec::Vec}; @@ -20,25 +21,31 @@ use crate::{ use super::cbor_helpers::{reject_duplicate_key, require_key}; const DATA: u8 = 1; +const REQUEST_ID: u8 = 2; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct ZcashBatchSigResult { data: Bytes, + request_id: Bytes, } impl ZcashBatchSigResult { - pub fn new(data: Bytes) -> Self { - Self { data } + pub fn new(request_id: Bytes, data: Bytes) -> Self { + Self { data, request_id } } pub fn get_data(&self) -> &[u8] { &self.data } + + pub fn get_request_id(&self) -> &[u8] { + &self.request_id + } } impl MapSize for ZcashBatchSigResult { fn map_size(&self) -> u64 { - 1 + 2 } } @@ -56,6 +63,7 @@ impl minicbor::Encode for ZcashBatchSigResult { ) -> Result<(), minicbor::encode::Error> { e.map(self.map_size())?; e.int(Int::from(DATA))?.bytes(&self.data)?; + e.int(Int::from(REQUEST_ID))?.bytes(&self.request_id)?; Ok(()) } } @@ -78,11 +86,18 @@ impl<'b, C> minicbor::Decode<'b, C> for ZcashBatchSigResult { )?; match key { DATA => obj.data = d.bytes()?.to_vec(), + REQUEST_ID => obj.request_id = d.bytes()?.to_vec(), _ => d.skip()?, } Ok(()) })?; require_key(&seen_keys, DATA, d, "missing zcash-batch-sig-result data")?; + require_key( + &seen_keys, + REQUEST_ID, + d, + "missing zcash-batch-sig-result request id", + )?; Ok(result) } } @@ -126,40 +141,63 @@ mod tests { #[test] fn round_trip() { let data = empty_batch_response(); - let result = ZcashBatchSigResult::new(data.clone()); + let request_id = vec![0xaa, 0xbb]; + let result = ZcashBatchSigResult::new(request_id.clone(), data.clone()); let encoded: Vec = result.try_into().unwrap(); let decoded = ZcashBatchSigResult::try_from(encoded).unwrap(); assert_eq!(decoded.get_data(), data); + assert_eq!(decoded.get_request_id(), request_id); } #[test] fn wire_encoding_is_stable() { - let encoded: Vec = ZcashBatchSigResult::new(empty_batch_response()) + let encoded: Vec = ZcashBatchSigResult::new(vec![0xaa, 0xbb], empty_batch_response()) .try_into() .unwrap(); - assert_eq!(hex::encode(encoded), "a1014950435a530100000000"); + assert_eq!(hex::encode(encoded), "a2014950435a5301000000000242aabb"); } #[test] fn preserves_empty_data() { - let encoded: Vec = ZcashBatchSigResult::new(vec![]).try_into().unwrap(); + let encoded: Vec = ZcashBatchSigResult::new(vec![0xaa, 0xbb], vec![]) + .try_into() + .unwrap(); let decoded = ZcashBatchSigResult::try_from(encoded).unwrap(); assert!(decoded.get_data().is_empty()); } + #[test] + fn preserves_empty_request_id() { + let encoded: Vec = ZcashBatchSigResult::new(vec![], empty_batch_response()) + .try_into() + .unwrap(); + let decoded = ZcashBatchSigResult::try_from(encoded).unwrap(); + + assert!(decoded.get_request_id().is_empty()); + } + #[test] fn rejects_missing_data() { - let err = ZcashBatchSigResult::try_from(vec![0xa1, 0x09, 0x00]).unwrap_err(); + let err = ZcashBatchSigResult::try_from(vec![0xa1, REQUEST_ID, 0x40]).unwrap_err(); assert!(err .to_string() .contains("missing zcash-batch-sig-result data")); } + #[test] + fn rejects_missing_request_id() { + let err = ZcashBatchSigResult::try_from(vec![0xa1, DATA, 0x40]).unwrap_err(); + + assert!(err + .to_string() + .contains("missing zcash-batch-sig-result request id")); + } + #[test] fn rejects_duplicate_keys() { let err = ZcashBatchSigResult::try_from(vec![0xa2, 0x01, 0x40, 0x01, 0x40]).unwrap_err(); @@ -171,9 +209,8 @@ mod tests { #[test] fn rejects_trailing_data() { - let mut encoded: Vec = ZcashBatchSigResult::new(empty_batch_response()) - .try_into() - .unwrap(); + let result = ZcashBatchSigResult::new(vec![0xaa, 0xbb], empty_batch_response()); + let mut encoded: Vec = result.try_into().unwrap(); encoded.push(0); let err = ZcashBatchSigResult::try_from(encoded).unwrap_err(); @@ -183,11 +220,12 @@ mod tests { #[test] fn skips_unknown_keys() { - let encoded = hex::decode("a2014950435a5301000000000982010a").unwrap(); + let encoded = hex::decode("a3014950435a5301000000000242aabb0982010a").unwrap(); let decoded = ZcashBatchSigResult::try_from(encoded).unwrap(); assert_eq!(decoded.get_data(), empty_batch_response()); + assert_eq!(decoded.get_request_id(), &[0xaa, 0xbb]); } #[test] diff --git a/libs/ur-registry/src/zcash/zcash_sign_batch.rs b/libs/ur-registry/src/zcash/zcash_sign_batch.rs index 91457cc..a37c6bc 100644 --- a/libs/ur-registry/src/zcash/zcash_sign_batch.rs +++ b/libs/ur-registry/src/zcash/zcash_sign_batch.rs @@ -3,7 +3,8 @@ //! The payload is the opaque output of //! `pczt::roles::signer::batch::BatchSignRequest::serialize`. The PCZT crate //! owns the versioned request encoding and its semantic validation; this -//! registry type only provides the outer UR/CBOR envelope. +//! registry type only provides the outer UR/CBOR envelope and the request id +//! used to correlate it with a signing result. use alloc::{string::ToString, vec::Vec}; @@ -20,25 +21,31 @@ use crate::{ use super::cbor_helpers::{reject_duplicate_key, require_key}; const DATA: u8 = 1; +const REQUEST_ID: u8 = 2; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct ZcashSignBatch { data: Bytes, + request_id: Bytes, } impl ZcashSignBatch { - pub fn new(data: Bytes) -> Self { - Self { data } + pub fn new(request_id: Bytes, data: Bytes) -> Self { + Self { data, request_id } } pub fn get_data(&self) -> &[u8] { &self.data } + + pub fn get_request_id(&self) -> &[u8] { + &self.request_id + } } impl MapSize for ZcashSignBatch { fn map_size(&self) -> u64 { - 1 + 2 } } @@ -56,6 +63,7 @@ impl minicbor::Encode for ZcashSignBatch { ) -> Result<(), minicbor::encode::Error> { e.map(self.map_size())?; e.int(Int::from(DATA))?.bytes(&self.data)?; + e.int(Int::from(REQUEST_ID))?.bytes(&self.request_id)?; Ok(()) } } @@ -78,11 +86,18 @@ impl<'b, C> minicbor::Decode<'b, C> for ZcashSignBatch { )?; match key { DATA => obj.data = d.bytes()?.to_vec(), + REQUEST_ID => obj.request_id = d.bytes()?.to_vec(), _ => d.skip()?, } Ok(()) })?; require_key(&seen_keys, DATA, d, "missing zcash-sign-batch data")?; + require_key( + &seen_keys, + REQUEST_ID, + d, + "missing zcash-sign-batch request id", + )?; Ok(result) } } @@ -125,38 +140,64 @@ mod tests { #[test] fn round_trip() { let data = empty_batch_request(); - let batch = ZcashSignBatch::new(data.clone()); + let request_id = vec![0xaa, 0xbb]; + let batch = ZcashSignBatch::new(request_id.clone(), data.clone()); let encoded: Vec = batch.try_into().unwrap(); let decoded = ZcashSignBatch::try_from(encoded).unwrap(); assert_eq!(decoded.get_data(), data); + assert_eq!(decoded.get_request_id(), request_id); } #[test] fn wire_encoding_is_stable() { - let encoded: Vec = ZcashSignBatch::new(empty_batch_request()) + let encoded: Vec = ZcashSignBatch::new(vec![0xaa, 0xbb], empty_batch_request()) .try_into() .unwrap(); - assert_eq!(hex::encode(encoded), "a1014d50435a42010000000200000000"); + assert_eq!( + hex::encode(encoded), + "a2014d50435a420100000002000000000242aabb" + ); } #[test] fn preserves_empty_data() { - let encoded: Vec = ZcashSignBatch::new(vec![]).try_into().unwrap(); + let encoded: Vec = ZcashSignBatch::new(vec![0xaa, 0xbb], vec![]) + .try_into() + .unwrap(); let decoded = ZcashSignBatch::try_from(encoded).unwrap(); assert!(decoded.get_data().is_empty()); } + #[test] + fn preserves_empty_request_id() { + let encoded: Vec = ZcashSignBatch::new(vec![], empty_batch_request()) + .try_into() + .unwrap(); + let decoded = ZcashSignBatch::try_from(encoded).unwrap(); + + assert!(decoded.get_request_id().is_empty()); + } + #[test] fn rejects_missing_data() { - let err = ZcashSignBatch::try_from(vec![0xa1, 0x09, 0x00]).unwrap_err(); + let err = ZcashSignBatch::try_from(vec![0xa1, REQUEST_ID, 0x40]).unwrap_err(); assert!(err.to_string().contains("missing zcash-sign-batch data")); } + #[test] + fn rejects_missing_request_id() { + let err = ZcashSignBatch::try_from(vec![0xa1, DATA, 0x40]).unwrap_err(); + + assert!(err + .to_string() + .contains("missing zcash-sign-batch request id")); + } + #[test] fn rejects_duplicate_keys() { let err = ZcashSignBatch::try_from(vec![0xa2, 0x01, 0x40, 0x01, 0x40]).unwrap_err(); @@ -168,7 +209,7 @@ mod tests { #[test] fn rejects_trailing_data() { - let mut encoded: Vec = ZcashSignBatch::new(empty_batch_request()) + let mut encoded: Vec = ZcashSignBatch::new(vec![0xaa, 0xbb], empty_batch_request()) .try_into() .unwrap(); encoded.push(0); @@ -180,11 +221,12 @@ mod tests { #[test] fn skips_unknown_keys() { - let encoded = hex::decode("a2014d50435a420100000002000000000982010a").unwrap(); + let encoded = hex::decode("a3014d50435a420100000002000000000242aabb0982010a").unwrap(); let decoded = ZcashSignBatch::try_from(encoded).unwrap(); assert_eq!(decoded.get_data(), empty_batch_request()); + assert_eq!(decoded.get_request_id(), &[0xaa, 0xbb]); } #[test] From bf5b6e4b5c533e8934c4611837db2b6567ae837e Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Fri, 10 Jul 2026 19:20:50 +0200 Subject: [PATCH 6/6] Reject indefinite Zcash batch CBOR maps --- libs/ur-parse-lib/src/keystone_ur_encoder.rs | 6 +- libs/ur-registry/src/zcash/cbor_helpers.rs | 20 ++++++ .../src/zcash/zcash_batch_sig_result.rs | 70 ++++++++++++------- .../ur-registry/src/zcash/zcash_sign_batch.rs | 63 ++++++++++------- 4 files changed, 105 insertions(+), 54 deletions(-) diff --git a/libs/ur-parse-lib/src/keystone_ur_encoder.rs b/libs/ur-parse-lib/src/keystone_ur_encoder.rs index b482c47..4fdf857 100644 --- a/libs/ur-parse-lib/src/keystone_ur_encoder.rs +++ b/libs/ur-parse-lib/src/keystone_ur_encoder.rs @@ -99,7 +99,7 @@ impl KeystoneUREncoder { #[cfg(test)] mod tests { - use crate::keystone_ur_decoder::{probe_decode, MultiURParseResult, URParseResult}; + use crate::keystone_ur_decoder::{MultiURParseResult, URParseResult, probe_decode}; use crate::keystone_ur_encoder::{cyclic_encode, probe_encode}; use alloc::string::ToString; use alloc::vec; @@ -111,8 +111,8 @@ mod tests { use ur_registry::zcash::zcash_batch_sig_result::ZcashBatchSigResult; use ur_registry::zcash::zcash_sign_batch::ZcashSignBatch; use ur_registry::zcash::zcash_sign_result::{ - ZcashSignMessageResult, ZcashSignResult, ZCASH_SIGN_RESULT_KIND_PCZT_V1, - ZCASH_SIGN_RESULT_VERSION, ZCASH_SIGN_STATUS_SIGNED, + ZCASH_SIGN_RESULT_KIND_PCZT_V1, ZCASH_SIGN_RESULT_VERSION, ZCASH_SIGN_STATUS_SIGNED, + ZcashSignMessageResult, ZcashSignResult, }; #[test] diff --git a/libs/ur-registry/src/zcash/cbor_helpers.rs b/libs/ur-registry/src/zcash/cbor_helpers.rs index e20ba75..6c5d141 100644 --- a/libs/ur-registry/src/zcash/cbor_helpers.rs +++ b/libs/ur-registry/src/zcash/cbor_helpers.rs @@ -1,6 +1,26 @@ use alloc::vec::Vec; use minicbor::Decoder; +/// Decodes a definite length CBOR map with `u8` keys. +pub(super) fn decode_definite_u8_map<'b, T, F>( + d: &mut Decoder<'b>, + obj: &mut T, + message: &'static str, + mut decode_entry: F, +) -> Result<(), minicbor::decode::Error> +where + F: FnMut(u8, &mut T, &mut Decoder<'b>) -> Result<(), minicbor::decode::Error>, +{ + let len = d + .map()? + .ok_or_else(|| minicbor::decode::Error::message(message).at(d.position()))?; + for _ in 0..len { + let key = d.u8()?; + decode_entry(key, obj, d)?; + } + Ok(()) +} + pub(super) fn reject_duplicate_key( seen_keys: &mut Vec, key: u8, diff --git a/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs b/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs index ec12bd7..293fc90 100644 --- a/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs +++ b/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs @@ -11,14 +11,13 @@ use alloc::{string::ToString, vec::Vec}; use minicbor::data::Int; use crate::{ - cbor::cbor_map, error::{URError, URResult}, registry_types::{RegistryType, ZCASH_BATCH_SIG_RESULT}, traits::{MapSize, RegistryItem}, types::Bytes, }; -use super::cbor_helpers::{reject_duplicate_key, require_key}; +use super::cbor_helpers::{decode_definite_u8_map, reject_duplicate_key, require_key}; const DATA: u8 = 1; const REQUEST_ID: u8 = 2; @@ -75,22 +74,25 @@ impl<'b, C> minicbor::Decode<'b, C> for ZcashBatchSigResult { ) -> Result { let mut result = ZcashBatchSigResult::default(); let mut seen_keys = Vec::new(); - cbor_map(d, &mut result, |key, obj, d| { - let key = - u8::try_from(key).map_err(|e| minicbor::decode::Error::message(e.to_string()))?; - reject_duplicate_key( - &mut seen_keys, - key, - d, - "duplicate key in zcash-batch-sig-result map", - )?; - match key { - DATA => obj.data = d.bytes()?.to_vec(), - REQUEST_ID => obj.request_id = d.bytes()?.to_vec(), - _ => d.skip()?, - } - Ok(()) - })?; + decode_definite_u8_map( + d, + &mut result, + "indefinite zcash-batch-sig-result map is unsupported", + |key, obj, d| { + reject_duplicate_key( + &mut seen_keys, + key, + d, + "duplicate key in zcash-batch-sig-result map", + )?; + match key { + DATA => obj.data = d.bytes()?.to_vec(), + REQUEST_ID => obj.request_id = d.bytes()?.to_vec(), + _ => d.skip()?, + } + Ok(()) + }, + )?; require_key(&seen_keys, DATA, d, "missing zcash-batch-sig-result data")?; require_key( &seen_keys, @@ -184,27 +186,41 @@ mod tests { fn rejects_missing_data() { let err = ZcashBatchSigResult::try_from(vec![0xa1, REQUEST_ID, 0x40]).unwrap_err(); - assert!(err - .to_string() - .contains("missing zcash-batch-sig-result data")); + assert!( + err.to_string() + .contains("missing zcash-batch-sig-result data") + ); } #[test] fn rejects_missing_request_id() { let err = ZcashBatchSigResult::try_from(vec![0xa1, DATA, 0x40]).unwrap_err(); - assert!(err - .to_string() - .contains("missing zcash-batch-sig-result request id")); + assert!( + err.to_string() + .contains("missing zcash-batch-sig-result request id") + ); } #[test] fn rejects_duplicate_keys() { let err = ZcashBatchSigResult::try_from(vec![0xa2, 0x01, 0x40, 0x01, 0x40]).unwrap_err(); - assert!(err - .to_string() - .contains("duplicate key in zcash-batch-sig-result map")); + assert!( + err.to_string() + .contains("duplicate key in zcash-batch-sig-result map") + ); + } + + #[test] + fn rejects_indefinite_map() { + let err = ZcashBatchSigResult::try_from(vec![0xbf, DATA, 0x40, REQUEST_ID, 0x40, 0xff]) + .unwrap_err(); + + assert!( + err.to_string() + .contains("indefinite zcash-batch-sig-result map is unsupported") + ); } #[test] diff --git a/libs/ur-registry/src/zcash/zcash_sign_batch.rs b/libs/ur-registry/src/zcash/zcash_sign_batch.rs index a37c6bc..d4c56e5 100644 --- a/libs/ur-registry/src/zcash/zcash_sign_batch.rs +++ b/libs/ur-registry/src/zcash/zcash_sign_batch.rs @@ -11,14 +11,13 @@ use alloc::{string::ToString, vec::Vec}; use minicbor::data::Int; use crate::{ - cbor::cbor_map, error::{URError, URResult}, registry_types::{RegistryType, ZCASH_SIGN_BATCH}, traits::{MapSize, RegistryItem}, types::Bytes, }; -use super::cbor_helpers::{reject_duplicate_key, require_key}; +use super::cbor_helpers::{decode_definite_u8_map, reject_duplicate_key, require_key}; const DATA: u8 = 1; const REQUEST_ID: u8 = 2; @@ -75,22 +74,25 @@ impl<'b, C> minicbor::Decode<'b, C> for ZcashSignBatch { ) -> Result { let mut result = ZcashSignBatch::default(); let mut seen_keys = Vec::new(); - cbor_map(d, &mut result, |key, obj, d| { - let key = - u8::try_from(key).map_err(|e| minicbor::decode::Error::message(e.to_string()))?; - reject_duplicate_key( - &mut seen_keys, - key, - d, - "duplicate key in zcash-sign-batch map", - )?; - match key { - DATA => obj.data = d.bytes()?.to_vec(), - REQUEST_ID => obj.request_id = d.bytes()?.to_vec(), - _ => d.skip()?, - } - Ok(()) - })?; + decode_definite_u8_map( + d, + &mut result, + "indefinite zcash-sign-batch map is unsupported", + |key, obj, d| { + reject_duplicate_key( + &mut seen_keys, + key, + d, + "duplicate key in zcash-sign-batch map", + )?; + match key { + DATA => obj.data = d.bytes()?.to_vec(), + REQUEST_ID => obj.request_id = d.bytes()?.to_vec(), + _ => d.skip()?, + } + Ok(()) + }, + )?; require_key(&seen_keys, DATA, d, "missing zcash-sign-batch data")?; require_key( &seen_keys, @@ -193,18 +195,31 @@ mod tests { fn rejects_missing_request_id() { let err = ZcashSignBatch::try_from(vec![0xa1, DATA, 0x40]).unwrap_err(); - assert!(err - .to_string() - .contains("missing zcash-sign-batch request id")); + assert!( + err.to_string() + .contains("missing zcash-sign-batch request id") + ); } #[test] fn rejects_duplicate_keys() { let err = ZcashSignBatch::try_from(vec![0xa2, 0x01, 0x40, 0x01, 0x40]).unwrap_err(); - assert!(err - .to_string() - .contains("duplicate key in zcash-sign-batch map")); + assert!( + err.to_string() + .contains("duplicate key in zcash-sign-batch map") + ); + } + + #[test] + fn rejects_indefinite_map() { + let err = + ZcashSignBatch::try_from(vec![0xbf, DATA, 0x40, REQUEST_ID, 0x40, 0xff]).unwrap_err(); + + assert!( + err.to_string() + .contains("indefinite zcash-sign-batch map is unsupported") + ); } #[test]