Skip to content

Commit 700c36d

Browse files
committed
ci(rc18): gate post-candidate firmware behavior
1 parent c477e72 commit 700c36d

10 files changed

Lines changed: 81 additions & 29 deletions

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ on:
2121
pull_request:
2222
branches: [master, develop, reconcile/upstream-sync]
2323

24+
# Every job only checks out source and emits runner-native annotations. Keep
25+
# the workflow token read-only even when the repository default is broader.
26+
permissions:
27+
contents: read
28+
2429
# One run per ref: a new push supersedes the old instead of both burning a
2530
# runner to completion.
2631
concurrency:

tests/test_msg_ethereum_clear_signing.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,11 @@ def test_replay_rejected_when_digest_differs(self):
10891089
def test_advanced_mode_gate(self):
10901090
"""AdvancedMode OFF + unknown contract + no metadata → hard reject;
10911091
ON → raw-data confirm path signs; recognized ERC-20 transfer unaffected."""
1092+
# RC18 predates the rule that loading a runtime signer itself requires
1093+
# AdvancedMode. The first released firmware line carrying that complete
1094+
# gate is 7.16; the older blind-transaction gate remains covered by
1095+
# test_msg_ethereum_signtx on RC18.
1096+
self.requires_firmware("7.16.0")
10921097
n = parse_path(DEVICE_PATH)
10931098
data = aave_supply_calldata(1000000000000000000)
10941099

@@ -1108,8 +1113,11 @@ def test_advanced_mode_gate(self):
11081113
to=AAVE_V3_POOL, value=0, data=data, chain_id=1)
11091114
self.fail("Expected Failure — blind signing disabled")
11101115
except CallException as e:
1111-
self.assertIn("Arbitrary contract data signing disabled by policy",
1112-
str(e))
1116+
message = str(e)
1117+
self.assertTrue(
1118+
"Arbitrary contract data signing disabled by policy" in message
1119+
or "Blind signing disabled by policy" in message,
1120+
"unexpected blind-sign refusal: %s" % message)
11131121

11141122
# ON → raw-data confirm path → signs
11151123
self.client.apply_policy("AdvancedMode", 1)
@@ -1163,8 +1171,11 @@ def test_cancel_clears_metadata_not_reused(self):
11631171
to=AAVE_V3_POOL, value=0, data=data, chain_id=chain_id)
11641172
self.fail("Expected Failure — stale metadata must not be reused")
11651173
except CallException as e:
1166-
self.assertIn("Arbitrary contract data signing disabled by policy",
1167-
str(e))
1174+
message = str(e)
1175+
self.assertTrue(
1176+
"Arbitrary contract data signing disabled by policy" in message
1177+
or "Blind signing disabled by policy" in message,
1178+
"unexpected blind-sign refusal: %s" % message)
11681179

11691180

11701181
# ── LoadClearsignSigner — the phase-1 trust path ───────────────────

tests/test_msg_ethereum_clearsign_additive.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@
8787
# METADATA_MAX_KEYS in include/keepkey/firmware/signed_metadata.h.
8888
METADATA_MAX_KEYS = 4
8989

90+
# RC18 verifies runtime metadata, but the successful-decode path did not yet
91+
# guarantee that the ordinary raw review survived byte-for-byte. That security
92+
# invariant landed after RC18 and first ships on the 7.16 line.
93+
ADDITIVE_REVIEW_FIRMWARE = "7.16.0"
94+
9095
# The Aave V3 supply() transaction every additive test signs. Real ABI
9196
# calldata (selector + 4 x 32-byte words), so the metadata below binds a
9297
# genuine transaction rather than a toy payload.
@@ -220,6 +225,7 @@ def test_successful_decode_still_runs_the_raw_review(self):
220225
byte-for-byte. 3 + num_args is the structural minimum from
221226
signed_metadata_confirm_screens(); pagination can only raise it.
222227
"""
228+
self.requires_firmware(ADDITIVE_REVIEW_FIRMWARE)
223229
self._load_signer()
224230
self._drop_setup_screenshots()
225231

@@ -277,6 +283,7 @@ def test_no_runtime_slot_can_reach_the_suppression_branch(self):
277283
at runtime and each one still shows the full baseline review after its
278284
decode. A slot that suppressed would be caught as a missing tail frame.
279285
"""
286+
self.requires_firmware(ADDITIVE_REVIEW_FIRMWARE)
280287
for key_id in range(METADATA_MAX_KEYS):
281288
self._load_signer(key_id=key_id, alias='CI Slot %d' % key_id)
282289
self._drop_setup_screenshots()
@@ -329,6 +336,7 @@ def test_v2_schema_decode_still_runs_the_raw_review(self):
329336
screen in its own baseline (the token path already skips it), so it
330337
could not show that the raw review survives.
331338
"""
339+
self.requires_firmware(ADDITIVE_REVIEW_FIRMWARE)
332340
self._load_signer()
333341
self._drop_setup_screenshots()
334342

tests/test_msg_ethereum_signtx.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,11 @@ def test_ethereum_blind_sign_blocked(self):
124124
)
125125
self.fail("Expected Failure -- blind signing should be blocked")
126126
except CallException as e:
127-
self.assertIn("Arbitrary contract data signing disabled by policy",
128-
str(e))
127+
message = str(e)
128+
self.assertTrue(
129+
"Arbitrary contract data signing disabled by policy" in message
130+
or "Blind signing disabled by policy" in message,
131+
"unexpected blind-sign refusal: %s" % message)
129132

130133
def test_ethereum_blind_sign_allowed(self):
131134
"""AdvancedMode ON + contract data = device shows BLIND SIGNATURE warning (7.15+).
@@ -270,7 +273,7 @@ def test_ethereum_sanity_checks(self):
270273
def test_ethereum_signtx_omitted_chain_id_rejected(self):
271274
"""An omitted chain_id must be refused, not silently signed pre-EIP-155.
272275
273-
Before 7.14.2 the `chain_id < 1` bounds check lived inside
276+
Before the post-RC18 hardening the `chain_id < 1` bounds check lived inside
274277
`if (msg->has_chain_id)`, so a host that simply left the field out
275278
reached chain_id == 0 without tripping it. Two things followed:
276279
@@ -286,7 +289,9 @@ def test_ethereum_signtx_omitted_chain_id_rejected(self):
286289
sibling tests in this file all now pass chain_id explicitly so they
287290
keep exercising their own subject rather than this one.
288291
"""
289-
self.requires_firmware("7.14.2")
292+
# Explicit zero was already rejected on RC18, but an omitted field was
293+
# not. The absent-field fix landed after RC18 and first ships in 7.16.
294+
self.requires_firmware("7.16.0")
290295
self.requires_fullFeature()
291296
self.setup_mnemonic_nopin_nopassphrase()
292297
self.client.apply_policy("AdvancedMode", 1)

tests/test_msg_resetdevice.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ def generate_entropy(strength, internal_entropy, external_entropy):
5656
return entropy_stripped
5757

5858
class TestDeviceReset(common.KeepKeyTest):
59+
POST_RC18_SETUP_FIRMWARE = "7.16.0"
60+
5961
def test_reset_device(self):
6062
# No PIN, no passphrase
6163
external_entropy = b'zlutoucky kun upel divoke ody' * 2
@@ -112,7 +114,9 @@ def test_reset_device(self):
112114
self.assertIsInstance(resp, proto.Success)
113115

114116
def test_reset_device_dice(self):
115-
self.requires_firmware("7.15.0")
117+
# On-device dice entry landed after the RC18 candidate. RC18 accepts
118+
# the forward-compatible field but follows the ordinary entropy flow.
119+
self.requires_firmware(self.POST_RC18_SETUP_FIRMWARE)
116120

117121
external_entropy = b'zlutoucky kun upel divoke ody' * 2
118122
strength = 256 # 99 rolls
@@ -200,15 +204,17 @@ def test_reset_reentry_disarms_entropy_ack(self):
200204
int_entropy, so a following EntropyAck derived the seed from
201205
sha256(0*32 || host_bytes) -- entirely host-chosen.
202206
203-
7.15 closes it EARLIER and more strongly than the original fix did.
207+
The post-RC18 setup hardening closes it EARLIER and more strongly than
208+
the original fix did.
204209
#429 replaced the separate awaiting_entropy flag with a single armed
205210
(kind) ceremony, and setup_stage() now REFUSES to open a second
206211
ceremony on top of an armed one. So the re-entry this test used to
207212
perform is rejected outright rather than being allowed and then
208213
disarmed -- there is no second ceremony to leave armed. Both halves are
209214
asserted below: the refusal, and then the original property.
210215
"""
211-
self.requires_firmware("7.15.0")
216+
# The single armed-ceremony guard is the post-RC18 #429 behavior.
217+
self.requires_firmware(self.POST_RC18_SETUP_FIRMWARE)
212218
self.client.wipe_device()
213219

214220
# Arm a reset and walk away without acking the entropy request.
@@ -258,17 +264,18 @@ def test_reset_device_pin(self):
258264
label='test'))
259265

260266
# display_random=True above is deliberate: the field stays in the wire
261-
# schema for host compatibility. Firmware 7.15.0 (fw 320f0eb5, "no
262-
# entropy display") stopped honouring it -- internal entropy is seed
267+
# schema for host compatibility. The post-RC18 setup hardening (fw
268+
# 320f0eb5, "no entropy display"), first shipped on 7.16, stopped
269+
# honouring it -- internal entropy is seed
263270
# pre-image material, and a host that sets the flag and reads that
264271
# screen once can compute SHA256(shown || ext) and derive the seed.
265272
#
266273
# Branch on the version rather than skipping the test: everything below
267274
# (PIN entry, EntropyRequest/Ack, mnemonic derivation) is version-
268275
# independent and must keep running on older firmware.
269276
f = self.client.features
270-
if (f.major_version, f.minor_version, f.patch_version) < (7, 15, 0):
271-
# Pre-7.15: the Internal Entropy screen legitimately still exists.
277+
if (f.major_version, f.minor_version, f.patch_version) < (7, 16, 0):
278+
# RC18 and older: the Internal Entropy screen still exists.
272279
self.assertIsInstance(ret, proto.ButtonRequest)
273280
self.client.debug.press_yes()
274281
ret = self.client.call_raw(proto.ButtonAck())
@@ -342,17 +349,18 @@ def test_failed_pin(self):
342349
label='test'))
343350

344351
# display_random=True above is deliberate: the field stays in the wire
345-
# schema for host compatibility. Firmware 7.15.0 (fw 320f0eb5, "no
346-
# entropy display") stopped honouring it -- internal entropy is seed
352+
# schema for host compatibility. The post-RC18 setup hardening (fw
353+
# 320f0eb5, "no entropy display"), first shipped on 7.16, stopped
354+
# honouring it -- internal entropy is seed
347355
# pre-image material, and a host that sets the flag and reads that
348356
# screen once can compute SHA256(shown || ext) and derive the seed.
349357
#
350358
# Branch on the version rather than skipping the test: everything below
351359
# (PIN entry, EntropyRequest/Ack, mnemonic derivation) is version-
352360
# independent and must keep running on older firmware.
353361
f = self.client.features
354-
if (f.major_version, f.minor_version, f.patch_version) < (7, 15, 0):
355-
# Pre-7.15: the Internal Entropy screen legitimately still exists.
362+
if (f.major_version, f.minor_version, f.patch_version) < (7, 16, 0):
363+
# RC18 and older: the Internal Entropy screen still exists.
356364
self.assertIsInstance(ret, proto.ButtonRequest)
357365
self.client.debug.press_yes()
358366
ret = self.client.call_raw(proto.ButtonAck())

tests/test_msg_session_trust_lifetime.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
Session and Trust Lifetime — provider trust must die on its own.
33
4-
Two claims in the 7.15 clear-sign design have never been tested end to end:
4+
Two post-RC18 clear-sign lifetime claims have never been tested end to end:
55
66
1. AdvancedMode is SESSION state, never a flash bit. storage.c writes bit 12
77
of the storage flags word as zero and ignores it on read (four sites:
@@ -198,7 +198,9 @@ def _resolve_executable(pid, comm, cwd):
198198

199199
class TestSessionTrustLifetime(common.KeepKeyTest):
200200

201-
MIN_FIRMWARE = "7.15.0"
201+
# RC18 still persisted AdvancedMode and retained runtime signers across
202+
# session teardown. The session-lifetime fixes first ship in 7.16.
203+
MIN_FIRMWARE = "7.16.0"
202204

203205
def setUp(self):
204206
super(TestSessionTrustLifetime, self).setUp()

tests/test_msg_solana_lut_attestation.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ class TestSolanaLutAttestation(common.KeepKeyTest):
3939

4040
def setUp(self):
4141
super(TestSolanaLutAttestation, self).setUp()
42-
self.requires_firmware("7.15.0")
42+
# KKSOLSW1 landed after the RC18 candidate and first ships in 7.16.
43+
# RC18 ignores the forward-compatible attestation fields, which makes
44+
# all negative-path tests pass vacuously unless the whole class gates.
45+
self.requires_firmware("7.16.0")
4346
self.requires_fullFeature()
4447
self.requires_message("LoadClearsignSigner")
4548
self.setup_mnemonic_allallall()

tests/test_msg_zcash_sign_pczt_device.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,10 @@ def test_shielded_output_review_is_two_screens(self):
234234
matters: one screen cannot hold both, and collapsing them back into one
235235
reintroduces exactly the defect.
236236
"""
237+
# RC18 has the Orchard flow but predates the repair that separates the
238+
# amount and 106-character unified address. That UI fix first ships in
239+
# 7.16, so do not mislabel it as an RC18 regression in this host suite.
240+
self.requires_firmware("7.16.0")
237241
actions = [note_action(CMX_ORCHARD)]
238242
screens = self._capture_button_screens()
239243

tests/test_sign_typed_data.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ def test_ethereum_sign_x402_eip3009(self):
3636
recipient and value embedded in the signed EIP-712 message.
3737
"""
3838
self.requires_fullFeature()
39-
self.requires_firmware("7.15.0")
39+
# RC18 still exposes the legacy JSON endpoint. Its fail-closed
40+
# retirement and the replacement streamed implementation land on 7.16.
41+
self.requires_firmware("7.16.0")
4042
self.requires_message("Ethereum712TypesValues")
4143
self.setup_mnemonic_allallall()
4244

@@ -75,7 +77,8 @@ def test_ethereum_sign_x402_eip3009(self):
7577
},
7678
}
7779

78-
# 7.14.2 DISABLED structured EIP-712 outright, pending canonical
80+
# Hardened firmware disables legacy structured EIP-712 outright,
81+
# pending canonical
7982
# display hardening: the device could not prove that what it rendered
8083
# was what it hashed. This vector is the x402 EIP-3009
8184
# TransferWithAuthorization payment flow, and it is currently REFUSED
@@ -129,8 +132,11 @@ def sign(test):
129132
sign(txtests['tests'][0])
130133
# The firmware names the remedy rather than just the refusal:
131134
# "Enable AdvancedMode to blind-sign typed hashes".
132-
self.assertIn('Enable AdvancedMode to blind-sign typed hashes',
133-
str(ctx.exception))
135+
message = str(ctx.exception)
136+
self.assertTrue(
137+
'Enable AdvancedMode to blind-sign typed hashes' in message
138+
or 'Typed-hash signing disabled by policy' in message,
139+
'unexpected typed-hash refusal: %s' % message)
134140

135141
self.client.apply_policy('AdvancedMode', True)
136142
try:

tests/test_verify_typed_data.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
class TestMsgE712Verify(common.KeepKeyTest):
3131

3232
def test_structured_eip712_is_refused(self):
33-
"""7.14.2 disables structured EIP-712 outright.
33+
"""Post-RC18 firmware disables legacy structured EIP-712 outright.
3434
3535
ethereum_structured_eip712_enabled() returns false
3636
(lib/firmware/ethereum.c), so fsm_msgEthereum712TypesValues fails closed
@@ -44,7 +44,7 @@ def test_structured_eip712_is_refused(self):
4444
replaced by test_verify below, not simply deleted.
4545
"""
4646
self.requires_fullFeature()
47-
self.requires_firmware("7.14.2")
47+
self.requires_firmware("7.16.0")
4848
self.setup_mnemonic_allallall()
4949

5050
try:
@@ -55,7 +55,7 @@ def test_structured_eip712_is_refused(self):
5555
value_prop='{"domain": {}}',
5656
typevals=1,
5757
)
58-
self.fail("Expected Failure -- structured EIP-712 is disabled in 7.14.2")
58+
self.fail("Expected Failure -- legacy structured EIP-712 is disabled")
5959
except CallException as e:
6060
self.assertIn("Structured EIP-712 disabled", str(e))
6161

0 commit comments

Comments
 (0)