feat(zcash): use bounded PCZT batches and compact responses
What changed, and why it matters
This commit refactors how Keystone's Zcash batch signing feature handles large batches. It replaces an older message envelope with a new format that uses the upstream Zcash PCZT batch signer, adds a 512 KiB total size cap, raises the maximum PCZT count from 35 to 50, and returns only compact signatures instead of full signed PCZT data. It also updates several Zcash Rust dependencies from a fork to upstream releases and tightens some digest helpers to handle missing optional fields safely. The changes are framed as a feature improvement and hardening, not as a fix for a known active vulnerability.
Treat as a routine feature/hardening change. Reviewers should verify that the new 512 KiB bound is enforced before any heap allocation or parsing of untrusted batch data, that the compact signature response cannot be misinterpreted as a full transaction, and that the zero-fill fallbacks in `pczt_ext.rs` are only reachable on data that has already failed policy checks or is otherwise non-signable. No immediate security response is indicated by the commit materials alone.
Security signals we found
Adds explicit 512 KiB byte bound on batch request/response data in addition to a count bound
Switches batch response from full signed PCZT bytes to compact spend-authorization signatures, reducing data exposure
Rejects duplicate canonical PCZT payloads in a batch
Moves Zcash Rust dependencies from a third-party fork back to upstream releases
Makes sighash digest helpers infallible for missing optional PCZT fields (cmx, Sapling anchor) by zero-filling
Refactors batch registry parsing to use pinned upstream `BatchSignRequest`/`BatchSignResponse` wire format
Adds tests for oversized batches, empty request IDs, duplicate payloads, and signing without a checked batch
Evidence from the diff
The patch rewrites the Zcash batch signing path in rust_c/src/zcash/mod.rs and rust/apps/zcash/src/lib.rs. The old ZcashSignBatch/ZcashSignResult UR registry shapes are replaced by opaque CBOR envelopes wrapping BatchSignRequest/BatchSignResponse from pczt::roles::signer::batch. Validation now enforces ZCASH_BATCH_MAX_PCZTS (50) and ZCASH_BATCH_MAX_TOTAL_BYTES (512 KiB), rejects duplicate canonical PCZTs, and requires non-empty request IDs. The response is changed to zcash-batch-sig-result carrying only (value_pool, action_index, signature) tuples rather than full signed PCZT bytes. Dependency patches move from valargroup/librustzcash to upstream zcash/librustzcash at rev 878db2074ae8ac2682d3e6c61c00f7018b6adc0c, bump orchard to 0.15.0, and switch ur-registry to a valargroup/keystone-sdk-rust revision that supports the new envelope. In pczt_ext.rs, action_cmx and Sapling anchor handling now fall back to zeroed bytes when optional fields are absent, keeping digest functions infallible on malformed/unchecked data.
Changed components
rust/rust_c/src/zcash/mod.rsrust/apps/zcash/src/lib.rsrust/zcash_vendor/src/pczt_ext.rsrust/Cargo.toml dependency patches (pczt, zcash_*, orchard, ur-registry)docs/protocols/ur_registrys/zcash.md.github/workflows/rust-zcash-checks.ymlInspect captured patch +534 / −417
diff --git a/.github/workflows/rust-zcash-checks.yml b/.github/workflows/rust-zcash-checks.yml
index 8baf5d1..77e6872 100644
--- a/.github/workflows/rust-zcash-checks.yml
+++ b/.github/workflows/rust-zcash-checks.yml
@@ -33,10 +33,10 @@ jobs:
- name: Run rust/apps/zcash
run: cd rust/apps/zcash && cargo +$RUST_TOOLCHAIN llvm-cov --fail-under-regions 69 --fail-under-functions 71 --fail-under-lines 76 --ignore-filename-regex 'keystore/*|utils/*|zcash_vendor/*'
- # The rust_c Zcash FFI tests (batch validation, reviewed-batch fingerprint)
- # only build under the simulator feature set (the device feature set has no
- # host panic handler), and the simulator's screen-capture dependency needs
- # macOS on CI.
+ # The rust_c Zcash FFI tests (batch validation, compact signatures-only
+ # response) only build under the simulator feature set (the device feature
+ # set has no host panic handler), and the simulator's screen-capture
+ # dependency needs macOS on CI.
RustCZcashTest:
name: rust_c Zcash FFI tests
runs-on: macos-latest
diff --git a/docs/protocols/ur_registrys/zcash.md b/docs/protocols/ur_registrys/zcash.md
index 3204315..953d521 100644
--- a/docs/protocols/ur_registrys/zcash.md
+++ b/docs/protocols/ur_registrys/zcash.md
@@ -55,71 +55,85 @@ zcash-pczt {
### Zcash Batch Signing
-`zcash-sign-batch` wraps multiple signing messages into one Keystone approval.
-Version 1 is supported by cypherpunk firmware and currently supports up to 35
-mainnet PCZT messages. It requires `atomic` to be `true`; if any message is
-invalid or cannot be signed, Keystone returns an error instead of a partial
-result. Batch PCZT entries must be fully Keystone-owned spends from supported
-shielded pools, currently Orchard or Ironwood. Transparent inputs and Sapling
-spends or outputs are rejected.
-
-The 35-message limit is the current batch memory budget for `pczt-v1`. A full
-35-message batch using the supported PCZT message shape was measured at about
-35% RAM on target hardware, so this version does not define separate byte caps
-for request ids, message ids, or payloads. Revisit the limit if new message
-kinds or substantially larger payload encodings are added.
-
-Message kinds:
+`zcash-sign-batch` wraps multiple PCZTs into one Keystone approval.
+The outer UR registry envelope carries a request id for response correlation and
+an opaque `data` field containing the PCZT-owned batch request. The matching
+compact response uses `zcash-batch-sig-result`, echoes the request id, and
+carries the PCZT-owned response in its own opaque `data` field.
+
+Batch version 1 is supported by cypherpunk firmware and currently accepts up to
+50 PCZTs. The encoded batch data and request id together, and the canonical PCZT
+payloads after decoding, must each fit within 512 KiB. The operation is atomic.
+If any PCZT is invalid or cannot be signed, Keystone returns an error instead of
+a partial result. PCZT entries with identical canonical encodings are rejected.
+Every spend must be fully Keystone-owned and use a supported shielded pool,
+currently Orchard or Ironwood. Transparent inputs and Sapling spends or outputs
+are rejected.
+
+#### Outer UR/CBOR envelopes
+
+Both registry types use definite-length CBOR maps with the same integer keys.
+Firmware requires `request-id` to be non-empty. Key `1` follows `zcash-pczt` by
+carrying opaque transaction data, and key `2` follows `zcash-sign-result` by
+carrying the request id.
```cddl
-pczt-v1 = 1
-```
-
-Networks:
+zcash-sign-batch = {
+ 1: bytes, ; BatchSignRequest::serialize output
+ 2: bytes, ; request-id
+}
-```cddl
-zcash-mainnet = 1
+zcash-batch-sig-result = {
+ 1: bytes, ; BatchSignResponse::serialize output
+ 2: bytes, ; echoed request-id
+}
```
-Result statuses:
+#### PCZT batch request
-```cddl
-signed = 0
+The request `data` encoding is
+`"PCZB" || batch_version_le || pczt_version_le || postcard_body`. Its Postcard
+body contains the PCZTs in request order. Both version fields are four-byte
+little-endian integers. Current encoders emit batch version 1 and PCZT version
+2. The shared PCZT version applies to every headerless PCZT wire value in the
+body.
+
+```rust
+struct BatchSignRequestBody {
+ pczts: Vec<PcztV2Wire>,
+}
```
-#### CDDL for Zcash Sign Batch
+The exact PCZT wire value is owned by the pinned
+[`pczt::roles::signer::batch`](https://github.com/zcash/librustzcash/blob/878db2074ae8ac2682d3e6c61c00f7018b6adc0c/pczt/src/roles/signer/batch.rs)
+implementation. There are no request or message ids in the inner payload. PCZT
+entries are correlated by position and must be unique within the request.
-```cddl
-zcash-sign-batch = {
- 1: uint, ; version. Must be 1.
- 2: bytes, ; request id. Echoed by zcash-sign-result.
- 3: uint, ; network. Must be zcash-mainnet.
- 4: [1*35 zcash-sign-message],
- ?11: bool, ; atomic. Defaults to true. Must be true.
-}
+#### PCZT batch signature response
-zcash-sign-message = {
- 1: bytes, ; caller-defined message id. Must be unique.
- 2: uint, ; message kind. Must be pczt-v1.
- 3: bytes, ; message payload. For pczt-v1 this is raw PCZT bytes. Must be unique in the batch.
- ?6: bytes.32, ; SHA-256 of payload.
-}
-```
+The response `data` encoding is `"PCZS" || batch_version_le || postcard_body`.
+Entry `i` contains the signatures produced for PCZT `i` in the request.
-#### CDDL for Zcash Sign Result
+```rust
+struct BatchSignResponseBody {
+ signatures: Vec<Vec<SpendAuthSignature>>,
+}
-```cddl
-zcash-sign-result = {
- 1: uint, ; version. Matches request version.
- 2: bytes, ; request id from zcash-sign-batch.
- 3: [1*35 zcash-sign-message-result],
+struct SpendAuthSignature {
+ value_pool: ValuePool,
+ action_index: u32,
+ signature: [u8; 64],
}
-zcash-sign-message-result = {
- 1: bytes, ; message id from zcash-sign-message.
- 2: uint, ; status. signed = 0.
- 3: uint, ; message kind from zcash-sign-message.
- 4: bytes, ; signed payload. For pczt-v1 this is signed PCZT bytes.
- 6: bytes.32, ; SHA-256 of signed payload.
+enum ValuePool {
+ Orchard,
+ Ironwood,
}
```
+
+Postcard encodes integer fields inside each body as varints; `ValuePool` uses
+the enum indexes Orchard = 0 and Ironwood = 1. Response entry `i` contains the
+signatures for request PCZT `i`. Each signature selects an action by value pool
+and action index, so the client can apply it to the corresponding unsigned PCZT
+without transporting another full PCZT. The outer response echoes the request
+id so the application can correlate it with the outstanding batch.
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index 79bf13c..8280a18 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -1678,7 +1678,7 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "equihash"
version = "0.3.0"
-source = "git+https://github.com/valargroup/librustzcash?rev=94d6e7fd8c76abb1d909390e10254a22f587981f#94d6e7fd8c76abb1d909390e10254a22f587981f"
+source = "git+https://github.com/zcash/librustzcash?rev=878db2074ae8ac2682d3e6c61c00f7018b6adc0c#878db2074ae8ac2682d3e6c61c00f7018b6adc0c"
dependencies = [
"blake2b_simd",
"corez",
@@ -1805,7 +1805,7 @@ dependencies = [
[[package]]
name = "f4jumble"
version = "0.1.1"
-source = "git+https://github.com/valargroup/librustzcash?rev=94d6e7fd8c76abb1d909390e10254a22f587981f#94d6e7fd8c76abb1d909390e10254a22f587981f"
+source = "git+https://github.com/zcash/librustzcash?rev=878db2074ae8ac2682d3e6c61c00f7018b6adc0c#878db2074ae8ac2682d3e6c61c00f7018b6adc0c"
dependencies = [
"blake2b_simd",
]
@@ -3041,8 +3041,8 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "orchard"
-version = "0.15.0-pre.2"
-source = "git+https://github.com/zcash/orchard?rev=475ef0ff77d45aebff93cb039d639250d82518a3#475ef0ff77d45aebff93cb039d639250d82518a3"
+version = "0.15.0"
+source = "git+https://github.com/zcash/orchard?rev=8995ee7e26f8b654a5457d05c95ee5b3132b3edd#8995ee7e26f8b654a5457d05c95ee5b3132b3edd"
dependencies = [
"aes",
"bitvec",
@@ -3143,7 +3143,7 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pczt"
version = "0.7.0"
-source = "git+https://github.com/valargroup/librustzcash?rev=94d6e7fd8c76abb1d909390e10254a22f587981f#94d6e7fd8c76abb1d909390e10254a22f587981f"
+source = "git+https://github.com/zcash/librustzcash?rev=878db2074ae8ac2682d3e6c61c00f7018b6adc0c#878db2074ae8ac2682d3e6c61c00f7018b6adc0c"
dependencies = [
"blake2b_simd",
"bls12_381",
@@ -4813,7 +4813,7 @@ dependencies = [
[[package]]
name = "ur-registry"
version = "1.0.5"
-source = "git+https://github.com/KeystoneHQ/keystone-sdk-rust.git?rev=0884de4b2e927bc3f95a98dff62045e0d492e574#0884de4b2e927bc3f95a98dff62045e0d492e574"
+source = "git+https://github.com/valargroup/keystone-sdk-rust.git?rev=bf5b6e4b5c533e8934c4611837db2b6567ae837e#bf5b6e4b5c533e8934c4611837db2b6567ae837e"
dependencies = [
"bs58",
"hex",
@@ -5410,13 +5410,13 @@ dependencies = [
[[package]]
name = "zcash_address"
-version = "0.13.0-pre.0"
-source = "git+https://github.com/valargroup/librustzcash?rev=94d6e7fd8c76abb1d909390e10254a22f587981f#94d6e7fd8c76abb1d909390e10254a22f587981f"
+version = "0.13.0"
+source = "git+https://github.com/zcash/librustzcash?rev=878db2074ae8ac2682d3e6c61c00f7018b6adc0c#878db2074ae8ac2682d3e6c61c00f7018b6adc0c"
dependencies = [
"bech32 0.11.0",
"bs58",
"corez",
- "f4jumble 0.1.1 (git+https://github.com/valargroup/librustzcash?rev=94d6e7fd8c76abb1d909390e10254a22f587981f)",
+ "f4jumble 0.1.1 (git+https://github.com/zcash/librustzcash?rev=878db2074ae8ac2682d3e6c61c00f7018b6adc0c)",
"zcash_encoding",
"zcash_protocol",
]
@@ -5424,7 +5424,7 @@ dependencies = [
[[package]]
name = "zcash_encoding"
version = "0.4.0"
-source = "git+https://github.com/valargroup/librustzcash?rev=94d6e7fd8c76abb1d909390e10254a22f587981f#94d6e7fd8c76abb1d909390e10254a22f587981f"
+source = "git+https://github.com/zcash/librustzcash?rev=878db2074ae8ac2682d3e6c61c00f7018b6adc0c#878db2074ae8ac2682d3e6c61c00f7018b6adc0c"
dependencies = [
"corez",
"hex",
@@ -5433,8 +5433,8 @@ dependencies = [
[[package]]
name = "zcash_keys"
-version = "0.15.0-pre.0"
-source = "git+https://github.com/valargroup/librustzcash?rev=94d6e7fd8c76abb1d909390e10254a22f587981f#94d6e7fd8c76abb1d909390e10254a22f587981f"
+version = "0.15.0"
+source = "git+https://github.com/zcash/librustzcash?rev=878db2074ae8ac2682d3e6c61c00f7018b6adc0c#878db2074ae8ac2682d3e6c61c00f7018b6adc0c"
dependencies = [
"bech32 0.11.0",
"bip32",
@@ -5472,8 +5472,8 @@ dependencies = [
[[package]]
name = "zcash_primitives"
-version = "0.29.0-pre.0"
-source = "git+https://github.com/valargroup/librustzcash?rev=94d6e7fd8c76abb1d909390e10254a22f587981f#94d6e7fd8c76abb1d909390e10254a22f587981f"
+version = "0.29.0"
+source = "git+https://github.com/zcash/librustzcash?rev=878db2074ae8ac2682d3e6c61c00f7018b6adc0c#878db2074ae8ac2682d3e6c61c00f7018b6adc0c"
dependencies = [
"blake2b_simd",
"block-buffer 0.11.0-rc.3",
@@ -5502,8 +5502,8 @@ dependencies = [
[[package]]
name = "zcash_protocol"
-version = "0.10.0-pre.0"
-source = "git+https://github.com/valargroup/librustzcash?rev=94d6e7fd8c76abb1d909390e10254a22f587981f#94d6e7fd8c76abb1d909390e10254a22f587981f"
+version = "0.10.0"
+source = "git+https://github.com/zcash/librustzcash?rev=878db2074ae8ac2682d3e6c61c00f7018b6adc0c#878db2074ae8ac2682d3e6c61c00f7018b6adc0c"
dependencies = [
"corez",
"hex",
@@ -5541,8 +5541,8 @@ dependencies = [
[[package]]
name = "zcash_transparent"
-version = "0.9.0-pre.0"
-source = "git+https://github.com/valargroup/librustzcash?rev=94d6e7fd8c76abb1d909390e10254a22f587981f#94d6e7fd8c76abb1d909390e10254a22f587981f"
+version = "0.9.0"
+source = "git+https://github.com/zcash/librustzcash?rev=878db2074ae8ac2682d3e6c61c00f7018b6adc0c#878db2074ae8ac2682d3e6c61c00f7018b6adc0c"
dependencies = [
"bip32",
"bs58",
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index c9e75e2..098315b 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -122,14 +122,15 @@ zeroize = { version = "1.8.2", default-features = false }
# third party dependencies end
[patch.crates-io]
-pczt = { git = "https://github.com/valargroup/librustzcash", rev = "94d6e7fd8c76abb1d909390e10254a22f587981f" }
-zcash_address = { git = "https://github.com/valargroup/librustzcash", rev = "94d6e7fd8c76abb1d909390e10254a22f587981f" }
-zcash_encoding = { git = "https://github.com/valargroup/librustzcash", rev = "94d6e7fd8c76abb1d909390e10254a22f587981f" }
-zcash_keys = { git = "https://github.com/valargroup/librustzcash", rev = "94d6e7fd8c76abb1d909390e10254a22f587981f" }
-zcash_primitives = { git = "https://github.com/valargroup/librustzcash", rev = "94d6e7fd8c76abb1d909390e10254a22f587981f" }
-zcash_protocol = { git = "https://github.com/valargroup/librustzcash", rev = "94d6e7fd8c76abb1d909390e10254a22f587981f" }
-zcash_transparent = { git = "https://github.com/valargroup/librustzcash", rev = "94d6e7fd8c76abb1d909390e10254a22f587981f" }
+pczt = { git = "https://github.com/zcash/librustzcash", rev = "878db2074ae8ac2682d3e6c61c00f7018b6adc0c" }
+zcash_address = { git = "https://github.com/zcash/librustzcash", rev = "878db2074ae8ac2682d3e6c61c00f7018b6adc0c" }
+zcash_encoding = { git = "https://github.com/zcash/librustzcash", rev = "878db2074ae8ac2682d3e6c61c00f7018b6adc0c" }
+zcash_keys = { git = "https://github.com/zcash/librustzcash", rev = "878db2074ae8ac2682d3e6c61c00f7018b6adc0c" }
+zcash_primitives = { git = "https://github.com/zcash/librustzcash", rev = "878db2074ae8ac2682d3e6c61c00f7018b6adc0c" }
+zcash_protocol = { git = "https://github.com/zcash/librustzcash", rev = "878db2074ae8ac2682d3e6c61c00f7018b6adc0c" }
+zcash_transparent = { git = "https://github.com/zcash/librustzcash", rev = "878db2074ae8ac2682d3e6c61c00f7018b6adc0c" }
# Keep `orchard` aligned with the pinned PCZT/librustzcash revision.
-orchard = { git = "https://github.com/zcash/orchard", rev = "475ef0ff77d45aebff93cb039d639250d82518a3" }
-# Use the upstream SDK rev with the Zcash batch registry types until they are published as a crate.
-ur-registry = { git = "https://github.com/KeystoneHQ/keystone-sdk-rust.git", rev = "0884de4b2e927bc3f95a98dff62045e0d492e574" }
+orchard = { git = "https://github.com/zcash/orchard", rev = "8995ee7e26f8b654a5457d05c95ee5b3132b3edd" }
+# Use the SDK rev that wraps PCZT-owned Postcard messages as opaque UR data and
+# correlates each request and result through the outer registry envelope.
+ur-registry = { git = "https://github.com/valargroup/keystone-sdk-rust.git", rev = "bf5b6e4b5c533e8934c4611837db2b6567ae837e" }
diff --git a/rust/apps/zcash/Cargo.toml b/rust/apps/zcash/Cargo.toml
index 3719c00..70a7f09 100644
--- a/rust/apps/zcash/Cargo.toml
+++ b/rust/apps/zcash/Cargo.toml
@@ -34,7 +34,7 @@ shardtree = "0.6.2"
# Nameable only so the transparent-only `legacy_test_support` can spell the
# `BuildConfig::orchard_pool_bundle_type` value; orchard is already built transitively
# via the `zcash_primitives` dev-dependency.
-orchard = { version = "0.15.0-pre.2", default-features = false }
+orchard = { version = "0.15.0", default-features = false }
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = [
diff --git a/rust/apps/zcash/src/lib.rs b/rust/apps/zcash/src/lib.rs
index e836aae..39e02d8 100644
--- a/rust/apps/zcash/src/lib.rs
+++ b/rust/apps/zcash/src/lib.rs
@@ -24,6 +24,8 @@ use zcash_vendor::{
zip32,
};
+#[cfg(feature = "cypherpunk")]
+use zcash_vendor::pczt::roles::signer::SpendAuthSignature;
#[cfg(any(test, feature = "multi_coins", feature = "cypherpunk"))]
use zcash_vendor::pczt::Pczt;
@@ -88,10 +90,9 @@ pub fn check_pczt_cypherpunk<P: consensus::Parameters>(
)
}
-/// Batch check for one `ZcashSignBatch` message: parses once, runs the full
-/// policy checks, enforces the batch shielded-action policy (the PCZT must be
-/// batch-signable by this account), and returns the normalized encoding. See
-/// `check_pczt_cypherpunk` for the normalization contract.
+/// Checks one PCZT from a batch request, enforcing the batch shielded-action
+/// policy, and returns its normalized encoding. See `check_pczt_cypherpunk`
+/// for the normalization contract.
#[cfg(feature = "cypherpunk")]
pub fn check_batch_pczt_cypherpunk<P: consensus::Parameters>(
params: &P,
@@ -127,7 +128,6 @@ fn check_pczt_cypherpunk_with_policy<P: consensus::Parameters>(
pczt.resolve_fields().map_err(|e| {
ZcashError::InvalidPczt(alloc::format!("resolve compact PCZT fields: {e:?}"))
})?;
-
let account_index = zip32::AccountId::try_from(account_index)
.map_err(|_e| ZcashError::InvalidDataError("invalid account index".to_string()))?;
let ufvk = UnifiedFullViewingKey::decode(params, ufvk_text)
@@ -501,10 +501,10 @@ fn compact_batch_migration_review(items: Vec<ParsedBatchItem>) -> Vec<ParsedPczt
}
/// Parses checked batch PCZTs and compacts eligible Orchard-to-Ironwood
-/// self-transfers without relying on message position.
+/// self-transfers without relying on PCZT position.
///
/// Every input must be normalized bytes produced by the batch check. A batch
-/// with more than one ordinary transaction uses full per-message review.
+/// with more than one ordinary transaction uses full review for each PCZT.
#[cfg(feature = "cypherpunk")]
pub fn parse_batch_with_migration_summary_cypherpunk<'a, P: consensus::Parameters>(
params: &P,
@@ -663,11 +663,12 @@ mod legacy_tests {
BranchId::Nu6_3.into(),
10,
MainNetwork.coin_type(),
- [0; 32],
- [0; 32],
+ None,
+ None,
)
.unwrap()
- .build();
+ .build()
+ .unwrap();
let result = check_pczt_multi_coins(
&MainNetwork,
@@ -983,6 +984,26 @@ fn sign_checked_pczt_with_policy<P: consensus::Parameters>(
.map_err(|e| ZcashError::SigningError(alloc::format!("serialize signed PCZT: {e:?}")))
}
+/// Extracts every Orchard-protocol spend authorization signature from a signed
+/// PCZT. Errors if the PCZT is unparseable or carries no such signature.
+#[cfg(feature = "cypherpunk")]
+pub fn extract_compact_sigs_from_signed_pczt(
+ signed_pczt: &[u8],
+) -> Result<Vec<SpendAuthSignature>> {
+ let signed_pczt = pczt::parse_pczt(signed_pczt)
+ .map_err(|_| ZcashError::InvalidPczt("invalid signed pczt data".to_string()))?;
+ let sigs =
+ zcash_vendor::pczt::roles::signer::extract_orchard_spend_auth_signatures(&signed_pczt);
+
+ if sigs.is_empty() {
+ return Err(ZcashError::SigningError(
+ "signed PCZT has no spend authorization signatures".to_string(),
+ ));
+ }
+
+ Ok(sigs)
+}
+
#[cfg(feature = "cypherpunk")]
#[cfg(test)]
mod tests {
@@ -1091,11 +1112,12 @@ mod tests {
BranchId::Nu6.into(),
10,
MainNetwork.coin_type(),
- [0; 32],
- [0; 32],
+ Some([0; 32]),
+ Some([0; 32]),
)
.unwrap()
.build()
+ .unwrap()
.serialize()
.unwrap();
let (mut prefix, rest) = postcard::take_from_bytes::<PcztWirePrefix>(&bytes[8..]).unwrap();
@@ -1191,7 +1213,9 @@ mod tests {
);
let mut builder = Builder::new(
¶ms,
- 10_000_000.into(),
+ // Exercise the legacy cross-address-enabled Orchard format. NU6.3
+ // rejects this spoof at construction before the wallet check runs.
+ 2_000_000.into(),
BuildConfig::Standard {
sapling_anchor: None,
orchard_anchor: Some(orchard::Anchor::empty_tree()),
@@ -1646,6 +1670,12 @@ mod tests {
.actions()
.iter()
.any(|action| action.spend().spend_auth_sig().is_some()));
+
+ let compact_sigs =
+ extract_compact_sigs_from_signed_pczt(&signed).expect("compact sigs should extract");
+ assert!(compact_sigs
+ .iter()
+ .any(|sig| sig.value_pool() == zcash_vendor::orchard::ValuePool::Ironwood));
}
#[test]
@@ -2078,7 +2108,7 @@ mod tests {
&ufvk_text,
&seed_fingerprint,
)
- .expect("message order must not affect migration classification");
+ .expect("PCZT order must not affect migration classification");
assert_eq!(parsed.len(), 2);
let summary = &parsed[1];
@@ -2089,7 +2119,7 @@ mod tests {
}
#[test]
- fn test_batch_migration_summary_keeps_single_message_uncompacted() {
+ fn test_batch_migration_summary_keeps_single_pczt_uncompacted() {
let sample = pczt::test_support::sample_migration_pczt();
let checked = check_batch_pczt_cypherpunk(
&pczt::test_support::Nu6_3Network,
@@ -2180,7 +2210,7 @@ mod tests {
&sample.seed_fingerprint,
0,
)
- .expect("per-message review must accept the memo-carrying transfer");
+ .expect("review for each PCZT must accept the memo-carrying transfer");
let shown_memo = parsed
.get_ironwood()
.expect("migration must show Ironwood outputs")
@@ -2427,7 +2457,7 @@ mod tests {
),
Err(ZcashError::InvalidPczt(message)) if message.contains("undecryptable")
),
- "ordinary per-message review must also reject the undecryptable output"
+ "ordinary review for each PCZT must also reject the undecryptable output"
);
}
diff --git a/rust/apps/zcash/src/pczt/mod.rs b/rust/apps/zcash/src/pczt/mod.rs
index 6afbf7e..ddcbeae 100644
--- a/rust/apps/zcash/src/pczt/mod.rs
+++ b/rust/apps/zcash/src/pczt/mod.rs
@@ -792,7 +792,7 @@ pub(crate) mod legacy_test_support {
keys::{AccountPrivKey, IncomingViewingKey},
},
zcash_protocol::{
- consensus::{MainNetwork, Parameters},
+ consensus::{MainNetwork, NetworkUpgrade, Parameters},
value::Zatoshis,
},
zip32,
@@ -868,7 +868,7 @@ pub(crate) mod legacy_test_support {
);
let mut builder = Builder::new(
¶ms,
- 10_000_000.into(),
+ params.activation_height(NetworkUpgrade::Nu5).unwrap(),
BuildConfig::Standard {
sapling_anchor: None,
orchard_anchor: None,
diff --git a/rust/apps/zcash/src/pczt/parse.rs b/rust/apps/zcash/src/pczt/parse.rs
index f37fab7..1db18ad 100644
--- a/rust/apps/zcash/src/pczt/parse.rs
+++ b/rust/apps/zcash/src/pczt/parse.rs
@@ -865,11 +865,12 @@ mod legacy_tests {
BranchId::Nu6_3.into(),
10,
MainNetwork.coin_type(),
- [0; 32],
- [0; 32],
+ None,
+ None,
)
.unwrap()
- .build();
+ .build()
+ .unwrap();
let result = parse_pczt_multi_coins(&MainNetwork, &[7u8; 32], &pczt);
diff --git a/rust/apps/zcash/src/pczt/sign.rs b/rust/apps/zcash/src/pczt/sign.rs
index 044808b..619c646 100644
--- a/rust/apps/zcash/src/pczt/sign.rs
+++ b/rust/apps/zcash/src/pczt/sign.rs
@@ -933,11 +933,12 @@ mod legacy_tests {
BranchId::Nu6_3.into(),
10,
MainNetwork.coin_type(),
- [0; 32],
- [0; 32],
+ None,
+ None,
)
.unwrap()
- .build();
+ .build()
+ .unwrap();
let result = sign_pczt(pczt, &[7u8; 32]);
diff --git a/rust/rust_c/src/common/ur.rs b/rust/rust_c/src/common/ur.rs
index 3d8a421..f0faf65 100644
--- a/rust/rust_c/src/common/ur.rs
+++ b/rust/rust_c/src/common/ur.rs
@@ -715,6 +715,8 @@ impl_response!(URParseMultiResult);
#[cfg(test)]
mod tests {
+ use alloc::vec;
+
use super::*;
#[test]
@@ -746,18 +748,35 @@ mod tests {
full_result.free();
}
- let zcash_result = UREncodeResult::encode_full_response(
+ let zcash_sign_result = UREncodeResult::encode_full_response(
vec![0; FRAGMENT_UNLIMITED_LENGTH + 1],
"zcash-sign-result".to_string(),
);
- assert_eq!(zcash_result.error_code, ErrorCodes::Success as u32);
- assert!(!zcash_result.is_multi_part);
- assert!(zcash_result.encoder.is_null());
- let zcash_data = unsafe { recover_c_char(zcash_result.data) };
+ assert_eq!(zcash_sign_result.error_code, ErrorCodes::Success as u32);
+ assert!(!zcash_sign_result.is_multi_part);
+ assert!(zcash_sign_result.encoder.is_null());
+ let zcash_data = unsafe { recover_c_char(zcash_sign_result.data) };
assert!(zcash_data.starts_with("UR:ZCASH-SIGN-RESULT/"));
assert!(!zcash_data.contains("/1-"));
unsafe {
- zcash_result.free();
+ zcash_sign_result.free();
+ }
+
+ let zcash_batch_sig_result = UREncodeResult::encode_full_response(
+ vec![0; FRAGMENT_UNLIMITED_LENGTH + 1],
+ "zcash-batch-sig-result".to_string(),
+ );
+ assert_eq!(
+ zcash_batch_sig_result.error_code,
+ ErrorCodes::Success as u32
+ );
+ assert!(!zcash_batch_sig_result.is_multi_part);
+ assert!(zcash_batch_sig_result.encoder.is_null());
+ let zcash_data = unsafe { recover_c_char(zcash_batch_sig_result.data) };
+ assert!(zcash_data.starts_with("UR:ZCASH-BATCH-SIG-RESULT/"));
+ assert!(!zcash_data.contains("/1-"));
+ unsafe {
+ zcash_batch_sig_result.free();
}
}
}
diff --git a/rust/rust_c/src/zcash/mod.rs b/rust/rust_c/src/zcash/mod.rs
index 8b6e6d3..703f218 100644
--- a/rust/rust_c/src/zcash/mod.rs
+++ b/rust/rust_c/src/zcash/mod.rs
@@ -23,20 +23,20 @@ use structs::DisplayPczt;
use structs::DisplayZcashBatch;
use structs::ZcashCheckedPczt;
use ur_registry::traits::RegistryItem;
+use ur_registry::zcash::zcash_batch_sig_result::ZcashBatchSigResult;
use ur_registry::zcash::zcash_pczt::ZcashPczt;
-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};
-use zcash_vendor::zcash_protocol::consensus::MainNetwork;
+use ur_registry::zcash::zcash_sign_batch::ZcashSignBatch;
+#[cfg(feature = "cypherpunk")]
+use zcash_vendor::pczt::roles::signer::batch::{BatchSignRequest, BatchSignResponse};
+use zcash_vendor::{pczt::Pczt, zcash_protocol::consensus::MainNetwork};
use zeroize::Zeroize;
-// Batch memory is intentionally bounded by message count rather than separate
-// byte caps. With the supported pczt-v1 messages, a full 35-message batch used
-// about 35% of RAM on target hardware. Revisit this if new message kinds or
-// substantially larger payload encodings are added.
-const ZCASH_BATCH_MAX_MESSAGES: usize = 35;
+// Cap both per-PCZT overhead and variable-size payload data to leave headroom
+// in shared device memory while processing a batch.
+#[cfg(feature = "cypherpunk")]
+const ZCASH_BATCH_MAX_PCZTS: usize = 50;
+#[cfg(feature = "cypherpunk")]
+const ZCASH_BATCH_MAX_TOTAL_BYTES: usize = 512 * 1024;
#[no_mangle]
pub unsafe extern "C" fn derive_zcash_ufvk(
@@ -207,84 +207,123 @@ pub unsafe extern "C" fn parse_zcash_tx_multi_coins(
}
}
-fn validate_zcash_batch(batch: &ZcashSignBatch) -> Result<(), RustCError> {
- let messages = batch.get_messages();
- if batch.get_version() != ZCASH_SIGN_BATCH_VERSION {
- return Err(RustCError::UnsupportedTransaction(format!(
- "unsupported Zcash batch version {}",
- batch.get_version()
- )));
- }
- if batch.get_network() != ZCASH_SIGN_BATCH_NETWORK_MAINNET {
- return Err(RustCError::UnsupportedTransaction(
- "only Zcash mainnet batch signing is supported".to_string(),
- ));
- }
- if batch.get_request_id().is_empty() {
- return Err(RustCError::InvalidData(
- "Zcash batch has no request id".to_string(),
- ));
- }
- if !batch.get_atomic() {
- return Err(RustCError::UnsupportedTransaction(
- "Zcash batch signing requires atomic=true".to_string(),
- ));
- }
- if messages.is_empty() {
+/// Enforces the count, canonical byte, and exact payload uniqueness limits.
+#[cfg(feature = "cypherpunk")]
+fn validate_zcash_batch_payloads(payloads: &[Vec<u8>]) -> Result<(), RustCError> {
+ if payloads.is_empty() {
return Err(RustCError::InvalidData(
- "Zcash batch has no messages".to_string(),
+ "Zcash batch has no PCZTs".to_string(),
));
}
- if messages.len() > ZCASH_BATCH_MAX_MESSAGES {
+ if payloads.len() > ZCASH_BATCH_MAX_PCZTS {
return Err(RustCError::UnsupportedTransaction(format!(
- "Zcash batch supports at most {ZCASH_BATCH_MAX_MESSAGES} messages"
+ "Zcash batch supports at most {ZCASH_BATCH_MAX_PCZTS} PCZTs"
)));
}
- for (index, message) in messages.iter().enumerate() {
- if message.get_kind() != ZCASH_SIGN_MESSAGE_KIND_PCZT_V1 {
- return Err(RustCError::UnsupportedTransaction(format!(
- "unsupported Zcash batch message kind {}",
- message.get_kind()
- )));
- }
- if message.get_id().is_empty() {
+ let mut total_payload_bytes = 0usize;
+ let mut payload_digests = Vec::with_capacity(payloads.len());
+ for (index, payload) in payloads.iter().enumerate() {
+ if payload.is_empty() {
return Err(RustCError::InvalidData(format!(
- "Zcash batch message {index} has no id"
+ "Zcash batch PCZT {index} has no payload"
)));
}
- if message.get_payload().is_empty() {
- return Err(RustCError::InvalidData(format!(
- "Zcash batch message {index} has no payload"
+ total_payload_bytes = total_payload_bytes.saturating_add(payload.len());
+ if total_payload_bytes > ZCASH_BATCH_MAX_TOTAL_BYTES {
+ return Err(RustCError::UnsupportedTransaction(format!(
+ "Zcash batch PCZTs exceed {ZCASH_BATCH_MAX_TOTAL_BYTES} bytes"
)));
}
- let digest = sha256(message.get_payload());
- if let Some(expected_digest) = message.get_payload_digest() {
- if expected_digest.as_slice() != digest.as_slice() {
- return Err(RustCError::InvalidData(format!(
- "Zcash batch message {index} payload digest mismatch"
- )));
- }
- }
-
- for previous in &messages[..index] {
- if sha256(previous.get_payload()) == digest {
- return Err(RustCError::InvalidData(
- "Zcash batch contains duplicate payloads".to_string(),
- ));
- }
- if previous.get_id() == message.get_id() {
- return Err(RustCError::InvalidData(
- "Zcash batch contains duplicate message ids".to_string(),
- ));
- }
+ let digest = sha256(payload);
+ if payload_digests.contains(&digest) {
+ return Err(RustCError::InvalidData(
+ "Zcash batch contains duplicate PCZTs".to_string(),
+ ));
}
+ payload_digests.push(digest);
}
Ok(())
}
+/// Serializes one logical batch PCZT into its canonical standalone encoding.
+#[cfg(feature = "cypherpunk")]
+fn serialize_batch_pczt(pczt: &Pczt) -> Result<Vec<u8>, RustCError> {
+ pczt.clone()
+ .serialize()
+ .map_err(|e| RustCError::InvalidData(format!("encode PCZT in batch request: {e:?}")))
+}
+
+/// Applies firmware batch limits to the request body owned by the PCZT crate.
+#[cfg(feature = "cypherpunk")]
+fn validate_zcash_batch(batch: &BatchSignRequest) -> Result<Vec<Vec<u8>>, RustCError> {
+ let payloads = batch
+ .pczts()
+ .iter()
+ .map(serialize_batch_pczt)
+ .collect::<Result<Vec<_>, _>>()?;
+ validate_zcash_batch_payloads(&payloads)?;
+ Ok(payloads)
+}
+
+/// Bounds the outer request before parsing or retaining its checked state.
+#[cfg(feature = "cypherpunk")]
+fn validate_zcash_batch_envelope(request_id: &[u8], data: &[u8]) -> Result<(), RustCError> {
+ if request_id.is_empty() {
+ return Err(RustCError::InvalidData(
+ "Zcash batch request id must not be empty".to_string(),
+ ));
+ }
+ if request_id.len().saturating_add(data.len()) > ZCASH_BATCH_MAX_TOTAL_BYTES {
+ return Err(RustCError::UnsupportedTransaction(format!(
+ "Zcash batch request exceeds {ZCASH_BATCH_MAX_TOTAL_BYTES} bytes"
+ )));
+ }
+ Ok(())
+}
+
+/// Parses the bounded outer registry into the PCZT crate's batch request.
+#[cfg(feature = "cypherpunk")]
+fn parse_zcash_batch_registry(registry: &ZcashSignBatch) -> Result<BatchSignRequest, RustCError> {
+ validate_zcash_batch_envelope(registry.get_request_id(), registry.get_data())?;
+ BatchSignRequest::parse(registry.get_data())
+ .map_err(|e| RustCError::InvalidData(format!("invalid PCZT batch request: {e:?}")))
+}
+
+/// Reopens the exact normalized envelope retained by the check step.
+#[cfg(feature = "cypherpunk")]
+fn parse_checked_zcash_batch(data: &[u8]) -> Result<(Vec<u8>, BatchSignRequest), RustCError> {
+ let registry = ZcashSignBatch::try_from(data.to_vec()).map_err(|e| {
+ RustCError::InvalidData(format!("decode checked Zcash batch envelope: {e:?}"))
+ })?;
+ let batch = parse_zcash_batch_registry(®istry)?;
+ Ok((registry.get_request_id().to_vec(), batch))
+}
+
+/// Retains normalized PCZTs and their request id as checked firmware state.
+#[cfg(feature = "cypherpunk")]
+fn encode_checked_zcash_batch(request_id: &[u8], data: Vec<u8>) -> Result<Vec<u8>, RustCError> {
+ validate_zcash_batch_envelope(request_id, &data)?;
+ ZcashSignBatch::new(request_id.to_vec(), data)
+ .try_into()
+ .map_err(|e| {
+ RustCError::InvalidData(format!("encode normalized Zcash batch envelope: {e:?}"))
+ })
+}
+
+/// Wraps the PCZT crate's signature response with its echoed request id.
+#[cfg(feature = "cypherpunk")]
+fn encode_zcash_batch_sig_result(
+ request_id: Vec<u8>,
+ data: Vec<u8>,
+) -> Result<Vec<u8>, RustCError> {
+ ZcashBatchSigResult::new(request_id, data)
+ .try_into()
+ .map_err(|e| RustCError::InvalidData(format!("encode Zcash batch result envelope: {e:?}")))
+}
+
#[cfg(feature = "cypherpunk")]
#[no_mangle]
pub unsafe extern "C" fn check_zcash_batch_tx_cypherpunk(
@@ -302,55 +341,64 @@ pub unsafe extern "C" fn check_zcash_batch_tx_cypherpunk(
))
.c_ptr();
}
- let batch = extract_ptr_with_type!(tx, ZcashSignBatch);
+ let registry = extract_ptr_with_type!(tx, ZcashSignBatch);
+ let batch = match parse_zcash_batch_registry(registry) {
+ Ok(batch) => batch,
+ Err(e) => return TransactionCheckResult::from(e).c_ptr(),
+ };
+ let request_id = registry.get_request_id().to_vec();
let ufvk_text = unsafe { recover_c_char(ufvk) };
let seed_fingerprint = extract_array!(seed_fingerprint, u8, 32);
let seed_fingerprint = seed_fingerprint.try_into().unwrap();
- if let Err(e) = validate_zcash_batch(batch) {
- return TransactionCheckResult::from(e).c_ptr();
- }
+ let payloads = match validate_zcash_batch(&batch) {
+ Ok(payloads) => payloads,
+ Err(e) => return TransactionCheckResult::from(e).c_ptr(),
+ };
- let mut checked_messages = Vec::with_capacity(batch.get_messages().len());
- for message in batch.get_messages() {
+ let mut checked_pczts = Vec::with_capacity(payloads.len());
+ for payload in payloads {
match app_zcash::check_batch_pczt_cypherpunk(
&MainNetwork,
- message.get_payload(),
+ &payload,
&ufvk_text,
seed_fingerprint,
account_index,
) {
Ok(normalized) => {
- let digest = sha256(&normalized).to_vec();
- checked_messages.push(ZcashSignMessage::new(
- message.get_id().clone(),
- message.get_kind(),
- normalized,
- Some(digest),
- ));
+ let pczt = match Pczt::parse(&normalized) {
+ Ok(pczt) => pczt,
+ Err(e) => {
+ return TransactionCheckResult::from(RustCError::InvalidData(format!(
+ "parse normalized PCZT in batch request: {e:?}"
+ )))
+ .c_ptr();
+ }
+ };
+ checked_pczts.push(pczt);
}
Err(e) => return TransactionCheckResult::from(e).c_ptr(),
}
}
- // Rebuild the envelope around the normalized payloads (with re-stamped
- // per-message digests) so parse/sign consume exactly what was checked.
- let normalized_batch = ZcashSignBatch::new(
- batch.get_version(),
- batch.get_request_id().clone(),
- batch.get_network(),
- checked_messages,
- Some(true),
- );
- match TryInto::<Vec<u8>>::try_into(normalized_batch) {
- Ok(bytes) => {
- *checked_batch = ZcashCheckedPczt::new(bytes).c_ptr();
- TransactionCheckResult::new().c_ptr()
+ // Rebuild the Postcard request around the normalized PCZTs so parse/sign
+ // consume exactly what was checked, then preserve the outer request id for
+ // the eventual batch result.
+ let normalized_request = match BatchSignRequest::new(checked_pczts).serialize() {
+ Ok(bytes) => bytes,
+ Err(e) => {
+ return TransactionCheckResult::from(RustCError::InvalidData(format!(
+ "encode normalized PCZT batch request: {e:?}"
+ )))
+ .c_ptr();
}
- // The encode error is a ur-registry error type; TransactionCheckResult
- // has no From impl for it, so map explicitly.
- Err(e) => TransactionCheckResult::from(RustCError::InvalidData(format!("{e:?}"))).c_ptr(),
- }
+ };
+ let normalized_batch = match encode_checked_zcash_batch(&request_id, normalized_request) {
+ Ok(bytes) => bytes,
+ Err(e) => return TransactionCheckResult::from(e).c_ptr(),
+ };
+ *checked_batch = ZcashCheckedPczt::new(normalized_batch).c_ptr();
+ TransactionCheckResult::new().c_ptr()
}
#[cfg(feature = "cypherpunk")]
@@ -378,31 +426,36 @@ pub unsafe extern "C" fn parse_zcash_batch_tx_cypherpunk(
Ok(bytes) => bytes,
Err(e) => return TransactionParseResult::from(e).c_ptr(),
};
- let batch = match ZcashSignBatch::try_from(bytes.to_vec()) {
- Ok(batch) => batch,
- Err(e) => {
- return TransactionParseResult::from(RustCError::InvalidData(format!("{e:?}"))).c_ptr()
- }
+ let batch = match parse_checked_zcash_batch(bytes) {
+ Ok((_, batch)) => batch,
+ Err(e) => return TransactionParseResult::from(e).c_ptr(),
};
let ufvk_text = unsafe { recover_c_char(ufvk) };
let seed_fingerprint = extract_array!(seed_fingerprint, u8, 32);
let seed_fingerprint = seed_fingerprint.try_into().unwrap();
- // The checked bytes are parsed once. Eligible Orchard-to-Ironwood transfers
- // are folded by content; ambiguous batches keep their ordinary review pages.
+ // Serialize the normalized PCZTs once, then parse each into a complete
+ // display model. Eligible Orchard-to-Ironwood transfers are folded by
+ // content; ambiguous batches keep their ordinary review pages.
+ let payloads = match batch
+ .pczts()
+ .iter()
+ .map(serialize_batch_pczt)
+ .collect::<Result<Vec<_>, _>>()
+ {
+ Ok(payloads) => payloads,
+ Err(e) => return TransactionParseResult::from(e).c_ptr(),
+ };
let parsed_items = match app_zcash::parse_batch_with_migration_summary_cypherpunk(
&MainNetwork,
- batch
- .get_messages()
- .iter()
- .map(|message| message.get_payload().as_slice()),
+ payloads.iter().map(Vec::as_slice),
&ufvk_text,
seed_fingerprint,
) {
Ok(items) => items,
Err(e) => return TransactionParseResult::from(e).c_ptr(),
};
- // Convert only after every message has parsed. These FFI values own heap
+ // Convert only after every PCZT has parsed. These FFI values own heap
// allocations freed by `free_TransactionParseResult_DisplayZcashBatch`, not
// Rust `Drop`; an early return after partial conversion would leak memory.
let display_items: Vec<DisplayPczt> = parsed_items.iter().map(DisplayPczt::from).collect();
@@ -439,8 +492,8 @@ unsafe fn sign_zcash_batch_tx_cypherpunk_dynamic(
let mut seed = extract_array_mut!(seed, u8, seed_len as usize);
let result = match checked.checked_bytes() {
- Ok(bytes) => match ZcashSignBatch::try_from(bytes.to_vec()) {
- Ok(batch) => match calculate_seed_fingerprint(seed) {
+ Ok(bytes) => match parse_checked_zcash_batch(bytes) {
+ Ok((request_id, batch)) => match calculate_seed_fingerprint(seed) {
Ok(seed_fingerprint) => {
if &seed_fingerprint != expected_seed_fingerprint {
seed.zeroize();
@@ -449,22 +502,32 @@ unsafe fn sign_zcash_batch_tx_cypherpunk_dynamic(
let mut results = Vec::new();
let mut error = None;
- for message in batch.get_messages() {
+ // Preserve request order and emit nothing unless every PCZT signs.
+ for pczt in batch.pczts() {
+ let payload = match serialize_batch_pczt(pczt) {
+ Ok(payload) => payload,
+ Err(e) => {
+ error = Some(UREncodeResult::from(e).c_ptr());
+ break;
+ }
+ };
match app_zcash::sign_checked_batch_pczt(
&MainNetwork,
- message.get_payload(),
+ &payload,
seed,
&seed_fingerprint,
account_index,
) {
Ok(payload) => {
- let payload_digest = sha256(&payload).to_vec();
- results.push(ZcashSignMessageResult::signed(
- message.get_id().clone(),
- message.get_kind(),
- payload,
- payload_digest,
- ));
+ match app_zcash::extract_compact_sigs_from_signed_pczt(&payload) {
+ Ok(compact_sigs) => {
+ results.push(compact_sigs);
+ }
+ Err(e) => {
+ error = Some(UREncodeResult::from(e).c_ptr());
+ break;
+ }
+ }
}
Err(e) => {
error = Some(UREncodeResult::from(e).c_ptr());
@@ -476,33 +539,41 @@ unsafe fn sign_zcash_batch_tx_cypherpunk_dynamic(
if let Some(error) = error {
error
} else {
- let result = ZcashSignResult::new(
- ZCASH_SIGN_BATCH_VERSION,
- batch.get_request_id().clone(),
- results,
- );
- match TryInto::<Vec<u8>>::try_into(result) {
+ let response = BatchSignResponse::new(results);
+ match response.serialize() {
Ok(bytes) => {
- let registry_type = ZcashSignResult::get_registry_type().get_type();
- if allow_multipart {
- UREncodeResult::encode(
- bytes,
- registry_type,
- max_fragment_length,
- )
- .c_ptr()
- } else {
- UREncodeResult::encode_full_response(bytes, registry_type)
- .c_ptr()
+ let registry_type =
+ ZcashBatchSigResult::get_registry_type().get_type();
+ match encode_zcash_batch_sig_result(request_id, bytes) {
+ Ok(cbor) => {
+ if allow_multipart {
+ UREncodeResult::encode(
+ cbor,
+ registry_type,
+ max_fragment_length,
+ )
+ .c_ptr()
+ } else {
+ UREncodeResult::encode_full_response(
+ cbor,
+ registry_type,
+ )
+ .c_ptr()
+ }
+ }
+ Err(e) => UREncodeResult::from(e).c_ptr(),
}
}
- Err(e) => UREncodeResult::from(e).c_ptr(),
+ Err(e) => UREncodeResult::from(RustCError::InvalidData(format!(
+ "encode PCZT batch response: {e:?}"
+ )))
+ .c_ptr(),
}
}
}
Err(e) => UREncodeResult::from(e).c_ptr(),
},
- Err(e) => UREncodeResult::from(RustCError::InvalidData(format!("{e:?}"))).c_ptr(),
+ Err(e) => UREncodeResult::from(e).c_ptr(),
},
Err(e) => UREncodeResult::from(e).c_ptr(),
};
@@ -789,211 +860,182 @@ mod tests {
use super::*;
- fn test_zcash_batch(messages: Vec<ZcashSignMessage>) -> ZcashSignBatch {
- ZcashSignBatch::new(
- ZCASH_SIGN_BATCH_VERSION,
- b"test-request".to_vec(),
- ZCASH_SIGN_BATCH_NETWORK_MAINNET,
- messages,
- Some(true),
- )
- }
-
- fn test_zcash_message(id: &[u8], payload: &[u8]) -> ZcashSignMessage {
- ZcashSignMessage::new(
- id.to_vec(),
- ZCASH_SIGN_MESSAGE_KIND_PCZT_V1,
- payload.to_vec(),
- Some(sha256(payload).to_vec()),
- )
+ #[cfg(feature = "cypherpunk")]
+ fn test_zcash_payloads(count: usize) -> Vec<Vec<u8>> {
+ (0..count)
+ .map(|index| format!("pczt-{index}").into_bytes())
+ .collect()
}
+ #[cfg(feature = "cypherpunk")]
#[test]
- fn test_validate_zcash_batch_accepts_valid_envelope() {
- let batch = test_zcash_batch(vec![
- test_zcash_message(b"one", b"pczt-one"),
- test_zcash_message(b"two", b"pczt-two"),
- ]);
+ fn test_validate_zcash_batch_accepts_valid_payloads() {
+ let payloads = test_zcash_payloads(2);
- validate_zcash_batch(&batch).unwrap();
+ validate_zcash_batch_payloads(&payloads).unwrap();
}
+ #[cfg(feature = "cypherpunk")]
#[test]
- fn test_validate_zcash_batch_accepts_missing_atomic_as_default() {
- let batch = ZcashSignBatch::new(
- ZCASH_SIGN_BATCH_VERSION,
- b"test-request".to_vec(),
- ZCASH_SIGN_BATCH_NETWORK_MAINNET,
- vec![test_zcash_message(b"one", b"pczt-one")],
- None,
- );
+ fn test_validate_zcash_batch_accepts_max_pczts() {
+ let payloads = test_zcash_payloads(ZCASH_BATCH_MAX_PCZTS);
- validate_zcash_batch(&batch).unwrap();
+ validate_zcash_batch_payloads(&payloads).unwrap();
}
+ #[cfg(feature = "cypherpunk")]
#[test]
- fn test_validate_zcash_batch_accepts_max_messages() {
- let batch = test_zcash_batch(
- (0..ZCASH_BATCH_MAX_MESSAGES)
- .map(|index| {
- test_zcash_message(
- format!("id-{index}").as_bytes(),
- format!("pczt-{index}").as_bytes(),
- )
- })
- .collect(),
- );
+ fn test_validate_zcash_batch_rejects_oversized_total_payload() {
+ // Three PCZTs whose summed payloads cross the byte bound: the count cap
+ // alone no longer bounds RAM, so the byte bound must reject this.
+ let big = vec![0xAB; ZCASH_BATCH_MAX_TOTAL_BYTES / 2];
+ let payloads = vec![big.clone(), [big.as_slice(), &[0x01]].concat(), vec![0x02]];
- validate_zcash_batch(&batch).unwrap();
+ let error = validate_zcash_batch_payloads(&payloads).unwrap_err();
+ assert!(matches!(
+ error,
+ RustCError::UnsupportedTransaction(message)
+ if message.contains("PCZTs exceed")
+ ));
}
+ #[cfg(feature = "cypherpunk")]
#[test]
- fn test_validate_zcash_batch_rejects_version_network_and_atomic_policy() {
- let message = test_zcash_message(b"one", b"pczt-one");
-
- let wrong_version = ZcashSignBatch::new(
- ZCASH_SIGN_BATCH_VERSION + 1,
- b"test-request".to_vec(),
- ZCASH_SIGN_BATCH_NETWORK_MAINNET,
- vec![message.clone()],
- Some(true),
+ fn test_validate_zcash_batch_rejects_empty_batch_and_payload() {
+ assert_eq!(
+ validate_zcash_batch_payloads(&[]).unwrap_err(),
+ RustCError::InvalidData("Zcash batch has no PCZTs".to_string())
);
- assert!(matches!(
- validate_zcash_batch(&wrong_version),
- Err(RustCError::UnsupportedTransaction(message))
- if message.contains("unsupported Zcash batch version")
- ));
- let wrong_network = ZcashSignBatch::new(
- ZCASH_SIGN_BATCH_VERSION,
- b"test-request".to_vec(),
- ZCASH_SIGN_BATCH_NETWORK_MAINNET + 1,
- vec![message.clone()],
- Some(true),
+ assert_eq!(
+ validate_zcash_batch_payloads(&[vec![]]).unwrap_err(),
+ RustCError::InvalidData("Zcash batch PCZT 0 has no payload".to_string())
);
- assert!(matches!(
- validate_zcash_batch(&wrong_network),
- Err(RustCError::UnsupportedTransaction(message))
- if message.contains("only Zcash mainnet")
- ));
+ }
+
+ #[cfg(feature = "cypherpunk")]
+ #[test]
+ fn test_validate_zcash_batch_rejects_too_many_pczts() {
+ let payloads = test_zcash_payloads(ZCASH_BATCH_MAX_PCZTS + 1);
- let non_atomic = ZcashSignBatch::new(
- ZCASH_SIGN_BATCH_VERSION,
- b"test-request".to_vec(),
- ZCASH_SIGN_BATCH_NETWORK_MAINNET,
- vec![message],
- Some(false),
- );
assert!(matches!(
- validate_zcash_batch(&non_atomic),
+ validate_zcash_batch_payloads(&payloads),
Err(RustCError::UnsupportedTransaction(message))
- if message.contains("atomic=true")
+ if message.contains("supports at most")
));
}
+ #[cfg(feature = "cypherpunk")]
#[test]
- fn test_validate_zcash_batch_rejects_empty_request_id_and_messages() {
- let empty_request_id = ZcashSignBatch::new(
- ZCASH_SIGN_BATCH_VERSION,
- vec![],
- ZCASH_SIGN_BATCH_NETWORK_MAINNET,
- vec![test_zcash_message(b"one", b"pczt-one")],
- Some(true),
- );
+ fn test_validate_zcash_batch_rejects_duplicate_pczts() {
+ let duplicate_payloads = vec![b"pczt".to_vec(), b"pczt".to_vec()];
assert_eq!(
- validate_zcash_batch(&empty_request_id).unwrap_err(),
- RustCError::InvalidData("Zcash batch has no request id".to_string())
+ validate_zcash_batch_payloads(&duplicate_payloads).unwrap_err(),
+ RustCError::InvalidData("Zcash batch contains duplicate PCZTs".to_string())
);
+ }
- let empty_messages = test_zcash_batch(vec![]);
- assert_eq!(
- validate_zcash_batch(&empty_messages).unwrap_err(),
- RustCError::InvalidData("Zcash batch has no messages".to_string())
- );
+ #[cfg(feature = "cypherpunk")]
+ fn empty_batch_request() -> Vec<u8> {
+ BatchSignRequest::new(vec![]).serialize().unwrap()
}
+ #[cfg(feature = "cypherpunk")]
#[test]
- fn test_validate_zcash_batch_rejects_invalid_message_fields() {
- let unsupported_kind = ZcashSignMessage::new(
- b"one".to_vec(),
- ZCASH_SIGN_MESSAGE_KIND_PCZT_V1 + 1,
- b"pczt-one".to_vec(),
- Some(sha256(b"pczt-one").to_vec()),
- );
- assert!(matches!(
- validate_zcash_batch(&test_zcash_batch(vec![unsupported_kind])),
- Err(RustCError::UnsupportedTransaction(message))
- if message.contains("unsupported Zcash batch message kind")
- ));
+ fn test_checked_zcash_batch_preserves_request_id() {
+ let request_id = vec![0xaa, 0xbb];
+ let checked = encode_checked_zcash_batch(&request_id, empty_batch_request()).unwrap();
- let empty_message_id = test_zcash_message(b"", b"pczt-one");
- assert_eq!(
- validate_zcash_batch(&test_zcash_batch(vec![empty_message_id])).unwrap_err(),
- RustCError::InvalidData("Zcash batch message 0 has no id".to_string())
- );
+ let (decoded_request_id, batch) = parse_checked_zcash_batch(&checked).unwrap();
- let empty_payload = test_zcash_message(b"one", b"");
- assert_eq!(
- validate_zcash_batch(&test_zcash_batch(vec![empty_payload])).unwrap_err(),
- RustCError::InvalidData("Zcash batch message 0 has no payload".to_string())
- );
+ assert_eq!(decoded_request_id, request_id);
+ assert!(batch.pczts().is_empty());
}
+ #[cfg(feature = "cypherpunk")]
#[test]
- fn test_validate_zcash_batch_rejects_too_many_messages() {
- let batch = test_zcash_batch(
- (0..=ZCASH_BATCH_MAX_MESSAGES)
- .map(|index| {
- test_zcash_message(
- format!("id-{index}").as_bytes(),
- format!("pczt-{index}").as_bytes(),
- )
- })
- .collect(),
+ fn test_zcash_batch_rejects_invalid_envelope_bounds() {
+ let registry = ZcashSignBatch::new(vec![], empty_batch_request());
+
+ assert_eq!(
+ parse_zcash_batch_registry(®istry).unwrap_err(),
+ RustCError::InvalidData("Zcash batch request id must not be empty".to_string())
);
+ let oversized = ZcashSignBatch::new(vec![0xaa], vec![0; ZCASH_BATCH_MAX_TOTAL_BYTES]);
assert!(matches!(
- validate_zcash_batch(&batch),
+ parse_zcash_batch_registry(&oversized),
Err(RustCError::UnsupportedTransaction(message))
- if message.contains("supports at most")
+ if message.contains("batch request exceeds")
));
}
+ #[cfg(feature = "cypherpunk")]
#[test]
- fn test_validate_zcash_batch_rejects_duplicate_ids_and_payloads() {
- let duplicate_ids = test_zcash_batch(vec![
- test_zcash_message(b"same", b"pczt-one"),
- test_zcash_message(b"same", b"pczt-two"),
+ fn test_encode_zcash_batch_sig_result_wraps_pczt_response() {
+ use zcash_vendor::{orchard::ValuePool, pczt::roles::signer::SpendAuthSignature};
+
+ let request_id = vec![0xaa, 0xbb];
+ let response = BatchSignResponse::new(vec![
+ vec![SpendAuthSignature::from_parts(
+ ValuePool::Orchard,
+ 0,
+ [0x11; 64],
+ )],
+ vec![SpendAuthSignature::from_parts(
+ ValuePool::Ironwood,
+ 3,
+ [0x22; 64],
+ )],
]);
- assert_eq!(
- validate_zcash_batch(&duplicate_ids).unwrap_err(),
- RustCError::InvalidData("Zcash batch contains duplicate message ids".to_string())
- );
+ let response_bytes = response.serialize().unwrap();
- let duplicate_payloads = test_zcash_batch(vec![
- test_zcash_message(b"one", b"pczt"),
- test_zcash_message(b"two", b"pczt"),
- ]);
+ let cbor =
+ encode_zcash_batch_sig_result(request_id.clone(), response_bytes.clone()).unwrap();
+ let decoded = ZcashBatchSigResult::try_from(cbor).unwrap();
+
+ assert_eq!(decoded.get_request_id(), request_id);
+ assert_eq!(decoded.get_data(), response_bytes);
assert_eq!(
- validate_zcash_batch(&duplicate_payloads).unwrap_err(),
- RustCError::InvalidData("Zcash batch contains duplicate payloads".to_string())
+ BatchSignResponse::parse(decoded.get_data()).unwrap(),
+ response
);
}
+ /// The batch signer fails closed: with no checked batch stored (a null
+ /// container) it refuses and produces no signature UR.
+ #[cfg(feature = "cypherpunk")]
#[test]
- fn test_validate_zcash_batch_rejects_payload_digest_mismatch() {
- let message = ZcashSignMessage::new(
- b"one".to_vec(),
- ZCASH_SIGN_MESSAGE_KIND_PCZT_V1,
- b"pczt-one".to_vec(),
- Some(sha256(b"different-payload").to_vec()),
- );
- let batch = test_zcash_batch(vec![message]);
-
- assert_eq!(
- validate_zcash_batch(&batch).unwrap_err(),
- RustCError::InvalidData("Zcash batch message 0 payload digest mismatch".to_string())
- );
+ fn test_sign_zcash_batch_refuses_without_checked_batch() {
+ for unlimited in [false, true] {
+ let result = unsafe {
+ if unlimited {
+ sign_zcash_batch_tx_cypherpunk_unlimited(
+ core::ptr::null_mut(),
+ core::ptr::null_mut(),
+ 0,
+ false,
+ core::ptr::null_mut(),
+ 0,
+ )
+ } else {
+ sign_zcash_batch_tx_cypherpunk(
+ core::ptr::null_mut(),
+ core::ptr::null_mut(),
+ 0,
+ false,
+ core::ptr::null_mut(),
+ 0,
+ )
+ }
+ };
+ assert!(!result.is_null());
+ assert!(
+ unsafe { (*result).data.is_null() },
+ "signing without a checked batch must not produce a signature UR"
+ );
+ unsafe { Box::from_raw(result).free() };
+ }
}
#[test]
diff --git a/rust/zcash_vendor/Cargo.toml b/rust/zcash_vendor/Cargo.toml
index f16ad23..e501be9 100644
--- a/rust/zcash_vendor/Cargo.toml
+++ b/rust/zcash_vendor/Cargo.toml
@@ -41,7 +41,7 @@ chacha20poly1305 = { version = "0.10.1", default-features = false, features = [
] }
postcard = { version = "1.0.3", features = ["alloc"] }
getset = { version = "0.1.3" }
-orchard = { version = "0.15.0-pre.2", default-features = false, optional = true }
+orchard = { version = "0.15.0", default-features = false, optional = true }
pczt = { version = "0.7", default-features = false }
serde = { workspace = true }
serde_with = { version = "3.11.0", features = [
diff --git a/rust/zcash_vendor/src/pczt_ext.rs b/rust/zcash_vendor/src/pczt_ext.rs
index 3ae3fef..3fb7a42 100644
--- a/rust/zcash_vendor/src/pczt_ext.rs
+++ b/rust/zcash_vendor/src/pczt_ext.rs
@@ -214,6 +214,13 @@ fn action_cv_net(action: &pczt::orchard::Action) -> &[u8; 32] {
action.cv_net().as_ref().unwrap_or(&ZERO)
}
+/// The action's note commitment bytes for the sighash. Like `cv_net`, `cmx` is
+/// optional in the v2 PCZT wire model and is resolved and checked before signing.
+fn action_cmx(action: &pczt::orchard::Action) -> &[u8; 32] {
+ static ZERO: [u8; 32] = [0; 32];
+ action.output().cmx().as_ref().unwrap_or(&ZERO)
+}
+
/// The output's encrypted note ciphertext for the sighash. `resolve_fields` restores the
/// full ciphertext from a memo-plaintext-only (`EncCiphertext::MemoPlaintext`) output; see
/// [`action_cv_net`] for why the zero fallback is unreachable in the signing path.
@@ -236,7 +243,7 @@ fn digest_orchard(pczt: &Pczt) -> Hash {
let enc_ciphertext = action_enc_ciphertext(action.output());
ch.update(action.spend().nullifier());
- ch.update(action.output().cmx());
+ ch.update(action_cmx(action));
ch.update(action.output().ephemeral_key());
ch.update(&enc_ciphertext[..ENC_CIPHERTEXT_COMPACT_LEN]);
@@ -277,7 +284,9 @@ fn hash_sapling_spends(pczt: &Pczt) -> Hash {
ch.update(s_spend.nullifier());
nh.update(s_spend.cv());
- nh.update(pczt.sapling().anchor());
+ // Checked v5 spends require an anchor. Zero only keeps digesting
+ // malformed unchecked data infallible; it cannot yield a valid transaction.
+ nh.update(pczt.sapling().anchor().as_ref().unwrap_or(&[0u8; 32]));
nh.update(s_spend.rk());
}
@@ -387,7 +396,7 @@ fn digest_orchard_shaped_v6(
let enc_ciphertext = action_enc_ciphertext(action.output());
ch.update(action.spend().nullifier());
- ch.update(action.output().cmx());
+ ch.update(action_cmx(action));
ch.update(action.output().ephemeral_key());
ch.update(&enc_ciphertext[..ENC_CIPHERTEXT_COMPACT_LEN]);
Why this scored 37/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.