fix(zcash): sign shielded PCZTs without RoleSigner to fit the device stack
What changed, and why it matters
This commit fixes a crash in the Keystone hardware wallet when signing certain advanced Zcash transactions. The previous code used a heavy upstream signing helper that needed too much memory, causing the device to reset during an Orchard-to-Ironwood migration. The patch replaces it with a leaner, in-house signer and adds the new v6/Ironwood hash logic needed to keep signatures valid. It also rejects v6 transactions containing Sapling spends because the lean signer does not yet support that specific hash domain.
Treat this as a reliability/availability fix with consensus-critical cryptographic changes. Review the new v6 sighash constants and 5-node layout against upstream zcash_primitives for bit-exact correctness, ensure the Sapling-spend rejection is enforced at all entry points, and verify the oracle tests run in CI for every future upstream pczt dependency update. Consider stack-size regression tests for the signing task.
Security signals we found
Stack overflow / device reset in signing task due to excessive stack usage by upstream RoleSigner
Replacement of heavy upstream signer with lean in-repo low_level_signer to fit device stack budget
Addition of consensus-critical NU6.3 v6 Orchard/Ironwood sighash domains in pczt_ext
Rejection of v6 Sapling spends because the new lean hasher does not cover that domain
Oracle tests comparing lean sighash bit-exact against upstream RoleSigner to detect upstream drift
Per-account spend-authorizing key cache and strict per-action validation preserved from prior path
Evidence from the diff
The cypherpunk Zcash PCZT signing path previously used upstream pczt::roles::signer::RoleSigner, which reconstructs a full TransactionData per bundle to compute the 32-byte ZIP-244 shielded sighash. That reconstruction consumes ~61 KB of stack, exceeding the 26 KB UiDisplayTask budget and causing a stack overflow/reset when signing Orchard→Ironwood migration batches. The patch switches to the existing low_level_signer driven by a new lean SeedSigner (PcztSigner), deriving keys and signing Orchard/Ironwood actions in place. It implements the NU6.3 v6 sighash layout in pczt_ext::shielded_sig_commitment: digest_orchard_v6/digest_ironwood_v6 use distinct v6 personalizations, omit the anchor from the effects digest, and append the Ironwood node in the 5-node to_hash_v6 layout. Oracle tests assert bit-exact equality against RoleSigner::shielded_sighash. validate_supported_pczt now rejects v6 PCZTs carrying Sapling spends because the lean hasher does not implement the v6 Sapling-spend domain.
Changed components
rust/apps/zcash/src/pczt/sign.rsrust/apps/zcash/src/pczt/mod.rsrust/zcash_vendor/src/pczt_ext.rsZcash cypherpunk PCZT signing pathOrchard and Ironwood shielded action signingKeystone 3 firmware UiDisplayTask stackInspect captured patch +603 / −217
diff --git a/rust/apps/zcash/src/pczt/mod.rs b/rust/apps/zcash/src/pczt/mod.rs
index 8c0bb0c..cda05a9 100644
--- a/rust/apps/zcash/src/pczt/mod.rs
+++ b/rust/apps/zcash/src/pczt/mod.rs
@@ -27,6 +27,20 @@ pub(crate) fn validate_supported_pczt(pczt: &Pczt) -> Result<(), ZcashError> {
"Ironwood actions require a v6 PCZT".to_string(),
));
}
+
+ // The lean v6 shielded sighash (`pczt_ext::shielded_sig_commitment`) implements
+ // the Orchard and Ironwood v6 commitment domains, but NOT the v6 Sapling-spend
+ // domain (which uses a distinct noncompact personalization and omits the
+ // per-spend anchor; ZIP-244). Keystone's cypherpunk signing never produces
+ // Sapling spend authorizations, so reject any v6 PCZT carrying a Sapling spend
+ // rather than committing our Orchard/Ironwood signature to a v5-rules (wrong)
+ // Sapling digest. Sapling OUTPUTS are version-independent in ZIP-244 and remain
+ // supported; v5 transactions are unaffected (the lean v5 Sapling digest matches).
+ if pczt_is_v6(pczt) && !pczt.sapling().spends().is_empty() {
+ return Err(ZcashError::InvalidPczt(
+ "Sapling spends are not supported in v6 transactions".to_string(),
+ ));
+ }
}
Ok(())
@@ -424,6 +438,116 @@ pub(crate) mod test_support {
}
}
+ // Orchard spend -> Ironwood output: a cross-pool migration, the message type
+ // the real batch uses (and the one never exercised on-device). Mirrors
+ // sample_ironwood_pczt but the *spent* note is an Orchard note.
+ #[cfg(zcash_unstable = "nu6.3")]
+ pub(crate) fn sample_migration_pczt() -> SamplePczt {
+ let params = Nu6_3Network;
+ let seed = [7u8; 32];
+ let ufvk_text = derive_ufvk(¶ms, &seed, "m/32'/133'/0'").unwrap();
+ let ufvk = UnifiedFullViewingKey::decode(¶ms, &ufvk_text).unwrap();
+ let orchard_fvk = ufvk.orchard().unwrap().clone();
+ let orchard_ivk = orchard_fvk.to_ivk(orchard::keys::Scope::External);
+ let orchard_ovk = orchard_fvk.to_ovk(orchard::keys::Scope::External);
+ let recipient = orchard_fvk.address_at(0u32, orchard::keys::Scope::External);
+
+ // The Orchard note being migrated: output (990_000) + cross-pool fee (20_000),
+ // so there is no change output.
+ let value = orchard::value::NoteValue::from_raw(1_010_000);
+ let note = {
+ let mut orchard_builder = orchard::builder::Builder::new(
+ orchard::BundleProtocol::OrchardPostNu6_3,
+ orchard::builder::BundleType::Coinbase,
+ orchard::Anchor::empty_tree(),
+ );
+ orchard_builder
+ .add_output(None, recipient, value, Memo::Empty.encode().into_bytes())
+ .unwrap();
+ let (bundle, meta) = orchard_builder.build::<i64>(&mut OsRng).unwrap().unwrap();
+ let action = bundle
+ .actions()
+ .get(meta.output_action_index(0).unwrap())
+ .unwrap();
+ let domain = orchard::note_encryption::OrchardDomain::for_action(action);
+ let (note, _, _) =
+ try_note_decryption(&domain, &orchard_ivk.prepare(), action).unwrap();
+ note
+ };
+
+ let (anchor, merkle_path) = {
+ let cmx: orchard::note::ExtractedNoteCommitment = note.commitment().into();
+ let leaf = orchard::tree::MerkleHashOrchard::from_cmx(&cmx);
+ let mut tree = ShardTree::<_, 32, 16>::new(
+ MemoryShardStore::<orchard::tree::MerkleHashOrchard, u32>::empty(),
+ 100,
+ );
+ tree.append(leaf, Retention::Marked).unwrap();
+ tree.checkpoint(9_999_999).unwrap();
+ let merkle_path = tree
+ .witness_at_checkpoint_depth(0.into(), 0)
+ .unwrap()
+ .unwrap();
+ let anchor = merkle_path.root(leaf);
+ (anchor.into(), merkle_path.into())
+ };
+
+ let mut builder = Builder::new(
+ ¶ms,
+ 10_000_000.into(),
+ BuildConfig::Standard {
+ sapling_anchor: None,
+ orchard_anchor: Some(anchor),
+ ironwood_anchor: Some(orchard::Anchor::empty_tree()),
+ },
+ );
+ builder
+ .add_orchard_spend::<zip317::FeeRule>(orchard_fvk.clone(), note, merkle_path)
+ .unwrap();
+ builder
+ .add_ironwood_output::<zip317::FeeRule>(
+ Some(orchard_ovk),
+ recipient,
+ Zatoshis::const_from_u64(990_000),
+ MemoBytes::empty(),
+ )
+ .unwrap();
+ let PcztResult {
+ pczt_parts,
+ orchard_meta,
+ ..
+ } = builder
+ .build_for_pczt(OsRng, &zip317::FeeRule::standard())
+ .unwrap();
+ let spend_action_index = orchard_meta.spend_action_index(0).unwrap();
+ let seed_fingerprint = calculate_seed_fingerprint(&seed).unwrap();
+ let derivation = orchard::pczt::Zip32Derivation::parse(
+ seed_fingerprint,
+ vec![
+ zip32::ChildIndex::hardened(32).index(),
+ zip32::ChildIndex::hardened(133).index(),
+ zip32::ChildIndex::hardened(0).index(),
+ ],
+ )
+ .unwrap();
+ let pczt = Updater::new(Creator::build_from_parts(pczt_parts).unwrap())
+ .update_orchard_with(|mut bundle| {
+ bundle.update_action_with(spend_action_index, |mut action| {
+ action.set_spend_zip32_derivation(derivation);
+ Ok(())
+ })
+ })
+ .unwrap()
+ .finish();
+
+ SamplePczt {
+ bytes: pczt.serialize(),
+ seed: seed.to_vec(),
+ ufvk_text,
+ seed_fingerprint,
+ }
+ }
+
#[cfg(zcash_unstable = "nu6.3")]
pub(crate) fn sample_orchard_change_pczt() -> SamplePczt {
let params = MainNetwork;
diff --git a/rust/apps/zcash/src/pczt/sign.rs b/rust/apps/zcash/src/pczt/sign.rs
index 233cbde..61ddf7a 100644
--- a/rust/apps/zcash/src/pczt/sign.rs
+++ b/rust/apps/zcash/src/pczt/sign.rs
@@ -22,14 +22,17 @@ use zcash_vendor::{
};
#[cfg(feature = "cypherpunk")]
-use zcash_vendor::{orchard, pczt::roles::signer::Signer as RoleSigner};
+use zcash_vendor::orchard;
-#[cfg(all(feature = "multi_coins", not(feature = "cypherpunk")))]
+#[cfg(any(feature = "cypherpunk", feature = "multi_coins"))]
use zcash_vendor::{
- pczt_ext::{self, PcztSigner as LegacyPcztSigner},
+ pczt_ext::{self, PcztSigner},
transparent::sighash::SignableInput,
};
+#[cfg(feature = "cypherpunk")]
+use {blake2b_simd::Hash, core::cell::Cell, core::cell::RefCell, rand_core::OsRng};
+
use crate::{errors::ZcashError, version::KEYSTONE_FW_VERSION};
/// `global.proprietary` key stamped into every signed PCZT response.
@@ -37,70 +40,13 @@ use crate::{errors::ZcashError, version::KEYSTONE_FW_VERSION};
/// whether the device meets their minimum version requirements.
const PROP_KEY_FW_VERSION: &str = "keystone:fw_version";
-#[derive(Debug)]
-#[cfg(feature = "cypherpunk")]
-enum SigningKeyCollectionError {
- Zcash(ZcashError),
- TransparentParse(transparent::pczt::ParseError),
- #[cfg(feature = "cypherpunk")]
- OrchardParse(orchard::pczt::ParseError),
- OrchardBundleParse(zcash_vendor::pczt::orchard::BundleParseError),
-}
-
-#[cfg(feature = "cypherpunk")]
-impl SigningKeyCollectionError {
- fn into_zcash(self) -> ZcashError {
- match self {
- SigningKeyCollectionError::Zcash(e) => e,
- SigningKeyCollectionError::TransparentParse(e) => {
- ZcashError::SigningError(format!("failed to parse transparent bundle: {e:?}"))
- }
- #[cfg(feature = "cypherpunk")]
- SigningKeyCollectionError::OrchardParse(e) => {
- ZcashError::SigningError(format!("failed to parse shielded bundle: {e:?}"))
- }
- SigningKeyCollectionError::OrchardBundleParse(e) => {
- ZcashError::SigningError(format!("failed to parse shielded bundle: {e:?}"))
- }
- }
- }
-}
-
-#[cfg(feature = "cypherpunk")]
-impl From<ZcashError> for SigningKeyCollectionError {
- fn from(e: ZcashError) -> Self {
- SigningKeyCollectionError::Zcash(e)
- }
-}
-
-#[cfg(feature = "cypherpunk")]
-impl From<transparent::pczt::ParseError> for SigningKeyCollectionError {
- fn from(e: transparent::pczt::ParseError) -> Self {
- SigningKeyCollectionError::TransparentParse(e)
- }
-}
-
-#[cfg(feature = "cypherpunk")]
-impl From<orchard::pczt::ParseError> for SigningKeyCollectionError {
- fn from(e: orchard::pczt::ParseError) -> Self {
- SigningKeyCollectionError::OrchardParse(e)
- }
-}
-
-#[cfg(feature = "cypherpunk")]
-impl From<zcash_vendor::pczt::orchard::BundleParseError> for SigningKeyCollectionError {
- fn from(e: zcash_vendor::pczt::orchard::BundleParseError) -> Self {
- SigningKeyCollectionError::OrchardBundleParse(e)
- }
-}
-
#[cfg(all(feature = "multi_coins", not(feature = "cypherpunk")))]
struct SeedSigner<'a> {
seed: &'a [u8],
}
#[cfg(all(feature = "multi_coins", not(feature = "cypherpunk")))]
-impl LegacyPcztSigner for SeedSigner<'_> {
+impl PcztSigner for SeedSigner<'_> {
type Error = ZcashError;
fn sign_transparent<F>(
@@ -155,45 +101,180 @@ fn reject_legacy_unsupported_pczt(pczt: &Pczt) -> Result<(), ZcashError> {
Ok(())
}
+/// Lean signer for the cypherpunk path. Drives the shallow `low_level_signer` and
+/// derives keys / signs each action in place, instead of materializing a full
+/// `RoleSigner` (which reconstructs the whole transaction to compute the sighash and
+/// blows the task stack). The sighash itself is the byte-level
+/// `pczt_ext::shielded_sig_commitment`, proven bit-exact against `RoleSigner` by the
+/// `test_lean_sighash_*` oracle tests.
#[cfg(feature = "cypherpunk")]
-pub fn sign_pczt(pczt: Pczt, seed: &[u8]) -> crate::Result<Vec<u8>> {
- super::validate_supported_pczt(&pczt)?;
- let transparent_keys = collect_transparent_signing_keys(&pczt, seed)?;
- let orchard_keys = collect_orchard_signing_keys(&pczt, seed, ShieldedPool::Orchard)?;
- #[cfg(zcash_unstable = "nu6.3")]
- let ironwood_keys = if super::pczt_should_process_ironwood(&pczt) {
- collect_orchard_signing_keys(&pczt, seed, ShieldedPool::Ironwood)?
- } else {
- Vec::new()
- };
+struct SeedSigner<'a> {
+ seed: &'a [u8],
+ seed_fingerprint: [u8; 32],
+ pool: ShieldedPool,
+ /// Per-account spend authorizing key cache. The seed fingerprint and the account
+ /// key depend only on (seed, account), not on the action, so a bundle with many
+ /// actions for one account derives once. Interior mutability because the
+ /// `PcztSigner` trait signs through `&self`.
+ ask_cache: RefCell<Vec<(zcash_vendor::zip32::AccountId, orchard::keys::SpendAuthorizingKey)>>,
+ /// Number of authorizations produced, so `sign_pczt` can distinguish "nothing of
+ /// ours to sign" (`PcztNoMyInputs`) from a successful signing.
+ signed: Cell<usize>,
+}
- let signature_count = transparent_keys.len() + orchard_keys.len();
- #[cfg(zcash_unstable = "nu6.3")]
- let signature_count = signature_count + ironwood_keys.len();
- if signature_count == 0 {
- return Err(ZcashError::PcztNoMyInputs);
+#[cfg(feature = "cypherpunk")]
+impl<'a> SeedSigner<'a> {
+ fn new(seed: &'a [u8], seed_fingerprint: [u8; 32], pool: ShieldedPool) -> Self {
+ Self {
+ seed,
+ seed_fingerprint,
+ pool,
+ ask_cache: RefCell::new(Vec::new()),
+ signed: Cell::new(0),
+ }
}
- let mut signer = RoleSigner::new(pczt)
- .map_err(|e| ZcashError::SigningError(format!("failed to prepare PCZT signer: {e:?}")))?;
+ fn spend_authorizing_key(
+ &self,
+ account_index: zcash_vendor::zip32::AccountId,
+ ) -> Result<orchard::keys::SpendAuthorizingKey, ZcashError> {
+ if let Some((_, ask)) = self
+ .ask_cache
+ .borrow()
+ .iter()
+ .find(|(cached, _)| *cached == account_index)
+ {
+ return Ok(ask.clone());
+ }
+ let osk = orchard::keys::SpendingKey::from_zip32_seed(self.seed, 133, account_index)
+ .map_err(|e| {
+ ZcashError::SigningError(format!(
+ "failed to derive {} spending key: {e:?}",
+ self.pool.label()
+ ))
+ })?;
+ let ask = orchard::keys::SpendAuthorizingKey::from(&osk);
+ self.ask_cache
+ .borrow_mut()
+ .push((account_index, ask.clone()));
+ Ok(ask)
+ }
+}
- for (index, sk) in transparent_keys {
- signer
- .sign_transparent(index, &sk)
- .map_err(|e| ZcashError::SigningError(format!("failed to sign input: {e:?}")))?;
+#[cfg(feature = "cypherpunk")]
+impl PcztSigner for SeedSigner<'_> {
+ type Error = ZcashError;
+
+ fn sign_transparent<F>(
+ &self,
+ index: usize,
+ input: &mut transparent::pczt::Input,
+ hash: F,
+ ) -> Result<(), Self::Error>
+ where
+ F: FnOnce(SignableInput) -> [u8; 32],
+ {
+ if let Some(path) = transparent_key_path_for_input(self.seed, input)? {
+ let sk = get_private_key_by_seed(self.seed, &path).map_err(|e| {
+ ZcashError::SigningError(format!("failed to get private key: {e:?}"))
+ })?;
+ let secp = secp256k1::Secp256k1::new();
+ input
+ .sign(index, hash, &sk, &secp)
+ .map_err(|e| ZcashError::SigningError(format!("failed to sign input: {e:?}")))?;
+ self.signed.set(self.signed.get() + 1);
+ }
+ Ok(())
}
- for (index, ask) in orchard_keys {
- signer.sign_orchard(index, &ask).map_err(|e| {
- ZcashError::SigningError(format!("failed to sign Orchard action: {e:?}"))
- })?;
+ fn sign_orchard(
+ &self,
+ action: &mut orchard::pczt::Action,
+ hash: Hash,
+ ) -> Result<(), Self::Error> {
+ // Strict per-action validation, ported verbatim from the previous
+ // collect_orchard_bundle_signing_keys so the lean signer keeps identical
+ // skip/reject semantics to the RoleSigner path.
+ let pool_label = self.pool.label();
+ if action.spend().spend_auth_sig().is_some() {
+ return Ok(());
+ }
+ if action.spend().dummy_sk().is_some() {
+ match action.spend().value().map(|value| value.inner()) {
+ Some(0) | None => return Ok(()),
+ Some(_) => {
+ return Err(ZcashError::InvalidPczt(format!(
+ "{pool_label} spend dummy_sk is only valid for dummy spends"
+ )));
+ }
+ }
+ }
+ if action.spend().value().is_none() {
+ return Ok(());
+ }
+ let Some(account_index) = super::matching_seed_supported_orchard_account(
+ &self.seed_fingerprint,
+ action.spend().zip32_derivation().as_ref(),
+ 133,
+ self.pool,
+ )?
+ else {
+ // Not derivable from this seed; not ours to sign.
+ return Ok(());
+ };
+
+ let ask = self.spend_authorizing_key(account_index)?;
+ action
+ .sign(
+ hash.as_bytes().try_into().expect("sighash is 32 bytes"),
+ &ask,
+ OsRng,
+ )
+ .map_err(|e| {
+ ZcashError::SigningError(format!("failed to sign {pool_label} action: {e:?}"))
+ })?;
+ self.signed.set(self.signed.get() + 1);
+ Ok(())
}
+}
+
+#[cfg(feature = "cypherpunk")]
+pub fn sign_pczt(pczt: Pczt, seed: &[u8]) -> crate::Result<Vec<u8>> {
+ super::validate_supported_pczt(&pczt)?;
+
+ let seed_fingerprint =
+ calculate_seed_fingerprint(seed).map_err(|e| ZcashError::SigningError(e.to_string()))?;
#[cfg(zcash_unstable = "nu6.3")]
- for (index, ask) in ironwood_keys {
- signer.sign_ironwood(index, &ask).map_err(|e| {
- ZcashError::SigningError(format!("failed to sign Ironwood action: {e:?}"))
- })?;
+ let process_ironwood = super::pczt_should_process_ironwood(&pczt);
+
+ // The orchard signer handles both the transparent inputs and the Orchard bundle
+ // (the pool only changes error labels for shielded actions). Ironwood gets its own.
+ let orchard_signer = SeedSigner::new(seed, seed_fingerprint, ShieldedPool::Orchard);
+
+ // Propagate the signer error directly (it is already a ZcashError): the strict
+ // validation in SeedSigner::sign_orchard returns ZcashError::InvalidPczt for bad
+ // ZIP 32 paths, which callers/tests distinguish from generic SigningError.
+ let signer = low_level_signer::Signer::new(pczt);
+ let signer = pczt_ext::sign_transparent(signer, &orchard_signer)?;
+ let signer = pczt_ext::sign_orchard(signer, &orchard_signer)?;
+
+ #[cfg(zcash_unstable = "nu6.3")]
+ let ironwood_signer = SeedSigner::new(seed, seed_fingerprint, ShieldedPool::Ironwood);
+ #[cfg(zcash_unstable = "nu6.3")]
+ let signer = if process_ironwood {
+ pczt_ext::sign_ironwood(signer, &ironwood_signer)?
+ } else {
+ signer
+ };
+
+ let mut signed = orchard_signer.signed.get();
+ #[cfg(zcash_unstable = "nu6.3")]
+ {
+ signed += ironwood_signer.signed.get();
+ }
+ if signed == 0 {
+ return Err(ZcashError::PcztNoMyInputs);
}
Ok(stamp_and_redact(signer.finish()).serialize())
@@ -327,125 +408,15 @@ fn transparent_key_path_for_input(
Ok(None)
}
-#[cfg(feature = "cypherpunk")]
-fn collect_transparent_signing_keys(
- pczt: &Pczt,
- seed: &[u8],
-) -> Result<Vec<(usize, secp256k1::SecretKey)>, ZcashError> {
- let mut keys = Vec::new();
- low_level_signer::Signer::new(pczt.clone())
- .sign_transparent_with(|_pczt, bundle, _tx_modifiable| {
- for (index, input) in bundle.inputs_mut().iter().enumerate() {
- if let Some(path) = transparent_key_path_for_input(seed, input)? {
- let sk = get_private_key_by_seed(seed, &path).map_err(|e| {
- ZcashError::SigningError(format!("failed to get private key: {e:?}"))
- })?;
- keys.push((index, sk));
- }
- }
- Ok::<_, SigningKeyCollectionError>(())
- })
- .map_err(SigningKeyCollectionError::into_zcash)?;
- Ok(keys)
-}
-
#[cfg(feature = "cypherpunk")]
use super::ShieldedPool;
-#[cfg(feature = "cypherpunk")]
-fn collect_orchard_signing_keys(
- pczt: &Pczt,
- seed: &[u8],
- pool: ShieldedPool,
-) -> Result<Vec<(usize, orchard::keys::SpendAuthorizingKey)>, ZcashError> {
- let mut keys = Vec::new();
-
- match pool {
- ShieldedPool::Orchard => {
- low_level_signer::Signer::new(pczt.clone())
- .sign_orchard_with(|_pczt, bundle, _tx_modifiable| {
- collect_orchard_bundle_signing_keys(&mut keys, seed, pool, bundle)
- })
- .map_err(SigningKeyCollectionError::into_zcash)?;
- }
- #[cfg(zcash_unstable = "nu6.3")]
- ShieldedPool::Ironwood => {
- if !super::pczt_should_process_ironwood(pczt) {
- return Ok(keys);
- }
- low_level_signer::Signer::new(pczt.clone())
- .sign_ironwood_with(|_pczt, bundle, _tx_modifiable| {
- collect_orchard_bundle_signing_keys(&mut keys, seed, pool, bundle)
- })
- .map_err(SigningKeyCollectionError::into_zcash)?;
- }
- }
-
- Ok(keys)
-}
-
-#[cfg(feature = "cypherpunk")]
-fn collect_orchard_bundle_signing_keys(
- keys: &mut Vec<(usize, orchard::keys::SpendAuthorizingKey)>,
- seed: &[u8],
- pool: ShieldedPool,
- bundle: &mut orchard::pczt::Bundle,
-) -> Result<(), SigningKeyCollectionError> {
- for (index, action) in bundle.actions().iter().enumerate() {
- let pool_label = pool.label();
- if action.spend().spend_auth_sig().is_some() {
- continue;
- }
- if action.spend().dummy_sk().is_some() {
- match action.spend().value().map(|value| value.inner()) {
- Some(0) | None => continue,
- Some(_) => {
- return Err(ZcashError::InvalidPczt(format!(
- "{pool_label} spend dummy_sk is only valid for dummy spends"
- ))
- .into());
- }
- }
- }
- if action.spend().value().is_none() {
- continue;
- }
- if let Some(ask) = spend_authorizing_key_for_action(seed, action, pool)? {
- keys.push((index, ask));
- }
- }
- Ok(())
-}
-
-#[cfg(feature = "cypherpunk")]
-fn spend_authorizing_key_for_action(
- seed: &[u8],
- action: &orchard::pczt::Action,
- pool: ShieldedPool,
-) -> Result<Option<orchard::keys::SpendAuthorizingKey>, ZcashError> {
- let pool_label = pool.label();
- let fingerprint =
- calculate_seed_fingerprint(seed).map_err(|e| ZcashError::SigningError(e.to_string()))?;
- let Some(account_index) = super::matching_seed_supported_orchard_account(
- &fingerprint,
- action.spend().zip32_derivation().as_ref(),
- 133,
- pool,
- )?
- else {
- return Ok(None);
- };
-
- let osk =
- orchard::keys::SpendingKey::from_zip32_seed(seed, 133, account_index).map_err(|e| {
- ZcashError::SigningError(format!("failed to derive {pool_label} spending key: {e:?}"))
- })?;
- Ok(Some(orchard::keys::SpendAuthorizingKey::from(&osk)))
-}
-
#[cfg(all(test, feature = "cypherpunk"))]
mod tests {
use super::*;
+ // RoleSigner is the upstream reference signer; tests use its sighash as the
+ // bit-exact oracle for the lean pczt_ext::shielded_sig_commitment.
+ use zcash_vendor::pczt::roles::signer::Signer as RoleSigner;
fn assert_invalid_pczt_message<T: core::fmt::Debug>(result: crate::Result<T>, expected: &str) {
match result {
@@ -470,6 +441,87 @@ mod tests {
assert!(matches!(result, Err(ZcashError::PcztNoMyInputs)));
}
+ // Consensus guard: the lean byte-level sighash (pczt_ext::shielded_sig_commitment) MUST
+ // equal the upstream RoleSigner sighash for every shielded shape we sign. These assert it
+ // bit-exact for an Orchard-only tx, a dual-pool Orchard->Ironwood migration, and an
+ // Ironwood spend, so any upstream sighash change turns CI red instead of silently
+ // producing wrong signatures on-device.
+ #[cfg(zcash_unstable = "nu6.3")]
+ #[test]
+ fn test_lean_sighash_control_orchard_only() {
+ let sample = crate::pczt::test_support::sample_orchard_change_pczt();
+ let pczt = Pczt::parse(&sample.bytes).unwrap();
+ let oracle = RoleSigner::new(pczt.clone()).unwrap().shielded_sighash();
+ let lean: [u8; 32] = zcash_vendor::pczt_ext::shielded_sig_commitment(&pczt, 0, None)
+ .as_bytes()
+ .try_into()
+ .unwrap();
+ assert_eq!(
+ lean, oracle,
+ "orchard-only: lean 4-node sighash should already equal RoleSigner"
+ );
+ }
+
+ #[cfg(zcash_unstable = "nu6.3")]
+ #[test]
+ fn test_lean_sighash_migration_dualpool() {
+ let sample = crate::pczt::test_support::sample_migration_pczt();
+ let pczt = Pczt::parse(&sample.bytes).unwrap();
+ let oracle = RoleSigner::new(pczt.clone()).unwrap().shielded_sighash();
+ let lean: [u8; 32] = zcash_vendor::pczt_ext::shielded_sig_commitment(&pczt, 0, None)
+ .as_bytes()
+ .try_into()
+ .unwrap();
+ assert_eq!(
+ lean, oracle,
+ "migration: lean sighash must match RoleSigner v6 (Ironwood) sighash"
+ );
+ }
+
+ #[cfg(zcash_unstable = "nu6.3")]
+ #[test]
+ fn test_lean_sighash_ironwood_spend() {
+ // Exercises a populated Ironwood bundle with a real spend action.
+ let sample = crate::pczt::test_support::sample_ironwood_pczt();
+ let pczt = Pczt::parse(&sample.bytes).unwrap();
+ let oracle = RoleSigner::new(pczt.clone()).unwrap().shielded_sighash();
+ let lean: [u8; 32] = zcash_vendor::pczt_ext::shielded_sig_commitment(&pczt, 0, None)
+ .as_bytes()
+ .try_into()
+ .unwrap();
+ assert_eq!(
+ lean, oracle,
+ "ironwood-spend: lean sighash must match RoleSigner v6 sighash"
+ );
+ }
+
+ // End-to-end: an Orchard->Ironwood migration signs the Orchard spend and leaves the
+ // output-only Ironwood bundle unsigned.
+ #[cfg(zcash_unstable = "nu6.3")]
+ #[test]
+ fn test_sign_pczt_migration_signs_orchard_only() {
+ let sample = crate::pczt::test_support::sample_migration_pczt();
+ let signed = sign_pczt(Pczt::parse(&sample.bytes).unwrap(), &sample.seed)
+ .expect("migration PCZT should sign");
+ let parsed = Pczt::parse(&signed).expect("signed migration PCZT must parse");
+ assert!(
+ parsed
+ .orchard()
+ .actions()
+ .iter()
+ .any(|a| a.spend().spend_auth_sig().is_some()),
+ "migration Orchard spend must be authorized",
+ );
+ assert!(
+ parsed
+ .ironwood()
+ .actions()
+ .iter()
+ .all(|a| a.spend().spend_auth_sig().is_none()),
+ "output-only Ironwood bundle must not be authorized",
+ );
+ }
+
#[cfg(zcash_unstable = "nu6.3")]
#[test]
fn test_sign_pczt_ironwood_spend() {
@@ -671,6 +723,7 @@ mod tests {
.expect("wallet-set min key must survive round trip");
assert_eq!(request_min.as_slice(), &[1u8, 2][..]);
}
+
}
#[cfg(all(test, feature = "multi_coins", not(feature = "cypherpunk")))]
diff --git a/rust/zcash_vendor/src/pczt_ext.rs b/rust/zcash_vendor/src/pczt_ext.rs
index 3926043..5fce6ae 100644
--- a/rust/zcash_vendor/src/pczt_ext.rs
+++ b/rust/zcash_vendor/src/pczt_ext.rs
@@ -296,7 +296,175 @@ fn hash_orchard_txid_empty() -> Hash {
hasher(ZCASH_ORCHARD_HASH_PERSONALIZATION).finalize()
}
-fn shielded_sig_commitment(pczt: &Pczt, lock_time: u32, input_info: Option<SignableInput>) -> Hash {
+// === NU6.3 v6 (Ironwood) sighash ===
+//
+// Under NU6.3 a transaction is v6 and its txid/sighash uses the 5-node layout from
+// upstream `zcash_primitives::transaction::txid::to_hash_v6`: it appends an Ironwood
+// node, and moves the Orchard bundle to the v6 commitment domain. Relative to the v5
+// (4-node) layout, the per-bundle effects digest for BOTH Orchard and Ironwood uses a
+// distinct bundle personalization and OMITS the anchor (upstream
+// `BundleCommitmentDomain::ORCHARD_V6` / `IRONWOOD_V6` set `effects_anchor = Omit`,
+// which is what makes the spend_auth_sig anchor-independent). Action sub-hashes, the
+// flag byte, and the value balance are unchanged in structure.
+//
+// This is consensus-critical and must stay bit-exact with upstream. The
+// `shielded_sig_commitment == RoleSigner::shielded_sighash` oracle tests in
+// apps/zcash/src/pczt/sign.rs guard it (red CI on any upstream drift).
+#[cfg(zcash_unstable = "nu6.3")]
+const ZCASH_ORCHARD_V6_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdOrchardH_v6";
+#[cfg(zcash_unstable = "nu6.3")]
+const ZCASH_IRONWOOD_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdIronwd_H_v6";
+#[cfg(zcash_unstable = "nu6.3")]
+const ZCASH_IRONWOOD_ACTIONS_COMPACT_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdIrnActCH_v6";
+#[cfg(zcash_unstable = "nu6.3")]
+const ZCASH_IRONWOOD_ACTIONS_MEMOS_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdIrnActMH_v6";
+#[cfg(zcash_unstable = "nu6.3")]
+const ZCASH_IRONWOOD_ACTIONS_NONCOMPACT_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdIrnActNH_v6";
+
+#[cfg(zcash_unstable = "nu6.3")]
+fn is_v6(pczt: &Pczt) -> bool {
+ *pczt.global().tx_version() == zcash_protocol::constants::V6_TX_VERSION
+ && *pczt.global().version_group_id() == zcash_protocol::constants::V6_VERSION_GROUP_ID
+}
+
+#[cfg(zcash_unstable = "nu6.3")]
+fn has_ironwood(pczt: &Pczt) -> bool {
+ !pczt.ironwood().actions().is_empty()
+}
+
+/// v6 effects digest for an Orchard-shaped bundle (Orchard or Ironwood), mirroring
+/// upstream `orchard::bundle::commitments::hash_bundle_txid_data_with_domain` for the
+/// `ORCHARD_V6` / `IRONWOOD_V6` domains: the three ZIP-244 action sub-hashes, the flag
+/// byte, the value balance, and — unlike v5 — NO anchor (`effects_anchor = Omit`).
+#[cfg(zcash_unstable = "nu6.3")]
+fn digest_orchard_shaped_v6(
+ bundle: &pczt::orchard::Bundle,
+ bundle_personalization: &[u8; 16],
+ compact_personalization: &[u8; 16],
+ memos_personalization: &[u8; 16],
+ noncompact_personalization: &[u8; 16],
+) -> Hash {
+ let mut h = hasher(bundle_personalization);
+
+ let mut ch = hasher(compact_personalization);
+ let mut mh = hasher(memos_personalization);
+ let mut nh = hasher(noncompact_personalization);
+
+ for action in bundle.actions().iter() {
+ ch.update(action.spend().nullifier());
+ ch.update(action.output().cmx());
+ ch.update(action.output().ephemeral_key());
+ ch.update(&action.output().enc_ciphertext()[..52]);
+
+ mh.update(&action.output().enc_ciphertext()[52..564]);
+
+ nh.update(action.cv_net());
+ nh.update(action.spend().rk());
+ nh.update(&action.output().enc_ciphertext()[564..]);
+ nh.update(action.output().out_ciphertext());
+ }
+
+ h.update(ch.finalize().as_bytes());
+ h.update(mh.finalize().as_bytes());
+ h.update(nh.finalize().as_bytes());
+ h.update(&[*bundle.flags()]);
+ let (magnitude, sign) = bundle.value_sum();
+ let value_balance = if *sign {
+ -(*magnitude as i64)
+ } else {
+ *magnitude as i64
+ };
+ h.update(&value_balance.to_le_bytes());
+ // v6 OMITS the anchor here (v5's digest_orchard appends `bundle.anchor()`).
+ h.finalize()
+}
+
+#[cfg(zcash_unstable = "nu6.3")]
+fn digest_orchard_v6(pczt: &Pczt) -> Hash {
+ digest_orchard_shaped_v6(
+ pczt.orchard(),
+ ZCASH_ORCHARD_V6_HASH_PERSONALIZATION,
+ ZCASH_ORCHARD_ACTIONS_COMPACT_HASH_PERSONALIZATION,
+ ZCASH_ORCHARD_ACTIONS_MEMOS_HASH_PERSONALIZATION,
+ ZCASH_ORCHARD_ACTIONS_NONCOMPACT_HASH_PERSONALIZATION,
+ )
+}
+
+#[cfg(zcash_unstable = "nu6.3")]
+fn digest_ironwood_v6(pczt: &Pczt) -> Hash {
+ digest_orchard_shaped_v6(
+ pczt.ironwood(),
+ ZCASH_IRONWOOD_HASH_PERSONALIZATION,
+ ZCASH_IRONWOOD_ACTIONS_COMPACT_HASH_PERSONALIZATION,
+ ZCASH_IRONWOOD_ACTIONS_MEMOS_HASH_PERSONALIZATION,
+ ZCASH_IRONWOOD_ACTIONS_NONCOMPACT_HASH_PERSONALIZATION,
+ )
+}
+
+#[cfg(zcash_unstable = "nu6.3")]
+fn hash_orchard_v6_txid_empty() -> Hash {
+ hasher(ZCASH_ORCHARD_V6_HASH_PERSONALIZATION).finalize()
+}
+
+#[cfg(zcash_unstable = "nu6.3")]
+fn hash_ironwood_v6_txid_empty() -> Hash {
+ hasher(ZCASH_IRONWOOD_HASH_PERSONALIZATION).finalize()
+}
+
+#[cfg(zcash_unstable = "nu6.3")]
+fn shielded_sig_commitment_v6(
+ pczt: &Pczt,
+ lock_time: u32,
+ input_info: Option<SignableInput>,
+) -> Hash {
+ let mut personal = [0; 16];
+ personal[..12].copy_from_slice(ZCASH_TX_PERSONALIZATION_PREFIX);
+ personal[12..].copy_from_slice(&pczt.global().consensus_branch_id().to_le_bytes());
+
+ let mut h = hasher(&personal);
+ h.update(digest_header(pczt, lock_time).as_bytes());
+ h.update(transparent_sig_digest(pczt, input_info).as_bytes());
+ h.update(
+ if has_sapling(pczt) {
+ digest_sapling(pczt)
+ } else {
+ hash_sapling_txid_empty()
+ }
+ .as_bytes(),
+ );
+ h.update(
+ if has_orchard(pczt) {
+ digest_orchard_v6(pczt)
+ } else {
+ hash_orchard_v6_txid_empty()
+ }
+ .as_bytes(),
+ );
+ h.update(
+ if has_ironwood(pczt) {
+ digest_ironwood_v6(pczt)
+ } else {
+ hash_ironwood_v6_txid_empty()
+ }
+ .as_bytes(),
+ );
+ h.finalize()
+}
+
+/// Computes the ZIP-244 shielded sighash (the 32-byte message a shielded spend
+/// authorizes) directly from the PCZT's serialized fields, branching to the v6 (NU6.3)
+/// layout for Ironwood-bearing transactions. This is the byte-level equivalent of the
+/// upstream `pczt::roles::signer::Signer::shielded_sighash`, but avoids reconstructing a
+/// full `TransactionData` so it fits the hardware wallet's signing stack budget.
+///
+/// `pub` so the `app_zcash` consensus oracle tests can assert it stays bit-exact against
+/// the upstream RoleSigner sighash (any divergence turns CI red rather than producing
+/// wrong on-device signatures).
+pub fn shielded_sig_commitment(pczt: &Pczt, lock_time: u32, input_info: Option<SignableInput>) -> Hash {
+ #[cfg(zcash_unstable = "nu6.3")]
+ if is_v6(pczt) {
+ return shielded_sig_commitment_v6(pczt, lock_time, input_info);
+ }
let mut personal = [0; 16];
personal[..12].copy_from_slice(ZCASH_TX_PERSONALIZATION_PREFIX);
personal[12..].copy_from_slice(&pczt.global().consensus_branch_id().to_le_bytes());
@@ -444,19 +612,60 @@ where
let lock_time = determine_lock_time(pczt.global(), pczt.transparent().inputs())
.ok_or(transparent::pczt::ParseError::InvalidRequiredHeightLocktime)?;
signable.actions_mut().iter_mut().try_for_each(|action| {
- match action.spend().value().map(|v| v.inner()) {
- //dummy spend maybe
- Some(0) | None => {
- return Ok(());
- }
- Some(_) => {
- signer.sign_orchard(action, shielded_sig_commitment(pczt, lock_time, None))?;
- *tx_modifiable &= !(FLAG_TRANSPARENT_INPUTS_MODIFIABLE
- | FLAG_TRANSPARENT_OUTPUTS_MODIFIABLE
- | FLAG_SHIELDED_MODIFIABLE);
- }
- }
- Ok(())
+ sign_orchard_action(pczt, lock_time, signer, action, tx_modifiable)
+ })
+ })
+}
+
+/// Shared per-action signing for the Orchard and Ironwood bundles. The sign/skip
+/// decision is delegated to the signer (it skips dummies via dummy_sk / unmatched
+/// derivation and signs wallet-controlled spends, including zero-value ones), so we
+/// must NOT pre-filter by value here — that would drop a wallet-controlled zero-value
+/// spend. `tx_modifiable` is cleared only when this call adds a new signature.
+#[cfg(feature = "orchard")]
+fn sign_orchard_action<T>(
+ pczt: &Pczt,
+ lock_time: u32,
+ signer: &T,
+ action: &mut orchard::pczt::Action,
+ tx_modifiable: &mut u8,
+) -> Result<(), T::Error>
+where
+ T: PcztSigner,
+{
+ // `None` carries no spend value to authorize; the signer needs a value to act on.
+ if action.spend().value().is_none() {
+ return Ok(());
+ }
+ let had_sig = action.spend().spend_auth_sig().is_some();
+ signer.sign_orchard(action, shielded_sig_commitment(pczt, lock_time, None))?;
+ if !had_sig && action.spend().spend_auth_sig().is_some() {
+ *tx_modifiable &= !(FLAG_TRANSPARENT_INPUTS_MODIFIABLE
+ | FLAG_TRANSPARENT_OUTPUTS_MODIFIABLE
+ | FLAG_SHIELDED_MODIFIABLE);
+ }
+ Ok(())
+}
+
+/// Sign the Ironwood (NU6.3) shielded bundle. Structurally identical to
+/// [`sign_orchard`] — Ironwood actions are `orchard::pczt::Action`s and reuse the same
+/// `PcztSigner::sign_orchard` — but drives `sign_ironwood_with` so the Ironwood bundle
+/// is the one parsed and mutated. Dummy/output-only actions (value 0 or absent) are
+/// skipped, so an Orchard→Ironwood migration (Ironwood output-only) produces no
+/// Ironwood signature here.
+#[cfg(all(feature = "orchard", zcash_unstable = "nu6.3"))]
+pub fn sign_ironwood<T>(llsigner: Signer, signer: &T) -> Result<Signer, T::Error>
+where
+ T: PcztSigner,
+ T::Error: From<pczt::orchard::BundleParseError>,
+ T::Error: From<orchard::pczt::ParseError>,
+ T::Error: From<transparent::pczt::ParseError>,
+{
+ llsigner.sign_ironwood_with::<T::Error, _>(|pczt, signable, tx_modifiable| {
+ let lock_time = determine_lock_time(pczt.global(), pczt.transparent().inputs())
+ .ok_or(transparent::pczt::ParseError::InvalidRequiredHeightLocktime)?;
+ signable.actions_mut().iter_mut().try_for_each(|action| {
+ sign_orchard_action(pczt, lock_time, signer, action, tx_modifiable)
})
})
}
Why this scored 54/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.