Skip to content

Commit 5ef90f5

Browse files
committed
test: close 7.14.2 presign evidence gaps
1 parent e6f118c commit 5ef90f5

10 files changed

Lines changed: 730 additions & 120 deletions

keepkeylib/client.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -460,20 +460,19 @@ def _check_request(self, msg):
460460
raise CallException(types.Failure_Other,
461461
"Expected %s, got %s" % (pprint(expected), pprint(msg)))
462462

463-
def _capture_oled(self):
463+
def _capture_oled(self, layout=None):
464464
"""Capture current OLED layout to screenshot directory."""
465465
if not SCREENSHOT:
466466
return
467467
if not self.debug:
468-
import sys
469-
print("[SCREENSHOT] SKIP: no debug link", file=sys.stderr)
470-
return
468+
raise RuntimeError("screenshot capture requested without debug link")
471469
try:
472-
layout = self.debug.read_layout()
470+
if layout is None:
471+
layout = self.debug.read_layout()
473472
if not layout or len(layout) < 1024:
474-
import sys
475-
print("[SCREENSHOT] SKIP: layout too small (%d bytes)" % (len(layout) if layout else 0), file=sys.stderr)
476-
return
473+
raise RuntimeError(
474+
"layout too small (%d bytes)" %
475+
(len(layout) if layout else 0))
477476
layout_bytes = len(layout)
478477
height = 64 if layout_bytes >= 2048 else 32
479478
rows = []
@@ -500,6 +499,7 @@ def _capture_oled(self):
500499
import sys, traceback
501500
print("[SCREENSHOT] ERROR: %s" % e, file=sys.stderr)
502501
traceback.print_exc(file=sys.stderr)
502+
raise
503503

504504
def callback_ButtonRequest(self, msg):
505505
if self.verbose:

scripts/generate-test-report.py

Lines changed: 208 additions & 46 deletions
Large diffs are not rendered by default.

tests/conftest.py

Lines changed: 70 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@
1212
import pytest
1313
import os
1414
import glob
15-
import ipaddress
15+
import hashlib
16+
import json
1617
import socket
1718
import sys
18-
from urllib.parse import urlparse
1919

2020
import requests
2121

@@ -56,22 +56,6 @@ def _patched_setUp(self):
5656
common.KeepKeyTest.setUp = _patched_setUp
5757

5858

59-
def _is_loopback_address(address):
60-
"""Allow emulator traffic while rejecting every external destination."""
61-
if not isinstance(address, tuple):
62-
# Unix-domain sockets are local by construction.
63-
return True
64-
host = address[0]
65-
if isinstance(host, bytes):
66-
host = host.decode('ascii')
67-
if host == 'localhost':
68-
return True
69-
try:
70-
return ipaddress.ip_address(host).is_loopback
71-
except (TypeError, ValueError):
72-
return False
73-
74-
7559
def _configured_emulator_endpoints(getaddrinfo):
7660
"""Return the exact UDP names and addresses configured by the test harness."""
7761
names = set()
@@ -102,7 +86,6 @@ def deny_external_network(monkeypatch, request):
10286
original_connect = socket.socket.connect
10387
original_connect_ex = socket.socket.connect_ex
10488
original_sendto = socket.socket.sendto
105-
original_request = requests.sessions.Session.request
10689
emulator_names, emulator_addresses = _configured_emulator_endpoints(
10790
original_getaddrinfo)
10891

@@ -113,42 +96,43 @@ def denied(destination):
11396

11497
def guarded_getaddrinfo(host, *args, **kwargs):
11598
port = args[0] if args else kwargs.get('port')
116-
if (not _is_loopback_address((host, 0)) and
117-
(host, port) not in emulator_names):
118-
denied(host)
99+
if (host, port) not in emulator_names:
100+
denied((host, port))
119101
return original_getaddrinfo(host, *args, **kwargs)
120102

121-
def allowed_socket_address(address):
122-
if _is_loopback_address(address):
123-
return True
103+
def allowed_socket_address(sock, address):
124104
if not isinstance(address, tuple) or len(address) < 2:
125105
return False
126-
return ((address[0], address[1]) in emulator_names or
127-
(address[0], address[1]) in emulator_addresses)
106+
# No Unix sockets, TCP loopback, or arbitrary localhost ports. The
107+
# authoritative suite may talk only to the two exact UDP transports
108+
# configured for this emulator run.
109+
if sock.family not in (socket.AF_INET, socket.AF_INET6):
110+
return False
111+
if (sock.type & 0x0f) != socket.SOCK_DGRAM:
112+
return False
113+
endpoint = (address[0], address[1])
114+
return endpoint in emulator_names or endpoint in emulator_addresses
128115

129116
def guarded_connect(sock, address):
130-
if not allowed_socket_address(address):
117+
if not allowed_socket_address(sock, address):
131118
denied(address)
132119
return original_connect(sock, address)
133120

134121
def guarded_connect_ex(sock, address):
135-
if not allowed_socket_address(address):
122+
if not allowed_socket_address(sock, address):
136123
denied(address)
137124
return original_connect_ex(sock, address)
138125

139126
def guarded_sendto(sock, data, *args):
140127
address = args[-1]
141-
if not allowed_socket_address(address):
128+
if not allowed_socket_address(sock, address):
142129
denied(address)
143130
return original_sendto(sock, data, *args)
144131

145132
def guarded_request(session, method, url, *args, **kwargs):
146-
hostname = urlparse(url).hostname
147-
if not _is_loopback_address((hostname, 0)):
148-
raise AssertionError(
149-
'authoritative test attempted HTTP access: test=%s method=%s '
150-
'url=%s' % (nodeid, method, url))
151-
return original_request(session, method, url, *args, **kwargs)
133+
raise AssertionError(
134+
'authoritative test attempted HTTP access: test=%s method=%s '
135+
'url=%s' % (nodeid, method, url))
152136

153137
monkeypatch.setattr(socket, 'getaddrinfo', guarded_getaddrinfo)
154138
monkeypatch.setattr(socket.socket, 'connect', guarded_connect)
@@ -167,5 +151,53 @@ def pytest_sessionfinish(session, exitstatus):
167151
if count == 0:
168152
print("FATAL: KEEPKEY_SCREENSHOT=1 but 0 PNGs captured. Screenshot pipeline is broken.", file=sys.stderr)
169153
session.exitstatus = 1
170-
else:
171-
print("[SCREENSHOT] Session complete: %d PNGs captured" % count, file=sys.stderr)
154+
return
155+
156+
try:
157+
sequence_count = 0
158+
for directory, _subdirs, files in os.walk(screenshot_dir):
159+
if not any(name.endswith('.png') for name in files):
160+
if 'frames.json' in files:
161+
raise AssertionError(
162+
'frame manifest has no PNG sequence: %s' % directory)
163+
continue
164+
other = sorted(name for name in files
165+
if name != 'frames.json')
166+
expected = ['btn%05d.png' % i for i in range(len(other))]
167+
if other != expected:
168+
raise AssertionError(
169+
'non-contiguous or unexpected OLED frames in %s: '
170+
'found=%r expected=%r' % (directory, other, expected))
171+
frames = []
172+
for name in expected:
173+
path = os.path.join(directory, name)
174+
with open(path, 'rb') as handle:
175+
digest = hashlib.sha256(handle.read()).hexdigest()
176+
frames.append({'file': name, 'sha256': digest})
177+
manifest = {
178+
'schema': 1,
179+
'group': os.path.basename(directory),
180+
'frame_count': len(frames),
181+
'frames': frames,
182+
}
183+
manifest_path = os.path.join(directory, 'frames.json')
184+
if os.path.isfile(manifest_path):
185+
with open(manifest_path, 'r') as handle:
186+
existing = json.load(handle)
187+
if existing != manifest:
188+
raise AssertionError(
189+
'frame manifest disagrees with capture: %s' %
190+
manifest_path)
191+
else:
192+
with open(manifest_path, 'w') as handle:
193+
json.dump(manifest, handle, sort_keys=True, indent=2)
194+
handle.write('\n')
195+
sequence_count += 1
196+
except (IOError, OSError, ValueError, AssertionError) as exc:
197+
print('FATAL: OLED sequence manifest failure: %s' % exc,
198+
file=sys.stderr)
199+
session.exitstatus = 1
200+
return
201+
202+
print("[SCREENSHOT] Session complete: %d PNGs in %d manifested sequences" %
203+
(count, sequence_count), file=sys.stderr)
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# This file is part of the KeepKey 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 version 3
7+
# as published by the Free Software Foundation.
8+
9+
"""Every byte passed through firmware ``confirm_data`` must reach the OLED."""
10+
11+
from __future__ import print_function
12+
13+
import binascii
14+
15+
import common
16+
17+
from keepkeylib import messages_eos_pb2 as eos_messages
18+
from keepkeylib import types_pb2 as types
19+
from keepkeylib.tools import parse_path
20+
from test_msg_display_disclosure import ScreenRecorder
21+
22+
23+
PREV_HASH = binascii.unhexlify(
24+
"d5f65ee80147b4bcc70b75e4bbf2d738"
25+
"2021b871bd8867ef8fa525ef50864882"
26+
)
27+
EOS_PATH = parse_path("m/44'/194'/0'/0/0")
28+
29+
30+
class TestConfirmDataDisclosure(common.KeepKeyTest):
31+
def setUp(self):
32+
super(TestConfirmDataDisclosure, self).setUp()
33+
self.requires_firmware("7.14.2")
34+
self.requires_fullFeature()
35+
self.setup_mnemonic_nopin_nopassphrase()
36+
37+
def _capture_op_return(self, payload, group):
38+
tx_input = types.TxInputType(
39+
address_n=[0], prev_hash=PREV_HASH, prev_index=0
40+
)
41+
payment = types.TxOutputType(
42+
address="1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1",
43+
amount=380000,
44+
script_type=types.PAYTOADDRESS,
45+
)
46+
data_output = types.TxOutputType(
47+
op_return_data=payload,
48+
amount=0,
49+
script_type=types.PAYTOOPRETURN,
50+
)
51+
recorder = ScreenRecorder(
52+
self.client, answer=True, screenshot_group=group
53+
)
54+
with recorder:
55+
_, serialized = self.client.sign_tx(
56+
"Bitcoin", [tx_input], [payment, data_output]
57+
)
58+
return recorder.fingerprint, serialized
59+
60+
def _capture_eos_memo(self, memo, group):
61+
transaction = {
62+
"chain_id": (
63+
"cf057bbfb72640471fd910bcb67639c22"
64+
"df9f92470936cddc1ade0e2f2e7dc4f"
65+
),
66+
"transaction": {
67+
"expiration": "2018-07-14T07:43:28",
68+
"ref_block_num": 6439,
69+
"ref_block_prefix": 2995713264,
70+
"max_net_usage_words": 0,
71+
"max_cpu_usage_ms": 0,
72+
"delay_sec": 0,
73+
"context_free_actions": [],
74+
"actions": [{
75+
"account": "eosio.token",
76+
"name": "transfer",
77+
"authorization": [{
78+
"actor": "miniminimini",
79+
"permission": "active",
80+
}],
81+
"data": {
82+
"from": "miniminimini",
83+
"to": "maximaximaxi",
84+
"quantity": "1.0000 EOS",
85+
"memo": memo,
86+
},
87+
}],
88+
"transaction_extensions": [],
89+
},
90+
}
91+
recorder = ScreenRecorder(
92+
self.client, answer=True, screenshot_group=group
93+
)
94+
with recorder:
95+
signed = self.client.eos_sign_tx(EOS_PATH, transaction)
96+
signature = (
97+
bytes(signed.signature_r),
98+
bytes(signed.signature_s),
99+
signed.signature_v,
100+
)
101+
return recorder.fingerprint, signature
102+
103+
def test_binary_op_return_tail_changes_oled_review(self):
104+
"""A byte after the old 50-byte binary preview must be displayed."""
105+
common_prefix = b"\x80" + b"A" * 79
106+
payload_a = common_prefix + b"X" * 16
107+
payload_b = common_prefix + b"Y" + b"X" * 15
108+
109+
screens_a, signed_a = self._capture_op_return(payload_a, "opreturn-a")
110+
# Run each member of the A/B pair from the same clean device state.
111+
# Multi-page review ends with a held confirmation, so merely opening a
112+
# new protocol session can retain a trailing emulator button event.
113+
self.client.wipe_device()
114+
self.setup_mnemonic_nopin_nopassphrase()
115+
screens_b, signed_b = self._capture_op_return(payload_b, "opreturn-b")
116+
117+
self.assertNotEqual(signed_a, signed_b)
118+
self.assertGreater(len(screens_a), 1)
119+
self.assertGreater(len(screens_b), 1)
120+
self.assertNotEqual(
121+
screens_a, screens_b,
122+
"different signed OP_RETURN tails produced identical OLED review",
123+
)
124+
125+
def test_non_ascii_eos_memo_tail_changes_oled_review(self):
126+
"""A UTF-8 memo byte after the old 50-byte preview must be displayed."""
127+
common_prefix = "\u00e9" + "A" * 79
128+
memo_a = common_prefix + "X" * 16
129+
memo_b = common_prefix + "Y" + "X" * 15
130+
131+
screens_a, signed_a = self._capture_eos_memo(memo_a, "eos-a")
132+
self.client.wipe_device()
133+
self.setup_mnemonic_nopin_nopassphrase()
134+
screens_b, signed_b = self._capture_eos_memo(memo_b, "eos-b")
135+
136+
self.assertNotEqual(signed_a, signed_b)
137+
self.assertGreater(len(screens_a), 1)
138+
self.assertGreater(len(screens_b), 1)
139+
self.assertNotEqual(
140+
screens_a, screens_b,
141+
"different signed EOS memo tails produced identical OLED review",
142+
)
143+
144+
def test_unknown_omni_property_changes_oled_review(self):
145+
"""Unsupported Omni assets must disclose bytes, not a shared ticker."""
146+
header = b"omni\x00\x00\x00\x00" + (999999).to_bytes(4, "big")
147+
payload_a = header + (1).to_bytes(8, "big")
148+
payload_b = header + (2).to_bytes(8, "big")
149+
150+
screens_a, signed_a = self._capture_op_return(payload_a, "omni-prop-a")
151+
self.client.wipe_device()
152+
self.setup_mnemonic_nopin_nopassphrase()
153+
screens_b, signed_b = self._capture_op_return(payload_b, "omni-prop-b")
154+
155+
self.assertNotEqual(signed_a, signed_b)
156+
self.assertNotEqual(
157+
screens_a, screens_b,
158+
"different unsupported Omni assets produced identical OLED review",
159+
)
160+
161+
def test_unknown_omni_type_changes_oled_review(self):
162+
"""Unsupported Omni operations must disclose the complete payload."""
163+
header = b"omni\x00\x00\x00\x01" + (31).to_bytes(4, "big")
164+
payload_a = header + (1).to_bytes(8, "big")
165+
payload_b = header + (2).to_bytes(8, "big")
166+
167+
screens_a, signed_a = self._capture_op_return(payload_a, "omni-type-a")
168+
self.client.wipe_device()
169+
self.setup_mnemonic_nopin_nopassphrase()
170+
screens_b, signed_b = self._capture_op_return(payload_b, "omni-type-b")
171+
172+
self.assertNotEqual(signed_a, signed_b)
173+
self.assertNotEqual(
174+
screens_a, screens_b,
175+
"different unsupported Omni operations produced identical review",
176+
)
177+
178+
179+
if __name__ == "__main__":
180+
import unittest
181+
unittest.main()

0 commit comments

Comments
 (0)