Skip to content

Commit a1fc64b

Browse files
committed
test: make authoritative transaction fixtures hermetic
1 parent 75028c4 commit a1fc64b

14 files changed

Lines changed: 937 additions & 109 deletions

‎.github/workflows/ci.yml‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ jobs:
7676
tests/test_clearsign_abi.py \
7777
tests/test_token_table_generators.py
7878
79+
- name: Verify offline transaction fixture manifest
80+
run: python tests/tx_fixture_manifest.py --check
81+
7982
- name: Lint summary
8083
run: |
8184
echo "## 🔑 KeepKey python-keepkey — Lint" >> "$GITHUB_STEP_SUMMARY"
@@ -86,6 +89,10 @@ jobs:
8689
echo "| Zcash PCZT contract | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY"
8790
echo "| ABI encoder | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY"
8891
echo "| Token-table generators | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY"
92+
echo "| Offline fixture integrity | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY"
93+
FIXTURE_SHA=$(sha256sum tests/txcache/manifest.json | cut -d' ' -f1)
94+
echo "" >> "$GITHUB_STEP_SUMMARY"
95+
echo "Fixture manifest SHA-256: `$FIXTURE_SHA`" >> "$GITHUB_STEP_SUMMARY"
8996
9097
# ═══════════════════════════════════════════════════════════
9198
# STAGE 2: TEST — pull published emulator, run pytest
@@ -245,15 +252,27 @@ jobs:
245252
# firmware and they resolve; run them standalone and they fail
246253
# claiming the sources are missing.
247254
cd keepkey-firmware/deps/python-keepkey/tests
255+
python tx_fixture_manifest.py --check
256+
sudo iptables -I OUTPUT 1 ! -o lo -m conntrack --ctstate NEW -j REJECT
257+
cleanup_network_gate() {
258+
sudo iptables -D OUTPUT ! -o lo -m conntrack --ctstate NEW -j REJECT
259+
}
260+
trap cleanup_network_gate EXIT
248261
pytest -v --junitxml=junit.xml 2>&1 | tee pytest-output.txt
249262
echo "${PIPESTATUS[0]}" > status
250263
251264
- name: Test summary
252265
if: always()
253266
run: |
254267
XML="keepkey-firmware/deps/python-keepkey/tests/junit.xml"
268+
MANIFEST="keepkey-firmware/deps/python-keepkey/tests/txcache/manifest.json"
255269
echo "## 🔑 KeepKey python-keepkey — Integration Tests" >> "$GITHUB_STEP_SUMMARY"
256270
echo "" >> "$GITHUB_STEP_SUMMARY"
271+
if [ -f "$MANIFEST" ]; then
272+
FIXTURE_SHA=$(sha256sum "$MANIFEST" | cut -d' ' -f1)
273+
echo "Fixture manifest SHA-256: `$FIXTURE_SHA`" >> "$GITHUB_STEP_SUMMARY"
274+
echo "" >> "$GITHUB_STEP_SUMMARY"
275+
fi
257276
258277
if [ ! -f "$XML" ]; then
259278
echo "❌ **No test results found** — suite may have crashed before completion." >> "$GITHUB_STEP_SUMMARY"
@@ -448,17 +467,29 @@ jobs:
448467
KK_UDP_TIMEOUT: "45"
449468
run: |
450469
cd keepkey-firmware/deps/python-keepkey/tests
470+
python tx_fixture_manifest.py --check
471+
sudo iptables -I OUTPUT 1 ! -o lo -m conntrack --ctstate NEW -j REJECT
472+
cleanup_network_gate() {
473+
sudo iptables -D OUTPUT ! -o lo -m conntrack --ctstate NEW -j REJECT
474+
}
475+
trap cleanup_network_gate EXIT
451476
pytest -v --junitxml=junit-rc18.xml 2>&1 | tee pytest-rc18-output.txt
452477
echo "${PIPESTATUS[0]}" > status-rc18
453478
454479
- name: RC18 summary
455480
if: always()
456481
run: |
457482
XML="keepkey-firmware/deps/python-keepkey/tests/junit-rc18.xml"
483+
MANIFEST="keepkey-firmware/deps/python-keepkey/tests/txcache/manifest.json"
458484
echo "## 🔑 python-keepkey — RC18 / 7.15.0" >> "$GITHUB_STEP_SUMMARY"
459485
echo "" >> "$GITHUB_STEP_SUMMARY"
460486
echo "Blocking release-target compatibility gate." >> "$GITHUB_STEP_SUMMARY"
461487
echo "" >> "$GITHUB_STEP_SUMMARY"
488+
if [ -f "$MANIFEST" ]; then
489+
FIXTURE_SHA=$(sha256sum "$MANIFEST" | cut -d' ' -f1)
490+
echo "Fixture manifest SHA-256: `$FIXTURE_SHA`" >> "$GITHUB_STEP_SUMMARY"
491+
echo "" >> "$GITHUB_STEP_SUMMARY"
492+
fi
462493
if [ ! -f "$XML" ]; then
463494
echo "❌ **No test results** — the suite crashed before completion." >> "$GITHUB_STEP_SUMMARY"
464495
else
@@ -657,6 +688,12 @@ jobs:
657688
KK_UDP_TIMEOUT: "45"
658689
run: |
659690
cd keepkey-firmware/deps/python-keepkey/tests
691+
python tx_fixture_manifest.py --check
692+
sudo iptables -I OUTPUT 1 ! -o lo -m conntrack --ctstate NEW -j REJECT
693+
cleanup_network_gate() {
694+
sudo iptables -D OUTPUT ! -o lo -m conntrack --ctstate NEW -j REJECT
695+
}
696+
trap cleanup_network_gate EXIT
660697
pytest -v --junitxml=junit-btc.xml test_msg_bitcoin_only_variant.py \
661698
2>&1 | tee pytest-btc-output.txt
662699
echo "${PIPESTATUS[0]}" > status-btc
@@ -693,8 +730,14 @@ jobs:
693730
if: always()
694731
run: |
695732
XML="keepkey-firmware/deps/python-keepkey/tests/junit-btc.xml"
733+
MANIFEST="keepkey-firmware/deps/python-keepkey/tests/txcache/manifest.json"
696734
echo "## 🔑 KeepKey python-keepkey — Bitcoin-only product boundary" >> "$GITHUB_STEP_SUMMARY"
697735
echo "" >> "$GITHUB_STEP_SUMMARY"
736+
if [ -f "$MANIFEST" ]; then
737+
FIXTURE_SHA=$(sha256sum "$MANIFEST" | cut -d' ' -f1)
738+
echo "Fixture manifest SHA-256: `$FIXTURE_SHA`" >> "$GITHUB_STEP_SUMMARY"
739+
echo "" >> "$GITHUB_STEP_SUMMARY"
740+
fi
698741
if [ ! -f "$XML" ]; then
699742
echo "❌ **No test results found** — suite may have crashed." >> "$GITHUB_STEP_SUMMARY"
700743
else
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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+
The implementation is intentionally isolated from the 7.14.2 Solana/TON
21+
disclosure and PDF-report branches. Reconcile those branches only after this
22+
one is reviewed, then repin firmware to the durable Python merge commit.
23+
24+
Affected surfaces:
25+
26+
- keepkeylib/tx_api.py adds configure_offline_fixtures(path), resolves a fixed
27+
absolute fixture root, and raises OfflineFixtureError naming the complete key
28+
instead of falling through to HTTP.
29+
- tests/common.py makes tests/txcache module-relative and enables offline-only
30+
mode for every KeepKeyTest.
31+
- tests/conftest.py rejects external DNS, socket, and HTTP access per test while
32+
permitting only loopback emulator traffic and Unix-domain sockets.
33+
- .github/workflows/ci.yml checks fixture integrity, adds a kernel outbound-new-
34+
connection deny rule during authoritative pytest, and records the manifest
35+
SHA-256 in every summary.
36+
- tests/tx_fixture_manifest.py and tests/test_tx_fixture_integrity.py account
37+
for every fixture, reconstruct canonical transactions, recompute every txid,
38+
test cwd independence and fail-closed misses, and statically reject new
39+
network-capable helpers even when pytest would not collect them.
40+
- tests/test_sign_typed_data.py and tests/test_verify_typed_data.py resolve JSON
41+
fixtures from their module directory.
42+
- The unused tests/zcash_rpc.py live-node helper was removed. It was not
43+
collected by pytest, contained a fixed private-node endpoint and embedded
44+
RPC credentials, and had no place in authoritative test infrastructure.
45+
46+
## Fixture rules
47+
48+
Each manifest entry records:
49+
50+
- source network and transaction ID;
51+
- response filename and SHA-256;
52+
- raw-response filename and SHA-256 where Zcash JoinSplit reconstruction needs
53+
it;
54+
- canonical serialized bytes and their SHA-256;
55+
- transaction-ID algorithm;
56+
- every authoritative test file that references it.
57+
58+
Bitcoin, Testnet, Bitcoin Gold, Dash, and pre-Overwinter Zcash transaction IDs
59+
use double SHA-256. Groestlcoin transaction IDs use one SHA-256 round, matching
60+
the current Groestlcoin Core HashWriter::GetHash() implementation:
61+
https://github.com/Groestlcoin/groestlcoin/blob/master/src/hash.h
62+
63+
Do not accept a fixture merely because its JSON txid field agrees with its
64+
filename. The canonical serialization must independently hash to the same ID.
65+
66+
The fork audit found and corrected one latent synthetic-fixture defect:
67+
6e320339...a6ee37 advertised a txid computed with the null outpoint index
68+
0xffffffff, while its decoded fixture said index 0. The corrected decoded
69+
fixture now agrees with its canonical bytes and txid. Two cache files with no
70+
authoritative references were removed.
71+
72+
## Required upstream migration
73+
74+
1. Port the fork commits without weakening the fail-closed behavior.
75+
2. Preserve public live TxApi clients for non-test callers, but ensure
76+
authoritative tests enable offline-only mode before constructing clients.
77+
3. Run python tests/tx_fixture_manifest.py --check as an early CI gate.
78+
4. Run all authoritative emulator suites with both the pytest network-denial
79+
control and OS-level outbound-new-connection denial.
80+
5. Treat a new transaction input as a fixture change requiring canonical-byte,
81+
response-hash, txid, reference, and manifest review.
82+
6. Feed the exact manifest SHA-256 into the release evidence/report pipeline.
83+
The report job must fail if the manifest is missing, stale, mutated, or not
84+
listed in provenance.
85+
7. Keep optional explorer/RPC probes in a separately named workflow that
86+
cannot satisfy or influence a required release check. Store them outside
87+
tests/ and obtain endpoints and credentials from the workflow environment;
88+
never commit either value.
89+
90+
## Acceptance criteria
91+
92+
- A clean checkout with an empty user cache runs the authoritative suite while
93+
outbound networking is denied.
94+
- Zero DNS, external socket, HTTP, explorer, or RPC attempt occurs.
95+
- A missing network/txid fixture fails immediately and names the requesting
96+
key; no HTTP fallback is possible.
97+
- Running from the repository root and from tests/ produces identical test
98+
counts, statuses, signed outputs, and manifest digest.
99+
- Every fixture source is content-hashed, every canonical transaction is
100+
retained, every txid is independently recomputed, and every fixture has at
101+
least one authoritative test reference.
102+
- No test is skipped or xfailed because a live service or fixture is
103+
unavailable.
104+
- Full Python JUnit is green before the Python commit is eligible for a
105+
firmware submodule repin.
106+
- The release report and provenance manifest contain the exact transaction
107+
fixture-manifest SHA-256.
108+
109+
## Upstream handback
110+
111+
Return the upstream PR URL, exact head and merge commits, full offline JUnit
112+
totals, fixture-manifest SHA-256, the network-denial result, CI run URL, and
113+
git diff --check. Call out any historical response that cannot be reconstructed
114+
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':

0 commit comments

Comments
 (0)