Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ jobs:
rm -rf deps/python-keepkey
mv ../.pykk deps/python-keepkey

# This is python-keepkey's standalone compatibility lane. Release
# OLED/PDF evidence is generated only by the exact-head firmware
# workflow, where the firmware and Python SHAs are pinned together.
# Do not inherit the firmware checkout's report/screenshot phase:
# it can describe a different (published/master) emulator and is
# therefore not release evidence for this Python PR.
cp deps/python-keepkey/.circleci/standalone-emulator-tests.sh \
scripts/emulator/python-keepkey-tests.sh

# ────────────────────────────────────────────────────────────────
# 3) Build the Docker-based emulator tests
# ────────────────────────────────────────────────────────────────
Expand Down
35 changes: 35 additions & 0 deletions .circleci/standalone-emulator-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/bin/sh
set -e

# python-keepkey's own CI is a compatibility test against the emulator supplied
# by the firmware checkout. It deliberately does not produce OLED/PDF release
# evidence. Authoritative display evidence is generated by keepkey-firmware CI,
# which pins both repositories to exact candidate SHAs.

mkdir -p /kkemu/test-reports/python-keepkey

echo "=== Waiting for emulator ==="
for attempt in $(seq 1 20); do
if echo -n "PINGPING" | nc -u -w1 kkemu 11044 2>/dev/null | grep -q PONG; then
echo "Emulator ready (attempt $attempt)"
break
fi
if [ "$attempt" -eq 20 ]; then
echo "Emulator did not become ready"
echo "1" > /kkemu/test-reports/python-keepkey/status
exit 1
fi
sleep 2
done

cd /kkemu/deps/python-keepkey/tests
set +e
KK_TRANSPORT_MAIN=kkemu:11044 \
KK_TRANSPORT_DEBUG=kkemu:11045 \
pytest -v --tb=short \
--junitxml=/kkemu/test-reports/python-keepkey/junit.xml
pytest_status=$?
set -e

echo "$pytest_status" > /kkemu/test-reports/python-keepkey/status
exit "$pytest_status"
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,20 @@ jobs:
- name: Syntax check
run: python -m py_compile keepkeylib/*.py

- name: Verify offline transaction fixture manifest
run: python tests/tx_fixture_manifest.py --check

- name: Lint summary
run: |
echo "## 🔑 KeepKey python-keepkey — Lint" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Check | Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Syntax | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY"
echo "| Offline fixture integrity | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY"
FIXTURE_SHA=$(sha256sum tests/txcache/manifest.json | cut -d' ' -f1)
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Fixture manifest SHA-256: `$FIXTURE_SHA`" >> "$GITHUB_STEP_SUMMARY"

# ═══════════════════════════════════════════════════════════
# STAGE 2: TEST — pull published emulator, run pytest
Expand Down Expand Up @@ -95,15 +102,33 @@ jobs:
PYTHONPATH: "${{ github.workspace }}/keepkeylib:${{ github.workspace }}"
run: |
cd tests
python tx_fixture_manifest.py --check
EMULATOR_IP=$(docker inspect -f \
'{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
"${{ job.services.kkemu.id }}")
test -n "$EMULATOR_IP"
sudo iptables -I OUTPUT 1 -d "$EMULATOR_IP" -j ACCEPT
sudo iptables -I OUTPUT 2 ! -o lo -m conntrack --ctstate NEW -j REJECT
cleanup_network_gate() {
sudo iptables -D OUTPUT ! -o lo -m conntrack --ctstate NEW -j REJECT
sudo iptables -D OUTPUT -d "$EMULATOR_IP" -j ACCEPT
}
trap cleanup_network_gate EXIT
pytest -v --junitxml=junit.xml 2>&1 | tee pytest-output.txt
echo "${PIPESTATUS[0]}" > status

- name: Test summary
if: always()
run: |
XML="tests/junit.xml"
MANIFEST="tests/txcache/manifest.json"
echo "## 🔑 KeepKey python-keepkey — Integration Tests" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ -f "$MANIFEST" ]; then
FIXTURE_SHA=$(sha256sum "$MANIFEST" | cut -d' ' -f1)
echo "Fixture manifest SHA-256: `$FIXTURE_SHA`" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
fi

if [ ! -f "$XML" ]; then
echo "❌ **No test results found** — suite may have crashed before completion." >> "$GITHUB_STEP_SUMMARY"
Expand Down
92 changes: 77 additions & 15 deletions keepkeylib/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,13 +432,13 @@ def set_mnemonic(self, mnemonic):

def call_raw(self, msg):

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

resp = super(DebugLinkMixin, self).call_raw(msg)
if isinstance(resp, proto.Failure):
self._capture_oled()
self._check_request(resp)
return resp

Expand All @@ -460,20 +460,19 @@ def _check_request(self, msg):
raise CallException(types.Failure_Other,
"Expected %s, got %s" % (pprint(expected), pprint(msg)))

def _capture_oled(self):
def _capture_oled(self, layout=None):
"""Capture current OLED layout to screenshot directory."""
if not SCREENSHOT:
return
if not self.debug:
import sys
print("[SCREENSHOT] SKIP: no debug link", file=sys.stderr)
return
raise RuntimeError("screenshot capture requested without debug link")
try:
layout = self.debug.read_layout()
if layout is None:
layout = self.debug.read_layout()
if not layout or len(layout) < 1024:
import sys
print("[SCREENSHOT] SKIP: layout too small (%d bytes)" % (len(layout) if layout else 0), file=sys.stderr)
return
raise RuntimeError(
"layout too small (%d bytes)" %
(len(layout) if layout else 0))
layout_bytes = len(layout)
height = 64 if layout_bytes >= 2048 else 32
rows = []
Expand All @@ -500,13 +499,73 @@ def _capture_oled(self):
import sys, traceback
print("[SCREENSHOT] ERROR: %s" % e, file=sys.stderr)
traceback.print_exc(file=sys.stderr)
raise

def _capture_oled_after_animation(self, seconds, required_region=None):
"""Capture the completed PIN/cipher frame, not its initial blank state."""
if not SCREENSHOT:
return
# The emulator's PIN/recovery loop blocks while waiting for host
# input. Wall-clock sleep alone therefore does not repaint: each
# DebugLink request wakes the loop for one 20 ms animation tick.
# Drive every required tick and retain the layout from the final poll.
layout = None
for _tick in range(int(seconds / 0.020) + 2):
time.sleep(0.025)
layout = self.debug.read_layout()
if required_region:
x0, x1, y0, y1 = required_region
lit = 0
for y in range(y0, y1):
for x in range(x0, x1):
byte_index = x + (y // 8) * 256
value = layout[byte_index]
if not isinstance(value, int):
value = ord(value)
lit += (value >> (y % 8)) & 1
area = (x1 - x0) * (y1 - y0)
if lit < 32 or area - lit < 32:
raise RuntimeError(
"animated OLED evidence lacks grid contrast "
"(%d lit of %d pixels)" % (lit, area))
self._capture_oled(layout)

def _read_oled_after_settle(self):
"""Return a stable confirmation frame after the render-loop handoff.

A ButtonRequest can reach the host one or more emulator ticks before
its OLED update. Sampling immediately duplicated the preceding prompt
and omitted the security-relevant next prompt while preserving the
expected file count. DebugLink reads advance the blocked render loop;
require at least five polls and three identical final layouts.
"""
candidate = None
stable_reads = 0
for tick in range(20):
time.sleep(0.025)
layout = self.debug.read_layout()
if layout == candidate:
stable_reads += 1
else:
candidate = layout
stable_reads = 1
if tick >= 4 and stable_reads >= 3:
return layout
raise RuntimeError(
'OLED confirmation did not settle before evidence capture')

def _capture_oled_after_settle(self):
"""Capture the settled frame used for confirmation evidence."""
if not SCREENSHOT:
return
self._capture_oled(self._read_oled_after_settle())

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

# Capture OLED screenshot BEFORE pressing button (confirmation screen)
self._capture_oled()
# Capture the completed OLED frame BEFORE pressing the button.
self._capture_oled_after_settle()

if self.auto_button:
if self.verbose:
Expand All @@ -520,6 +579,9 @@ def callback_ButtonRequest(self, msg):
return proto.ButtonAck()

def callback_PinMatrixRequest(self, msg):
# Firmware animates the randomized grid for PIN_MAX_ANIMATION_MS
# (1000 ms). Sampling immediately captures only the prompt/blank mask.
self._capture_oled_after_animation(1.05, (192, 256, 0, 64))
if self.pin_correct:
pin = self.debug.read_pin_encoded()
else:
Expand Down
50 changes: 43 additions & 7 deletions keepkeylib/tx_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,24 @@
from decimal import Decimal
import requests
import json
import os
import struct

from . import types_pb2 as proto_types

cache_dir = None
offline_only = False


class OfflineFixtureError(Exception):
"""An authoritative transaction fixture is missing or malformed."""


def configure_offline_fixtures(path):
"""Make transaction lookup fail closed against a fixed fixture tree."""
global cache_dir, offline_only
cache_dir = os.path.abspath(path)
offline_only = True


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

def fetch_json(self, url, resource, resourceid):
global cache_dir
global cache_dir, offline_only
cache_file = None
if cache_dir:
cache_file = '%s/%s_%s_%s.json' % (cache_dir, self.network, resource, resourceid)
try: # looking into cache first
fixture_name = '%s_%s_%s.json' % (
self.network, resource, resourceid)
if os.path.basename(fixture_name) != fixture_name:
raise OfflineFixtureError(
'Invalid fixture key: network=%s resource=%s id=%s' %
(self.network, resource, resourceid))
cache_file = os.path.join(cache_dir, fixture_name)
try: # looking into cache first
with open(cache_file) as f:
j = json.load(f)
return j
except:
pass
return json.load(f)
except OSError as exc:
if offline_only:
raise OfflineFixtureError(
'Missing offline transaction fixture: '
'network=%s resource=%s id=%s path=%s' %
(self.network, resource, resourceid, cache_file)
) from exc
except (TypeError, ValueError) as exc:
if offline_only:
raise OfflineFixtureError(
'Invalid offline transaction fixture: '
'network=%s resource=%s id=%s path=%s' %
(self.network, resource, resourceid, cache_file)
) from exc
if offline_only:
raise OfflineFixtureError(
'Offline transaction fixtures are enabled without a fixture '
'directory: network=%s resource=%s id=%s' %
(self.network, resource, resourceid))
try:
# print('request %s/%s/%s' % (self.url, resource, resourceid))
r = requests.get('%s/%s/%s' % (self.url, resource, resourceid), headers={'User-agent': 'Mozilla/5.0'})
Expand Down
Loading
Loading