Skip to content

Commit 3bbf996

Browse files
committed
test(zcash): sign a shielded transaction on an actual device
ZcashSignPCZT had never been sent to a device by anything in this suite. Every test in test_msg_zcash_sign_pczt drives a ScriptedTransport with canned responses; the device-driven Zcash tests cover transparent signing, display-address, FVK derivation and the seed fingerprint. So the on-device shielded path had no automated coverage at all, and seven green checks in section Z read exactly like device coverage while proving only that the client serialises its messages in the right order. That is how a confirm screen which cannot physically fit its amount line shipped unnoticed. The per-output shielded confirm is the verification gate for Orchard output values -- total_amount on the summary prompt is taken straight from the host message -- and a unified address is 106 characters, three full body rows, against a three-row body. The amount never rendered. The fixtures are the firmware's own known-answer vectors from unittests/firmware/zcash.cpp, so the device's cmx recomputation accepts them without needing a Pallas implementation in Python. The same note under both pools commits to a different value, which is what makes the pool tests possible at all. Four tests: - the output review is two screens, and they render differently and non-blank (read_layout returns a framebuffer, not text, so the assertions are structural rather than OCR) - a one-bit change to the recipient breaks the commitment - the Orchard commitment is refused when Ironwood is declared - the Ironwood commitment for that same note is accepted Two gates the offline fixtures do not satisfy had to be met for real firmware: the header digest is recomputed and compared, and for a shielded-only transaction the verified fee reduces to orchard_value_balance and must equal the declared fee. Both are computed here rather than canned. VERIFIED AS A REGRESSION TEST, not just written: run against the shipped 7.15.0 RC emulator (docker image from run 31284108490, the 27970b0c6 build) it fails with "expected 2 ConfirmOutput screens, got 1", while the commitment-binding and pool-selection tests pass. It reproduces the defect on the firmware that has it. Catalogued as Z22-Z25 with screenshot hints, so the shielded confirm screens finally appear in the report -- the RC run captured 1037 OLED frames and not one came from a shielded flow.
1 parent 5761392 commit 3bbf996

2 files changed

Lines changed: 334 additions & 0 deletions

File tree

scripts/generate-test-report.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2068,6 +2068,42 @@ def _arg_shown(a):
20682068
'Duplicate action requests rejected',
20692069
'A repeated device request for the same action index aborts the streaming session.',
20702070
[]),
2071+
('Z22', 'test_msg_zcash_sign_pczt_device',
2072+
'test_shielded_output_review_is_two_screens',
2073+
'Shielded output review: amount and full address (ON DEVICE)',
2074+
'The first test in this suite that sends ZcashSignPCZT to an actual device -- Z15-Z21 '
2075+
'above are offline contract tests against a scripted transport. Signs a shielded-only '
2076+
'transaction built from the firmware\'s own known-answer note vector, so the device '
2077+
'accepts its recomputed commitment, and asserts the output review is two screens. It '
2078+
'has to be: a unified address is 106 characters, three full body rows, and the body is '
2079+
'three rows total, so a single confirm holding the question, the address and the amount '
2080+
'renders 76 characters of address and silently drops the rest along with the amount. '
2081+
'That screen is the verification gate for Orchard output values -- total_amount on the '
2082+
'summary is a host-supplied prompt -- so the amount vanishing there is the whole trust '
2083+
'story. Verified as a regression test: against the shipped 7.15.0 RC emulator it fails '
2084+
'with "expected 2 ConfirmOutput screens, got 1".',
2085+
['Shielded amount review', 'Shielded recipient address']),
2086+
('Z23', 'test_msg_zcash_sign_pczt_device',
2087+
'test_note_commitment_binds_the_recipient',
2088+
'Tampered recipient breaks the note commitment (ON DEVICE)',
2089+
'Flipping one bit of the recipient makes the device-recomputed cmx disagree with the '
2090+
'supplied commitment, and signing is refused. This is what stops a host displaying one '
2091+
'recipient while committing to another.',
2092+
[]),
2093+
('Z24', 'test_msg_zcash_sign_pczt_device',
2094+
'test_pool_selection_is_honoured',
2095+
'Orchard commitment rejected under the Ironwood pool (ON DEVICE)',
2096+
'The same note commits to a different value in each pool, so offering the Orchard '
2097+
'commitment while declaring Ironwood must be rejected. Passes trivially if the device '
2098+
'ignores shielded_pool, which is why it is paired with Z25.',
2099+
[]),
2100+
('Z25', 'test_msg_zcash_sign_pczt_device',
2101+
'test_ironwood_note_is_accepted',
2102+
'Ironwood commitment for the same note is accepted (ON DEVICE)',
2103+
'The positive half of Z24: identical inputs, Ironwood commitment, accepted. Together '
2104+
'they prove the pool branch is selected by shielded_pool rather than one path serving '
2105+
'both.',
2106+
[]),
20712107
]),
20722108

20732109
('D', 'BIP-85 Child Derivation', '7.14.0',
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
"""Device-level Zcash shielded signing.
2+
3+
Every other PCZT test in this suite is an offline contract test: they drive a
4+
ScriptedTransport with canned responses and never reach a device. That left the
5+
on-device shielded path with no automated coverage at all -- and it is not a
6+
quiet corner of the firmware. fsm_msg_zcash.h calls total_amount "a summary
7+
prompt" and delegates verification of Orchard output *values* to the per-output
8+
confirm screen, so that screen is the whole trust story for a shielded send.
9+
10+
Nothing had ever rendered it. The RC run captured 1037 OLED frames and not one
11+
came from a shielded flow, which is how a confirm that could not physically fit
12+
its amount line shipped unnoticed.
13+
14+
The note fixtures are the known-answer vectors from
15+
unittests/firmware/zcash.cpp (OrchardNoteCommitment_KnownVectorAndProgress,
16+
IronwoodNoteCommitment_V3KnownVector, OrchardReceiverToUnifiedAddress_KnownVector),
17+
so the device's own cmx recomputation accepts them. Same note under both pools,
18+
with a different commitment each -- which is what lets us prove the device
19+
actually honours shielded_pool instead of ignoring it.
20+
"""
21+
22+
import hashlib
23+
import struct
24+
import unittest
25+
26+
import common
27+
28+
from keepkeylib import messages_pb2 as proto
29+
from keepkeylib import types_pb2 as proto_types
30+
from keepkeylib import messages_zcash_pb2 as zcash_proto
31+
32+
33+
H = 0x80000000
34+
ADDRESS_N = [H + 32, H + 133, H]
35+
36+
# --- known-answer note, from unittests/firmware/zcash.cpp -------------------
37+
RECIPIENT = bytes.fromhex(
38+
'3c150e6098b861716cc7f62835f69feb302193c92660444f26624fd13e00ea7a'
39+
'c774cd55074d6367efef37') # 43 bytes
40+
RHO = bytes.fromhex(
41+
'112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00')
42+
RSEED = bytes.fromhex(
43+
'cafebabedeadbeef0102030405060708090a0b0c0d0e0f101112131415161718')
44+
VALUE = 12345678
45+
46+
CMX_ORCHARD = bytes.fromhex(
47+
'02defb39c8f2e1ecc945189373cf2a8e21d4e154398efa1621d5fb989e1deb36')
48+
CMX_IRONWOOD = bytes.fromhex(
49+
'896ee345d8b0409872172537666a482409661a22ad77c09896a3e71765f18633')
50+
51+
# OrchardReceiverToUnifiedAddress_KnownVector. 106 characters -- three full
52+
# body rows on their own, which is the entire reason the confirm needs two
53+
# screens instead of one.
54+
EXPECTED_UA = ('u1ut4h93zg5670tyqss7tneru3t7h6dk62r9hhyxyrpv3nwwe9dnyj5l0ruwygf'
55+
'74gp5f3zklj5xly4h8h54un3asugt9mn6gwfqsq3wq7')
56+
57+
ORCHARD_TX = dict(tx_version=5, version_group_id=0x26A7270A, branch_id=0x5437F330)
58+
IRONWOOD_TX = dict(tx_version=6, version_group_id=0xD884B698, branch_id=0x37A5165B)
59+
60+
ANCHOR = b'\x13' * 32
61+
FLAGS = 3
62+
63+
64+
def _b2b(person, data):
65+
return hashlib.blake2b(data, digest_size=32, person=person).digest()
66+
67+
68+
def header_digest(tx_version, version_group_id, branch_id, lock_time, expiry):
69+
"""BLAKE2b-256('ZTxIdHeadersHash', 20-byte LE header). zcash.c:840-857."""
70+
header = struct.pack('<IIIII', tx_version | 0x80000000, version_group_id,
71+
branch_id, lock_time, expiry)
72+
return _b2b(b'ZTxIdHeadersHash', header)
73+
74+
75+
def bundle_digest(actions, ironwood, tx_version,
76+
flags=FLAGS, value_balance=0, anchor=ANCHOR):
77+
"""The shielded bundle digest the device recomputes. fsm_msg_zcash.h:1116-1189.
78+
79+
The anchor is folded in only for pre-v6 transactions, and that is keyed on
80+
tx_version rather than on the pool.
81+
"""
82+
if ironwood:
83+
pc, pm, pn, pb = (b'ZTxIdIrnActCH_v6', b'ZTxIdIrnActMH_v6',
84+
b'ZTxIdIrnActNH_v6', b'ZTxIdIronwd_H_v6')
85+
else:
86+
pc, pm, pn, pb = (b'ZTxIdOrcActCHash', b'ZTxIdOrcActMHash',
87+
b'ZTxIdOrcActNHash', b'ZTxIdOrchardHash')
88+
89+
compact = _b2b(pc, b''.join(
90+
a['nullifier'] + a['cmx'] + a['epk'] + a['enc_compact'] for a in actions))
91+
memos = _b2b(pm, b''.join(a['enc_memo'] for a in actions))
92+
noncompact = _b2b(pn, b''.join(
93+
a['cv_net'] + a['rk'] + a['enc_noncompact'] + a['out_ciphertext']
94+
for a in actions))
95+
96+
body = compact + memos + noncompact + bytes([flags]) + struct.pack('<q', value_balance)
97+
if tx_version != 6:
98+
body += anchor
99+
return _b2b(pb, body)
100+
101+
102+
def note_action(cmx, recipient=RECIPIENT, value=VALUE, rseed=RSEED):
103+
"""One action carrying real output metadata.
104+
105+
Every field is size-checked by the firmware (fsm_msg_zcash.h:1068-1088) and
106+
is_spend must be present even when false. is_spend=False keeps this focused
107+
on the confirm screens: no RedPallas signature is emitted, so the test does
108+
not need an rk consistent with the device's spend authorizing key.
109+
"""
110+
return {
111+
'alpha': b'\x01' * 32,
112+
'nullifier': RHO, # the firmware feeds this in as rho
113+
'cmx': cmx,
114+
'epk': b'\x02' * 32,
115+
'enc_compact': b'\x03' * 52,
116+
'enc_memo': b'\x04' * 512,
117+
'enc_noncompact': b'\x05' * 16,
118+
'cv_net': b'\x06' * 32,
119+
'rk': b'\x07' * 32,
120+
'out_ciphertext': b'\x08' * 80,
121+
'is_spend': False,
122+
'value': value,
123+
'recipient': recipient,
124+
'rseed': rseed,
125+
}
126+
127+
128+
def sign_kwargs(actions, ironwood=False, **overrides):
129+
"""A shielded-only request the firmware will actually accept.
130+
131+
Two gates the offline fixtures do not satisfy: the header digest is
132+
recomputed and compared (fsm_msg_zcash.h:677-685), and for a shielded-only
133+
transaction the verified fee reduces to orchard_value_balance, which must
134+
equal the declared fee (fsm_msg_zcash.h:281-326). Both are zero here.
135+
"""
136+
tx = dict(IRONWOOD_TX if ironwood else ORCHARD_TX)
137+
tx.update({k: overrides.pop(k) for k in list(overrides)
138+
if k in ('tx_version', 'version_group_id', 'branch_id')})
139+
lock_time, expiry = 0, 0
140+
141+
digest = bundle_digest(actions, ironwood, tx['tx_version'])
142+
kwargs = {
143+
'address_n': ADDRESS_N,
144+
'actions': actions,
145+
'account': 0,
146+
'total_amount': VALUE,
147+
'fee': 0,
148+
'lock_time': lock_time,
149+
'expiry_height': expiry,
150+
'orchard_flags': FLAGS,
151+
'orchard_value_balance': 0,
152+
'orchard_anchor': ANCHOR,
153+
'header_digest': header_digest(tx['tx_version'], tx['version_group_id'],
154+
tx['branch_id'], lock_time, expiry),
155+
'orchard_digest': digest,
156+
}
157+
kwargs.update(tx)
158+
if ironwood:
159+
kwargs['shielded_pool'] = zcash_proto.ZCASH_SHIELDED_POOL_IRONWOOD
160+
kwargs['ironwood_digest'] = digest
161+
# orchard_digest is still required to be present and 32 bytes, but for
162+
# Ironwood it is the ironwood_digest that is verified against the
163+
# actions; this one only feeds the locally derived sighash.
164+
kwargs['orchard_digest'] = b'\x00' * 32
165+
kwargs.update(overrides)
166+
return kwargs
167+
168+
169+
def _lit_pixels(layout):
170+
"""Count set pixels in a raw 2048-byte OLED framebuffer.
171+
172+
read_layout returns the framebuffer, not text -- there is no glyph decoder
173+
anywhere in this repo -- so screen assertions here are structural: a screen
174+
that renders nothing, or two screens that render identically, are both
175+
detectable without OCR.
176+
"""
177+
total = 0
178+
for b in layout:
179+
if isinstance(b, str):
180+
b = ord(b)
181+
total += bin(b).count('1')
182+
return total
183+
184+
185+
class TestZcashShieldedSigningDevice(common.KeepKeyTest):
186+
187+
def setUp(self):
188+
super(TestZcashShieldedSigningDevice, self).setUp()
189+
self.requires_firmware("7.15.0")
190+
self.requires_fullFeature()
191+
self.requires_message("ZcashSignPCZT")
192+
self.setup_mnemonic_allallall()
193+
194+
def _capture_button_screens(self):
195+
"""Record the framebuffer at each ButtonRequest, before it is acked."""
196+
screens = []
197+
original = self.client.callback_ButtonRequest
198+
199+
def capture(msg):
200+
screens.append((msg.code, self.client.debug.read_layout()))
201+
return original(msg)
202+
203+
self.client.callback_ButtonRequest = capture
204+
return screens
205+
206+
def test_shielded_output_review_is_two_screens(self):
207+
"""The amount and the full address must each get a screen of their own.
208+
209+
A unified address is 106 characters, which is three full body rows. The
210+
standard notification body is three rows and draw_string simply stops
211+
emitting once a character will not fit -- no scroll, no pagination, no
212+
indication. So a single confirm holding the question, the address and
213+
the amount rendered the question plus the first 76 address characters
214+
and silently dropped the rest along with the entire amount line.
215+
216+
Two ConfirmOutput requests per action is therefore the assertion that
217+
matters: one screen cannot hold both, and collapsing them back into one
218+
reintroduces exactly the defect.
219+
"""
220+
actions = [note_action(CMX_ORCHARD)]
221+
screens = self._capture_button_screens()
222+
223+
result = self.client.zcash_sign_pczt(**sign_kwargs(actions))
224+
225+
self.assertIsInstance(result, zcash_proto.ZcashSignedPCZT)
226+
self.assertEqual(len(result.signatures), 0) # no is_spend action
227+
228+
outputs = [(code, layout) for code, layout in screens
229+
if code == proto_types.ButtonRequest_ConfirmOutput]
230+
# KeepKeyTest.assertEqual takes no message argument (common.py:114).
231+
self.assertTrue(
232+
len(outputs) == 2,
233+
"expected an amount screen and an address screen per shielded "
234+
"output; got %d ConfirmOutput screen(s). One screen cannot fit a "
235+
"106-character unified address plus an amount line."
236+
% len(outputs))
237+
238+
amount_screen, address_screen = outputs[0][1], outputs[1][1]
239+
self.assertNotEqual(bytes(amount_screen), bytes(address_screen),
240+
"the two review screens rendered identically")
241+
for name, layout in (('amount', amount_screen), ('address', address_screen)):
242+
self.assertEqual(len(layout), 2048)
243+
self.assertGreater(_lit_pixels(layout), 200,
244+
"%s screen rendered (near-)blank" % name)
245+
246+
# The address occupies three dense rows; the amount line is one short
247+
# row. If the address screen were truncated to the amount screen's
248+
# content this ordering would not hold.
249+
self.assertGreater(_lit_pixels(address_screen), _lit_pixels(amount_screen))
250+
251+
def test_note_commitment_binds_the_recipient(self):
252+
"""Flipping one recipient bit must break the commitment check.
253+
254+
This is what stops a host from showing one recipient and committing to
255+
another: the device recomputes cmx from recipient, value, rho and rseed
256+
and compares it to the supplied commitment.
257+
"""
258+
tampered = bytearray(RECIPIENT)
259+
tampered[0] ^= 0x01
260+
actions = [note_action(CMX_ORCHARD, recipient=bytes(tampered))]
261+
262+
with self.assertRaises(Exception) as caught:
263+
self.client.zcash_sign_pczt(**sign_kwargs(actions))
264+
self.assertIn('commitment mismatch', str(caught.exception))
265+
266+
def test_pool_selection_is_honoured(self):
267+
"""The same note commits differently in each pool.
268+
269+
Orchard and Ironwood derive a different cmx from identical inputs, so
270+
offering the Orchard commitment while declaring the Ironwood pool must
271+
be rejected. If the device ignored shielded_pool this would pass.
272+
"""
273+
actions = [note_action(CMX_ORCHARD)]
274+
275+
with self.assertRaises(Exception) as caught:
276+
self.client.zcash_sign_pczt(**sign_kwargs(actions, ironwood=True))
277+
self.assertIn('commitment mismatch', str(caught.exception))
278+
279+
def test_ironwood_note_is_accepted(self):
280+
"""The Ironwood commitment for that same note is accepted.
281+
282+
The positive half of the pool test -- together they prove the branch is
283+
selected by shielded_pool rather than one path serving both.
284+
"""
285+
actions = [note_action(CMX_IRONWOOD)]
286+
screens = self._capture_button_screens()
287+
288+
result = self.client.zcash_sign_pczt(**sign_kwargs(actions, ironwood=True))
289+
290+
self.assertIsInstance(result, zcash_proto.ZcashSignedPCZT)
291+
outputs = [c for c, _ in screens
292+
if c == proto_types.ButtonRequest_ConfirmOutput]
293+
self.assertTrue(len(outputs) == 2,
294+
"expected 2 ConfirmOutput screens, got %d" % len(outputs))
295+
296+
297+
if __name__ == '__main__':
298+
unittest.main()

0 commit comments

Comments
 (0)