Skip to content

Commit 1a69741

Browse files
committed
fix(osmosis): fence MsgSend to uosmo — the firmware signs a denom it does not display
Review found that removing the old denom whitelist opened a real display/signature divergence, and that my test certified it as correct. osmosis_signTxUpdateMsgSend takes only (amount, to_address) and hardcodes "denom":"uosmo" into the amino JSON it hashes; fsm_msgOsmosisMsgAck renders whatever denom arrived. So a uatom send DISPLAYS "1500000 uatom" and SIGNS 1500000 uosmo. Exploitable in the large: a big number of some worthless ibc/... token on screen, a big number of OSMO in the signature. osmosis_signTxUpdateMsgDelegate already takes a denom — MsgSend is the outlier, and this is a pre-existing firmware bug my client change made reachable. Fenced client-side until the firmware serializer accepts a denom. The denom is still forwarded, since the firmware needs it to decide whether to scale and the fence is the temporary half. test_osmosis_send_unknown_denom_shown_raw asserted only len(signature)==64, so it PASSED against that divergence and published it as intended behaviour — worse than no test. Replaced with one that asserts the refusal, and documents the check to write once the firmware is fixed: otherwise-identical uosmo and uatom transactions must produce DIFFERENT signatures. Also corrects two overstatements the same review caught: - "exact at any magnitude" was false. osmosis_formatAmount converts with an unchecked strtoull(), so it is exact only for a canonical decimal uint64. strtoull saturates past UINT64_MAX and accepts whitespace, a sign and 0x — "18446744073709551616" and "-1" both display as 18446744073.709551615 OSMO while the original string is hashed. Same divergence, different disguise; needs a firmware-side canonical-range check. - G36 claimed enforcement of the extension cap, the 1-8 count bound and per-weight range. The mapped test asserts only unsorted, duplicate and over-100%. Narrowed to what it actually proves. P4 no longer carries a screenshot hint: the refusal happens client-side, so there is no frame to capture.
1 parent 52e1fb5 commit 1a69741

3 files changed

Lines changed: 66 additions & 19 deletions

File tree

keepkeylib/client.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,10 +1003,27 @@ def osmosis_sign_tx(
10031003
# COSMOS denom, so a native OSMO send was impossible — dropped
10041004
# the denom instead of forwarding it, and assigned an int to
10051005
# OsmosisMsgSend.amount, which is a string field and would have
1006-
# raised even for uatom. No denom whitelist belongs here at all:
1007-
# the firmware decides how to render each one (uosmo scaled to
1008-
# OSMO, anything else shown as raw base units).
1006+
# raised even for uatom.
1007+
#
1008+
# The denom IS now forwarded (the firmware needs it to decide
1009+
# whether to scale), but MsgSend is still fenced to uosmo, and
1010+
# not for the reason the old whitelist implied:
1011+
# osmosis_signTxUpdateMsgSend takes only (amount, to_address)
1012+
# and HARDCODES "denom":"uosmo" into the amino JSON it hashes,
1013+
# while fsm_msgOsmosisMsgAck renders whatever denom arrives.
1014+
# Forwarding a different one therefore DISPLAYS one asset and
1015+
# SIGNS another — e.g. a large amount of some worthless ibc/...
1016+
# token on screen, a large amount of OSMO in the signature.
1017+
# osmosis_signTxUpdateMsgDelegate already takes a denom, so
1018+
# MsgSend is the outlier. Lift this fence only once the
1019+
# firmware serializer accepts a denom.
10091020
coin = msg['value']['amount'][0]
1021+
if coin['denom'] != 'uosmo':
1022+
raise CallException(
1023+
"Osmosis.MsgSend",
1024+
"Only uosmo is signable: the firmware MsgSend serializer "
1025+
"hardcodes uosmo in the sighash, so any other denom would "
1026+
"be displayed but not signed (got %s)" % coin['denom'])
10101027
resp = self.call(osmosis_proto.OsmosisMsgAck(
10111028
send=osmosis_proto.OsmosisMsgSend(
10121029
from_address=msg['value']['from_address'],

scripts/generate-test-report.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -992,11 +992,14 @@ def _arg_shown(a):
992992
'500 uosmo is 0.000500 OSMO — no integer part and six decimals; it must not collapse '
993993
'to 0 or lose the trailing digits.',
994994
['Sub-unit amount']),
995-
('P4', 'test_msg_osmosis_signtx', 'test_osmosis_send_unknown_denom_shown_raw',
996-
'Unknown denom shown as base units',
997-
'Only uosmo is scaled. For any other denom the device shows the integer verbatim '
998-
'rather than guessing a precision — guessing is how a 1000x display error happens.',
999-
['Raw denom amount']),
995+
('P4', 'test_msg_osmosis_signtx', 'test_osmosis_send_non_uosmo_denom_is_refused',
996+
'Non-uosmo MsgSend is refused, not displayed',
997+
'osmosis_signTxUpdateMsgSend hardcodes "denom":"uosmo" into the amino JSON it '
998+
'hashes while the confirm screen renders whatever denom arrived, so any other denom '
999+
'would display one asset and sign another. Refused client-side until the firmware '
1000+
'serializer accepts a denom.',
1001+
# Refusal happens before the device is reached — no frame to capture.
1002+
[]),
10001003
('P5', 'test_msg_osmosis_signtx', 'test_osmosis_amount_is_committed_to_the_signature',
10011004
'Displayed amount is in the digest',
10021005
'Two sends differing only in amount produce different signatures, so the confirm '
@@ -1553,9 +1556,15 @@ def _arg_shown(a):
15531556
'user published earlier and is not reviewing on this screen.',
15541557
['Payout options screens']),
15551558
('G36', 'test_msg_hive', 'test_hive_sign_ops_comment_options_beneficiary_rules',
1556-
'Beneficiary rules enforced on-device',
1557-
'At most one extension, 1-8 strictly-ascending unique accounts, each weight and the '
1558-
'total within 10000 bp. Unsorted, duplicate and >100% lists are all refused.',
1559+
'Beneficiary ordering, uniqueness and total enforced on-device',
1560+
# Scoped to exactly what the mapped test asserts. It covers three
1561+
# rejections — unsorted, duplicate, and weights summing over 100%.
1562+
# The extension-count cap, the 1-8 count bound and per-beneficiary
1563+
# weight range are enforced by the parser but are NOT exercised here,
1564+
# so the entry must not claim them.
1565+
'Beneficiaries must be strictly ascending by account (which also makes them unique) '
1566+
'and their weights must sum to no more than 10000 bp. Unsorted, duplicate and '
1567+
'over-100% lists are each refused.',
15591568
# Rejection-only: every case here is _assert_ops_fails, so the device
15601569
# refuses before drawing anything and the capture would be three
15611570
# frames of the idle home screen — a report entry that LOOKS like

tests/test_msg_osmosis_signtx.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,16 @@
1414
1515
The signature was over the correct amount either way — the lie was only on
1616
the screen, which is the half a hardware wallet exists to get right. It now
17-
formats with bn_format_uint64 (integer math, exact at any magnitude).
17+
formats with bn_format_uint64 in integer math.
18+
19+
KNOWN LIMIT, do not overstate this: osmosis_formatAmount converts with an
20+
unchecked strtoull(), so it is exact only for a CANONICAL decimal uint64.
21+
strtoull saturates past UINT64_MAX and also accepts leading whitespace, a
22+
sign, and 0x — so "18446744073709551616" or "-1" display as
23+
18446744073.709551615 OSMO while the original string is what gets hashed.
24+
That is the same display/signature divergence in a different disguise and
25+
needs a firmware-side canonical-range check; these tests deliberately do not
26+
claim otherwise.
1827
1928
These tests are paired with SECTIONS entries carrying screenshot hints, so
2029
the rendered frame is captured as evidence. A test asserting only "it signed"
@@ -112,17 +121,29 @@ def test_osmosis_send_subunit_amount(self):
112121
sig = self._sign(500)
113122
self.assertEqual(len(sig.signature), 64)
114123

115-
def test_osmosis_send_unknown_denom_shown_raw(self):
116-
"""Only uosmo is scaled. The device does not know an arbitrary denom's
117-
precision, so it shows the base-unit integer verbatim rather than
118-
guessing a decimal point — guessing is how a 1000x display error
119-
happens."""
124+
def test_osmosis_send_non_uosmo_denom_is_refused(self):
125+
"""A non-uosmo MsgSend must not reach the device at all.
126+
127+
osmosis_signTxUpdateMsgSend takes only (amount, to_address) and
128+
hardcodes "denom":"uosmo" into the amino JSON it hashes, while
129+
fsm_msgOsmosisMsgAck renders whatever denom arrived. Sending uatom
130+
therefore DISPLAYS "1500000 uatom" and SIGNS 1500000 uosmo — the
131+
display/signature divergence a hardware wallet exists to prevent, and
132+
exploitable in the large: a big number of some worthless ibc/... token
133+
on screen, a big number of OSMO in the signature.
134+
135+
This asserts the fence, NOT that arbitrary denoms work. When the
136+
firmware serializer takes a denom, replace this with the real check:
137+
otherwise-identical uosmo and uatom transactions must produce
138+
DIFFERENT signatures.
139+
"""
120140
self.requires_fullFeature()
121141
self.requires_firmware("7.15.0")
122142
self.setup_mnemonic_nopin_nopassphrase()
123143

124-
sig = self._sign(1500000, denom='uatom')
125-
self.assertEqual(len(sig.signature), 64)
144+
with self.assertRaises(Exception) as ctx:
145+
self._sign(1500000, denom='uatom')
146+
self.assertIn('uosmo', str(ctx.exception))
126147

127148
def test_osmosis_amount_is_committed_to_the_signature(self):
128149
"""Guards the pairing between what is shown and what is signed: two

0 commit comments

Comments
 (0)