From 6c7f084f8483c7ca02e97a7369b9e5b61fc560e2 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 20 Jun 2026 19:07:48 -0600 Subject: [PATCH 1/2] Refactor Zcash registry CBOR handling and harden decoders Preparatory commit ahead of the batch/result signing types. Extracts shared cbor_helpers (reject_duplicate_key, require_key), converts zcash-accounts and zcash-unified-full-viewing-key off the impl_cbor_bytes! macro to explicit TryFrom/TryInto, and hardens the decoders to reject duplicate map keys, missing required fields, unexpected nested registry tags, and trailing data. Adds URType variants and registry roundtrip tests for the existing types; the zcash-accounts encoder keeps its two-field shape with device_version decode-only. No new wire types. --- libs/ur-parse-lib/Cargo.toml | 2 +- libs/ur-parse-lib/src/keystone_ur_decoder.rs | 52 ++++ libs/ur-registry/src/macros_impl.rs | 4 - libs/ur-registry/src/registry_types.rs | 13 +- libs/ur-registry/src/zcash/cbor_helpers.rs | 28 +++ libs/ur-registry/src/zcash/mod.rs | 2 + libs/ur-registry/src/zcash/zcash_accounts.rs | 228 ++++++++++++++---- .../zcash/zcash_unified_full_viewing_key.rs | 127 ++++++++-- 8 files changed, 389 insertions(+), 67 deletions(-) create mode 100644 libs/ur-registry/src/zcash/cbor_helpers.rs diff --git a/libs/ur-parse-lib/Cargo.toml b/libs/ur-parse-lib/Cargo.toml index 8918903..42b3d56 100644 --- a/libs/ur-parse-lib/Cargo.toml +++ b/libs/ur-parse-lib/Cargo.toml @@ -10,7 +10,7 @@ homepage = "https://github.com/KeystoneHQ/keystone-sdk-rust" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -ur-registry = { path = "../ur-registry", version = "1.0.3", default-features = false } +ur-registry = { path = "../ur-registry", version = "1.0.3", default-features = false, features = ["core"] } ur = { package = "keystone-ur", version = "0.1.0", default-features = false } hex = { version = "0.4.3", features = ["alloc"], default-features = false } diff --git a/libs/ur-parse-lib/src/keystone_ur_decoder.rs b/libs/ur-parse-lib/src/keystone_ur_decoder.rs index f27d616..f562182 100644 --- a/libs/ur-parse-lib/src/keystone_ur_decoder.rs +++ b/libs/ur-parse-lib/src/keystone_ur_decoder.rs @@ -124,12 +124,16 @@ impl fmt::Debug for URParseResult { #[cfg(test)] mod tests { use crate::keystone_ur_decoder::{probe_decode, MultiURParseResult, URParseResult}; + use crate::keystone_ur_encoder::probe_encode; use alloc::string::ToString; use alloc::vec; use alloc::{string::String, vec::Vec}; use ur_registry::crypto_psbt::CryptoPSBT; use ur_registry::ethereum::eth_sign_request::EthSignRequest; use ur_registry::sui::sui_sign_request::SuiSignRequest; + use ur_registry::traits::RegistryItem; + use ur_registry::zcash::zcash_accounts::ZcashAccounts; + use ur_registry::zcash::zcash_unified_full_viewing_key::ZcashUnifiedFullViewingKey; use ur_registry::{ cardano::cardano_sign_request::CardanoSignRequest, crypto_key_path::CryptoKeyPath, }; @@ -164,6 +168,54 @@ mod tests { } } + #[test] + fn test_decode_zcash_accounts_registry_ur() { + let seed_fingerprint = hex::decode("d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1").unwrap(); + let accounts = ZcashAccounts::new(seed_fingerprint.clone(), vec![]); + let cbor: Vec = accounts.try_into().unwrap(); + let encoded = + probe_encode(&cbor, 400, ZcashAccounts::get_registry_type().get_type()).unwrap(); + + assert!(!encoded.is_multi_part); + let decoded: URParseResult = probe_decode(encoded.data).unwrap(); + let decoded_accounts = decoded.data.unwrap(); + + assert_eq!(decoded.ur_type.unwrap().get_type_str(), "zcash-accounts"); + assert_eq!(decoded_accounts.get_seed_fingerprint(), seed_fingerprint); + assert!(decoded_accounts.get_accounts().is_empty()); + assert_eq!(decoded_accounts.get_device_version(), None); + } + + #[test] + fn test_decode_zcash_unified_full_viewing_key_registry_ur() { + let expected_ufvk = "uview1qqqqqqqqqqqqqq8rzd0efkm6ej5n0twzum9czt9kj5y7jxjm9qz3uq9qgpqqqqqqqqqqqqqq9en0hkucteqncqcfqcqcpz4wuwl"; + let ufvk = ZcashUnifiedFullViewingKey::new( + expected_ufvk.to_string(), + 7, + Some("Keystone".to_string()), + ); + let cbor: Vec = ufvk.try_into().unwrap(); + let encoded = probe_encode( + &cbor, + 400, + ZcashUnifiedFullViewingKey::get_registry_type().get_type(), + ) + .unwrap(); + + assert!(!encoded.is_multi_part); + let decoded: URParseResult = + probe_decode(encoded.data).unwrap(); + let decoded_ufvk = decoded.data.unwrap(); + + assert_eq!( + decoded.ur_type.unwrap().get_type_str(), + "zcash-unified-full-viewing-key" + ); + assert_eq!(decoded_ufvk.get_ufvk(), expected_ufvk); + assert_eq!(decoded_ufvk.get_index(), 7); + assert_eq!(decoded_ufvk.get_name(), Some("Keystone".to_string())); + } + #[test] fn test_decode_eth_sign_request() { let ur = "ur:eth-sign-request/onadtpdagdwnbstbpfkidafxlbprqzdiktfldlaxheaohddlaoweaalalrhkisdlaelrhkisdlcwlfgmaymwvttkvsptykhkfwswosbdlrhhtiknftkihsnbfxdalnhtwfbeknfzaelartaxaaaaaaahtaaddyoeadlocsdwykcsfnykaeykaewkaocyjokbwejzvdrtpssp"; diff --git a/libs/ur-registry/src/macros_impl.rs b/libs/ur-registry/src/macros_impl.rs index 0470a24..5b7bc22 100644 --- a/libs/ur-registry/src/macros_impl.rs +++ b/libs/ur-registry/src/macros_impl.rs @@ -55,9 +55,7 @@ use crate::stellar::{ use crate::sui::sui_signature::SuiSignature; use crate::sui::{sui_sign_hash_request::SuiSignHashRequest, sui_sign_request::SuiSignRequest}; use crate::ton::{ton_sign_request::TonSignRequest, ton_signature::TonSignature}; -use crate::zcash::zcash_accounts::ZcashAccounts; use crate::zcash::zcash_pczt::ZcashPczt; -use crate::zcash::zcash_unified_full_viewing_key::ZcashUnifiedFullViewingKey; use crate::{ aptos::{aptos_sign_request::AptosSignRequest, aptos_signature::AptosSignature}, cardano::cardano_sign_tx_hash_request::CardanoSignTxHashRequest, @@ -120,8 +118,6 @@ impl_cbor_bytes!( QRHardwareCall, BtcSignRequest, BtcSignature, - ZcashAccounts, - ZcashUnifiedFullViewingKey, XmrOutput, XmrKeyImage, XmrTxUnsigned, diff --git a/libs/ur-registry/src/registry_types.rs b/libs/ur-registry/src/registry_types.rs index 77ed7e9..e8d6af0 100644 --- a/libs/ur-registry/src/registry_types.rs +++ b/libs/ur-registry/src/registry_types.rs @@ -28,6 +28,8 @@ pub enum URType { Bytes(String), BtcSignRequest(String), KeystoneSignRequest(String), + ZcashAccounts(String), + ZcashUnifiedFullViewingKey(String), ZcashPczt(String), XmrOutput(String), XmrTxUnsigned(String), @@ -75,6 +77,10 @@ impl URType { } "qr-hardware-call" => Ok(URType::QRHardwareCall(type_str.to_string())), "ton-sign-request" => Ok(URType::TonSignRequest(type_str.to_string())), + "zcash-accounts" => Ok(URType::ZcashAccounts(type_str.to_string())), + "zcash-unified-full-viewing-key" => { + Ok(URType::ZcashUnifiedFullViewingKey(type_str.to_string())) + } "zcash-pczt" => Ok(URType::ZcashPczt(type_str.to_string())), "tron-sign-request" => Ok(URType::TronSignRequest(type_str.to_string())), "tron-signature" => Ok(URType::TronSignature(type_str.to_string())), @@ -117,6 +123,8 @@ impl URType { URType::EvmSignRequest(type_str) => type_str.to_string(), URType::QRHardwareCall(type_str) => type_str.to_string(), URType::TonSignRequest(type_str) => type_str.to_string(), + URType::ZcashAccounts(type_str) => type_str.to_string(), + URType::ZcashUnifiedFullViewingKey(type_str) => type_str.to_string(), URType::ZcashPczt(type_str) => type_str.to_string(), URType::TronSignRequest(type_str) => type_str.to_string(), URType::TronSignature(type_str) => type_str.to_string(), @@ -160,7 +168,8 @@ pub const CRYPTO_MULTI_ACCOUNTS: RegistryType = RegistryType("crypto-multi-accou // ETH pub const ETH_SIGN_REQUEST: RegistryType = RegistryType("eth-sign-request", Some(401)); pub const ETH_SIGNATURE: RegistryType = RegistryType("eth-signature", Some(402)); -pub const ETH_BATCH_SIGN_REQUEST: RegistryType = RegistryType("eth-batch-sign-request", Some(40404)); +pub const ETH_BATCH_SIGN_REQUEST: RegistryType = + RegistryType("eth-batch-sign-request", Some(40404)); pub const ETH_BATCH_SIGNATURE: RegistryType = RegistryType("eth-batch-signature", Some(40405)); // SOL pub const SOL_SIGN_REQUEST: RegistryType = RegistryType("sol-sign-request", Some(1101)); @@ -253,6 +262,8 @@ pub const IOTA_SIGN_HASH_REQUEST: RegistryType = RegistryType("iota-sign-hash-re pub const KASPA_PSKT: RegistryType = RegistryType("kaspa-pskt", Some(8601)); // Zcash +// Zcash registry tags are shared with Keystone firmware. The signing tags +// continue the account and PCZT block so SDK and firmware agree on wire types. pub const ZCASH_ACCOUNTS: RegistryType = RegistryType("zcash-accounts", Some(49201)); pub const ZCASH_FULL_VIEWING_KEY: RegistryType = RegistryType("zcash-full-viewing-key", Some(49202)); diff --git a/libs/ur-registry/src/zcash/cbor_helpers.rs b/libs/ur-registry/src/zcash/cbor_helpers.rs new file mode 100644 index 0000000..e20ba75 --- /dev/null +++ b/libs/ur-registry/src/zcash/cbor_helpers.rs @@ -0,0 +1,28 @@ +use alloc::vec::Vec; +use minicbor::Decoder; + +pub(super) fn reject_duplicate_key( + seen_keys: &mut Vec, + key: u8, + d: &Decoder<'_>, + message: &'static str, +) -> Result<(), minicbor::decode::Error> { + if seen_keys.contains(&key) { + return Err(minicbor::decode::Error::message(message).at(d.position())); + } + seen_keys.push(key); + Ok(()) +} + +pub(super) fn require_key( + seen_keys: &[u8], + key: u8, + d: &Decoder<'_>, + message: &'static str, +) -> Result<(), minicbor::decode::Error> { + if seen_keys.contains(&key) { + Ok(()) + } else { + Err(minicbor::decode::Error::message(message).at(d.position())) + } +} diff --git a/libs/ur-registry/src/zcash/mod.rs b/libs/ur-registry/src/zcash/mod.rs index 252f5b8..de1495c 100644 --- a/libs/ur-registry/src/zcash/mod.rs +++ b/libs/ur-registry/src/zcash/mod.rs @@ -1,3 +1,5 @@ +mod cbor_helpers; + pub mod zcash_accounts; pub mod zcash_pczt; pub mod zcash_unified_full_viewing_key; diff --git a/libs/ur-registry/src/zcash/zcash_accounts.rs b/libs/ur-registry/src/zcash/zcash_accounts.rs index 4ac53aa..f88b0e0 100644 --- a/libs/ur-registry/src/zcash/zcash_accounts.rs +++ b/libs/ur-registry/src/zcash/zcash_accounts.rs @@ -8,18 +8,26 @@ //! with a map containing: //! - Seed fingerprint: A byte string that uniquely identifies the seed //! - Accounts: An array of Zcash unified full viewing keys +//! +//! Decode also accepts a device version string at CBOR map key 3 if present. +//! The standard encoder does not emit that field, preserving the existing +//! two-key account export shape for older consumers. - -use alloc::{string::{String, ToString}, vec::Vec}; +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; use minicbor::data::{Int, Tag}; use crate::{ cbor::{cbor_array, cbor_map}, + error::{URError, URResult}, registry_types::{RegistryType, ZCASH_ACCOUNTS, ZCASH_UNIFIED_FULL_VIEWING_KEY}, traits::{MapSize, RegistryItem}, types::Bytes, }; +use super::cbor_helpers::{reject_duplicate_key, require_key}; use super::zcash_unified_full_viewing_key::ZcashUnifiedFullViewingKey; const SEED_FINGERPRINT: u8 = 1; @@ -28,16 +36,13 @@ const DEVICE_VERSION: u8 = 3; #[derive(Debug, Clone, Default)] pub struct ZcashAccounts { - pub seed_fingerprint: Bytes, - pub accounts: Vec, - pub device_version: Option, + seed_fingerprint: Bytes, + accounts: Vec, + device_version: Option, } impl ZcashAccounts { - pub fn new( - seed_fingerprint: Bytes, - accounts: Vec, - ) -> Self { + pub fn new(seed_fingerprint: Bytes, accounts: Vec) -> Self { Self { seed_fingerprint, accounts, @@ -65,6 +70,8 @@ impl ZcashAccounts { self.device_version.clone() } + /// Stores device version metadata without changing the canonical + /// `zcash-accounts` encoding. pub fn set_device_version(&mut self, device_version: String) { self.device_version = Some(device_version); } @@ -72,11 +79,7 @@ impl ZcashAccounts { impl MapSize for ZcashAccounts { fn map_size(&self) -> u64 { - let mut size = 2; - if self.device_version.is_some() { - size += 1; - } - size + 2 } } @@ -104,10 +107,6 @@ impl minicbor::Encode for ZcashAccounts { ZcashUnifiedFullViewingKey::encode(account, e, _ctx)?; } - if let Some(device_version) = &self.device_version { - e.int(Int::from(DEVICE_VERSION))?.str(device_version)?; - } - Ok(()) } } @@ -115,9 +114,16 @@ impl minicbor::Encode for ZcashAccounts { impl<'b, C> minicbor::Decode<'b, C> for ZcashAccounts { fn decode(d: &mut minicbor::Decoder<'b>, ctx: &mut C) -> Result { let mut result = ZcashAccounts::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-accounts map", + )?; match key { SEED_FINGERPRINT => { obj.seed_fingerprint = d.bytes()?.to_vec(); @@ -125,7 +131,13 @@ impl<'b, C> minicbor::Decode<'b, C> for ZcashAccounts { ACCOUNTS => { let mut keys: Vec = alloc::vec![]; cbor_array(d, obj, |_index, _obj, d| { - d.tag()?; + let tag = d.tag()?; + if tag != Tag::Unassigned(ZCASH_UNIFIED_FULL_VIEWING_KEY.get_tag()) { + return Err(minicbor::decode::Error::message( + "unexpected zcash account registry tag", + ) + .at(d.position())); + } keys.push(ZcashUnifiedFullViewingKey::decode(d, ctx)?); Ok(()) })?; @@ -134,19 +146,52 @@ impl<'b, C> minicbor::Decode<'b, C> for ZcashAccounts { DEVICE_VERSION => { obj.device_version = Some(d.str()?.to_string()); } - _ => {} + _ => { + d.skip()?; + } } Ok(()) })?; + require_key( + &seen_keys, + SEED_FINGERPRINT, + d, + "missing zcash-accounts seed fingerprint", + )?; + require_key(&seen_keys, ACCOUNTS, d, "missing zcash-accounts accounts")?; Ok(result) } } +impl TryFrom> for ZcashAccounts { + type Error = URError; + + fn try_from(value: Vec) -> URResult { + let mut decoder = minicbor::Decoder::new(&value); + let accounts = >::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-accounts".to_string(), + )); + } + Ok(accounts) + } +} + +impl TryInto> for ZcashAccounts { + type Error = URError; + + fn try_into(self) -> URResult> { + minicbor::to_vec(self).map_err(|e| URError::CborEncodeError(e.to_string())) + } +} + #[cfg(test)] mod tests { use super::*; - use alloc::vec; use crate::zcash::zcash_unified_full_viewing_key::ZcashUnifiedFullViewingKey; + use alloc::vec; #[test] fn test_zcash_accounts_encode_decode() { @@ -175,47 +220,146 @@ mod tests { assert_eq!(decoded.seed_fingerprint, accounts.seed_fingerprint); assert_eq!(decoded.accounts.len(), 2); - assert_eq!(decoded.accounts[0].get_ufvk(), accounts.accounts[0].get_ufvk()); - assert_eq!(decoded.accounts[0].get_index(), accounts.accounts[0].get_index()); - assert_eq!(decoded.accounts[0].get_name(), accounts.accounts[0].get_name()); - assert_eq!(decoded.accounts[1].get_ufvk(), accounts.accounts[1].get_ufvk()); - assert_eq!(decoded.accounts[1].get_index(), accounts.accounts[1].get_index()); - assert_eq!(decoded.accounts[1].get_name(), accounts.accounts[1].get_name()); + assert_eq!( + decoded.accounts[0].get_ufvk(), + accounts.accounts[0].get_ufvk() + ); + assert_eq!( + decoded.accounts[0].get_index(), + accounts.accounts[0].get_index() + ); + assert_eq!( + decoded.accounts[0].get_name(), + accounts.accounts[0].get_name() + ); + assert_eq!( + decoded.accounts[1].get_ufvk(), + accounts.accounts[1].get_ufvk() + ); + assert_eq!( + decoded.accounts[1].get_index(), + accounts.accounts[1].get_index() + ); + assert_eq!( + decoded.accounts[1].get_name(), + accounts.accounts[1].get_name() + ); assert_eq!(decoded.device_version, None); } #[test] - fn test_zcash_accounts_with_device_version() { + fn test_zcash_accounts_decodes_device_version_extension() { let seed_fingerprint = hex::decode("d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1").unwrap(); + let cbor = hex::decode("a30150d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d102800365312e322e33").unwrap(); + let decoded: ZcashAccounts = minicbor::decode(&cbor).unwrap(); - let ufvk = ZcashUnifiedFullViewingKey::new( - "uview1qqqqqqqqqqqqqq8rzd0efkm6ej5n0twzum9czt9kj5y7jxjm9qz3uq9qgpqqqqqqqqqqqqqq9en0hkucteqncqcfqcqcpz4wuwl".to_string(), - 0, - Some("Keystone 1".to_string()) - ); + assert_eq!(decoded.seed_fingerprint, seed_fingerprint); + assert_eq!(decoded.device_version, Some("1.2.3".to_string())); + assert!(decoded.accounts.is_empty()); + } - let mut accounts = ZcashAccounts::new(seed_fingerprint, vec![ufvk]); + #[test] + fn test_zcash_accounts_encoder_omits_device_version_for_compatibility() { + let seed_fingerprint = hex::decode("d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1").unwrap(); + let mut accounts = ZcashAccounts::new(seed_fingerprint, vec![]); accounts.set_device_version("1.2.3".to_string()); let cbor = minicbor::to_vec(&accounts).unwrap(); - let decoded: ZcashAccounts = minicbor::decode(&cbor).unwrap(); - assert_eq!(decoded.device_version, Some("1.2.3".to_string())); - assert_eq!(decoded.accounts.len(), 1); + assert_eq!( + hex::encode(&cbor), + "a20150d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d10280" + ); + let decoded: ZcashAccounts = minicbor::decode(&cbor).unwrap(); + assert_eq!(decoded.device_version, None); } #[test] fn test_zcash_accounts_without_device_version_decodes_from_old_cbor() { - // Encode without device_version, then decode — simulates old firmware let seed_fingerprint = hex::decode("d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1").unwrap(); - let accounts = ZcashAccounts::new(seed_fingerprint, vec![]); - - let cbor = minicbor::to_vec(&accounts).unwrap(); + let cbor = hex::decode("a20150d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d10280").unwrap(); let decoded: ZcashAccounts = minicbor::decode(&cbor).unwrap(); + assert_eq!(decoded.seed_fingerprint, seed_fingerprint); + assert!(decoded.accounts.is_empty()); assert_eq!(decoded.device_version, None); } + #[test] + fn test_zcash_accounts_rejects_missing_seed_fingerprint() { + let cbor = hex::decode("a10280").unwrap(); + + let result: Result = minicbor::decode(&cbor); + + assert!(result + .unwrap_err() + .to_string() + .contains("missing zcash-accounts seed fingerprint")); + } + + #[test] + fn test_zcash_accounts_rejects_missing_accounts() { + let cbor = hex::decode("a10150d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1").unwrap(); + + let result: Result = minicbor::decode(&cbor); + + assert!(result + .unwrap_err() + .to_string() + .contains("missing zcash-accounts accounts")); + } + + #[test] + fn test_zcash_accounts_rejects_duplicate_keys() { + for cbor_hex in ["a3014001400280", "a3014002800280", "a401400280036131036132"] { + let cbor = hex::decode(cbor_hex).unwrap(); + + let result: Result = minicbor::decode(&cbor); + + assert!(result + .unwrap_err() + .to_string() + .contains("duplicate key in zcash-accounts map")); + } + } + + #[test] + fn test_zcash_accounts_rejects_unexpected_account_tag() { + let cbor = + hex::decode("a20150d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d10281d9c034a20161750200").unwrap(); + + let result: Result = minicbor::decode(&cbor); + + assert!(result + .unwrap_err() + .to_string() + .contains("unexpected zcash account registry tag")); + } + + #[test] + fn test_zcash_accounts_try_from_rejects_trailing_data() { + let mut cbor = hex::decode("a20150d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d10280").unwrap(); + cbor.push(0x00); + + let err = ZcashAccounts::try_from(cbor).unwrap_err(); + + assert!(err.to_string().contains("trailing data")); + } + + #[test] + fn test_zcash_accounts_skips_unknown_key_and_preserves_device_version() { + let seed_fingerprint = hex::decode("d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1").unwrap(); + let cbor = + hex::decode("a40150d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1098201a16178f502800365312e322e33") + .unwrap(); + + let decoded: ZcashAccounts = minicbor::decode(&cbor).unwrap(); + + assert_eq!(decoded.seed_fingerprint, seed_fingerprint); + assert!(decoded.accounts.is_empty()); + assert_eq!(decoded.device_version, Some("1.2.3".to_string())); + } + #[test] fn test_zcash_accounts_empty() { let seed_fingerprint = hex::decode("d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1").unwrap(); @@ -237,6 +381,6 @@ mod tests { let mut accounts_with_version = ZcashAccounts::new(vec![], vec![]); accounts_with_version.set_device_version("1.0.0".to_string()); - assert_eq!(accounts_with_version.map_size(), 3); + assert_eq!(accounts_with_version.map_size(), 2); } } diff --git a/libs/ur-registry/src/zcash/zcash_unified_full_viewing_key.rs b/libs/ur-registry/src/zcash/zcash_unified_full_viewing_key.rs index 0dfdbb0..71bce78 100644 --- a/libs/ur-registry/src/zcash/zcash_unified_full_viewing_key.rs +++ b/libs/ur-registry/src/zcash/zcash_unified_full_viewing_key.rs @@ -11,17 +11,22 @@ //! - Name: An optional account name (if provided) //! - -use alloc::string::{String, ToString}; +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; use minicbor::data::Int; use crate::{ cbor::cbor_map, + error::{URError, URResult}, impl_template_struct, registry_types::{RegistryType, ZCASH_UNIFIED_FULL_VIEWING_KEY}, traits::{MapSize, RegistryItem}, }; +use super::cbor_helpers::{reject_duplicate_key, require_key}; + const UFVK: u8 = 1; const INDEX: u8 = 2; const NAME: u8 = 3; @@ -68,11 +73,16 @@ impl minicbor::Encode for ZcashUnifiedFullViewingKey { } impl<'b, C> minicbor::Decode<'b, C> for ZcashUnifiedFullViewingKey { - fn decode(d: &mut minicbor::Decoder<'b>, ctx: &mut C) -> Result { + fn decode( + d: &mut minicbor::Decoder<'b>, + _ctx: &mut C, + ) -> Result { let mut result = ZcashUnifiedFullViewingKey::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-ufvk map")?; match key { UFVK => { obj.ufvk = d.str()?.to_string(); @@ -83,14 +93,43 @@ impl<'b, C> minicbor::Decode<'b, C> for ZcashUnifiedFullViewingKey { NAME => { obj.name = Some(d.str()?.to_string()); } - _ => {} + _ => { + d.skip()?; + } } Ok(()) })?; + require_key(&seen_keys, UFVK, d, "missing zcash-ufvk ufvk")?; + require_key(&seen_keys, INDEX, d, "missing zcash-ufvk index")?; Ok(result) } } +impl TryFrom> for ZcashUnifiedFullViewingKey { + type Error = URError; + + fn try_from(value: Vec) -> URResult { + let mut decoder = minicbor::Decoder::new(&value); + let ufvk = + >::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-ufvk".to_string(), + )); + } + Ok(ufvk) + } +} + +impl TryInto> for ZcashUnifiedFullViewingKey { + type Error = URError; + + fn try_into(self) -> URResult> { + minicbor::to_vec(self).map_err(|e| URError::CborEncodeError(e.to_string())) + } +} + #[cfg(test)] mod tests { use super::*; @@ -103,15 +142,15 @@ mod tests { index: 0, name: Some("Keystone".to_string()), }; - + let cbor = minicbor::to_vec(&ufvk).unwrap(); let decoded: ZcashUnifiedFullViewingKey = minicbor::decode(&cbor).unwrap(); - + assert_eq!(decoded.ufvk, ufvk.ufvk); assert_eq!(decoded.index, ufvk.index); assert_eq!(decoded.name, ufvk.name); } - + #[test] fn test_zcash_unified_full_viewing_key_without_name() { let ufvk = ZcashUnifiedFullViewingKey { @@ -119,15 +158,61 @@ mod tests { index: 1, name: None, }; - + let cbor = minicbor::to_vec(&ufvk).unwrap(); let decoded: ZcashUnifiedFullViewingKey = minicbor::decode(&cbor).unwrap(); - + assert_eq!(decoded.ufvk, ufvk.ufvk); assert_eq!(decoded.index, ufvk.index); assert_eq!(decoded.name, None); } - + + #[test] + fn test_zcash_unified_full_viewing_key_rejects_missing_required_keys() { + for (cbor_hex, message) in [ + ("a10200", "missing zcash-ufvk ufvk"), + ("a1016175", "missing zcash-ufvk index"), + ] { + let cbor = hex::decode(cbor_hex).unwrap(); + + let result: Result = minicbor::decode(&cbor); + + assert!(result.unwrap_err().to_string().contains(message)); + } + } + + #[test] + fn test_zcash_unified_full_viewing_key_rejects_duplicate_keys() { + let cbor = hex::decode("a30161750161760200").unwrap(); + + let result: Result = minicbor::decode(&cbor); + + assert!(result + .unwrap_err() + .to_string() + .contains("duplicate key in zcash-ufvk map")); + } + + #[test] + fn test_zcash_unified_full_viewing_key_try_from_rejects_trailing_data() { + let mut cbor = hex::decode("a20161750200").unwrap(); + cbor.push(0x00); + + let err = ZcashUnifiedFullViewingKey::try_from(cbor).unwrap_err(); + + assert!(err.to_string().contains("trailing data")); + } + + #[test] + fn test_zcash_unified_full_viewing_key_skips_unknown_keys() { + let cbor = hex::decode("a301617502000982010a").unwrap(); + + let decoded: ZcashUnifiedFullViewingKey = minicbor::decode(&cbor).unwrap(); + + assert_eq!(decoded.ufvk, "u"); + assert_eq!(decoded.index, 0); + } + #[test] fn test_map_size_with_name() { let ufvk = ZcashUnifiedFullViewingKey { @@ -135,10 +220,10 @@ mod tests { index: 0, name: Some("Keystone".to_string()), }; - + assert_eq!(ufvk.map_size(), 3); } - + #[test] fn test_map_size_without_name() { let ufvk = ZcashUnifiedFullViewingKey { @@ -146,23 +231,27 @@ mod tests { index: 0, name: None, }; - + assert_eq!(ufvk.map_size(), 2); } - + #[test] fn test_registry_type() { - assert_eq!(ZcashUnifiedFullViewingKey::get_registry_type().get_type(), "zcash-unified-full-viewing-key"); + assert_eq!( + ZcashUnifiedFullViewingKey::get_registry_type().get_type(), + "zcash-unified-full-viewing-key" + ); } - + #[test] fn test_new_constructor() { let ufvk_str = "uview1qqqqqqqqqqqqqq8rzd0efkm6ej5n0twzum9czt9kj5y7jxjm9qz3uq9qgpqqqqqqqqqqqqqq9en0hkucteqncqcfqcqcpz4wuwl"; let index = 5; let name = "Keystone 1"; - - let ufvk = ZcashUnifiedFullViewingKey::new(ufvk_str.to_string(), index, Some(name.to_string())); - + + let ufvk = + ZcashUnifiedFullViewingKey::new(ufvk_str.to_string(), index, Some(name.to_string())); + assert_eq!(ufvk.get_ufvk(), ufvk_str); assert_eq!(ufvk.get_index(), index); assert_eq!(ufvk.get_name(), Some(name.to_string())); From dd6bdeba8f18b986cfd9fe55bef100213348f111 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Sat, 20 Jun 2026 19:07:58 -0600 Subject: [PATCH 2/2] Add Zcash signing batch/result UR types --- libs/ur-parse-lib/src/keystone_ur_encoder.rs | 186 ++++- .../src/zcash/zcash_accounts.rs | 91 ++- libs/ur-registry/src/registry_types.rs | 8 + libs/ur-registry/src/zcash/mod.rs | 2 + .../ur-registry/src/zcash/zcash_sign_batch.rs | 663 +++++++++++++++++ .../src/zcash/zcash_sign_result.rs | 672 ++++++++++++++++++ 6 files changed, 1611 insertions(+), 11 deletions(-) create mode 100644 libs/ur-registry/src/zcash/zcash_sign_batch.rs create mode 100644 libs/ur-registry/src/zcash/zcash_sign_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 92abf3d..d66ef8f 100644 --- a/libs/ur-parse-lib/src/keystone_ur_encoder.rs +++ b/libs/ur-parse-lib/src/keystone_ur_encoder.rs @@ -99,12 +99,195 @@ impl KeystoneUREncoder { #[cfg(test)] mod tests { + use crate::keystone_ur_decoder::{probe_decode, MultiURParseResult, URParseResult}; use crate::keystone_ur_encoder::{cyclic_encode, probe_encode}; + use alloc::string::ToString; + use alloc::vec; use alloc::vec::Vec; use hex::FromHex; use ur_registry::crypto_psbt::CryptoPSBT; use ur_registry::extend::qr_hardware_call::QRHardwareCall; - use ur_registry::traits::RegistryItem; + use ur_registry::traits::{RegistryItem, UR}; + 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_result::{ + ZcashSignMessageResult, ZcashSignResult, ZCASH_SIGN_RESULT_KIND_PCZT_V1, + ZCASH_SIGN_RESULT_VERSION, ZCASH_SIGN_STATUS_SIGNED, + }; + + #[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), + ); + 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_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) + ); + } + + #[test] + fn test_registry_ur_encoder_type_is_compatible() { + let crypto = CryptoPSBT::new(vec![0xaa, 0xbb, 0xcc]); + let mut encoder = super::KeystoneUREncoder::new(crypto.to_ur_encoder(400)); + + assert_eq!(encoder.fragment_count(), 1); + + let part = encoder.next_part().unwrap(); + assert!(part.starts_with("ur:crypto-psbt/")); + } + + #[test] + fn test_encode_decode_zcash_sign_result_ur() { + let payload_digest = + Vec::from_hex("f2dbc955d1edad3014bc907efc15e93adb4412cdee847d261cd942998693e590") + .unwrap(); + let result = ZcashSignResult::new( + ZCASH_SIGN_RESULT_VERSION, + vec![0xaa, 0xbb], + vec![ZcashSignMessageResult::signed( + vec![0x01], + ZCASH_SIGN_RESULT_KIND_PCZT_V1, + b"signed-pczt-result".to_vec(), + payload_digest.clone(), + )], + ); + let cbor: Vec = result.try_into().unwrap(); + let encoded = + probe_encode(&cbor, 400, ZcashSignResult::get_registry_type().get_type()).unwrap(); + let literal_ur = "ur:zcash-sign-result/otadadaofwpkrkaxlyonadfpadaoaeaxadaagmjkiniojtihiedpjoiaknjydpjpihjkkpjzjyamhdcxwzuysogottwepmdybbrfmhkbztbzwlftuyfybgsnwylrkidscetafwnllnmuvwmhuorkvwgr"; + + assert!(!encoded.is_multi_part); + assert_eq!(encoded.data, literal_ur); + + let decoded: URParseResult = probe_decode(literal_ur.to_string()).unwrap(); + let decoded_result = decoded.data.unwrap(); + + assert_eq!(decoded.ur_type.unwrap().get_type_str(), "zcash-sign-result"); + assert_eq!(decoded_result.get_version(), ZCASH_SIGN_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_id(), &vec![0x01]); + assert_eq!( + decoded_result.get_results()[0].get_status(), + ZCASH_SIGN_STATUS_SIGNED + ); + assert_eq!( + decoded_result.get_results()[0].get_kind(), + ZCASH_SIGN_RESULT_KIND_PCZT_V1 + ); + assert_eq!( + decoded_result.get_results()[0].get_payload(), + &b"signed-pczt-result".to_vec() + ); + assert_eq!( + decoded_result.get_results()[0].get_payload_digest(), + &payload_digest + ); + } + + #[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 cbor: Vec = batch.try_into().unwrap(); + let encoded = + probe_encode(&cbor, 100, ZcashSignBatch::get_registry_type().get_type()).unwrap(); + + assert!(encoded.is_multi_part); + let first: URParseResult = probe_decode(encoded.data).unwrap(); + assert!(first.is_multi_part); + assert!(first.data.is_none()); + + let mut decoder = first.decoder.unwrap(); + let mut encoder = encoded.encoder.unwrap(); + let fragment_count = encoder.fragment_count(); + let mut decoded_batch = None; + for _ in 1..fragment_count { + let part = encoder.next_part().unwrap(); + let parsed: MultiURParseResult = decoder.parse_ur(part).unwrap(); + if parsed.is_complete { + decoded_batch = parsed.data; + break; + } + } + 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) + ); + } #[test] fn test_encode_ada_hardware_call() { @@ -166,7 +349,6 @@ mod tests { } } - #[test] fn test_cyclic_encode() { let crypto = CryptoPSBT::new( diff --git a/libs/ur-registry-ffi/src/zcash/zcash_accounts.rs b/libs/ur-registry-ffi/src/zcash/zcash_accounts.rs index ce15d9a..f13952a 100644 --- a/libs/ur-registry-ffi/src/zcash/zcash_accounts.rs +++ b/libs/ur-registry-ffi/src/zcash/zcash_accounts.rs @@ -1,4 +1,3 @@ - // This module provides FFI (Foreign Function Interface) functions for handling Zcash accounts // data structures. It allows for conversion between CBOR-encoded Uniform Resources (URs) and // Zcash account information. @@ -6,22 +5,29 @@ // The module exports functions for parsing Zcash accounts from UR format, which includes: // - Seed fingerprint: A unique identifier for the seed that generated the accounts // - Accounts: A collection of Zcash Unified Full Viewing Keys (UFVKs) with their metadata +// - Device version: An optional firmware version included in parsed JSON when present // // Each Zcash Unified Account contains: // - UFVK: The Unified Full Viewing Key string // - Index: The account index // - Name: An optional account name +// +// The registry decoder rejects malformed account CBOR, including missing +// required fields, duplicate map keys, unexpected nested UFVK tags, and trailing +// data. This FFI layer maps those decode failures to the stable JSON error +// string used by existing callers. use crate::export; use anyhow::{format_err, Error}; use serde::{Deserialize, Serialize}; use serde_json::json; -use ur_registry::{crypto_hd_key::CryptoHDKey, registry_types::ZCASH_ACCOUNTS}; +use ur_registry::registry_types::ZCASH_ACCOUNTS; #[derive(Default, Clone, Debug, Serialize, Deserialize)] struct ZcashAccounts { seed_fingerprint: String, accounts: Vec, + #[serde(skip_serializing_if = "Option::is_none")] device_version: Option, } @@ -104,21 +110,29 @@ mod tests { Some("Keystone 2".to_string()) ); - let ur_accounts = ur_registry::zcash::zcash_accounts::ZcashAccounts::new( + let mut ur_accounts = ur_registry::zcash::zcash_accounts::ZcashAccounts::new( seed_fingerprint.clone(), vec![ufvk1, ufvk2], ); + ur_accounts.set_device_version("1.2.3".to_string()); let ffi_accounts: ZcashAccounts = ur_accounts.into(); assert_eq!(ffi_accounts.seed_fingerprint, hex::encode(seed_fingerprint)); + assert_eq!(ffi_accounts.device_version, Some("1.2.3".to_string())); assert_eq!(ffi_accounts.accounts.len(), 2); assert_eq!(ffi_accounts.accounts[0].ufvk, "uview1qqqqqqqqqqqqqq8rzd0efkm6ej5n0twzum9czt9kj5y7jxjm9qz3uq9qgpqqqqqqqqqqqqqq9en0hkucteqncqcfqcqcpz4wuwl"); assert_eq!(ffi_accounts.accounts[0].index, 0); - assert_eq!(ffi_accounts.accounts[0].name, Some("Keystone 1".to_string())); + assert_eq!( + ffi_accounts.accounts[0].name, + Some("Keystone 1".to_string()) + ); assert_eq!(ffi_accounts.accounts[1].ufvk, "uview1qqqqqqqqqqqqqq8rzd0efkm6ej5n0twzum9czt9kj5y7jxjm9qz3uq9qgpqqqqqqqqqqqqqq9en0hkucteqncqcfqcqcpz4wuwl"); assert_eq!(ffi_accounts.accounts[1].index, 1); - assert_eq!(ffi_accounts.accounts[1].name, Some("Keystone 2".to_string())); + assert_eq!( + ffi_accounts.accounts[1].name, + Some("Keystone 2".to_string()) + ); } #[test] @@ -139,11 +153,13 @@ mod tests { #[test] fn test_parse_zcash_accounts() { let seed_fingerprint = vec![0xd1; 16]; + let expected_seed_fingerprint = hex::encode(&seed_fingerprint); + let expected_ufvk = "uview1qqqqqqqqqqqqqq8rzd0efkm6ej5n0twzum9czt9kj5y7jxjm9qz3uq9qgpqqqqqqqqqqqqqq9en0hkucteqncqcfqcqcpz4wuwl"; let ufvk = ZcashUnifiedFullViewingKey::new( - "uview1qqqqqqqqqqqqqq8rzd0efkm6ej5n0twzum9czt9kj5y7jxjm9qz3uq9qgpqqqqqqqqqqqqqq9en0hkucteqncqcfqcqcpz4wuwl".to_string(), + expected_ufvk.to_string(), 0, - Some("Keystone".to_string()) + Some("Keystone".to_string()), ); let ur_accounts = @@ -157,9 +173,44 @@ mod tests { let json_result: serde_json::Value = serde_json::from_str(&result).unwrap(); assert!(json_result.get("error").is_none()); - assert!(json_result.get("seed_fingerprint").is_some()); - assert!(json_result.get("accounts").is_some()); + assert_eq!(json_result["seed_fingerprint"], expected_seed_fingerprint); + assert!(json_result.get("device_version").is_none()); assert_eq!(json_result["accounts"].as_array().unwrap().len(), 1); + assert_eq!(json_result["accounts"][0]["ufvk"], expected_ufvk); + assert_eq!(json_result["accounts"][0]["index"], 0); + assert_eq!(json_result["accounts"][0]["name"], "Keystone"); + } + + #[test] + fn test_parse_zcash_accounts_includes_device_version_when_cbor_has_extension() { + let cbor_hex = "a30150d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d102800365312e322e33"; + + let result = parse_zcash_accounts(&ZCASH_ACCOUNTS.get_type(), cbor_hex); + + let json_result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert!(json_result.get("error").is_none()); + assert_eq!( + json_result["seed_fingerprint"], + "d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1" + ); + assert_eq!(json_result["device_version"], "1.2.3"); + assert_eq!(json_result["accounts"].as_array().unwrap().len(), 0); + } + + #[test] + fn test_parse_zcash_accounts_omits_device_version_when_absent() { + let cbor_hex = "a20150d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d10280"; + + let result = parse_zcash_accounts(&ZCASH_ACCOUNTS.get_type(), cbor_hex); + + let json_result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert!(json_result.get("error").is_none()); + assert_eq!( + json_result["seed_fingerprint"], + "d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1" + ); + assert_eq!(json_result["accounts"].as_array().unwrap().len(), 0); + assert!(json_result.get("device_version").is_none()); } #[test] @@ -179,4 +230,26 @@ mod tests { assert!(json_result.get("error").is_some()); assert_eq!(json_result["error"], "zcash accounts is invalid"); } + + #[test] + fn test_parse_zcash_accounts_rejects_trailing_data() { + let cbor_hex = "a20150d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1028000"; + + let result = parse_zcash_accounts(&ZCASH_ACCOUNTS.get_type(), cbor_hex); + let json_result: serde_json::Value = serde_json::from_str(&result).unwrap(); + + assert!(json_result.get("error").is_some()); + assert_eq!(json_result["error"], "zcash accounts is invalid"); + } + + #[test] + fn test_parse_zcash_accounts_rejects_duplicate_keys() { + let cbor_hex = "a3014001400280"; + + let result = parse_zcash_accounts(&ZCASH_ACCOUNTS.get_type(), cbor_hex); + let json_result: serde_json::Value = serde_json::from_str(&result).unwrap(); + + assert!(json_result.get("error").is_some()); + assert_eq!(json_result["error"], "zcash accounts is invalid"); + } } diff --git a/libs/ur-registry/src/registry_types.rs b/libs/ur-registry/src/registry_types.rs index e8d6af0..1f690c6 100644 --- a/libs/ur-registry/src/registry_types.rs +++ b/libs/ur-registry/src/registry_types.rs @@ -31,6 +31,8 @@ pub enum URType { ZcashAccounts(String), ZcashUnifiedFullViewingKey(String), ZcashPczt(String), + ZcashSignBatch(String), + ZcashSignResult(String), XmrOutput(String), XmrTxUnsigned(String), AvaxSignRequest(String), @@ -82,6 +84,8 @@ impl URType { Ok(URType::ZcashUnifiedFullViewingKey(type_str.to_string())) } "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())), "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())), @@ -126,6 +130,8 @@ impl URType { URType::ZcashAccounts(type_str) => type_str.to_string(), URType::ZcashUnifiedFullViewingKey(type_str) => type_str.to_string(), 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::TronSignRequest(type_str) => type_str.to_string(), URType::TronSignature(type_str) => type_str.to_string(), URType::XmrOutput(type_str) => type_str.to_string(), @@ -270,3 +276,5 @@ pub const ZCASH_FULL_VIEWING_KEY: RegistryType = pub const ZCASH_UNIFIED_FULL_VIEWING_KEY: RegistryType = RegistryType("zcash-unified-full-viewing-key", Some(49203)); 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)); diff --git a/libs/ur-registry/src/zcash/mod.rs b/libs/ur-registry/src/zcash/mod.rs index de1495c..a1d4ca6 100644 --- a/libs/ur-registry/src/zcash/mod.rs +++ b/libs/ur-registry/src/zcash/mod.rs @@ -2,4 +2,6 @@ mod cbor_helpers; pub mod zcash_accounts; pub mod zcash_pczt; +pub mod zcash_sign_batch; +pub mod zcash_sign_result; pub mod zcash_unified_full_viewing_key; diff --git a/libs/ur-registry/src/zcash/zcash_sign_batch.rs b/libs/ur-registry/src/zcash/zcash_sign_batch.rs new file mode 100644 index 0000000..2d0006c --- /dev/null +++ b/libs/ur-registry/src/zcash/zcash_sign_batch.rs @@ -0,0 +1,663 @@ +//! Zcash signing batch 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. + +use super::cbor_helpers::{reject_duplicate_key, require_key}; +use crate::{ + registry_types::{RegistryType, ZCASH_SIGN_BATCH}, + 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 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 + } + + pub fn get_network(&self) -> u32 { + self.network + } + + pub fn get_messages(&self) -> &Vec { + &self.messages + } + + /// 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 RegistryItem for ZcashSignBatch { + fn get_registry_type() -> RegistryType<'static> { + ZCASH_SIGN_BATCH + } +} + +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, + _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)?; + } + Ok(()) + } +} + +impl<'b, C> minicbor::Decode<'b, C> for ZcashSignBatch { + fn decode(d: &mut 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()?; + reject_duplicate_key( + &mut seen_keys, + key, + d, + "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()?), + _ => 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(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()?, + } + } + 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) + } +} + +#[cfg(test)] +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) + ); + } + + #[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 + ); + + 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() + ); + } + + #[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(); + + 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()); + } + + #[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(); + let decoded = ZcashSignBatch::try_from(encoded).unwrap(); + + assert_eq!(decoded.get_atomic_field(), None); + assert!(decoded.get_atomic()); + } + + #[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(); + + assert!(err.to_string().contains("duplicate key")); + } + + #[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(); + + assert!(err + .to_string() + .contains("duplicate key in zcash-sign-message 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); + + let err = ZcashSignBatch::try_from(encoded).unwrap_err(); + + assert!(err.to_string().contains("trailing data")); + } + + #[test] + fn test_zcash_sign_batch_rejects_indefinite_top_level_map() { + let indefinite_map = vec![0xbf, 0xff]; + + 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)); + } + } + + #[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)); + } + } + + #[test] + fn test_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 new file mode 100644 index 0000000..3ab59d2 --- /dev/null +++ b/libs/ur-registry/src/zcash/zcash_sign_result.rs @@ -0,0 +1,672 @@ +//! Zcash signing result Registry Type. +//! +//! This module implements CBOR encoding and decoding for a device response to a +//! Zcash signing batch. Each result is correlated to the input message by id. +//! +//! 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, 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 crate::{ + registry_types::{RegistryType, ZCASH_SIGN_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_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; +/// 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; + +const VERSION: u8 = 1; +const REQUEST_ID: u8 = 2; +const RESULTS: u8 = 3; + +const MESSAGE_ID: u8 = 1; +const RESULT_STATUS: u8 = 2; +const RESULT_KIND: u8 = 3; +const RESULT_PAYLOAD: u8 = 4; +const RESULT_PAYLOAD_DIGEST: u8 = 6; + +#[derive(Clone, Debug, Default)] +pub struct ZcashSignResult { + version: u32, + request_id: Vec, + results: Vec, +} + +impl ZcashSignResult { + /// Builds a signing 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, + } + } + + pub fn get_version(&self) -> u32 { + self.version + } + + pub fn get_request_id(&self) -> &Vec { + &self.request_id + } + + pub fn get_results(&self) -> &Vec { + &self.results + } +} + +impl RegistryItem for ZcashSignResult { + fn get_registry_type() -> RegistryType<'static> { + ZCASH_SIGN_RESULT + } +} + +impl MapSize for ZcashSignResult { + fn map_size(&self) -> u64 { + 3 + } +} + +#[derive(Clone, Debug, Default)] +pub struct ZcashSignMessageResult { + id: Vec, + status: u32, + kind: u32, + payload: Vec, + payload_digest: Vec, +} + +impl ZcashSignMessageResult { + /// Builds a message result container. The SDK does not validate protocol + /// policy such as supported status, kind, id uniqueness, or digest length + /// here. + pub fn new( + id: Vec, + status: u32, + kind: u32, + payload: Vec, + payload_digest: Vec, + ) -> Self { + Self { + id, + status, + kind, + payload, + payload_digest, + } + } + + /// Builds a signed message result container with the registered signed + /// status. Callers still validate kind, id correlation, and digest policy. + pub fn signed(id: Vec, kind: u32, payload: Vec, payload_digest: Vec) -> Self { + Self { + id, + status: ZCASH_SIGN_STATUS_SIGNED, + kind, + payload, + payload_digest, + } + } + + pub fn get_id(&self) -> &Vec { + &self.id + } + + pub fn get_status(&self) -> u32 { + self.status + } + + pub fn get_kind(&self) -> u32 { + self.kind + } + + pub fn get_payload(&self) -> &Vec { + &self.payload + } + + pub fn get_payload_digest(&self) -> &Vec { + &self.payload_digest + } +} + +impl MapSize for ZcashSignMessageResult { + fn map_size(&self) -> u64 { + 5 + } +} + +impl TryFrom> for ZcashSignResult { + 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-sign-result".to_string(), + )); + } + Ok(result) + } +} + +impl TryInto> for ZcashSignResult { + type Error = URError; + + fn try_into(self) -> URResult> { + minicbor::to_vec(self).map_err(|e| URError::CborEncodeError(e.to_string())) + } +} + +impl minicbor::Encode for ZcashSignResult { + 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 ZcashSignMessageResult { + 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.id)?; + e.int(Int::from(RESULT_STATUS))?.u32(self.status)?; + e.int(Int::from(RESULT_KIND))?.u32(self.kind)?; + e.int(Int::from(RESULT_PAYLOAD))?.bytes(&self.payload)?; + e.int(Int::from(RESULT_PAYLOAD_DIGEST))? + .bytes(&self.payload_digest)?; + Ok(()) + } +} + +impl<'b, C> minicbor::Decode<'b, C> for ZcashSignResult { + fn decode(d: &mut Decoder<'b>, ctx: &mut C) -> Result { + let mut result = ZcashSignResult::default(); + let len = d.map()?.ok_or_else(|| { + minicbor::decode::Error::message("indefinite zcash-sign-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-sign-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-sign-result results array is unsupported", + ) + .at(d.position()) + })?; + for _ in 0..len { + results.push(ZcashSignMessageResult::decode(d, ctx)?); + } + result.results = results; + } + _ => d.skip()?, + } + } + require_key(&seen_keys, VERSION, d, "missing zcash-sign-result version")?; + require_key( + &seen_keys, + REQUEST_ID, + d, + "missing zcash-sign-result request id", + )?; + require_key(&seen_keys, RESULTS, d, "missing zcash-sign-result results")?; + Ok(result) + } +} + +impl<'b, C> minicbor::Decode<'b, C> for ZcashSignMessageResult { + fn decode(d: &mut Decoder<'b>, _ctx: &mut C) -> Result { + let mut result = ZcashSignMessageResult::default(); + let len = d.map()?.ok_or_else(|| { + minicbor::decode::Error::message( + "indefinite zcash-sign-message-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-sign-message-result map", + )?; + match key { + MESSAGE_ID => result.id = d.bytes()?.to_vec(), + RESULT_STATUS => result.status = d.u32()?, + RESULT_KIND => result.kind = d.u32()?, + RESULT_PAYLOAD => result.payload = d.bytes()?.to_vec(), + RESULT_PAYLOAD_DIGEST => result.payload_digest = d.bytes()?.to_vec(), + _ => d.skip()?, + } + } + require_key( + &seen_keys, + MESSAGE_ID, + d, + "missing zcash-sign-message-result id", + )?; + require_key( + &seen_keys, + RESULT_STATUS, + d, + "missing zcash-sign-message-result status", + )?; + require_key( + &seen_keys, + RESULT_KIND, + d, + "missing zcash-sign-message-result kind", + )?; + require_key( + &seen_keys, + RESULT_PAYLOAD, + d, + "missing zcash-sign-message-result payload", + )?; + require_key( + &seen_keys, + RESULT_PAYLOAD_DIGEST, + d, + "missing zcash-sign-message-result payload digest", + )?; + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + #[test] + fn test_zcash_sign_result_encode_decode() { + let payload_digest = + hex::decode("ee9040f65c341855e070ff438eb0ea9d5b831b2a2c270fb7ef592d750408e3b3") + .unwrap(); + let result = ZcashSignResult::new( + ZCASH_SIGN_RESULT_VERSION, + vec![0xaa, 0xbb], + vec![ZcashSignMessageResult::signed( + vec![0x01], + ZCASH_SIGN_RESULT_KIND_PCZT_V1, + vec![0x02, 0x03], + payload_digest.clone(), + )], + ); + + let encoded: Vec = result.clone().try_into().unwrap(); + let decoded = ZcashSignResult::try_from(encoded).unwrap(); + + assert_eq!(decoded.get_version(), ZCASH_SIGN_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_id(), &vec![0x01]); + assert_eq!( + decoded.get_results()[0].get_status(), + ZCASH_SIGN_STATUS_SIGNED + ); + assert_eq!( + decoded.get_results()[0].get_kind(), + ZCASH_SIGN_RESULT_KIND_PCZT_V1 + ); + assert_eq!(decoded.get_results()[0].get_payload(), &vec![0x02, 0x03]); + assert_eq!( + decoded.get_results()[0].get_payload_digest(), + &payload_digest + ); + } + + #[test] + fn test_zcash_sign_result_decodes_literal_cbor_fixture() { + let cbor = hex::decode( + "a301010242aabb0381a50141010200030104527369676e65642d70637a742d726573756c74065820f2dbc955d1edad3014bc907efc15e93adb4412cdee847d261cd942998693e590", + ) + .unwrap(); + let payload_digest = + hex::decode("f2dbc955d1edad3014bc907efc15e93adb4412cdee847d261cd942998693e590") + .unwrap(); + + let decoded = ZcashSignResult::try_from(cbor.clone()).unwrap(); + + assert_eq!(decoded.get_version(), ZCASH_SIGN_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_id(), &vec![0x01]); + assert_eq!( + decoded.get_results()[0].get_status(), + ZCASH_SIGN_STATUS_SIGNED + ); + assert_eq!( + decoded.get_results()[0].get_kind(), + ZCASH_SIGN_RESULT_KIND_PCZT_V1 + ); + assert_eq!( + decoded.get_results()[0].get_payload(), + &b"signed-pczt-result".to_vec() + ); + assert_eq!( + decoded.get_results()[0].get_payload_digest(), + &payload_digest + ); + assert_eq!(decoded.get_results()[0].get_payload_digest().len(), 32); + + let result = ZcashSignResult::new( + ZCASH_SIGN_RESULT_VERSION, + vec![0xaa, 0xbb], + vec![ZcashSignMessageResult::signed( + vec![0x01], + ZCASH_SIGN_RESULT_KIND_PCZT_V1, + b"signed-pczt-result".to_vec(), + payload_digest, + )], + ); + let encoded: Vec = result.try_into().unwrap(); + + assert_eq!(encoded, cbor); + } + + #[test] + fn test_zcash_sign_result_skips_unknown_fields() { + let fixtures = [ + // Unknown top level key 9 with value [1, {"x": true}]. + "a401010242aabb0381a50141010200030104527369676e65642d70637a742d726573756c74065820f2dbc955d1edad3014bc907efc15e93adb4412cdee847d261cd942998693e590098201a16178f5", + // Unknown nested result key 9 with value [1, {"x": true}]. + "a301010242aabb0381a60141010200030104527369676e65642d70637a742d726573756c74098201a16178f5065820f2dbc955d1edad3014bc907efc15e93adb4412cdee847d261cd942998693e590", + ]; + let payload_digest = + hex::decode("f2dbc955d1edad3014bc907efc15e93adb4412cdee847d261cd942998693e590") + .unwrap(); + + for cbor_hex in fixtures { + let decoded = ZcashSignResult::try_from(hex::decode(cbor_hex).unwrap()).unwrap(); + + assert_eq!(decoded.get_version(), ZCASH_SIGN_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_id(), &vec![0x01]); + assert_eq!( + decoded.get_results()[0].get_status(), + ZCASH_SIGN_STATUS_SIGNED + ); + assert_eq!( + decoded.get_results()[0].get_kind(), + ZCASH_SIGN_RESULT_KIND_PCZT_V1 + ); + assert_eq!( + decoded.get_results()[0].get_payload(), + &b"signed-pczt-result".to_vec() + ); + assert_eq!( + decoded.get_results()[0].get_payload_digest(), + &payload_digest + ); + } + } + + #[test] + fn test_zcash_sign_result_decodes_unknown_policy_values() { + let payload_digest = + hex::decode("ee9040f65c341855e070ff438eb0ea9d5b831b2a2c270fb7ef592d750408e3b3") + .unwrap(); + let result = ZcashSignResult::new( + 99, + vec![0xaa, 0xbb], + vec![ZcashSignMessageResult::new( + vec![0x01], + 42, + 77, + b"policy-is-external".to_vec(), + payload_digest.clone(), + )], + ); + let encoded: Vec = result.try_into().unwrap(); + + let decoded = ZcashSignResult::try_from(encoded).unwrap(); + + assert_eq!(decoded.get_version(), 99); + assert_eq!(decoded.get_request_id(), &vec![0xaa, 0xbb]); + assert_eq!(decoded.get_results().len(), 1); + assert_eq!(decoded.get_results()[0].get_id(), &vec![0x01]); + assert_eq!(decoded.get_results()[0].get_status(), 42); + assert_eq!(decoded.get_results()[0].get_kind(), 77); + assert_eq!( + decoded.get_results()[0].get_payload(), + &b"policy-is-external".to_vec() + ); + assert_eq!( + decoded.get_results()[0].get_payload_digest(), + &payload_digest + ); + } + + #[test] + fn test_zcash_sign_result_decodes_duplicate_result_ids() { + let first_digest = + hex::decode("7a66e6be087afee0665161828bd11dc1e201fdb8c2d72786ee485e29897c8da4") + .unwrap(); + let second_digest = + hex::decode("f2dbc955d1edad3014bc907efc15e93adb4412cdee847d261cd942998693e590") + .unwrap(); + let result = ZcashSignResult::new( + ZCASH_SIGN_RESULT_VERSION, + vec![0xaa, 0xbb], + vec![ + ZcashSignMessageResult::signed( + vec![0x01], + ZCASH_SIGN_RESULT_KIND_PCZT_V1, + b"first".to_vec(), + first_digest.clone(), + ), + ZcashSignMessageResult::signed( + vec![0x01], + ZCASH_SIGN_RESULT_KIND_PCZT_V1, + b"second".to_vec(), + second_digest.clone(), + ), + ], + ); + let encoded: Vec = result.try_into().unwrap(); + + let decoded = ZcashSignResult::try_from(encoded).unwrap(); + + assert_eq!(decoded.get_results().len(), 2); + assert_eq!(decoded.get_results()[0].get_id(), &vec![0x01]); + assert_eq!(decoded.get_results()[1].get_id(), &vec![0x01]); + assert_eq!(decoded.get_results()[0].get_payload(), &b"first".to_vec()); + assert_eq!(decoded.get_results()[1].get_payload(), &b"second".to_vec()); + assert_eq!(decoded.get_results()[0].get_payload_digest(), &first_digest); + assert_eq!( + decoded.get_results()[1].get_payload_digest(), + &second_digest + ); + } + + #[test] + fn test_zcash_sign_result_rejects_duplicate_keys() { + let duplicate_version_keys = vec![0xa2, VERSION, 0x01, VERSION, 0x02]; + + let err = ZcashSignResult::try_from(duplicate_version_keys).unwrap_err(); + + assert!(err.to_string().contains("duplicate key")); + } + + #[test] + fn test_zcash_sign_result_rejects_duplicate_message_result_keys() { + let duplicate_message_id_keys = vec![ + 0xa3, + VERSION, + 0x01, + REQUEST_ID, + 0x40, + RESULTS, + 0x81, + 0xa6, + MESSAGE_ID, + 0x40, + MESSAGE_ID, + 0x40, + RESULT_STATUS, + 0x00, + RESULT_KIND, + 0x01, + RESULT_PAYLOAD, + 0x40, + RESULT_PAYLOAD_DIGEST, + 0x40, + ]; + + let err = ZcashSignResult::try_from(duplicate_message_id_keys).unwrap_err(); + + assert!(err + .to_string() + .contains("duplicate key in zcash-sign-message-result map")); + } + + #[test] + fn test_zcash_sign_result_rejects_trailing_data() { + let result = ZcashSignResult::new(ZCASH_SIGN_RESULT_VERSION, vec![], vec![]); + let mut encoded: Vec = result.try_into().unwrap(); + encoded.push(0x00); + + let err = ZcashSignResult::try_from(encoded).unwrap_err(); + + assert!(err.to_string().contains("trailing data")); + } + + #[test] + fn test_zcash_sign_result_rejects_indefinite_top_level_map() { + let indefinite_map = vec![0xbf, 0xff]; + + let err = ZcashSignResult::try_from(indefinite_map).unwrap_err(); + + assert!(err.to_string().contains("indefinite zcash-sign-result map")); + } + + #[test] + fn test_zcash_sign_result_rejects_indefinite_results_array() { + let indefinite_results = vec![0xa3, VERSION, 0x01, REQUEST_ID, 0x40, RESULTS, 0x9f, 0xff]; + + let err = ZcashSignResult::try_from(indefinite_results).unwrap_err(); + + assert!(err.to_string().contains("results array")); + } + + #[test] + fn test_zcash_sign_result_rejects_indefinite_message_result_map() { + let indefinite_message_result = vec![ + 0xa3, VERSION, 0x01, REQUEST_ID, 0x40, RESULTS, 0x81, 0xbf, 0xff, + ]; + + let err = ZcashSignResult::try_from(indefinite_message_result).unwrap_err(); + + assert!(err + .to_string() + .contains("indefinite zcash-sign-message-result map")); + } + + #[test] + fn test_zcash_sign_result_rejects_missing_required_top_level_key() { + for (cbor_hex, message) in [ + ("a202400380", "missing zcash-sign-result version"), + ("a201010380", "missing zcash-sign-result request id"), + ("a20101024101", "missing zcash-sign-result results"), + ] { + let cbor = hex::decode(cbor_hex).unwrap(); + + let err = ZcashSignResult::try_from(cbor).unwrap_err(); + + assert!(err.to_string().contains(message)); + } + } + + #[test] + fn test_zcash_sign_result_rejects_missing_required_message_key() { + for (cbor_hex, message) in [ + ( + "a3010102400381a40200030104400640", + "missing zcash-sign-message-result id", + ), + ( + "a3010102400381a40140030104400640", + "missing zcash-sign-message-result status", + ), + ( + "a3010102400381a40140020004400640", + "missing zcash-sign-message-result kind", + ), + ( + "a3010102400381a40140020003010640", + "missing zcash-sign-message-result payload", + ), + ( + "a3010102400381a40140020003010440", + "missing zcash-sign-message-result payload digest", + ), + ] { + let cbor = hex::decode(cbor_hex).unwrap(); + + let err = ZcashSignResult::try_from(cbor).unwrap_err(); + + assert!(err.to_string().contains(message)); + } + } + + #[test] + fn test_registry_type() { + assert_eq!( + ZcashSignResult::get_registry_type().get_type(), + "zcash-sign-result" + ); + } +}