Skip to content

Commit cef50e5

Browse files
committed
test: cover signing session security boundaries
1 parent e353ce5 commit cef50e5

2 files changed

Lines changed: 159 additions & 0 deletions

File tree

keepkeylib/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1549,6 +1549,9 @@ def sign_tx(self, coin_name, inputs, outputs, version=None, lock_time=None, debu
15491549
else:
15501550
msg.outputs_cnt = len(current_tx.outputs)
15511551
msg.extra_data_len = len(current_tx.extra_data) if current_tx.extra_data else 0
1552+
if debug_processor is not None:
1553+
from copy import deepcopy
1554+
msg = debug_processor(res, deepcopy(msg))
15521555
res = self.call(proto.TxAck(tx=msg))
15531556
continue
15541557

@@ -1591,6 +1594,9 @@ def sign_tx(self, coin_name, inputs, outputs, version=None, lock_time=None, debu
15911594
o, l = res.details.extra_data_offset, res.details.extra_data_len
15921595
msg = types.TransactionType()
15931596
msg.extra_data = current_tx.extra_data[o:o + l]
1597+
if debug_processor is not None:
1598+
from copy import deepcopy
1599+
msg = debug_processor(res, deepcopy(msg))
15941600
res = self.call(proto.TxAck(tx=msg))
15951601
continue
15961602

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# This file is part of the TREZOR project.
2+
#
3+
# Copyright (C) 2026 KeepKey
4+
#
5+
# This library is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU Lesser General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
10+
from __future__ import print_function
11+
12+
import binascii
13+
import unittest
14+
15+
import common
16+
import keepkeylib.ckd_public as ckd_public
17+
import keepkeylib.messages_pb2 as proto
18+
import keepkeylib.types_pb2 as proto_types
19+
from keepkeylib.client import CallException
20+
21+
22+
class TestSigningBoundaries(common.KeepKeyTest):
23+
PREV_HASH = binascii.unhexlify(
24+
'd5f65ee80147b4bcc70b75e4bbf2d7382021b871bd8867ef8fa525ef50864882')
25+
XPUB = (
26+
'xpub661MyMwAqRbcF1zGijBb2K6x9YiJPh58xpcCeLvTxMX6spkY3PcpJ4ABcCyW'
27+
'fskq5DDxM3e6Ez5ePCqG5bnPUXR4wL8TZWyoDaUdiWW7bKy')
28+
29+
def _input(self):
30+
return proto_types.TxInputType(
31+
address_n=[0],
32+
prev_hash=self.PREV_HASH,
33+
prev_index=0,
34+
)
35+
36+
def _ordinary_output(self):
37+
return proto_types.TxOutputType(
38+
address='1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1',
39+
amount=380000,
40+
script_type=proto_types.PAYTOADDRESS,
41+
)
42+
43+
@staticmethod
44+
def _request_key(request):
45+
return (
46+
request.request_type,
47+
request.details.request_index,
48+
bytes(request.details.tx_hash),
49+
request.details.extra_data_offset,
50+
request.details.extra_data_len,
51+
)
52+
53+
def _assert_late_txack_rejected(self):
54+
response = self.client.call_raw(
55+
proto.TxAck(tx=proto_types.TransactionType()))
56+
self.assertIsInstance(response, proto.Failure)
57+
self.assertEqual(response.code, proto_types.Failure_UnexpectedMessage)
58+
59+
def test_clear_session_aborts_every_txrequest_stage(self):
60+
self.setup_mnemonic_nopin_nopassphrase()
61+
62+
stage_trace = []
63+
64+
def record_stage(request, message):
65+
stage_trace.append(self._request_key(request))
66+
return message
67+
68+
signatures, serialized_tx = self.client.sign_tx(
69+
'Bitcoin', [self._input()], [self._ordinary_output()],
70+
debug_processor=record_stage)
71+
self.assertTrue(signatures[0])
72+
self.assertTrue(serialized_tx)
73+
self.assertTrue(stage_trace)
74+
self.assertIn(proto_types.TXMETA,
75+
[stage[0] for stage in stage_trace])
76+
self.assertIn(proto_types.TXINPUT,
77+
[stage[0] for stage in stage_trace])
78+
self.assertIn(proto_types.TXOUTPUT,
79+
[stage[0] for stage in stage_trace])
80+
81+
for target_index, expected_stage in enumerate(stage_trace):
82+
seen = []
83+
84+
def clear_at_target(request, message):
85+
seen.append(self._request_key(request))
86+
if len(seen) - 1 == target_index:
87+
response = self.client.call_raw(proto.ClearSession())
88+
self.assertIsInstance(response, proto.Success)
89+
return message
90+
91+
with self.assertRaises(CallException):
92+
self.client.sign_tx(
93+
'Bitcoin', [self._input()], [self._ordinary_output()],
94+
debug_processor=clear_at_target)
95+
96+
self.assertEqual(seen[-1], expected_stage)
97+
self.assertEqual(len(seen), target_index + 1)
98+
self._assert_late_txack_rejected()
99+
100+
def _invalid_multisig(self, m, n):
101+
node = ckd_public.deserialize(self.XPUB)
102+
return proto_types.MultisigRedeemScriptType(
103+
pubkeys=[
104+
proto_types.HDNodePathType(node=node, address_n=[i + 1])
105+
for i in range(n)
106+
],
107+
signatures=[b''] * n,
108+
m=m,
109+
)
110+
111+
def test_invalid_multisig_outputs_never_serialize_or_sign(self):
112+
self.setup_mnemonic_nopin_nopassphrase()
113+
114+
invalid_quorums = (
115+
(0, 1), # m == 0
116+
(1, 0), # n == 0
117+
(2, 1), # m > n
118+
(16, 15), # m > 15
119+
(1, 16), # n > 15
120+
)
121+
122+
for internal in (False, True):
123+
for m, n in invalid_quorums:
124+
output = proto_types.TxOutputType(
125+
address_n=[1] if internal else [],
126+
amount=380000,
127+
script_type=proto_types.PAYTOMULTISIG,
128+
multisig=self._invalid_multisig(m, n),
129+
)
130+
signed_material = []
131+
132+
def observe_response(request, message):
133+
if request.HasField('serialized'):
134+
serialized = request.serialized
135+
if (serialized.HasField('serialized_tx') or
136+
serialized.HasField('signature')):
137+
signed_material.append(serialized)
138+
return message
139+
140+
with self.assertRaises(CallException):
141+
self.client.sign_tx(
142+
'Bitcoin', [self._input()], [output],
143+
debug_processor=observe_response)
144+
145+
self.assertEqual(signed_material, [])
146+
response = self.client.call_raw(
147+
proto.TxAck(tx=proto_types.TransactionType()))
148+
self.assertIsInstance(response, proto.Failure)
149+
self.client.call_raw(proto.ClearSession())
150+
151+
152+
if __name__ == '__main__':
153+
unittest.main()

0 commit comments

Comments
 (0)