Skip to content

Commit 8e59e4c

Browse files
Merge pull request #59 from BitHighlander/fix/alpha-audit-followups
fix(audit): require complete signing and screenshot evidence
2 parents 8e456e2 + 4641244 commit 8e59e4c

12 files changed

Lines changed: 242 additions & 75 deletions

.circleci/config.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ jobs:
1111
- run:
1212
name: Clone python-keepkey (current branch)
1313
command: |
14-
git clone --depth 1 -b "$CIRCLE_BRANCH" https://github.com/keepkey/python-keepkey.git .pykk
14+
git clone --depth 1 -b "$CIRCLE_BRANCH" \
15+
"https://github.com/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}.git" .pykk
1516
cd .pykk && git submodule update --init --recursive
1617
1718
# ────────────────────────────────────────────────────────────────
@@ -27,8 +28,12 @@ jobs:
2728
# Move python-keepkey out of the way
2829
mv .pykk ../
2930
30-
# Clone firmware repository (expects $FIRMWARE_REPO env var)
31-
git clone --depth 1 -b master "$FIRMWARE_REPO" .
31+
# Test the same exact companion implementation as the GitHub
32+
# Actions integration lane. Bump deliberately with that workflow.
33+
git init .
34+
git remote add origin https://github.com/BitHighlander/keepkey-firmware.git
35+
git fetch --depth 1 origin 54b169a7036b29db22944d962fb666b50aef9083
36+
git checkout --detach FETCH_HEAD
3237
3338
# Initialise firmware submodules
3439
git submodule update --init --recursive

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ jobs:
130130
# integration-btc job) and the Ironwood known-answer vectors. So this
131131
# job validates 7.16.0; it does not validate the RC18 dependency
132132
# graph. Bump deliberately, and re-read that claim when you do.
133-
ref: 6d5e1917b7cc80a5b84a6ec79c82cffef3aab4c9
133+
ref: 54b169a7036b29db22944d962fb666b50aef9083
134134
path: keepkey-firmware
135135

136136
# NOT `submodules: recursive`. trezor-firmware carries a micropython

keepkeylib/client.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -523,18 +523,26 @@ def _capture_oled(self):
523523
print("[SCREENSHOT] ERROR: %s" % e, file=sys.stderr)
524524
traceback.print_exc(file=sys.stderr)
525525

526+
def capture_oled(self):
527+
"""Capture a settled confirmation screen in a manual protocol flow.
528+
529+
Tests that use call_raw() need per-step control and never dispatch
530+
callback_ButtonRequest(). Keeping the settle delay in this public
531+
helper makes their evidence equivalent to the automatic callback path.
532+
"""
533+
if SCREENSHOT:
534+
time.sleep(SCREENSHOT_SETTLE_SECONDS)
535+
self._capture_oled()
536+
526537
def callback_ButtonRequest(self, msg):
527538
if self.verbose:
528539
log("ButtonRequest code: " + get_buttonrequest_value(msg.code))
529540

530541
# The firmware emits ButtonRequest immediately before drawing the
531542
# confirmation. Allow the emulator's render transition to settle so
532543
# regression evidence cannot capture a partially drawn OLED.
533-
if SCREENSHOT:
534-
time.sleep(SCREENSHOT_SETTLE_SECONDS)
535-
536544
# Capture OLED screenshot BEFORE pressing button (confirmation screen)
537-
self._capture_oled()
545+
self.capture_oled()
538546

539547
if self.auto_button:
540548
if self.verbose:

keepkeylib/eth/ethereum_tokens.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,30 @@ def add_tokens(self, network):
2929
fullpath = os.path.join(dirname, filename)
3030

3131
if not os.path.isfile(fullpath):
32-
return
32+
continue
3333

3434
with open(fullpath, 'r') as f:
3535
token = json.load(f)
3636

3737
self.tokens.append(ETHToken(token, network))
3838

3939
def build(self):
40+
source = HERE + '/ethereum-lists/src/tokens'
41+
if not os.path.isdir(source):
42+
raise RuntimeError(
43+
'vetted ethereum-lists token source is missing; initialize '
44+
'submodules recursively before generating firmware tables')
45+
4046
with open(HERE + '/ethereum_networks.json', 'r') as f:
4147
networks = json.load(f)
4248

4349
for network in networks:
4450
self.add_tokens(network)
4551

52+
if not self.tokens:
53+
raise RuntimeError(
54+
'vetted ethereum-lists token source produced zero candidates')
55+
4656
def serialize_c(self, outf):
4757
# Flash budget: this table is the largest read-only symbol in the ARM
4858
# image. See token_policy for why it is capped rather than complete.

scripts/generate-test-report.py

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -526,10 +526,9 @@ def _arg_shown(a):
526526
'0x transformERC20 raw disclosure',
527527
'A 1480-byte transformERC20 payload exceeds one 1024-byte chunk. The device must NOT '
528528
'clear-sign it as a token swap, because the bytes past the initial chunk are hashed '
529-
'without being decoded. With AdvancedMode on it falls to the raw path, where the byte '
530-
'count shown must be the FULL length (1480), not the chunk length (1024) - a short '
531-
'count would under-report what is being signed.',
532-
['Raw contract data screen showing the full byte count']),
529+
'without being decoded. With AdvancedMode on it falls to the raw path, where the final '
530+
'screen commits to all 1480 bytes with a Keccak-256 the user can compare to the host.',
531+
['Complete contract-data Keccak-256 commitment']),
533532
('J2', 'test_msg_ethereum_erc20_0x_signtx', 'test_sign_0x_swap_ERC20_to_ETH',
534533
'0x sellToUniswap names both assets',
535534
'Clear-signing is only honest when BOTH token words resolve to known assets. This '
@@ -538,18 +537,24 @@ def _arg_shown(a):
538537
['Swap screen naming both assets and amounts']),
539538
('J3', 'test_msg_ethereum_erc20_0x_signtx', 'test_sign_longdata_swap',
540539
'Long 0x calldata stays disclosed',
541-
'Calldata spanning multiple chunks must not silently lose its tail from the display '
542-
'while remaining inside the signature.',
543-
['Contract data screen']),
540+
'Calldata spanning multiple chunks must not silently lose its tail from the user-visible '
541+
'commitment while remaining inside the signature.',
542+
['Complete contract-data Keccak-256 commitment']),
543+
('J4', 'test_msg_ethereum_signing_guards',
544+
'test_streamed_calldata_tail_changes_user_commitment',
545+
'A streamed tail changes the approval screens',
546+
'Signs two equal-length payloads with identical initial 1024-byte chunks and a one-bit '
547+
'difference in the final EthereumTxAck byte. Their ordered OLED frame sequences must '
548+
'differ, proving the complete-calldata Keccak-256 -- not merely the visible prefix or '
549+
'declared length -- reaches the user before either signature is emitted.',
550+
['Complete contract-data Keccak-256 commitment']),
544551
('J8', 'test_msg_ethereum_signing_guards',
545552
'test_contract_handler_streamed_calldata_signs_full_data',
546553
'Streamed calldata is fully covered',
547-
'Calldata delivered across several chunks must be hashed in full and disclosed in full. '
548-
'This is the positive control for the chunk-completeness gate. NOTE: every test in '
549-
'test_msg_ethereum_signing_guards currently SKIPS in CI under requires_firmware, so no '
550-
'screen can be captured for it yet - the screenshot list stays empty until the gate '
551-
'opens, rather than declaring an expectation nothing can satisfy.',
552-
[]),
554+
'Calldata delivered across several chunks must be hashed in full, and the final OLED '
555+
'commitment must cover that same complete byte string. This is the positive control for '
556+
'the chunk-completeness gate.',
557+
['Complete contract-data Keccak-256 commitment']),
553558
('J9', 'test_msg_ethereum_signing_guards', 'test_eip1559_requires_chain_id',
554559
'Omitted chain_id is refused before any screen',
555560
'Without a chain_id the device cannot name the network, and a signature would be '
@@ -1133,21 +1138,25 @@ def _arg_shown(a):
11331138
'Failure on the wire.',
11341139
[]),
11351140
('E17', 'test_msg_ethereum_erc20_uniswap_liquidity', 'test_sign_uni_approve_liquidity_ETH',
1136-
'Uniswap V2 add-liquidity approve (pending)',
1137-
'PENDING, disclosed: known emulator limitation — an approve to an unknown (non-registry) '
1138-
'token contract cannot complete against the kkemu emulator (matches the sibling '
1139-
'add/remove-liquidity skips below); the device-firmware path is not in question, only '
1140-
'CI emulator coverage. Real-device testing is unaffected.',
1141-
[]),
1141+
'Uniswap V2 LP-token approval',
1142+
'Approves the Uniswap V2 FOX/WETH LP token for the canonical router. The exact pool '
1143+
'identity and full-LP allowance are shown before the generic fee review, and the fixed '
1144+
'signature proves the reviewed transaction bytes are the bytes signed.',
1145+
['Full LP allowance', 'LP token and pool address', 'Fee and final approval']),
11421146
('E18', 'test_msg_ethereum_erc20_uniswap_liquidity', 'test_sign_uni_add_liquidity_ETH',
1143-
'Uniswap V2 add liquidity ETH+token (pending)',
1144-
'PENDING, disclosed: same emulator limitation as E17 — a daily-driver LP-deposit flow '
1145-
'with no PDF proof on this build; tracked for real-device verification.',
1146-
[]),
1147+
'Uniswap V2 add liquidity ETH+token',
1148+
'Clear-signs both desired/minimum FOX and ETH amounts, the signed recipient, and the '
1149+
'deadline before the final fee review. The fixed signature binds those confirmations '
1150+
'to the complete addLiquidityETH calldata.',
1151+
['FOX desired amount', 'FOX minimum', 'Recipient', 'ETH desired amount',
1152+
'ETH minimum', 'Deadline', 'Fee and final approval']),
11471153
('E19', 'test_msg_ethereum_erc20_uniswap_liquidity', 'test_sign_uni_remove_liquidity_ETH',
1148-
'Uniswap V2 remove liquidity ETH+token (pending)',
1149-
'PENDING, disclosed: same emulator limitation as E17.',
1150-
[]),
1154+
'Uniswap V2 remove liquidity ETH+token',
1155+
'Clear-signs the LP burn amount, minimum FOX and ETH outputs, the non-self signed '
1156+
'recipient, and deadline before the final fee review. This is the regression for the '
1157+
'recipient-confirmation path that previously cancelled after the user approved it.',
1158+
['LP burn amount', 'FOX minimum', 'Recipient', 'ETH minimum', 'Deadline',
1159+
'Fee and final approval']),
11511160
('E20', 'test_msg_ethereum_thorchain_deposit', 'test_deposit_legacy_selector',
11521161
'THORChain router deposit() (legacy selector)',
11531162
'Cross-chain swap via the THORChain router contract — a daily-driver EVM<->THORChain '
@@ -2930,7 +2939,7 @@ def _arg_shown(a):
29302939
'f2cee375...912090f and messageHash c52c0ee5...4b371e, both published, both matched on '
29312940
'hardware and in the emulator.',
29322941
['Domain name', 'Domain version', 'chainId', 'verifyingContract (42 chars, in full)',
2933-
'Cow / wallet', 'Bob / wallet', 'contents']),
2942+
'From name: Cow', 'From wallet', 'To name: Bob', 'To wallet', 'contents']),
29342943
('TD2', 'test_msg_eip712_streaming', 'test_array_of_structs_walks',
29352944
'An array of structs walks and signs',
29362945
'Arrays hash WITHOUT a typeHash prefix -- enc(array) is the keccak of the concatenated '

tests/test_msg_eip712_streaming.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ def _walk(self, doc, max_steps=400):
118118
resp = self.client.call_raw(msg)
119119
for _ in range(max_steps):
120120
if isinstance(resp, proto.ButtonRequest):
121+
# This is a manual, device-driven call_raw() loop, so no
122+
# callback_ButtonRequest() will capture the field being
123+
# approved. Retain it while that exact field is still active.
124+
self.client.capture_oled()
121125
self.client.debug.press_yes()
122126
resp = self.client.call_raw(proto.ButtonAck())
123127
elif isinstance(resp, eth.EthereumTypedDataStructRequest):
@@ -140,6 +144,8 @@ def setUp(self):
140144
self.requires_structured_eip712()
141145
self.setup_mnemonic_nopin_nopassphrase()
142146
self.client.apply_policy('AdvancedMode', 1)
147+
# The report entries describe typed-data fields, not the policy prompt.
148+
self.client.reset_screenshots()
143149

144150
def test_spec_example_matches_the_published_hashes(self):
145151
"""The device's own hashes equal the EIP-712 reference implementation's.

tests/test_msg_ethereum_erc20_uniswap_liquidity.py

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,24 +27,6 @@
2727

2828
class TestMsgEthereumUniswaptxERC20(common.KeepKeyTest):
2929

30-
def setUp(self):
31-
super(TestMsgEthereumUniswaptxERC20, self).setUp()
32-
# Every test in this file approves or spends against the ETH/FOX pool,
33-
# whose contract is NOT in the token table. Approving an unknown token
34-
# contract does not complete on the emulator: the device never returns,
35-
# so these tests HANG instead of failing, and CI kills the whole run on
36-
# its no-output timeout -- taking every later test with it.
37-
#
38-
# This is a firmware-side limitation, not a gap in the tests. It is
39-
# gated here rather than deleted so the coverage returns automatically
40-
# once the firmware completes this path. Known-token approves
41-
# (test_msg_ethereum_erc20_approve) run here and pass; on real hardware
42-
# this path is exercised by the app.
43-
if self.client.features.firmware_variant[0:8] == "Emulator":
44-
self.skipTest(
45-
"Uniswap liquidity against an unknown token contract does not "
46-
"complete on the emulator")
47-
4830
def test_sign_uni_approve_liquidity_ETH(self):
4931
self.requires_fullFeature()
5032
self.requires_firmware("7.1.0")
@@ -102,7 +84,9 @@ def test_sign_uni_add_liquidity_ETH(self):
10284

10385
def test_sign_uni_remove_liquidity_ETH(self):
10486
self.requires_fullFeature()
105-
self.requires_firmware("7.1.0")
87+
# Sending the withdrawn assets to a third-party recipient was refused
88+
# by RC18. The reviewed external-recipient flow lands on the 7.16 line.
89+
self.requires_firmware("7.16.0")
10690
self.setup_mnemonic_nopin_nopassphrase()
10791

10892
# remove liquidity from the ETH/FOX pool

tests/test_msg_ethereum_signing_guards.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ def test_legacy_with_max_fee_rejected(self):
204204
def _sign_streamed(self, selector):
205205
"""Sign the streaming-path tx with `selector`, recording its screens."""
206206
data = selector + self.STREAMED_TAIL
207+
return self._sign_streamed_data(data)
208+
209+
def _sign_streamed_data(self, data):
210+
"""Sign exact streamed calldata and retain every approval framebuffer."""
207211
with _ScreenRecorder(self.client) as rec:
208212
sig_v, sig_r, sig_s = self.client.ethereum_sign_tx(
209213
data=data, **self.STREAM_TX)
@@ -270,6 +274,35 @@ def test_streamed_handler_calldata_is_not_clear_signed(self):
270274
# Any clear-sign summary would be one or more EXTRA confirm screens.
271275
self.assertEqual(len(observed.frames), len(baseline.frames))
272276

277+
def test_streamed_calldata_tail_changes_user_commitment(self):
278+
"""Bytes arriving after the initial chunk must change what is shown.
279+
280+
Both transactions have identical first 1024-byte protobuf chunks and
281+
identical lengths; only the final byte in EthereumTxAck differs. The
282+
pre-fix firmware displayed the same prefix/count screens for both and
283+
then hashed the distinct tails invisibly. The complete-calldata
284+
Keccak-256 confirmation makes the approval sequences distinguishable.
285+
"""
286+
self.requires_firmware("7.16.0")
287+
self.requires_fullFeature()
288+
self.setup_mnemonic_nopin_nopassphrase()
289+
self.client.apply_policy("AdvancedMode", 1)
290+
291+
base_data = self.NO_HANDLER_SELECTOR + self.STREAMED_TAIL
292+
changed_data = base_data[:-1] + binascii.unhexlify(
293+
"%02x" % (bytearray(base_data)[-1] ^ 0x01))
294+
self.assertEqual(base_data[:1024], changed_data[:1024])
295+
296+
baseline, _, baseline_sig = self._sign_streamed_data(base_data)
297+
self._assert_signed_full_calldata(base_data, baseline_sig)
298+
changed, _, changed_sig = self._sign_streamed_data(changed_data)
299+
self._assert_signed_full_calldata(changed_data, changed_sig)
300+
301+
self.assertEqual(len(baseline.frames), len(changed.frames))
302+
self.assertNotEqual(
303+
baseline.layouts, changed.layouts,
304+
"a streamed tail changed the signature but not the user's screens")
305+
273306

274307
if __name__ == "__main__":
275308
unittest.main()

tests/test_msg_recoverydevice_cipher.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,18 @@ def test_nopin_nopassphrase(self):
111111
ret = self.client.call_raw(proto.ButtonAck())
112112

113113
mnemonic_words = mnemonic.split(' ')
114+
captured_cipher = False
114115

115116
for index, word in enumerate(mnemonic_words):
116117
for character in word:
117118
self.assertIsInstance(ret, proto.CharacterRequest)
118119
cipher = self.client.debug.read_recovery_cipher()
120+
if not captured_cipher:
121+
# CharacterRequest is driven manually and never reaches
122+
# callback_ButtonRequest(); capture only after DebugLink
123+
# proves this is the active randomized cipher grid.
124+
self.client.capture_oled()
125+
captured_cipher = True
119126

120127
encoded_character = cipher[ord(character) - 97]
121128
ret = self.client.call_raw(proto.CharacterAck(character=encoded_character))

tests/test_msg_resetdevice.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ def test_reset_device(self):
8787
mnemonic = []
8888
while isinstance(resp, proto.ButtonRequest):
8989
mnemonic.append(self.client.debug.read_reset_word())
90+
if len(mnemonic) == 1:
91+
# Manual call_raw() flow: bind the report evidence to a word
92+
# the DebugLink confirms is currently on the device.
93+
self.client.capture_oled()
9094
self.client.debug.press_yes()
9195
resp = self.client.call_raw(proto.ButtonAck())
9296

@@ -132,6 +136,7 @@ def test_reset_device_dice(self):
132136
# Device announces the on-device dice entry screen
133137
self.assertIsInstance(ret, proto.ButtonRequest)
134138
self.assertEqual(ret.code, proto_types.ButtonRequest_DiceRoll)
139+
self.client.capture_oled()
135140

136141
# Ack without blocking on the reply: the device only leaves the dice
137142
# screen once the rolls are complete, and input is ignored until the
@@ -169,6 +174,7 @@ def test_reset_device_dice(self):
169174
dice_digest = self.client.debug.read_dice_digest()
170175
self.assertEqual(dice_digest,
171176
hashlib.sha256(expected.encode('ascii')).digest())
177+
self.client.capture_oled()
172178

173179
self.client.debug.press_yes()
174180
ret = self.client.call_raw(proto.ButtonAck())

0 commit comments

Comments
 (0)