Skip to content

Commit 8b479c5

Browse files
committed
test: make authoritative transaction fixtures hermetic
1 parent b93f95c commit 8b479c5

13 files changed

Lines changed: 896 additions & 109 deletions

‎.github/workflows/ci.yml‎

Lines changed: 19 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,27 @@ jobs:
95102
PYTHONPATH: "${{ github.workspace }}/keepkeylib:${{ github.workspace }}"
96103
run: |
97104
cd tests
105+
python tx_fixture_manifest.py --check
106+
sudo iptables -I OUTPUT 1 ! -o lo -m conntrack --ctstate NEW -j REJECT
107+
cleanup_network_gate() {
108+
sudo iptables -D OUTPUT ! -o lo -m conntrack --ctstate NEW -j REJECT
109+
}
110+
trap cleanup_network_gate EXIT
98111
pytest -v --junitxml=junit.xml 2>&1 | tee pytest-output.txt
99112
echo "${PIPESTATUS[0]}" > status
100113
101114
- name: Test summary
102115
if: always()
103116
run: |
104117
XML="tests/junit.xml"
118+
MANIFEST="tests/txcache/manifest.json"
105119
echo "## 🔑 KeepKey python-keepkey — Integration Tests" >> "$GITHUB_STEP_SUMMARY"
106120
echo "" >> "$GITHUB_STEP_SUMMARY"
121+
if [ -f "$MANIFEST" ]; then
122+
FIXTURE_SHA=$(sha256sum "$MANIFEST" | cut -d' ' -f1)
123+
echo "Fixture manifest SHA-256: `$FIXTURE_SHA`" >> "$GITHUB_STEP_SUMMARY"
124+
echo "" >> "$GITHUB_STEP_SUMMARY"
125+
fi
107126
108127
if [ ! -f "$XML" ]; then
109128
echo "❌ **No test results found** — suite may have crashed before completion." >> "$GITHUB_STEP_SUMMARY"
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Handoff: authoritative python-keepkey tests must be fully offline
2+
3+
## Non-negotiable release rule
4+
5+
The authoritative Python suite must never depend on an explorer, RPC service,
6+
DNS, TLS, remote retention, or the caller's working directory. A missing input
7+
is a named fixture failure, not permission to fetch mutable data. Optional live
8+
compatibility probes may exist only in a separate, non-authoritative workflow;
9+
they must never contribute release JUnit, report totals, artifacts, or a
10+
GO/NO-GO decision.
11+
12+
This work must be ported to the upstream keepkey/python-keepkey repository by
13+
reviewed PR. The fork implementation is the reference; no upstream branch was
14+
modified while preparing it.
15+
16+
## Fork reference implementation
17+
18+
Branch: BitHighlander/python-keepkey:fix/hermetic-release-tests
19+
20+
Release port: BitHighlander/python-keepkey:fix/7142-hermetic-tests, based
21+
exactly on b93f95c5698328a391487ecb97a3d3f6ea74159a. Its fixture-manifest
22+
SHA-256 is ae9f78b4cf934d501edcddc38ca671c0e095f9c7761845dade49fa07ac92837b.
23+
Use this branch, rather than merging alpha/develop, for the isolated 7.14.2 PR.
24+
25+
The implementation is intentionally isolated from the 7.14.2 Solana/TON
26+
disclosure and PDF-report branches. Reconcile those branches only after this
27+
one is reviewed, then repin firmware to the durable Python merge commit.
28+
29+
Affected surfaces:
30+
31+
- keepkeylib/tx_api.py adds configure_offline_fixtures(path), resolves a fixed
32+
absolute fixture root, and raises OfflineFixtureError naming the complete key
33+
instead of falling through to HTTP.
34+
- tests/common.py makes tests/txcache module-relative and enables offline-only
35+
mode for every KeepKeyTest.
36+
- tests/conftest.py rejects external DNS, socket, and HTTP access per test while
37+
permitting only loopback emulator traffic and Unix-domain sockets.
38+
- .github/workflows/ci.yml checks fixture integrity, adds a kernel outbound-new-
39+
connection deny rule during authoritative pytest, and records the manifest
40+
SHA-256 in every summary.
41+
- tests/tx_fixture_manifest.py and tests/test_tx_fixture_integrity.py account
42+
for every fixture, reconstruct canonical transactions, recompute every txid,
43+
test cwd independence and fail-closed misses, and statically reject new
44+
network-capable helpers even when pytest would not collect them.
45+
- tests/test_sign_typed_data.py and tests/test_verify_typed_data.py resolve JSON
46+
fixtures from their module directory.
47+
- The unused tests/zcash_rpc.py live-node helper was removed. It was not
48+
collected by pytest, contained a fixed private-node endpoint and embedded
49+
RPC credentials, and had no place in authoritative test infrastructure.
50+
51+
## Fixture rules
52+
53+
Each manifest entry records:
54+
55+
- source network and transaction ID;
56+
- response filename and SHA-256;
57+
- raw-response filename and SHA-256 where Zcash JoinSplit reconstruction needs
58+
it;
59+
- canonical serialized bytes and their SHA-256;
60+
- transaction-ID algorithm;
61+
- every authoritative test file that references it.
62+
63+
Bitcoin, Testnet, Bitcoin Gold, Dash, and pre-Overwinter Zcash transaction IDs
64+
use double SHA-256. Groestlcoin transaction IDs use one SHA-256 round, matching
65+
the current Groestlcoin Core HashWriter::GetHash() implementation:
66+
https://github.com/Groestlcoin/groestlcoin/blob/master/src/hash.h
67+
68+
Do not accept a fixture merely because its JSON txid field agrees with its
69+
filename. The canonical serialization must independently hash to the same ID.
70+
71+
The fork audit found and corrected one latent synthetic-fixture defect:
72+
6e320339...a6ee37 advertised a txid computed with the null outpoint index
73+
0xffffffff, while its decoded fixture said index 0. The corrected decoded
74+
fixture now agrees with its canonical bytes and txid. Two cache files with no
75+
authoritative references were removed. The 7.14.2 release branch does not
76+
contain that later Taproot fixture, so its port keeps it absent and regenerates
77+
the manifest from the 26 transaction fixtures that release tests actually use.
78+
79+
## Required upstream migration
80+
81+
1. Port the fork commits without weakening the fail-closed behavior.
82+
For 7.14.2, start from b93f95c and use fix/7142-hermetic-tests; do not merge
83+
the alpha/develop report catalog into the active PDF remediation branch.
84+
2. Preserve public live TxApi clients for non-test callers, but ensure
85+
authoritative tests enable offline-only mode before constructing clients.
86+
3. Run python tests/tx_fixture_manifest.py --check as an early CI gate.
87+
4. Run all authoritative emulator suites with both the pytest network-denial
88+
control and OS-level outbound-new-connection denial.
89+
5. Treat a new transaction input as a fixture change requiring canonical-byte,
90+
response-hash, txid, reference, and manifest review.
91+
6. Feed the exact manifest SHA-256 into the release evidence/report pipeline.
92+
The report job must fail if the manifest is missing, stale, mutated, or not
93+
listed in provenance.
94+
7. Keep optional explorer/RPC probes in a separately named workflow that
95+
cannot satisfy or influence a required release check. Store them outside
96+
tests/ and obtain endpoints and credentials from the workflow environment;
97+
never commit either value.
98+
99+
## Acceptance criteria
100+
101+
- A clean checkout with an empty user cache runs the authoritative suite while
102+
outbound networking is denied.
103+
- Zero DNS, external socket, HTTP, explorer, or RPC attempt occurs.
104+
- A missing network/txid fixture fails immediately and names the requesting
105+
key; no HTTP fallback is possible.
106+
- Running from the repository root and from tests/ produces identical test
107+
counts, statuses, signed outputs, and manifest digest.
108+
- Every fixture source is content-hashed, every canonical transaction is
109+
retained, every txid is independently recomputed, and every fixture has at
110+
least one authoritative test reference.
111+
- No test is skipped or xfailed because a live service or fixture is
112+
unavailable.
113+
- Full Python JUnit is green before the Python commit is eligible for a
114+
firmware submodule repin.
115+
- The release report and provenance manifest contain the exact transaction
116+
fixture-manifest SHA-256.
117+
118+
## Upstream handback
119+
120+
Return the upstream PR URL, exact head and merge commits, full offline JUnit
121+
totals, fixture-manifest SHA-256, the network-denial result, CI run URL, and
122+
git diff --check. Call out any historical response that cannot be reconstructed
123+
exactly; do not silently replace, weaken, delete, or skip it.

‎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'})

‎tests/common.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030
from keepkeylib.client import KeepKeyClient, KeepKeyDebuglinkClient, KeepKeyDebuglinkClientVerbose
3131
from keepkeylib import tx_api
3232

33-
tx_api.cache_dir = 'txcache'
33+
TX_FIXTURE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
34+
'txcache')
35+
tx_api.configure_offline_fixtures(TX_FIXTURE_DIR)
3436
VERBOSE = False
3537

3638
class KeepKeyTest(unittest.TestCase):

‎tests/conftest.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
import pytest
1313
import os
1414
import glob
15+
import ipaddress
16+
import socket
1517
import sys
18+
from urllib.parse import urlparse
19+
20+
import requests
1621

1722
if os.environ.get('KEEPKEY_SCREENSHOT') == '1':
1823
import common
@@ -51,6 +56,73 @@ def _patched_setUp(self):
5156
common.KeepKeyTest.setUp = _patched_setUp
5257

5358

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+
75+
@pytest.fixture(autouse=True)
76+
def deny_external_network(monkeypatch, request):
77+
"""Fail an authoritative test at its first non-loopback network access."""
78+
nodeid = request.node.nodeid
79+
original_getaddrinfo = socket.getaddrinfo
80+
original_connect = socket.socket.connect
81+
original_connect_ex = socket.socket.connect_ex
82+
original_sendto = socket.socket.sendto
83+
original_request = requests.sessions.Session.request
84+
85+
def denied(destination):
86+
raise AssertionError(
87+
'authoritative test attempted external network access: '
88+
'test=%s destination=%r' % (nodeid, destination))
89+
90+
def guarded_getaddrinfo(host, *args, **kwargs):
91+
if not _is_loopback_address((host, 0)):
92+
denied(host)
93+
return original_getaddrinfo(host, *args, **kwargs)
94+
95+
def guarded_connect(sock, address):
96+
if not _is_loopback_address(address):
97+
denied(address)
98+
return original_connect(sock, address)
99+
100+
def guarded_connect_ex(sock, address):
101+
if not _is_loopback_address(address):
102+
denied(address)
103+
return original_connect_ex(sock, address)
104+
105+
def guarded_sendto(sock, data, *args):
106+
address = args[-1]
107+
if not _is_loopback_address(address):
108+
denied(address)
109+
return original_sendto(sock, data, *args)
110+
111+
def guarded_request(session, method, url, *args, **kwargs):
112+
hostname = urlparse(url).hostname
113+
if not _is_loopback_address((hostname, 0)):
114+
raise AssertionError(
115+
'authoritative test attempted HTTP access: test=%s method=%s '
116+
'url=%s' % (nodeid, method, url))
117+
return original_request(session, method, url, *args, **kwargs)
118+
119+
monkeypatch.setattr(socket, 'getaddrinfo', guarded_getaddrinfo)
120+
monkeypatch.setattr(socket.socket, 'connect', guarded_connect)
121+
monkeypatch.setattr(socket.socket, 'connect_ex', guarded_connect_ex)
122+
monkeypatch.setattr(socket.socket, 'sendto', guarded_sendto)
123+
monkeypatch.setattr(requests.sessions.Session, 'request', guarded_request)
124+
125+
54126
def pytest_sessionfinish(session, exitstatus):
55127
"""Fail-fast: if screenshots were requested but none captured, fail the session."""
56128
if os.environ.get('KEEPKEY_SCREENSHOT') != '1':

‎tests/test_sign_typed_data.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import common
1919
import binascii
2020
import json
21+
import os
2122

2223
import keepkeylib.messages_pb2 as proto
2324
import keepkeylib.messages_ethereum_pb2 as eth_proto
@@ -35,9 +36,10 @@ def test_ethereum_sign_typed_data_hash(self):
3536
# 7.14.2 gates precomputed typed hashes behind AdvancedMode: the device
3637
# cannot bind the hash to any typed data it displayed. Opt in explicitly.
3738
self.client.apply_policy("AdvancedMode", 1)
38-
f = open('sign_typed_data.json')
39-
txtests = json.load(f)
40-
f.close()
39+
fixture_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
40+
'sign_typed_data.json')
41+
with open(fixture_path) as f:
42+
txtests = json.load(f)
4143

4244
for test in txtests['tests']:
4345
print("test: ", json.dumps(test['name']))

0 commit comments

Comments
 (0)