From f6b688eb9b6eb928f400b4cd7d2d623fdbc3b34d Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Mon, 13 Jul 2026 17:23:27 +0400 Subject: [PATCH 1/3] Report firmware version in Zcash batch results --- libs/ur-parse-lib/src/keystone_ur_encoder.rs | 8 +- .../src/zcash/zcash_batch_sig_result.rs | 166 +++++++++++++----- 2 files changed, 131 insertions(+), 43 deletions(-) diff --git a/libs/ur-parse-lib/src/keystone_ur_encoder.rs b/libs/ur-parse-lib/src/keystone_ur_encoder.rs index 4fdf857..72d6e26 100644 --- a/libs/ur-parse-lib/src/keystone_ur_encoder.rs +++ b/libs/ur-parse-lib/src/keystone_ur_encoder.rs @@ -202,7 +202,12 @@ mod tests { // "PCZS" || batch_version_le || Postcard body. let response = vec![b'P', b'C', b'Z', b'S', 1, 0, 0, 0, 0]; let request_id = vec![0xaa, 0xbb]; - let result = ZcashBatchSigResult::new(request_id.clone(), response.clone()); + let firmware_version = vec![12, 5, 0]; + let result = ZcashBatchSigResult::new( + request_id.clone(), + response.clone(), + firmware_version.clone(), + ); let cbor: Vec = result.try_into().unwrap(); let encoded = probe_encode( &cbor, @@ -222,6 +227,7 @@ mod tests { ); assert_eq!(decoded_result.get_data(), response); assert_eq!(decoded_result.get_request_id(), request_id); + assert_eq!(decoded_result.get_firmware_version(), firmware_version); } #[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 293fc90..027f8c1 100644 --- a/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs +++ b/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs @@ -3,8 +3,9 @@ //! 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 and the request id -//! copied from the corresponding signing request. +//! registry type only provides the outer UR/CBOR envelope, the request id +//! copied from the corresponding signing request, and the signing device's +//! firmware version. use alloc::{string::ToString, vec::Vec}; @@ -21,16 +22,22 @@ use super::cbor_helpers::{decode_definite_u8_map, reject_duplicate_key, require_ const DATA: u8 = 1; const REQUEST_ID: u8 = 2; +const FIRMWARE_VERSION: u8 = 3; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct ZcashBatchSigResult { data: Bytes, request_id: Bytes, + firmware_version: Bytes, } impl ZcashBatchSigResult { - pub fn new(request_id: Bytes, data: Bytes) -> Self { - Self { data, request_id } + pub fn new(request_id: Bytes, data: Bytes, firmware_version: Bytes) -> Self { + Self { + data, + request_id, + firmware_version, + } } pub fn get_data(&self) -> &[u8] { @@ -40,11 +47,15 @@ impl ZcashBatchSigResult { pub fn get_request_id(&self) -> &[u8] { &self.request_id } + + pub fn get_firmware_version(&self) -> &[u8] { + &self.firmware_version + } } impl MapSize for ZcashBatchSigResult { fn map_size(&self) -> u64 { - 2 + 3 } } @@ -63,6 +74,8 @@ impl minicbor::Encode for ZcashBatchSigResult { e.map(self.map_size())?; e.int(Int::from(DATA))?.bytes(&self.data)?; e.int(Int::from(REQUEST_ID))?.bytes(&self.request_id)?; + e.int(Int::from(FIRMWARE_VERSION))? + .bytes(&self.firmware_version)?; Ok(()) } } @@ -88,6 +101,7 @@ 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(), + FIRMWARE_VERSION => obj.firmware_version = d.bytes()?.to_vec(), _ => d.skip()?, } Ok(()) @@ -100,6 +114,12 @@ impl<'b, C> minicbor::Decode<'b, C> for ZcashBatchSigResult { d, "missing zcash-batch-sig-result request id", )?; + require_key( + &seen_keys, + FIRMWARE_VERSION, + d, + "missing zcash-batch-sig-result firmware version", + )?; Ok(result) } } @@ -140,33 +160,45 @@ mod tests { vec![b'P', b'C', b'Z', b'S', 1, 0, 0, 0, 0] } + fn firmware_version() -> Vec { + vec![12, 5, 0] + } + #[test] fn round_trip() { let data = empty_batch_response(); let request_id = vec![0xaa, 0xbb]; - let result = ZcashBatchSigResult::new(request_id.clone(), data.clone()); + let firmware_version = firmware_version(); + let result = + ZcashBatchSigResult::new(request_id.clone(), data.clone(), firmware_version.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); + assert_eq!(decoded.get_firmware_version(), firmware_version); } #[test] fn wire_encoding_is_stable() { - let encoded: Vec = ZcashBatchSigResult::new(vec![0xaa, 0xbb], empty_batch_response()) - .try_into() - .unwrap(); + let encoded: Vec = + ZcashBatchSigResult::new(vec![0xaa, 0xbb], empty_batch_response(), firmware_version()) + .try_into() + .unwrap(); - assert_eq!(hex::encode(encoded), "a2014950435a5301000000000242aabb"); + assert_eq!( + hex::encode(encoded), + "a3014950435a5301000000000242aabb03430c0500" + ); } #[test] fn preserves_empty_data() { - let encoded: Vec = ZcashBatchSigResult::new(vec![0xaa, 0xbb], vec![]) - .try_into() - .unwrap(); + let encoded: Vec = + ZcashBatchSigResult::new(vec![0xaa, 0xbb], vec![], firmware_version()) + .try_into() + .unwrap(); let decoded = ZcashBatchSigResult::try_from(encoded).unwrap(); assert!(decoded.get_data().is_empty()); @@ -174,9 +206,10 @@ mod tests { #[test] fn preserves_empty_request_id() { - let encoded: Vec = ZcashBatchSigResult::new(vec![], empty_batch_response()) - .try_into() - .unwrap(); + let encoded: Vec = + ZcashBatchSigResult::new(vec![], empty_batch_response(), firmware_version()) + .try_into() + .unwrap(); let decoded = ZcashBatchSigResult::try_from(encoded).unwrap(); assert!(decoded.get_request_id().is_empty()); @@ -184,48 +217,96 @@ mod tests { #[test] 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") - ); + let err = ZcashBatchSigResult::try_from(vec![ + 0xa2, + REQUEST_ID, + 0x40, + FIRMWARE_VERSION, + 0x43, + 12, + 5, + 0, + ]) + .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(); + let err = + ZcashBatchSigResult::try_from(vec![0xa2, DATA, 0x40, FIRMWARE_VERSION, 0x43, 12, 5, 0]) + .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(); + fn rejects_missing_firmware_version() { + let err = + ZcashBatchSigResult::try_from(vec![0xa2, DATA, 0x40, REQUEST_ID, 0x40]).unwrap_err(); - assert!( - err.to_string() - .contains("duplicate key in zcash-batch-sig-result map") - ); + assert!(err + .to_string() + .contains("missing zcash-batch-sig-result firmware version")); } #[test] - fn rejects_indefinite_map() { - let err = ZcashBatchSigResult::try_from(vec![0xbf, DATA, 0x40, REQUEST_ID, 0x40, 0xff]) - .unwrap_err(); + fn rejects_duplicate_firmware_version() { + let err = ZcashBatchSigResult::try_from(vec![ + 0xa4, + DATA, + 0x40, + REQUEST_ID, + 0x40, + FIRMWARE_VERSION, + 0x43, + 12, + 5, + 0, + FIRMWARE_VERSION, + 0x43, + 12, + 5, + 0, + ]) + .unwrap_err(); + + assert!(err + .to_string() + .contains("duplicate key in zcash-batch-sig-result map")); + } - assert!( - err.to_string() - .contains("indefinite zcash-batch-sig-result map is unsupported") - ); + #[test] + fn rejects_indefinite_map() { + let err = ZcashBatchSigResult::try_from(vec![ + 0xbf, + DATA, + 0x40, + REQUEST_ID, + 0x40, + FIRMWARE_VERSION, + 0x43, + 12, + 5, + 0, + 0xff, + ]) + .unwrap_err(); + + assert!(err + .to_string() + .contains("indefinite zcash-batch-sig-result map is unsupported")); } #[test] fn rejects_trailing_data() { - let result = ZcashBatchSigResult::new(vec![0xaa, 0xbb], empty_batch_response()); + let result = + ZcashBatchSigResult::new(vec![0xaa, 0xbb], empty_batch_response(), firmware_version()); let mut encoded: Vec = result.try_into().unwrap(); encoded.push(0); @@ -236,12 +317,13 @@ mod tests { #[test] fn skips_unknown_keys() { - let encoded = hex::decode("a3014950435a5301000000000242aabb0982010a").unwrap(); + let encoded = hex::decode("a4014950435a5301000000000242aabb03430c05000982010a").unwrap(); let decoded = ZcashBatchSigResult::try_from(encoded).unwrap(); assert_eq!(decoded.get_data(), empty_batch_response()); assert_eq!(decoded.get_request_id(), &[0xaa, 0xbb]); + assert_eq!(decoded.get_firmware_version(), &[12, 5, 0]); } #[test] From 50820ec7e688192d38806a99c945c5ec3dacc132 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Mon, 13 Jul 2026 17:28:29 +0400 Subject: [PATCH 2/3] Use synthetic firmware version fixtures --- libs/ur-parse-lib/src/keystone_ur_encoder.rs | 2 +- .../src/zcash/zcash_batch_sig_result.rs | 34 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/libs/ur-parse-lib/src/keystone_ur_encoder.rs b/libs/ur-parse-lib/src/keystone_ur_encoder.rs index 72d6e26..0f4c0b4 100644 --- a/libs/ur-parse-lib/src/keystone_ur_encoder.rs +++ b/libs/ur-parse-lib/src/keystone_ur_encoder.rs @@ -202,7 +202,7 @@ mod tests { // "PCZS" || batch_version_le || Postcard body. let response = vec![b'P', b'C', b'Z', b'S', 1, 0, 0, 0, 0]; let request_id = vec![0xaa, 0xbb]; - let firmware_version = vec![12, 5, 0]; + let firmware_version = vec![1, 2, 3]; let result = ZcashBatchSigResult::new( request_id.clone(), response.clone(), 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 027f8c1..bc1a9e3 100644 --- a/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs +++ b/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs @@ -161,7 +161,7 @@ mod tests { } fn firmware_version() -> Vec { - vec![12, 5, 0] + vec![1, 2, 3] } #[test] @@ -189,7 +189,7 @@ mod tests { assert_eq!( hex::encode(encoded), - "a3014950435a5301000000000242aabb03430c0500" + "a3014950435a5301000000000242aabb0343010203" ); } @@ -223,9 +223,9 @@ mod tests { 0x40, FIRMWARE_VERSION, 0x43, - 12, - 5, - 0, + 1, + 2, + 3, ]) .unwrap_err(); @@ -237,7 +237,7 @@ mod tests { #[test] fn rejects_missing_request_id() { let err = - ZcashBatchSigResult::try_from(vec![0xa2, DATA, 0x40, FIRMWARE_VERSION, 0x43, 12, 5, 0]) + ZcashBatchSigResult::try_from(vec![0xa2, DATA, 0x40, FIRMWARE_VERSION, 0x43, 1, 2, 3]) .unwrap_err(); assert!(err @@ -265,14 +265,14 @@ mod tests { 0x40, FIRMWARE_VERSION, 0x43, - 12, - 5, - 0, + 1, + 2, + 3, FIRMWARE_VERSION, 0x43, - 12, - 5, - 0, + 1, + 2, + 3, ]) .unwrap_err(); @@ -291,9 +291,9 @@ mod tests { 0x40, FIRMWARE_VERSION, 0x43, - 12, - 5, - 0, + 1, + 2, + 3, 0xff, ]) .unwrap_err(); @@ -317,13 +317,13 @@ mod tests { #[test] fn skips_unknown_keys() { - let encoded = hex::decode("a4014950435a5301000000000242aabb03430c05000982010a").unwrap(); + let encoded = hex::decode("a4014950435a5301000000000242aabb03430102030982010a").unwrap(); let decoded = ZcashBatchSigResult::try_from(encoded).unwrap(); assert_eq!(decoded.get_data(), empty_batch_response()); assert_eq!(decoded.get_request_id(), &[0xaa, 0xbb]); - assert_eq!(decoded.get_firmware_version(), &[12, 5, 0]); + assert_eq!(decoded.get_firmware_version(), &[1, 2, 3]); } #[test] From c2119436f5246be05b1ba877a7e6b63f51c01339 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Mon, 13 Jul 2026 17:38:21 +0400 Subject: [PATCH 3/3] Validate batch firmware version encoding --- libs/ur-parse-lib/src/keystone_ur_encoder.rs | 6 +- .../src/zcash/zcash_batch_sig_result.rs | 78 ++++++++++++++++--- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/libs/ur-parse-lib/src/keystone_ur_encoder.rs b/libs/ur-parse-lib/src/keystone_ur_encoder.rs index 0f4c0b4..7c05d26 100644 --- a/libs/ur-parse-lib/src/keystone_ur_encoder.rs +++ b/libs/ur-parse-lib/src/keystone_ur_encoder.rs @@ -202,11 +202,11 @@ mod tests { // "PCZS" || batch_version_le || Postcard body. let response = vec![b'P', b'C', b'Z', b'S', 1, 0, 0, 0, 0]; let request_id = vec![0xaa, 0xbb]; - let firmware_version = vec![1, 2, 3]; + let firmware_version = [1, 2, 3]; let result = ZcashBatchSigResult::new( request_id.clone(), response.clone(), - firmware_version.clone(), + firmware_version, ); let cbor: Vec = result.try_into().unwrap(); let encoded = probe_encode( @@ -227,7 +227,7 @@ mod tests { ); assert_eq!(decoded_result.get_data(), response); assert_eq!(decoded_result.get_request_id(), request_id); - assert_eq!(decoded_result.get_firmware_version(), firmware_version); + assert_eq!(decoded_result.get_firmware_version(), &firmware_version); } #[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 bc1a9e3..22e2fad 100644 --- a/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs +++ b/libs/ur-registry/src/zcash/zcash_batch_sig_result.rs @@ -6,6 +6,14 @@ //! registry type only provides the outer UR/CBOR envelope, the request id //! copied from the corresponding signing request, and the signing device's //! firmware version. +//! +//! ```text +//! zcash-batch-sig-result = { +//! 1: bstr, ; serialized PCZT BatchSignResponse +//! 2: bstr, ; request id +//! 3: bstr .size 3 ; raw [major, minor, build] firmware version +//! } +//! ``` use alloc::{string::ToString, vec::Vec}; @@ -28,11 +36,15 @@ const FIRMWARE_VERSION: u8 = 3; pub struct ZcashBatchSigResult { data: Bytes, request_id: Bytes, - firmware_version: Bytes, + firmware_version: [u8; 3], } impl ZcashBatchSigResult { - pub fn new(request_id: Bytes, data: Bytes, firmware_version: Bytes) -> Self { + /// Builds a batch signature result with the raw firmware build version. + /// + /// Each element is the corresponding major, minor, or build component. + /// Display-only version offsets are not applied to this value. + pub fn new(request_id: Bytes, data: Bytes, firmware_version: [u8; 3]) -> Self { Self { data, request_id, @@ -48,7 +60,7 @@ impl ZcashBatchSigResult { &self.request_id } - pub fn get_firmware_version(&self) -> &[u8] { + pub fn get_firmware_version(&self) -> &[u8; 3] { &self.firmware_version } } @@ -101,7 +113,14 @@ 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(), - FIRMWARE_VERSION => obj.firmware_version = d.bytes()?.to_vec(), + FIRMWARE_VERSION => { + obj.firmware_version = d.bytes()?.try_into().map_err(|_| { + minicbor::decode::Error::message( + "zcash-batch-sig-result firmware version must be exactly 3 bytes", + ) + .at(d.position()) + })? + } _ => d.skip()?, } Ok(()) @@ -160,8 +179,8 @@ mod tests { vec![b'P', b'C', b'Z', b'S', 1, 0, 0, 0, 0] } - fn firmware_version() -> Vec { - vec![1, 2, 3] + fn firmware_version() -> [u8; 3] { + [1, 2, 3] } #[test] @@ -169,15 +188,14 @@ mod tests { let data = empty_batch_response(); let request_id = vec![0xaa, 0xbb]; let firmware_version = firmware_version(); - let result = - ZcashBatchSigResult::new(request_id.clone(), data.clone(), firmware_version.clone()); + let result = ZcashBatchSigResult::new(request_id.clone(), data.clone(), firmware_version); 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); - assert_eq!(decoded.get_firmware_version(), firmware_version); + assert_eq!(decoded.get_firmware_version(), &firmware_version); } #[test] @@ -255,6 +273,48 @@ mod tests { .contains("missing zcash-batch-sig-result firmware version")); } + #[test] + fn rejects_short_firmware_version() { + let err = ZcashBatchSigResult::try_from(vec![ + 0xa3, + DATA, + 0x40, + REQUEST_ID, + 0x40, + FIRMWARE_VERSION, + 0x42, + 1, + 2, + ]) + .unwrap_err(); + + assert!(err + .to_string() + .contains("firmware version must be exactly 3 bytes")); + } + + #[test] + fn rejects_long_firmware_version() { + let err = ZcashBatchSigResult::try_from(vec![ + 0xa3, + DATA, + 0x40, + REQUEST_ID, + 0x40, + FIRMWARE_VERSION, + 0x44, + 1, + 2, + 3, + 4, + ]) + .unwrap_err(); + + assert!(err + .to_string() + .contains("firmware version must be exactly 3 bytes")); + } + #[test] fn rejects_duplicate_firmware_version() { let err = ZcashBatchSigResult::try_from(vec![