Skip to content

Commit 65f69f7

Browse files
committed
fix(osmosis): version-gate the denom restriction
osmosis_sign_tx rejected every non-uosmo denomination unconditionally, so the IBC and factory denoms firmware actually supports were unreachable through the public helper -- a caller had to drive OsmosisMsgAck by hand. Firmware commits the host-supplied denom to the signed Amino document from 7.14.2 on (osmosis_signTxUpdateMsgSend escapes it verbatim). Before 7.14.2 the serializer hardcoded uosmo and would sign a uosmo transfer the caller never asked for, so the fail-closed behaviour is correct there and only there. Gated on that boundary, matching thorchain_sign_tx. Adds TestOsmosisClientDenom: forwarding on 7.15.0 and on 7.14.2, rejection on 7.14.1, and uosmo still signing on legacy firmware. Offline, so it runs without an emulator. Also makes zcash_display_address's address_n optional. messages-zcash.proto marks address_n and account each required only if the other is omitted, but the required positional made the documented account-only form fail in Python before a request was built.
1 parent 4e4374b commit 65f69f7

2 files changed

Lines changed: 141 additions & 17 deletions

File tree

keepkeylib/client.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,27 +1054,33 @@ def osmosis_sign_tx(
10541054
# OsmosisMsgSend.amount, which is a string field and would have
10551055
# raised even for uatom.
10561056
#
1057-
# This restriction is a HOST policy, not a firmware invariant.
1057+
# Version-gated exactly like thorchain_sign_tx above, and for
1058+
# the same reason.
1059+
#
10581060
# Firmware does not reject a non-uosmo denom on the
1059-
# OsmosisMsgAck path: since 7.14.2 (firmware c9dccf68),
1061+
# OsmosisMsgAck path. Since 7.14.2 (firmware c9dccf68)
10601062
# osmosis_signTxUpdateMsgSend escapes the host-supplied denom
1061-
# straight into the signed Amino document, which is what
1063+
# straight into the signed Amino document -- which is what
10621064
# test_osmosis_send_denom_is_committed_to_the_signature proves
1063-
# over the raw wire, and the only strcmp against "uosmo" left
1065+
# over the raw wire -- and the only strcmp against "uosmo" left
10641066
# in firmware picks the display exponent.
10651067
#
1066-
# The check stays because this helper is not version-gated and
1067-
# firmware older than 7.14.2 hardcoded "uosmo" in the
1068-
# serializer: it would ignore the denom sent here and sign a
1069-
# uosmo transfer the caller never asked for. Fail closed rather
1070-
# than silently mis-sign. A caller that needs an IBC or factory
1071-
# denom on 7.15 can drive OsmosisMsgAck directly, or this
1072-
# helper can grow the same version gate thorchain_sign_tx uses.
1068+
# BEFORE 7.14.2 the serializer hardcoded "uosmo": it would
1069+
# ignore the denom sent here and sign a uosmo transfer the
1070+
# caller never asked for. So fail closed there, and expose the
1071+
# field on firmware that actually commits it. An unconditional
1072+
# rejection made the supported IBC and factory-denom cases
1073+
# unreachable through this helper.
10731074
coin = msg['value']['amount'][0]
1074-
if coin['denom'] != 'uosmo':
1075+
firmware_version = (
1076+
self.features.major_version,
1077+
self.features.minor_version,
1078+
self.features.patch_version,
1079+
)
1080+
if coin['denom'] != 'uosmo' and firmware_version < (7, 14, 2):
10751081
raise CallException(
10761082
"Osmosis.MsgSend",
1077-
"Only uosmo is signable by Osmosis MsgSend (got %s)" %
1083+
"Unsupported denomination before firmware 7.14.2: %s" %
10781084
coin['denom'])
10791085
resp = self.call(osmosis_proto.OsmosisMsgAck(
10801086
send=osmosis_proto.OsmosisMsgSend(
@@ -1900,7 +1906,7 @@ def ton_sign_message(self, address_n, message, show_display=False):
19001906

19011907
# ── Zcash Address Display ─────────────────────────────────
19021908
@expect(zcash_proto.ZcashAddress)
1903-
def zcash_display_address(self, address_n, account=None,
1909+
def zcash_display_address(self, address_n=None, account=None,
19041910
expected_seed_fingerprint=None):
19051911
"""Display a Zcash unified address on the device for user confirmation.
19061912
@@ -1910,7 +1916,9 @@ def zcash_display_address(self, address_n, account=None,
19101916
are reserved on ZcashDisplayAddress).
19111917
19121918
Args:
1913-
address_n: ZIP-32 derivation path [32', 133', account']
1919+
address_n: ZIP-32 derivation path [32', 133', account'].
1920+
Optional -- messages-zcash.proto marks it "required if account
1921+
omitted", so either form is valid and exactly one is needed.
19141922
account: account index (alternative to full path)
19151923
expected_seed_fingerprint: optional 32-byte ZIP-32 §6.1 seed
19161924
fingerprint. If provided, device verifies the match before
@@ -1920,7 +1928,16 @@ def zcash_display_address(self, address_n, account=None,
19201928
ZcashAddress with .address and .seed_fingerprint of the
19211929
attesting device.
19221930
"""
1923-
kwargs = dict(address_n=address_n)
1931+
# The protocol accepts EITHER form. Sending address_n unconditionally
1932+
# made the documented account-only call impossible: it failed in Python
1933+
# before a request was built.
1934+
if address_n is None and account is None:
1935+
raise ValueError(
1936+
"zcash_display_address needs address_n or account "
1937+
"(messages-zcash.proto: each is required if the other is omitted)")
1938+
kwargs = {}
1939+
if address_n is not None:
1940+
kwargs['address_n'] = address_n
19241941
if account is not None:
19251942
kwargs['account'] = account
19261943
if expected_seed_fingerprint is not None:

tests/test_msg_osmosis_signtx.py

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@
3131

3232
from binascii import hexlify
3333

34+
from keepkeylib import messages_pb2 as base_proto
3435
from keepkeylib import messages_osmosis_pb2 as osmosis_proto
35-
from keepkeylib.client import CallException
36+
from keepkeylib.client import CallException, ProtocolMixin
3637
from keepkeylib.tools import parse_path
3738

3839
# Osmosis uses the Cosmos coin type (118), not one of its own.
@@ -232,5 +233,111 @@ def test_osmosis_signing_is_deterministic(self):
232233
self.assertEqual(hexlify(first.signature), hexlify(second.signature))
233234

234235

236+
class _SessionTransport(object):
237+
def session_begin(self):
238+
pass
239+
240+
def session_end(self):
241+
pass
242+
243+
244+
class _ScriptedOsmosisClient(object):
245+
"""Offline driver for the public osmosis_sign_tx helper.
246+
247+
Mirrors _ScriptedThorchainClient in test_msg_thorchain_signtx.py: no
248+
device, so the version gate can be exercised at both firmware versions in
249+
a run that does not need an emulator.
250+
"""
251+
252+
osmosis_sign_tx = ProtocolMixin.osmosis_sign_tx
253+
254+
def __init__(self, version):
255+
self.features = base_proto.Features(
256+
major_version=version[0],
257+
minor_version=version[1],
258+
patch_version=version[2],
259+
)
260+
self.transport = _SessionTransport()
261+
self.responses = [
262+
osmosis_proto.OsmosisMsgRequest(),
263+
osmosis_proto.OsmosisSignedTx(
264+
public_key=b'\x02' + b'\x11' * 32,
265+
signature=b'\x22' * 64,
266+
),
267+
]
268+
self.sent = []
269+
270+
def call(self, message):
271+
self.sent.append(message)
272+
if not self.responses:
273+
raise AssertionError('unexpected device call: %s' % type(message))
274+
return self.responses.pop(0)
275+
276+
277+
class TestOsmosisClientDenom(unittest.TestCase):
278+
"""The public helper must reach the denominations firmware supports.
279+
280+
Firmware commits the host-supplied denom to the signed Amino document from
281+
7.14.2 on (osmosis_signTxUpdateMsgSend escapes it verbatim), so an
282+
unconditional uosmo-only check in the helper made every supported IBC and
283+
factory denom unreachable except by driving OsmosisMsgAck by hand.
284+
Before 7.14.2 the serializer hardcoded uosmo, so a non-uosmo send there
285+
would sign a uosmo transfer the caller never asked for -- fail closed.
286+
"""
287+
288+
ADDRESS_N = [0x8000002C, 0x80000076, 0x80000000, 0, 0]
289+
ADDR = 'osmo1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8'
290+
IBC_DENOM = 'ibc/' + ('A' * 64)
291+
292+
def _sign(self, client, denom):
293+
return client.osmosis_sign_tx(
294+
address_n=self.ADDRESS_N,
295+
account_number=92,
296+
chain_id='osmosis-1',
297+
fee=3000,
298+
gas=200000,
299+
msgs=[{
300+
'type': 'osmosis-sdk/MsgSend',
301+
'value': {
302+
'amount': [{'denom': denom, 'amount': '1500000'}],
303+
'from_address': self.ADDR,
304+
'to_address': self.ADDR,
305+
},
306+
}],
307+
memo='client denom test',
308+
sequence=3,
309+
)
310+
311+
def test_ibc_denom_is_forwarded_on_7_15(self):
312+
client = _ScriptedOsmosisClient((7, 15, 0))
313+
response = self._sign(client, self.IBC_DENOM)
314+
315+
self.assertIsInstance(response, osmosis_proto.OsmosisSignedTx)
316+
self.assertEqual(client.sent[1].send.denom, self.IBC_DENOM)
317+
318+
def test_ibc_denom_is_forwarded_on_7_14_2(self):
319+
"""7.14.2 is the first release whose serializer commits the denom."""
320+
client = _ScriptedOsmosisClient((7, 14, 2))
321+
response = self._sign(client, self.IBC_DENOM)
322+
323+
self.assertIsInstance(response, osmosis_proto.OsmosisSignedTx)
324+
self.assertEqual(client.sent[1].send.denom, self.IBC_DENOM)
325+
326+
def test_non_uosmo_denom_is_rejected_before_7_14_2(self):
327+
client = _ScriptedOsmosisClient((7, 14, 1))
328+
329+
with self.assertRaises(CallException) as ctx:
330+
self._sign(client, self.IBC_DENOM)
331+
self.assertIn('Unsupported denomination before firmware 7.14.2',
332+
str(ctx.exception))
333+
334+
def test_uosmo_still_signs_on_legacy_firmware(self):
335+
client = _ScriptedOsmosisClient((7, 14, 1))
336+
response = self._sign(client, 'uosmo')
337+
338+
self.assertIsInstance(response, osmosis_proto.OsmosisSignedTx)
339+
self.assertEqual(client.sent[1].send.denom, 'uosmo')
340+
341+
235342
if __name__ == '__main__':
236343
unittest.main()

0 commit comments

Comments
 (0)