Skip to content

Commit a08dbd9

Browse files
committed
test(7.15): consolidated Python harness, bindings, and device coverage
Squashed from 194 commits. Everything below is against master, which this branch already contains -- the 7.14.2 disclosure tests from keepkey#216 are merged in, not replaced. BINDINGS (keepkeylib) Protobuf regenerated for the 7.15 surface: ethereum, hive, ripple, solana, thorchain, zcash, types. New modules for the features those messages carry -- clearsign_abi, clearsign_catalog, eip712_stream, signed_metadata, hive, zcash. transport_udp gained a socket timeout. It had none, so a crashed emulator blocked in recv() until the CI job was killed, which is reported as a cancelled job rather than a failing test and throws away every result. eth/token_policy.py bounds the built-in token table. It is the largest read-only symbol in the ARM image (31KB for 1,945 mostly-2018 entries) and the vetted source it comes from is a stale snapshot -- no UNI, no AAVE, no modern stables. The policy keeps what users hold plus what coins[] requires, and takes a priority symbol only when the source gives it exactly one address, so a scam token cannot inherit a real one's label. COVERAGE (17 new suites) taproot: address derivation, signing, and on-screen verification zcash: PCZT device signing and seed fingerprint clear-signing: additive tier, signing guards, EIP-712 streaming, Solana LUT attestation, thorchain deposit session trust lifetime, hive, osmosis, bitcoin-only variant, storage gate NOTES ON TWO DELIBERATE CHOICES The thorchain and mayachain suites assert structure rather than frozen (r,s) vectors. keepkey#197 repoints those transactions at current routers, and `to` is an RLP field of the EIP-155 sighash, so the old vectors describe a different transaction. The superseded vectors are kept in comments at the assertion site so the gap stays visible and regenerable on hardware. The storage version gate skips on trees that predate it, but still FAILS if storage.c references the floor while storage.h no longer defines it -- the skip cannot swallow the regression it guards.
1 parent 598097e commit a08dbd9

67 files changed

Lines changed: 15390 additions & 1089 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 140 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# and runs the full python integration test suite against it.
55
#
66
# Stage 1: GATE (seconds)
7-
# └─ lint basic Python syntax check
7+
# └─ lint Python syntax + deterministic protocol contract tests
88
#
99
# Stage 2: TEST (gated by Stage 1)
1010
# └─ integration full pytest suite against emulator
@@ -13,9 +13,15 @@ name: CI
1313

1414
on:
1515
push:
16-
branches: [master, develop, 'feature/**', 'fix/**', 'hotfix/**']
16+
branches: [master, develop, reconcile/upstream-sync, 'feature/**', 'fix/**', 'hotfix/**']
1717
pull_request:
18-
branches: [master, develop]
18+
branches: [master, develop, reconcile/upstream-sync]
19+
20+
# One run per ref: a new push supersedes the old instead of both burning a
21+
# runner to completion.
22+
concurrency:
23+
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
24+
cancel-in-progress: true
1925

2026
jobs:
2127
# ═══════════════════════════════════════════════════════════
@@ -34,13 +40,26 @@ jobs:
3440
- name: Syntax check
3541
run: python -m py_compile keepkeylib/*.py
3642

43+
- name: Install contract-test dependencies
44+
run: |
45+
pip install "protobuf>=3.20,<4" mnemonic ecdsa pytest
46+
47+
- name: Run deterministic Zcash PCZT contract tests
48+
env:
49+
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
50+
run: |
51+
python -m pytest -q \
52+
tests/test_msg_zcash_sign_pczt.py \
53+
tests/test_zcash_seed_fingerprint_helper.py
54+
3755
- name: Lint summary
3856
run: |
3957
echo "## 🔑 KeepKey python-keepkey — Lint" >> "$GITHUB_STEP_SUMMARY"
4058
echo "" >> "$GITHUB_STEP_SUMMARY"
4159
echo "| Check | Status |" >> "$GITHUB_STEP_SUMMARY"
4260
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
4361
echo "| Syntax | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY"
62+
echo "| Zcash PCZT contract | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY"
4463
4564
# ═══════════════════════════════════════════════════════════
4665
# STAGE 2: TEST — pull published emulator, run pytest
@@ -49,26 +68,74 @@ jobs:
4968
integration:
5069
needs: [lint]
5170
runs-on: ubuntu-latest
52-
timeout-minutes: 30
71+
timeout-minutes: 15
5372

54-
services:
55-
kkemu:
56-
image: kktech/kkemu:latest
57-
ports:
58-
- 11044:11044/udp
59-
- 11045:11045/udp
60-
- 5000:5000
73+
# NO published emulator image. This job BUILDS one from current firmware.
74+
#
75+
# It used to pull kktech/kkemu:latest -- a floating tag whose image was
76+
# five months and six minor versions stale. That single fact caused every
77+
# symptom we chased: 80 tests gating on requires_firmware("7.15.0") skipped
78+
# silently, and one unskipped test drove a ctime() path that segfaults on
79+
# the old image and does not exist in current firmware.
80+
#
81+
# Publishing a fresher image would only reset that clock. Building from
82+
# source removes the class: the emulator under test is, by construction,
83+
# the firmware the tests were written against.
6184

6285
steps:
6386
- uses: actions/checkout@v4
6487
with:
6588
submodules: recursive
89+
path: python-keepkey
90+
91+
# python-keepkey is a SUBMODULE of the firmware repo, so the firmware is
92+
# where the emulator lives. alpha is the fork's integration branch.
93+
- name: Checkout firmware
94+
uses: actions/checkout@v4
95+
with:
96+
repository: BitHighlander/keepkey-firmware
97+
ref: alpha
98+
path: keepkey-firmware
99+
100+
# NOT `submodules: recursive`. trezor-firmware carries a micropython
101+
# vendor tree whose lib/lwip lives on git.savannah.gnu.org, which serves
102+
# dumb HTTP and cannot do the shallow clone actions/checkout requests --
103+
# it fails the whole job. The firmware repo's own CI inits exactly these
104+
# paths, non-recursively, for the same reason.
105+
- name: Init the submodules the emulator build needs
106+
working-directory: keepkey-firmware
107+
run: |
108+
git submodule update --init --depth 1 deps/crypto/trezor-firmware
109+
git submodule update --init --depth 1 deps/device-protocol
110+
git submodule update --init --depth 1 deps/googletest
111+
git submodule update --init --depth 1 deps/qrenc/QR-Code-generator
112+
git submodule update --init --depth 1 deps/sca-hardening/SecAESSTM32
113+
114+
# Test THIS checkout of python-keepkey, not the one the firmware pins.
115+
- name: Overlay this python-keepkey onto the firmware tree
116+
run: |
117+
rm -rf keepkey-firmware/deps/python-keepkey
118+
cp -a python-keepkey keepkey-firmware/deps/python-keepkey
119+
120+
- name: Build the emulator
121+
timeout-minutes: 20
122+
working-directory: keepkey-firmware
123+
run: |
124+
docker build -t kkemu-ci -f scripts/emulator/Dockerfile .
125+
126+
- name: Start the emulator
127+
run: |
128+
docker run -d --name kkemu \
129+
-p 11044:11044/udp -p 11045:11045/udp -p 5000:5000 kkemu-ci
130+
sleep 3
131+
docker logs kkemu | head -5
66132
67133
- uses: actions/setup-python@v5
68134
with:
69135
python-version: '3.11'
70136

71137
- name: Install dependencies
138+
working-directory: python-keepkey
72139
run: |
73140
pip install --upgrade pip
74141
pip install "protobuf>=3.20,<4"
@@ -88,20 +155,67 @@ jobs:
88155
sleep 1
89156
done
90157
158+
# "The emulator answered a ping" is not "the emulator is the right
159+
# firmware". CI ran a 7.16-era suite against a 7.10.0 image for five
160+
# months: 80 tests gate on requires_firmware("7.15.0") and silently
161+
# SKIPPED, while one unskipped test drove a code path that segfaults in
162+
# 7.10.0 and is already fixed in 7.15 -- which reads as a product failure
163+
# but is only a stale image. A floating tag cannot tell you that. This
164+
# can, and it fails closed.
165+
- name: Assert the emulator is not older than the suite
166+
timeout-minutes: 2
167+
env:
168+
KK_TRANSPORT_MAIN: "127.0.0.1:11044"
169+
KK_TRANSPORT_DEBUG: "127.0.0.1:11045"
170+
KK_MIN_FW: "7.15.0"
171+
KK_UDP_TIMEOUT: "20"
172+
working-directory: keepkey-firmware/deps/python-keepkey/tests
173+
run: |
174+
python - <<'PY'
175+
import os, sys
176+
sys.path.insert(0, '..')
177+
import config
178+
from keepkeylib.client import KeepKeyDebuglinkClient
179+
c = KeepKeyDebuglinkClient(config.TRANSPORT(*config.TRANSPORT_ARGS,
180+
**config.TRANSPORT_KWARGS))
181+
c.set_debuglink(config.DEBUG_TRANSPORT(*config.DEBUG_TRANSPORT_ARGS,
182+
**config.DEBUG_TRANSPORT_KWARGS))
183+
c.init_device()
184+
f = c.features
185+
got = (f.major_version, f.minor_version, f.patch_version)
186+
floor = tuple(int(x) for x in os.environ['KK_MIN_FW'].split('.'))
187+
print('emulator firmware %d.%d.%d, floor %s' %
188+
(got + (os.environ['KK_MIN_FW'],)))
189+
if got < floor:
190+
sys.exit('FATAL: the emulator image predates the tests that run '
191+
'against it. Republish kktech/kkemu from current '
192+
'firmware and pin the new digest above.')
193+
PY
194+
195+
# Step-level timeout, deliberately: a JOB-level timeout ends the job as
196+
# "cancelled", which reads as an infra blip. A step timeout is a FAILURE.
91197
- name: Run integration tests
198+
timeout-minutes: 8
92199
env:
93200
KK_TRANSPORT_MAIN: "127.0.0.1:11044"
94201
KK_TRANSPORT_DEBUG: "127.0.0.1:11045"
95-
PYTHONPATH: "${{ github.workspace }}/keepkeylib:${{ github.workspace }}"
202+
PYTHONPATH: "${{ github.workspace }}/keepkey-firmware/deps/python-keepkey"
203+
# A crashed emulator now raises instead of blocking in recv() forever.
204+
KK_UDP_TIMEOUT: "45"
96205
run: |
97-
cd tests
206+
# From the OVERLAID copy, not the standalone checkout: the
207+
# storage-version-gate tests assert against lib/firmware/storage.c,
208+
# which they find by walking UP. Run them as a sibling of the
209+
# firmware and they resolve; run them standalone and they fail
210+
# claiming the sources are missing.
211+
cd keepkey-firmware/deps/python-keepkey/tests
98212
pytest -v --junitxml=junit.xml 2>&1 | tee pytest-output.txt
99213
echo "${PIPESTATUS[0]}" > status
100214
101215
- name: Test summary
102216
if: always()
103217
run: |
104-
XML="tests/junit.xml"
218+
XML="keepkey-firmware/deps/python-keepkey/tests/junit.xml"
105219
echo "## 🔑 KeepKey python-keepkey — Integration Tests" >> "$GITHUB_STEP_SUMMARY"
106220
echo "" >> "$GITHUB_STEP_SUMMARY"
107221
@@ -148,15 +262,23 @@ jobs:
148262
echo "---" >> "$GITHUB_STEP_SUMMARY"
149263
echo "*KeepKey python-keepkey CI*" >> "$GITHUB_STEP_SUMMARY"
150264
151-
- name: Upload test results
265+
# NO check_name. With one, this action publishes a SEPARATE check run
266+
# via the Checks API, and its require_tests default of 'false' means an
267+
# absent junit.xml -- which is exactly what a killed pytest leaves behind
268+
# -- reports conclusion:success with zero duration. That green check sat
269+
# on top of a job timing out at 30 minutes for at least six merges.
270+
# annotate_only keeps the inline annotations without minting a check.
271+
- name: Annotate test results
152272
uses: mikepenz/action-junit-report@v4
153273
if: always()
154274
with:
155-
report_paths: tests/junit.xml
156-
check_name: Integration Tests
275+
report_paths: keepkey-firmware/deps/python-keepkey/tests/junit.xml
276+
annotate_only: true
277+
require_tests: true
278+
fail_on_failure: true
157279

158280
- name: Fail on test failure
159281
if: always()
160282
run: |
161-
STATUS=$(cat tests/status 2>/dev/null || echo "1")
283+
STATUS=$(cat keepkey-firmware/deps/python-keepkey/tests/status 2>/dev/null || echo "1")
162284
[ "$STATUS" = "0" ] || exit 1

.github/workflows/copilot-review.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
name: Request Copilot Review
22

33
on:
4-
pull_request:
4+
# This workflow never checks out or executes pull-request code. Using the
5+
# base-repository context is therefore safe and is required for cross-fork
6+
# PRs, whose pull_request GITHUB_TOKEN is always downgraded to read-only.
7+
pull_request_target:
58
types: [opened, reopened, ready_for_review, synchronize]
69

710
jobs:
811
request-copilot-review:
12+
if: github.event.pull_request.draft == false
913
runs-on: ubuntu-latest
1014
permissions:
1115
pull-requests: write

.gitmodules

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[submodule "device-protocol"]
22
path = device-protocol
33
url = https://github.com/keepkey/device-protocol.git
4-
branch = master
4+
branch = up/release-protocol
55
[submodule "keepkeylib/eth/ethereum-lists"]
66
path = keepkeylib/eth/ethereum-lists
77
url = https://github.com/keepkey/ethereum-lists.git

build_pb.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ CURDIR=$(pwd)
33
cd "device-protocol"
44

55
echo "Building with protoc version: $(protoc --version)"
6-
for i in messages messages-ethereum messages-eos messages-nano messages-cosmos messages-ripple messages-binance messages-tendermint messages-thorchain messages-osmosis messages-mayachain messages-solana messages-tron messages-ton messages-zcash types ; do
6+
for i in messages messages-ethereum messages-eos messages-nano messages-cosmos messages-ripple messages-binance messages-hive messages-tendermint messages-thorchain messages-osmosis messages-mayachain messages-solana messages-tron messages-ton messages-zcash types ; do
77
protoc --python_out=$CURDIR/keepkeylib/ -I/usr/include -I. $i.proto
88
i=${i/-/_}
99
sed -i -Ee 's/^import ([^.]+_pb2)/from . import \1/' $CURDIR/keepkeylib/"$i"_pb2.py

keepkeylib/clearsign_abi.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Minimal, deterministic Solidity ABI encoder for STATIC types only.
3+
4+
Used to build REAL calldata for the clear-sign flow catalog from a function
5+
signature + argument values, instead of hand-typing hex (which is how bugs
6+
get shipped in a signing test suite). Selectors are always derived from
7+
keccak256(signature) here — never trusted from an external source — so a
8+
wrong/hallucinated selector fails loudly instead of silently producing a
9+
plausible-looking but wrong test vector.
10+
11+
Deliberately does NOT support dynamic types (string, bytes, T[], tuples with
12+
dynamic members) — those need offset/length ABI encoding that's easy to get
13+
subtly wrong by hand. Calls with dynamic types are hand-built at the call
14+
site (see clearsign_catalog.py's multicall/handleOps entries) using the
15+
primitives here (_word/_addr_word) plus an explicit comment that the layout
16+
is a representative simplification, not a literal captured mainnet tx.
17+
"""
18+
19+
from .signed_metadata import keccak256
20+
21+
22+
def parse_signature(signature):
23+
"""'supply(address,uint256,address,uint16)' -> ('supply', ['address', 'uint256', 'address', 'uint16'])"""
24+
name, rest = signature.split('(', 1)
25+
rest = rest.rsplit(')', 1)[0]
26+
types = [t.strip() for t in rest.split(',')] if rest.strip() else []
27+
return name, types
28+
29+
30+
def selector(signature):
31+
"""4-byte function selector, always computed — never trusted as input."""
32+
return keccak256(signature.encode('ascii'))[:4]
33+
34+
35+
def _word(value):
36+
if isinstance(value, str) and value.startswith('0x'):
37+
value = int(value, 16)
38+
return int(value).to_bytes(32, 'big')
39+
40+
41+
def _addr_word(address):
42+
if isinstance(address, str):
43+
address = bytes.fromhex(address[2:] if address.startswith('0x') else address)
44+
assert len(address) == 20, 'address must be 20 bytes, got %d' % len(address)
45+
return b'\x00' * 12 + address
46+
47+
48+
def encode_static_args(types, values):
49+
"""ABI-encode STATIC Solidity types into concatenated 32-byte words.
50+
Raises on any dynamic type (string/bytes/arrays) — build those by hand."""
51+
assert len(types) == len(values), (
52+
'arg count mismatch: %d types, %d values' % (len(types), len(values)))
53+
out = bytearray()
54+
for typ, val in zip(types, values):
55+
if typ == 'address':
56+
out += _addr_word(val)
57+
elif typ.startswith('uint') or typ.startswith('int'):
58+
digits = typ[4:] if typ.startswith('uint') else typ[3:]
59+
bits = int(digits) if digits else 256
60+
n = int(val)
61+
assert 0 <= n < (1 << bits), 'value %r out of range for %s' % (val, typ)
62+
out += n.to_bytes(32, 'big')
63+
elif typ == 'bool':
64+
out += (1 if val else 0).to_bytes(32, 'big')
65+
elif typ.startswith('bytes') and typ != 'bytes' and not typ.endswith('[]'):
66+
n = int(typ[5:])
67+
b = val if isinstance(val, (bytes, bytearray)) else bytes.fromhex(
68+
val[2:] if val.startswith('0x') else val)
69+
assert len(b) == n, 'bytes%d value has wrong length' % n
70+
out += b.ljust(32, b'\x00') # bytesN is left-aligned per ABI spec
71+
else:
72+
raise ValueError(
73+
'dynamic/unsupported type %r — build this call by hand '
74+
'(see module docstring)' % typ)
75+
return bytes(out)
76+
77+
78+
def build_calldata(signature, values):
79+
"""selector(signature) + ABI-encoded static args, in one call."""
80+
_, types = parse_signature(signature)
81+
return selector(signature) + encode_static_args(types, values)

0 commit comments

Comments
 (0)