Skip to content

Commit 951913f

Browse files
committed
test(zcash): enforce RC18 PCZT signing contract
1 parent 9ce1aeb commit 951913f

3 files changed

Lines changed: 325 additions & 205 deletions

File tree

.github/workflows/ci.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# and runs the full python integration test suite against it.
55
#
66
# Stage 1: GATE (seconds)
7-
# └─ lint basic Python syntax check
7+
# └─ lint Python syntax + deterministic protocol contract tests
88
#
99
# Stage 2: TEST (gated by Stage 1)
1010
# └─ integration full pytest suite against emulator
@@ -34,13 +34,26 @@ jobs:
3434
- name: Syntax check
3535
run: python -m py_compile keepkeylib/*.py
3636

37+
- name: Install contract-test dependencies
38+
run: |
39+
pip install "protobuf>=3.20,<4" mnemonic ecdsa pytest
40+
41+
- name: Run deterministic Zcash PCZT contract tests
42+
env:
43+
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
44+
run: |
45+
python -m pytest -q \
46+
tests/test_msg_zcash_sign_pczt.py \
47+
tests/test_zcash_seed_fingerprint_helper.py
48+
3749
- name: Lint summary
3850
run: |
3951
echo "## 🔑 KeepKey python-keepkey — Lint" >> "$GITHUB_STEP_SUMMARY"
4052
echo "" >> "$GITHUB_STEP_SUMMARY"
4153
echo "| Check | Status |" >> "$GITHUB_STEP_SUMMARY"
4254
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
4355
echo "| Syntax | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY"
56+
echo "| Zcash PCZT contract | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY"
4457
4558
# ═══════════════════════════════════════════════════════════
4659
# STAGE 2: TEST — pull published emulator, run pytest

keepkeylib/client.py

Lines changed: 117 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1861,14 +1861,18 @@ def zcash_sign_pczt(self, address_n, actions, account=None,
18611861
header_digest=None, transparent_digest=None,
18621862
sapling_digest=None, orchard_digest=None,
18631863
orchard_flags=None, orchard_value_balance=None,
1864-
orchard_anchor=None, transparent_inputs=None,
1865-
expected_seed_fingerprint=None):
1864+
orchard_anchor=None, tx_version=None,
1865+
version_group_id=None, lock_time=None,
1866+
expiry_height=None, transparent_outputs=None,
1867+
transparent_inputs=None,
1868+
expected_seed_fingerprint=None,
1869+
return_transparent_signatures=False):
18661870
"""Sign a Zcash Orchard shielded transaction via PCZT protocol.
18671871
1868-
Phase 2: Sends ZcashSignPCZT, then loops on ZcashPCZTActionAck
1869-
feeding Orchard actions one at a time.
1870-
Phase 3: If transparent_inputs provided, handles ZcashTransparentSig
1871-
loop for transparent-to-shielded (shielding) transactions.
1872+
Streams transparent outputs, then transparent inputs, then Orchard
1873+
actions in the exact order requested by firmware 7.15. Orchard
1874+
signatures are compact: the response contains one signature for each
1875+
action whose explicit ``is_spend`` value is true, in action order.
18721876
18731877
Args:
18741878
address_n: ZIP-32 derivation path [32', 133', account']
@@ -1884,14 +1888,36 @@ def zcash_sign_pczt(self, address_n, actions, account=None,
18841888
orchard_flags: bundle flags byte (enables digest verification)
18851889
orchard_value_balance: signed i64 value balance
18861890
orchard_anchor: 32-byte anchor
1891+
tx_version: transaction version used to verify header_digest
1892+
version_group_id: transaction version group ID
1893+
lock_time: transaction lock time
1894+
expiry_height: transaction expiry height
1895+
transparent_outputs: output dicts matching ZcashTransparentOutput
1896+
transparent_inputs: input dicts matching ZcashTransparentInput;
1897+
host-provided per-input sighashes are rejected by RC18
1898+
return_transparent_signatures: when true, return a tuple of
1899+
(ZcashSignedPCZT, [DER transparent signatures])
18871900
18881901
Returns:
1889-
ZcashSignedPCZT with .signatures list and optional .txid
1902+
ZcashSignedPCZT with compact Orchard signatures and optional txid,
1903+
or a tuple including transparent signatures when requested.
18901904
"""
18911905
n_actions = len(actions)
18921906
if n_actions == 0:
18931907
raise ValueError("Must have at least one action")
18941908

1909+
for idx, action in enumerate(actions):
1910+
if 'is_spend' not in action or not isinstance(action['is_spend'], bool):
1911+
raise ValueError(
1912+
"Orchard action %d must explicitly set boolean is_spend" % idx)
1913+
1914+
transparent_outputs = transparent_outputs or []
1915+
transparent_inputs = transparent_inputs or []
1916+
for inp in transparent_inputs:
1917+
if 'sighash' in inp:
1918+
raise ValueError(
1919+
"Host-provided transparent sighash is rejected by firmware 7.15")
1920+
18951921
# Build the initial signing request — only send address_n,
18961922
# let firmware derive account from the path. Only set account
18971923
# explicitly if the caller passed it.
@@ -1918,42 +1944,112 @@ def zcash_sign_pczt(self, address_n, actions, account=None,
19181944
kwargs['orchard_value_balance'] = orchard_value_balance
19191945
if orchard_anchor is not None:
19201946
kwargs['orchard_anchor'] = orchard_anchor
1947+
if tx_version is not None:
1948+
kwargs['tx_version'] = tx_version
1949+
if version_group_id is not None:
1950+
kwargs['version_group_id'] = version_group_id
1951+
if lock_time is not None:
1952+
kwargs['lock_time'] = lock_time
1953+
if expiry_height is not None:
1954+
kwargs['expiry_height'] = expiry_height
1955+
if transparent_outputs:
1956+
kwargs['n_transparent_outputs'] = len(transparent_outputs)
1957+
if transparent_inputs:
1958+
kwargs['n_transparent_inputs'] = len(transparent_inputs)
19211959
if expected_seed_fingerprint is not None:
19221960
kwargs['expected_seed_fingerprint'] = expected_seed_fingerprint
19231961

19241962
resp = self.call(zcash_proto.ZcashSignPCZT(**kwargs))
19251963

1926-
# Phase 2: Orchard action-ack loop — device asks for actions one at a time
1964+
# Transparent plaintext is streamed outputs-first. Firmware uses field
1965+
# presence to distinguish output and input acknowledgments, so never
1966+
# infer a missing index as zero.
1967+
sent_outputs = 0
1968+
while (sent_outputs < len(transparent_outputs) and
1969+
isinstance(resp, zcash_proto.ZcashTransparentAck)):
1970+
if not resp.HasField('next_output_index'):
1971+
raise Exception("Device did not request the next transparent output")
1972+
idx = resp.next_output_index
1973+
if idx != sent_outputs:
1974+
raise Exception(
1975+
"Device requested transparent output %d after %d outputs"
1976+
% (idx, sent_outputs))
1977+
if idx >= len(transparent_outputs):
1978+
raise Exception(
1979+
"Device requested transparent output %d but only %d provided"
1980+
% (idx, len(transparent_outputs)))
1981+
output = dict(transparent_outputs[idx])
1982+
output.pop('index', None)
1983+
resp = self.call(zcash_proto.ZcashTransparentOutput(index=idx, **output))
1984+
sent_outputs += 1
1985+
1986+
sent_inputs = 0
1987+
while (sent_inputs < len(transparent_inputs) and
1988+
isinstance(resp, zcash_proto.ZcashTransparentAck)):
1989+
if not resp.HasField('next_input_index'):
1990+
raise Exception("Device did not request the next transparent input")
1991+
idx = resp.next_input_index
1992+
if idx != sent_inputs:
1993+
raise Exception(
1994+
"Device requested transparent input %d after %d inputs"
1995+
% (idx, sent_inputs))
1996+
if idx >= len(transparent_inputs):
1997+
raise Exception(
1998+
"Device requested transparent input %d but only %d provided"
1999+
% (idx, len(transparent_inputs)))
2000+
inp = dict(transparent_inputs[idx])
2001+
inp.pop('index', None)
2002+
resp = self.call(zcash_proto.ZcashTransparentInput(index=idx, **inp))
2003+
sent_inputs += 1
2004+
2005+
if sent_outputs != len(transparent_outputs):
2006+
raise Exception("Device did not request every transparent output")
2007+
if sent_inputs != len(transparent_inputs):
2008+
raise Exception("Device did not request every transparent input")
2009+
2010+
# Orchard action-ack loop: the device chooses the next action index.
2011+
sent_actions = set()
19272012
while isinstance(resp, zcash_proto.ZcashPCZTActionAck):
2013+
if not resp.HasField('next_index'):
2014+
raise Exception("Device did not identify the next Orchard action")
19282015
idx = resp.next_index
19292016
if idx >= n_actions:
19302017
raise Exception(
19312018
"Device requested action index %d but only %d actions provided"
19322019
% (idx, n_actions))
2020+
if idx in sent_actions:
2021+
raise Exception("Device requested Orchard action %d twice" % idx)
19332022
action = actions[idx]
19342023
resp = self.call(zcash_proto.ZcashPCZTAction(index=idx, **action))
2024+
sent_actions.add(idx)
2025+
2026+
if sent_actions != set(range(n_actions)):
2027+
raise Exception("Device did not request every Orchard action")
19352028

1936-
# Phase 3: Transparent input signing — device sends back signatures
1937-
# and may request transparent inputs for shielding transactions
2029+
# RC18 defers transparent signatures until every Orchard action, digest,
2030+
# and fee has passed. They are emitted immediately before SignedPCZT.
19382031
transparent_sigs = []
1939-
while isinstance(resp, zcash_proto.ZcashTransparentSig):
1940-
transparent_sigs.append(resp)
1941-
if not transparent_inputs:
1942-
raise Exception(
1943-
"Device sent ZcashTransparentSig but no transparent_inputs provided")
1944-
if resp.next_index >= len(transparent_inputs):
1945-
raise Exception(
1946-
"Device requested transparent input %d but only %d provided"
1947-
% (resp.next_index, len(transparent_inputs)))
1948-
inp = transparent_inputs[resp.next_index]
1949-
resp = self.call(zcash_proto.ZcashTransparentInput(**inp))
2032+
if isinstance(resp, zcash_proto.ZcashTransparentSigned):
2033+
transparent_sigs = list(resp.signatures)
2034+
resp = self.transport.read_blocking()
19502035

19512036
if isinstance(resp, proto.Failure):
19522037
raise Exception("Zcash signing failed: %s" % resp.message)
19532038

19542039
if not isinstance(resp, zcash_proto.ZcashSignedPCZT):
19552040
raise Exception("Unexpected response type: %s" % type(resp))
19562041

2042+
expected_signatures = sum(1 for action in actions if action['is_spend'])
2043+
if len(resp.signatures) != expected_signatures:
2044+
raise Exception(
2045+
"Device returned %d Orchard signatures for %d real spends"
2046+
% (len(resp.signatures), expected_signatures))
2047+
for signature in resp.signatures:
2048+
if len(signature) != 64:
2049+
raise Exception("Device returned an invalid RedPallas signature")
2050+
2051+
if return_transparent_signatures:
2052+
return resp, transparent_sigs
19572053
return resp
19582054

19592055
# ── Hive ────────────────────────────────────────────────────

0 commit comments

Comments
 (0)