Skip to content

Commit 2682c66

Browse files
authored
Merge pull request #219 from keepkey/release/7.14.2-rc31-presign-tests
test: update 7.14.2 presign authentication ordering
2 parents 598097e + 35a2da6 commit 2682c66

37 files changed

Lines changed: 3057 additions & 292 deletions

.circleci/config.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ jobs:
3737
rm -rf deps/python-keepkey
3838
mv ../.pykk deps/python-keepkey
3939
40+
# This is python-keepkey's standalone compatibility lane. Release
41+
# OLED/PDF evidence is generated only by the exact-head firmware
42+
# workflow, where the firmware and Python SHAs are pinned together.
43+
# Do not inherit the firmware checkout's report/screenshot phase:
44+
# it can describe a different (published/master) emulator and is
45+
# therefore not release evidence for this Python PR.
46+
cp deps/python-keepkey/.circleci/standalone-emulator-tests.sh \
47+
scripts/emulator/python-keepkey-tests.sh
48+
4049
# ────────────────────────────────────────────────────────────────
4150
# 3) Build the Docker-based emulator tests
4251
# ────────────────────────────────────────────────────────────────
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/bin/sh
2+
set -e
3+
4+
# python-keepkey's own CI is a compatibility test against the emulator supplied
5+
# by the firmware checkout. It deliberately does not produce OLED/PDF release
6+
# evidence. Authoritative display evidence is generated by keepkey-firmware CI,
7+
# which pins both repositories to exact candidate SHAs.
8+
9+
mkdir -p /kkemu/test-reports/python-keepkey
10+
11+
echo "=== Waiting for emulator ==="
12+
for attempt in $(seq 1 20); do
13+
if echo -n "PINGPING" | nc -u -w1 kkemu 11044 2>/dev/null | grep -q PONG; then
14+
echo "Emulator ready (attempt $attempt)"
15+
break
16+
fi
17+
if [ "$attempt" -eq 20 ]; then
18+
echo "Emulator did not become ready"
19+
echo "1" > /kkemu/test-reports/python-keepkey/status
20+
exit 1
21+
fi
22+
sleep 2
23+
done
24+
25+
cd /kkemu/deps/python-keepkey/tests
26+
set +e
27+
KK_TRANSPORT_MAIN=kkemu:11044 \
28+
KK_TRANSPORT_DEBUG=kkemu:11045 \
29+
pytest -v --tb=short \
30+
--junitxml=/kkemu/test-reports/python-keepkey/junit.xml
31+
pytest_status=$?
32+
set -e
33+
34+
echo "$pytest_status" > /kkemu/test-reports/python-keepkey/status
35+
exit "$pytest_status"

.github/workflows/ci.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,20 @@ jobs:
3434
- name: Syntax check
3535
run: python -m py_compile keepkeylib/*.py
3636

37+
- name: Verify offline transaction fixture manifest
38+
run: python tests/tx_fixture_manifest.py --check
39+
3740
- name: Lint summary
3841
run: |
3942
echo "## 🔑 KeepKey python-keepkey — Lint" >> "$GITHUB_STEP_SUMMARY"
4043
echo "" >> "$GITHUB_STEP_SUMMARY"
4144
echo "| Check | Status |" >> "$GITHUB_STEP_SUMMARY"
4245
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
4346
echo "| Syntax | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY"
47+
echo "| Offline fixture integrity | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY"
48+
FIXTURE_SHA=$(sha256sum tests/txcache/manifest.json | cut -d' ' -f1)
49+
echo "" >> "$GITHUB_STEP_SUMMARY"
50+
echo "Fixture manifest SHA-256: `$FIXTURE_SHA`" >> "$GITHUB_STEP_SUMMARY"
4451
4552
# ═══════════════════════════════════════════════════════════
4653
# STAGE 2: TEST — pull published emulator, run pytest
@@ -95,15 +102,33 @@ jobs:
95102
PYTHONPATH: "${{ github.workspace }}/keepkeylib:${{ github.workspace }}"
96103
run: |
97104
cd tests
105+
python tx_fixture_manifest.py --check
106+
EMULATOR_IP=$(docker inspect -f \
107+
'{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
108+
"${{ job.services.kkemu.id }}")
109+
test -n "$EMULATOR_IP"
110+
sudo iptables -I OUTPUT 1 -d "$EMULATOR_IP" -j ACCEPT
111+
sudo iptables -I OUTPUT 2 ! -o lo -m conntrack --ctstate NEW -j REJECT
112+
cleanup_network_gate() {
113+
sudo iptables -D OUTPUT ! -o lo -m conntrack --ctstate NEW -j REJECT
114+
sudo iptables -D OUTPUT -d "$EMULATOR_IP" -j ACCEPT
115+
}
116+
trap cleanup_network_gate EXIT
98117
pytest -v --junitxml=junit.xml 2>&1 | tee pytest-output.txt
99118
echo "${PIPESTATUS[0]}" > status
100119
101120
- name: Test summary
102121
if: always()
103122
run: |
104123
XML="tests/junit.xml"
124+
MANIFEST="tests/txcache/manifest.json"
105125
echo "## 🔑 KeepKey python-keepkey — Integration Tests" >> "$GITHUB_STEP_SUMMARY"
106126
echo "" >> "$GITHUB_STEP_SUMMARY"
127+
if [ -f "$MANIFEST" ]; then
128+
FIXTURE_SHA=$(sha256sum "$MANIFEST" | cut -d' ' -f1)
129+
echo "Fixture manifest SHA-256: `$FIXTURE_SHA`" >> "$GITHUB_STEP_SUMMARY"
130+
echo "" >> "$GITHUB_STEP_SUMMARY"
131+
fi
107132
108133
if [ ! -f "$XML" ]; then
109134
echo "❌ **No test results found** — suite may have crashed before completion." >> "$GITHUB_STEP_SUMMARY"

keepkeylib/client.py

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -432,13 +432,13 @@ def set_mnemonic(self, mnemonic):
432432

433433
def call_raw(self, msg):
434434

435-
# Screenshot capture disabled in call_raw (captures idle screens, adds latency).
436-
# Real confirmation screenshots are captured in callback_ButtonRequest instead.
437-
# Exception: capture on Failure (rejection screens like invalid BIP-39 word).
435+
# Screenshot capture is disabled in call_raw: a wire Failure is often
436+
# emitted after the UI has already returned to the lock/home screen.
437+
# Treating that framebuffer as operation evidence created convincing
438+
# but unrelated blank/lock frames. Tests that claim a visible rejection
439+
# capture it explicitly at the point the firmware renders it.
438440

439441
resp = super(DebugLinkMixin, self).call_raw(msg)
440-
if isinstance(resp, proto.Failure):
441-
self._capture_oled()
442442
self._check_request(resp)
443443
return resp
444444

@@ -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,13 +499,73 @@ 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
503+
504+
def _capture_oled_after_animation(self, seconds, required_region=None):
505+
"""Capture the completed PIN/cipher frame, not its initial blank state."""
506+
if not SCREENSHOT:
507+
return
508+
# The emulator's PIN/recovery loop blocks while waiting for host
509+
# input. Wall-clock sleep alone therefore does not repaint: each
510+
# DebugLink request wakes the loop for one 20 ms animation tick.
511+
# Drive every required tick and retain the layout from the final poll.
512+
layout = None
513+
for _tick in range(int(seconds / 0.020) + 2):
514+
time.sleep(0.025)
515+
layout = self.debug.read_layout()
516+
if required_region:
517+
x0, x1, y0, y1 = required_region
518+
lit = 0
519+
for y in range(y0, y1):
520+
for x in range(x0, x1):
521+
byte_index = x + (y // 8) * 256
522+
value = layout[byte_index]
523+
if not isinstance(value, int):
524+
value = ord(value)
525+
lit += (value >> (y % 8)) & 1
526+
area = (x1 - x0) * (y1 - y0)
527+
if lit < 32 or area - lit < 32:
528+
raise RuntimeError(
529+
"animated OLED evidence lacks grid contrast "
530+
"(%d lit of %d pixels)" % (lit, area))
531+
self._capture_oled(layout)
532+
533+
def _read_oled_after_settle(self):
534+
"""Return a stable confirmation frame after the render-loop handoff.
535+
536+
A ButtonRequest can reach the host one or more emulator ticks before
537+
its OLED update. Sampling immediately duplicated the preceding prompt
538+
and omitted the security-relevant next prompt while preserving the
539+
expected file count. DebugLink reads advance the blocked render loop;
540+
require at least five polls and three identical final layouts.
541+
"""
542+
candidate = None
543+
stable_reads = 0
544+
for tick in range(20):
545+
time.sleep(0.025)
546+
layout = self.debug.read_layout()
547+
if layout == candidate:
548+
stable_reads += 1
549+
else:
550+
candidate = layout
551+
stable_reads = 1
552+
if tick >= 4 and stable_reads >= 3:
553+
return layout
554+
raise RuntimeError(
555+
'OLED confirmation did not settle before evidence capture')
556+
557+
def _capture_oled_after_settle(self):
558+
"""Capture the settled frame used for confirmation evidence."""
559+
if not SCREENSHOT:
560+
return
561+
self._capture_oled(self._read_oled_after_settle())
503562

504563
def callback_ButtonRequest(self, msg):
505564
if self.verbose:
506565
log("ButtonRequest code: " + get_buttonrequest_value(msg.code))
507566

508-
# Capture OLED screenshot BEFORE pressing button (confirmation screen)
509-
self._capture_oled()
567+
# Capture the completed OLED frame BEFORE pressing the button.
568+
self._capture_oled_after_settle()
510569

511570
if self.auto_button:
512571
if self.verbose:
@@ -520,6 +579,9 @@ def callback_ButtonRequest(self, msg):
520579
return proto.ButtonAck()
521580

522581
def callback_PinMatrixRequest(self, msg):
582+
# Firmware animates the randomized grid for PIN_MAX_ANIMATION_MS
583+
# (1000 ms). Sampling immediately captures only the prompt/blank mask.
584+
self._capture_oled_after_animation(1.05, (192, 256, 0, 64))
523585
if self.pin_correct:
524586
pin = self.debug.read_pin_encoded()
525587
else:

keepkeylib/tx_api.py

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,24 @@
2121
from decimal import Decimal
2222
import requests
2323
import json
24+
import os
2425
import struct
2526

2627
from . import types_pb2 as proto_types
2728

2829
cache_dir = None
30+
offline_only = False
31+
32+
33+
class OfflineFixtureError(Exception):
34+
"""An authoritative transaction fixture is missing or malformed."""
35+
36+
37+
def configure_offline_fixtures(path):
38+
"""Make transaction lookup fail closed against a fixed fixture tree."""
39+
global cache_dir, offline_only
40+
cache_dir = os.path.abspath(path)
41+
offline_only = True
2942

3043

3144
def pack_varint(n):
@@ -46,15 +59,38 @@ def __init__(self, network, url):
4659
self.url = url
4760

4861
def fetch_json(self, url, resource, resourceid):
49-
global cache_dir
62+
global cache_dir, offline_only
63+
cache_file = None
5064
if cache_dir:
51-
cache_file = '%s/%s_%s_%s.json' % (cache_dir, self.network, resource, resourceid)
52-
try: # looking into cache first
65+
fixture_name = '%s_%s_%s.json' % (
66+
self.network, resource, resourceid)
67+
if os.path.basename(fixture_name) != fixture_name:
68+
raise OfflineFixtureError(
69+
'Invalid fixture key: network=%s resource=%s id=%s' %
70+
(self.network, resource, resourceid))
71+
cache_file = os.path.join(cache_dir, fixture_name)
72+
try: # looking into cache first
5373
with open(cache_file) as f:
54-
j = json.load(f)
55-
return j
56-
except:
57-
pass
74+
return json.load(f)
75+
except OSError as exc:
76+
if offline_only:
77+
raise OfflineFixtureError(
78+
'Missing offline transaction fixture: '
79+
'network=%s resource=%s id=%s path=%s' %
80+
(self.network, resource, resourceid, cache_file)
81+
) from exc
82+
except (TypeError, ValueError) as exc:
83+
if offline_only:
84+
raise OfflineFixtureError(
85+
'Invalid offline transaction fixture: '
86+
'network=%s resource=%s id=%s path=%s' %
87+
(self.network, resource, resourceid, cache_file)
88+
) from exc
89+
if offline_only:
90+
raise OfflineFixtureError(
91+
'Offline transaction fixtures are enabled without a fixture '
92+
'directory: network=%s resource=%s id=%s' %
93+
(self.network, resource, resourceid))
5894
try:
5995
# print('request %s/%s/%s' % (self.url, resource, resourceid))
6096
r = requests.get('%s/%s/%s' % (self.url, resource, resourceid), headers={'User-agent': 'Mozilla/5.0'})

0 commit comments

Comments
 (0)