Skip to content

Commit 4da59c8

Browse files
committed
feat(tests): add client methods, tests, and SECTIONS for message-signing parity
Covers all four firmware feature branches in one python-keepkey branch: Client methods (keepkeylib/client.py): - tron_sign_typed_hash() — TIP-712 hash mode - ton_sign_message() — bare Ed25519 (AdvancedMode-gated) - solana_sign_offchain_message() — domain-separated envelope Test files: - test_msg_tron_signtypedhash.py (6 tests) — TIP-712 hash + domain-only + rejection cases - test_msg_ton_signmessage.py (6 tests) — AdvancedMode gate + sign + pubkey consistency - test_msg_solana_signoffchainmessage.py (7 tests) — format/version validation + envelope-not-raw assertion (load-bearing: catches firmware regressions that drop the envelope) Report generator (scripts/generate-test-report.py): - TRON section: T6-T18 added (7 SignMessage + 6 TIP-712), description mentions TIP-191 + TIP-712, dropped 'NEW:' prefix (no longer literally new) - TON section: N8-N13 added, description mentions SignMessage + AdvancedMode gate, TON Connect ton_proof noted as deferred - Solana section: S13-S19 added, description mentions off-chain envelope as 'the proper fix for the SignMessage AdvancedMode band-aid' - KK_BUILD_LABEL env var support — CI sets to '<head_ref>@<sha[0:8]>' so per-PR PDFs are no longer byte-identical (was issue #2 in the report audit: PR #221/#223/#224 all produced the same PDF md5) These additions only render when the corresponding firmware handler is present (requires_message gates the test classes), so PRs that don't yet implement a feature still get green PDFs without false coverage.
1 parent ca1063e commit 4da59c8

5 files changed

Lines changed: 540 additions & 8 deletions

File tree

keepkeylib/client.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1633,6 +1633,20 @@ def solana_sign_message(self, address_n, message):
16331633
solana_proto.SolanaSignMessage(address_n=address_n, message=message)
16341634
)
16351635

1636+
@expect(solana_proto.SolanaOffchainMessageSignature)
1637+
def solana_sign_offchain_message(
1638+
self, address_n, message, version=0, message_format=0, show_display=False
1639+
):
1640+
return self.call(
1641+
solana_proto.SolanaSignOffchainMessage(
1642+
address_n=address_n,
1643+
version=version,
1644+
message_format=message_format,
1645+
message=message,
1646+
show_display=show_display,
1647+
)
1648+
)
1649+
16361650
# ── Tron ───────────────────────────────────────────────────
16371651
@expect(tron_proto.TronAddress)
16381652
def tron_get_address(self, address_n, show_display=False):
@@ -1665,6 +1679,16 @@ def tron_verify_message(self, address, signature, message):
16651679
)
16661680
)
16671681

1682+
@expect(tron_proto.TronTypedDataSignature)
1683+
def tron_sign_typed_hash(self, address_n, domain_separator_hash, message_hash=None):
1684+
msg = tron_proto.TronSignTypedHash(
1685+
address_n=address_n,
1686+
domain_separator_hash=domain_separator_hash,
1687+
)
1688+
if message_hash is not None:
1689+
msg.message_hash = message_hash
1690+
return self.call(msg)
1691+
16681692
# ── TON ────────────────────────────────────────────────────
16691693
@expect(ton_proto.TonAddress)
16701694
def ton_get_address(self, address_n, show_display=False):
@@ -1678,6 +1702,16 @@ def ton_sign_tx(self, address_n, raw_tx):
16781702
ton_proto.TonSignTx(address_n=address_n, raw_tx=raw_tx)
16791703
)
16801704

1705+
@expect(ton_proto.TonMessageSignature)
1706+
def ton_sign_message(self, address_n, message, show_display=False):
1707+
return self.call(
1708+
ton_proto.TonSignMessage(
1709+
address_n=address_n,
1710+
message=message,
1711+
show_display=show_display,
1712+
)
1713+
)
1714+
16811715
# ── Zcash Address Display ─────────────────────────────────
16821716
@expect(zcash_proto.ZcashAddress)
16831717
def zcash_display_address(self, address_n, address, ak, nk, rivk, account=None):

scripts/generate-test-report.py

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -813,13 +813,15 @@ def parse_junit(path):
813813
]),
814814

815815
('S', 'Solana', '7.14.0',
816-
'NEW: Full Solana with Ed25519 (SLIP-10), base58 addresses, 37 instruction types across 7 '
816+
'Full Solana with Ed25519 (SLIP-10), base58 addresses, 37 instruction types across 7 '
817817
'programs. Key security fix: full 44-character address display replaces old 8-char truncation '
818-
'that was a spoofing vector.',
818+
'that was a spoofing vector. Off-chain message signing (domain-separated envelope) added '
819+
'in 7.14.x — the proper fix for the SignMessage AdvancedMode band-aid.',
819820
[
820821
'ADDRESS: m/44\'/501\'/0\' Ed25519 -> full 44-char base58 on OLED',
821822
'SIGN TX: Parse instructions -> per-instruction confirmation -> Ed25519 sign',
822-
'SIGN MESSAGE: Arbitrary bytes -> hex display -> Ed25519 sign',
823+
'SIGN MESSAGE: Arbitrary bytes -> hex display -> Ed25519 sign (AdvancedMode-gated)',
824+
'SIGN OFFCHAIN: "\\xff" || "solana offchain" || ver || fmt || len || msg -> Ed25519 sign',
823825
],
824826
[
825827
('S1', 'test_msg_solana_getaddress', 'test_solana_get_address',
@@ -858,14 +860,41 @@ def parse_junit(path):
858860
'SPL Token with metadata',
859861
'Token transfer with SolanaTokenInfo (mint, symbol, decimals). OLED shows human-readable token name.',
860862
['Token name + amount']),
863+
('S13', 'test_msg_solana_signoffchainmessage', 'test_sign_ascii_message',
864+
'Off-chain ASCII (fmt 0)',
865+
'Domain-separated envelope; format 0 (printable ASCII) renders + signs.',
866+
['Off-chain message confirm']),
867+
('S14', 'test_msg_solana_signoffchainmessage', 'test_sign_at_size_ceiling',
868+
'Off-chain at 1212-byte ceiling',
869+
'Spec ceiling for formats 0/1; firmware accepts at the limit.', []),
870+
('S15', 'test_msg_solana_signoffchainmessage', 'test_format_2_rejected',
871+
'Reject format 2',
872+
'Extended UTF-8 (Ledger-only blind sign) explicitly rejected on this device.', []),
873+
('S16', 'test_msg_solana_signoffchainmessage', 'test_invalid_version_rejected',
874+
'Reject non-zero version',
875+
'Spec defines only version 0; firmware rejects others.', []),
876+
('S17', 'test_msg_solana_signoffchainmessage', 'test_empty_message_rejected',
877+
'Reject empty message',
878+
'Zero-length payload rejected with SyntaxError.', []),
879+
('S18', 'test_msg_solana_signoffchainmessage', 'test_pubkey_matches_get_address',
880+
'Pubkey binding',
881+
'Returned public_key Base58 == solana_get_address() at same path.', []),
882+
('S19', 'test_msg_solana_signoffchainmessage', 'test_envelope_signature_verifies_offdevice',
883+
'Envelope (not raw msg) is signed',
884+
'Off-device Ed25519 verify against the envelope succeeds; against the bare message fails. Catches firmware regressions that drop the envelope.',
885+
[]),
861886
]),
862887

863888
('T', 'TRON', '7.14.0',
864-
'NEW: TRON with secp256k1 signing, base58 addresses. Blind-sign via raw_data. '
865-
'Structured reconstruct-then-sign and TRC-20 clear-signing deferred to 7.15+.',
889+
'TRON with secp256k1 signing, base58 addresses. Blind-sign via raw_data. '
890+
'TIP-191 personal_sign + VerifyMessage and TIP-712 typed-data hash mode '
891+
'added in 7.14.x. Structured reconstruct-then-sign and TRC-20 clear-signing '
892+
'deferred to 7.15+.',
866893
[
867894
'ADDRESS: m/44\'/195\'/0\'/0/0 -> full 34-char base58 TRON address',
868895
'BLIND-SIGN: Raw protobuf data -> hash + sign',
896+
'TIP-191: keccak256("\\x19TRON Signed Message:\\n" + len + msg) -> secp256k1',
897+
'TIP-712: keccak256("\\x19\\x01" + domainHash + msgHash) -> secp256k1',
869898
],
870899
[
871900
('T1', 'test_msg_tron_getaddress', 'test_tron_get_address',
@@ -880,16 +909,45 @@ def parse_junit(path):
880909
'Sign TRX blind (raw_data)', 'Raw protobuf data triggers blind sign path. Shows amount + address if provided.', ['TRON blind sign']),
881910
('T5', 'test_msg_tron_signtx', 'test_tron_sign_missing_fields_rejected',
882911
'Missing fields rejected', 'Incomplete transaction data is refused.', []),
912+
('T6', 'test_msg_tron_signmessage', 'test_sign_text_roundtrip',
913+
'TIP-191 sign + verify text', 'Sign printable ASCII message; verify the returned signature with the same client.', ['TRON message confirm']),
914+
('T7', 'test_msg_tron_signmessage', 'test_sign_bytes_roundtrip',
915+
'TIP-191 sign + verify bytes', 'Non-printable byte payload renders as hex preview, round-trips through verify.', ['TRON bytes confirm']),
916+
('T8', 'test_msg_tron_signmessage', 'test_sign_empty_message',
917+
'TIP-191 empty message', 'Zero-length message is valid per TIP-191 (ASCII "0" length encoding).', []),
918+
('T9', 'test_msg_tron_signmessage', 'test_sign_address_matches_get_address',
919+
'Address binding', 'Signature response address equals tron_get_address() at the same path.', []),
920+
('T10', 'test_msg_tron_signmessage', 'test_verify_rejects_corrupted_signature',
921+
'Reject corrupted signature', 'Flipping a byte in r/s causes verify to recover a different pubkey -> Failure.', []),
922+
('T11', 'test_msg_tron_signmessage', 'test_verify_rejects_wrong_message',
923+
'Reject wrong message', 'Valid signature against a different message -> Failure.', []),
924+
('T12', 'test_msg_tron_signmessage', 'test_invalid_path_rejected',
925+
'Reject non-TRON path', 'm/44\'/60\'/... rejected by firmware path guard before signing.', []),
926+
('T13', 'test_msg_tron_signtypedhash', 'test_sign_typed_hash_with_message',
927+
'TIP-712 sign hash + message', 'Domain + message hash mode; 65-byte recoverable secp256k1 sig.', ['TIP-712 confirm dialogs']),
928+
('T14', 'test_msg_tron_signtypedhash', 'test_sign_typed_hash_domain_only',
929+
'TIP-712 domain-only', 'primaryType=EIP712Domain case: no message hash, domain digest only.', []),
930+
('T15', 'test_msg_tron_signtypedhash', 'test_sign_typed_hash_address_matches_get_address',
931+
'Address binding', 'Sig response address matches tron_get_address() at same path.', []),
932+
('T16', 'test_msg_tron_signtypedhash', 'test_invalid_domain_hash_length_rejected',
933+
'Reject bad domain hash', '31-byte domain_separator_hash rejected; must be exactly 32 bytes.', []),
934+
('T17', 'test_msg_tron_signtypedhash', 'test_invalid_message_hash_length_rejected',
935+
'Reject bad message hash', '33-byte message_hash rejected; must be exactly 32 bytes.', []),
936+
('T18', 'test_msg_tron_signtypedhash', 'test_invalid_path_rejected',
937+
'Reject non-TRON path', 'm/44\'/60\'/... rejected by path guard before TIP-712 signing.', []),
883938
]),
884939

885940
('N', 'TON', '7.14.0',
886-
'NEW: TON v4r2 wallet contracts. Ed25519 signing with structured field display. '
887-
'Blind-sign for raw transactions. Memo/comment support. '
888-
'Full clear-sign with cell tree reconstruction deferred to 7.15+.',
941+
'TON v4r2 wallet contracts. Ed25519 signing with structured field display. '
942+
'Blind-sign for raw transactions. Memo/comment support. Bare Ed25519 SignMessage '
943+
'added in 7.14.x with AdvancedMode policy gate (no domain separation; TON Connect '
944+
'ton_proof envelope deferred to a follow-up). Full clear-sign with cell tree '
945+
'reconstruction deferred to 7.15+.',
889946
[
890947
'ADDRESS: m/44\'/607\'/0\' -> full 48-char base64url TON address',
891948
'STRUCTURED: Amount + address + memo shown as display context -> sign',
892949
'BLIND-SIGN: Raw tx without structured fields -> "BLIND SIGNATURE" warning',
950+
'SIGN-MESSAGE: Raw Ed25519 over message bytes; gated by AdvancedMode policy',
893951
],
894952
[
895953
('N1', 'test_msg_ton_getaddress', 'test_ton_get_address',
@@ -908,6 +966,18 @@ def parse_junit(path):
908966
'Sign TON blind', 'Raw tx without structured fields triggers blind sign.', ['Blind warning']),
909967
('N7', 'test_msg_ton_signtx', 'test_ton_sign_missing_fields_rejected',
910968
'Missing fields rejected', 'Incomplete data refused.', []),
969+
('N8', 'test_msg_ton_signmessage', 'test_blocked_when_advanced_mode_disabled',
970+
'AdvancedMode gate blocks default', 'Without AdvancedMode policy, request fails with ActionCancelled.', []),
971+
('N9', 'test_msg_ton_signmessage', 'test_sign_text_advanced_mode',
972+
'Sign text (AdvancedMode)', '64-byte Ed25519 sig + 32-byte pubkey returned for printable text.', ['TON message confirm']),
973+
('N10', 'test_msg_ton_signmessage', 'test_sign_bytes_advanced_mode',
974+
'Sign bytes (AdvancedMode)', 'Non-printable bytes render as hex preview before signing.', ['TON bytes confirm']),
975+
('N11', 'test_msg_ton_signmessage', 'test_empty_message_rejected',
976+
'Reject empty message', 'Zero-length message rejected with SyntaxError.', []),
977+
('N12', 'test_msg_ton_signmessage', 'test_invalid_path_rejected',
978+
'Reject non-TON path', 'Solana / non-TON BIP-44 path rejected by guard.', []),
979+
('N13', 'test_msg_ton_signmessage', 'test_pubkey_consistency',
980+
'Pubkey stable, sig varies', 'Same path returns same Ed25519 pubkey across signs; different msgs -> different sigs.', []),
911981
]),
912982

913983
('Z', 'Zcash Orchard', '7.14.0',
@@ -972,6 +1042,10 @@ def parse_junit(path):
9721042
def render(output_path, fw_version, results, screenshot_dir=None):
9731043
pdf = PDF(); pb = PB(pdf)
9741044
ts = datetime.now().strftime('%Y-%m-%d %H:%M')
1045+
# Optional build label (branch + commit) for per-PR differentiation.
1046+
# CI sets KK_BUILD_LABEL from "${head_ref}@${sha:0:8}" so identical
1047+
# SECTIONS + same firmware version still produce distinct PDFs.
1048+
build_label = os.environ.get('KK_BUILD_LABEL', '').strip()
9751049
active = [(l,t,mf,bg,fl,tests) for l,t,mf,bg,fl,tests in SECTIONS if ver_ge(fw_version, mf)]
9761050
# Separate specs section (no tests) from test sections
9771051
specs = [s for s in active if not s[5]]
@@ -994,6 +1068,8 @@ def render(output_path, fw_version, results, screenshot_dir=None):
9941068
pb.text(11, f'Firmware {fw_version} | {ts} | {failed} FAILED of {total} tests', bold=True, color=RED)
9951069
else:
9961070
pb.text(10, f'Firmware {fw_version} | {ts} | {total} tests: {passed} passed, {skipped} pending')
1071+
if build_label:
1072+
pb.text(9, f'Build: {build_label}')
9971073
pb.gap(6)
9981074
pb.text(12, 'Sections', bold=True)
9991075
_shown_tested = _shown_pending = False
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# This file is part of the KeepKey project.
2+
#
3+
# Copyright (C) 2026 KeepKey
4+
#
5+
# This library is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU Lesser General Public License version 3
7+
# as published by the Free Software Foundation.
8+
9+
import struct
10+
import unittest
11+
12+
import pytest
13+
14+
try:
15+
from keepkeylib import messages_solana_pb2 as _sol_msgs
16+
_has_offchain = hasattr(_sol_msgs, 'SolanaSignOffchainMessage')
17+
except Exception:
18+
_has_offchain = False
19+
20+
import common
21+
from keepkeylib.client import CallException
22+
from keepkeylib.tools import parse_path
23+
24+
25+
SOLANA_DEFAULT_PATH = "m/44'/501'/0'/0'"
26+
27+
# Per the Solana off-chain message spec:
28+
# "\xff" || "solana offchain" || version:u8 || format:u8
29+
# || length:u16 LE || message bytes
30+
SOL_OFFCHAIN_PREFIX = b"\xffsolana offchain"
31+
32+
33+
def _envelope(message, version=0, message_format=0):
34+
return (
35+
SOL_OFFCHAIN_PREFIX
36+
+ bytes([version, message_format])
37+
+ struct.pack("<H", len(message))
38+
+ message
39+
)
40+
41+
42+
@unittest.skipUnless(
43+
_has_offchain,
44+
"SolanaSignOffchainMessage protobuf not available in this build",
45+
)
46+
class TestMsgSolanaSignOffchainMessage(common.KeepKeyTest):
47+
48+
def setUp(self):
49+
super().setUp()
50+
self.requires_firmware("7.14.0")
51+
self.requires_message("SolanaSignOffchainMessage")
52+
53+
def test_sign_ascii_message(self):
54+
"""Format 0 (ASCII): printable text signs successfully."""
55+
self.requires_fullFeature()
56+
self.setup_mnemonic_allallall()
57+
58+
resp = self.client.solana_sign_offchain_message(
59+
parse_path(SOLANA_DEFAULT_PATH),
60+
b"Sign this off-chain message",
61+
message_format=0,
62+
)
63+
self.assertEqual(len(resp.signature), 64) # Ed25519
64+
self.assertEqual(len(resp.public_key), 32) # Ed25519
65+
66+
def test_sign_at_size_ceiling(self):
67+
"""1212-byte message at the spec ceiling for formats 0/1 succeeds."""
68+
self.requires_fullFeature()
69+
self.setup_mnemonic_allallall()
70+
71+
resp = self.client.solana_sign_offchain_message(
72+
parse_path(SOLANA_DEFAULT_PATH),
73+
b"a" * 1212,
74+
message_format=0,
75+
)
76+
self.assertEqual(len(resp.signature), 64)
77+
78+
def test_format_2_rejected(self):
79+
"""Extended UTF-8 (format=2) is Ledger-only and not supported on device."""
80+
self.requires_fullFeature()
81+
self.setup_mnemonic_allallall()
82+
83+
with pytest.raises(CallException):
84+
self.client.solana_sign_offchain_message(
85+
parse_path(SOLANA_DEFAULT_PATH),
86+
b"hello",
87+
message_format=2,
88+
)
89+
90+
def test_invalid_version_rejected(self):
91+
"""Spec defines only version 0; non-zero must be rejected."""
92+
self.requires_fullFeature()
93+
self.setup_mnemonic_allallall()
94+
95+
with pytest.raises(CallException):
96+
self.client.solana_sign_offchain_message(
97+
parse_path(SOLANA_DEFAULT_PATH),
98+
b"hello",
99+
version=1,
100+
)
101+
102+
def test_empty_message_rejected(self):
103+
"""Zero-length message rejected with SyntaxError."""
104+
self.requires_fullFeature()
105+
self.setup_mnemonic_allallall()
106+
107+
with pytest.raises(CallException):
108+
self.client.solana_sign_offchain_message(
109+
parse_path(SOLANA_DEFAULT_PATH),
110+
b"",
111+
)
112+
113+
def test_pubkey_matches_get_address(self):
114+
"""Returned public_key must match SolanaGetAddress at the same path."""
115+
self.requires_fullFeature()
116+
self.setup_mnemonic_allallall()
117+
118+
addr_resp = self.client.solana_get_address(
119+
parse_path(SOLANA_DEFAULT_PATH),
120+
)
121+
sig_resp = self.client.solana_sign_offchain_message(
122+
parse_path(SOLANA_DEFAULT_PATH),
123+
b"binding-check",
124+
)
125+
# Solana addresses are Base58(pubkey); compare by re-encoding the pubkey.
126+
from keepkeylib import tools
127+
derived_addr = tools.b58encode(bytes(sig_resp.public_key))
128+
self.assertEqual(derived_addr, addr_resp.address)
129+
130+
def test_envelope_signature_verifies_offdevice(self):
131+
"""Signature must verify against the spec envelope, not the bare message.
132+
133+
This is the load-bearing assertion: if firmware accidentally signs
134+
msg.message instead of envelope(msg.message), this test catches it.
135+
"""
136+
self.requires_fullFeature()
137+
self.setup_mnemonic_allallall()
138+
139+
message = b"verify-envelope"
140+
resp = self.client.solana_sign_offchain_message(
141+
parse_path(SOLANA_DEFAULT_PATH),
142+
message,
143+
)
144+
145+
try:
146+
from nacl.signing import VerifyKey
147+
from nacl.exceptions import BadSignatureError
148+
except ImportError:
149+
self.skipTest("nacl not installed; envelope verification skipped")
150+
151+
envelope = _envelope(message, version=0, message_format=0)
152+
vk = VerifyKey(bytes(resp.public_key))
153+
# Should verify the envelope, not the raw message.
154+
vk.verify(envelope, bytes(resp.signature))
155+
156+
with self.assertRaises(BadSignatureError):
157+
vk.verify(message, bytes(resp.signature))
158+
159+
160+
if __name__ == "__main__":
161+
unittest.main()

0 commit comments

Comments
 (0)