Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion device-protocol
52 changes: 48 additions & 4 deletions keepkeylib/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,39 @@ def e712_types_values(self, n, types_prop, ptype_prop, value_prop, typevals):
response = self.call(msg)
return response

def ethereum_sign_typed_data(self, n, typed_data):
"""Clear-sign structured EIP-712 data on the device.

The firmware hashes the domain and message itself and displays every
typed value before signing. This is the safe path for EIP-3009 x402
payments; ``ethereum_sign_typed_data_hash`` remains the explicit
AdvancedMode-only fallback for callers that only have precomputed
hashes.
"""
required = ('types', 'primaryType', 'domain')
missing = [name for name in required if name not in typed_data]
if missing:
raise ValueError('Missing EIP-712 property: %s' % ', '.join(missing))

# The legacy structured firmware endpoint expects the standard EIP-712
# root property names to remain present in each streamed JSON fragment.
types_prop = json.dumps(
{'types': typed_data['types']}, separators=(',', ':'))
ptype_prop = json.dumps(
{'primaryType': typed_data['primaryType']}, separators=(',', ':'))

# Firmware receives domain and message separately, and retains the
# independently-computed domain separator only until message signing.
self.e712_types_values(
n, types_prop, ptype_prop,
json.dumps({'domain': typed_data['domain']}, separators=(',', ':')),
1)
return self.e712_types_values(
n, types_prop, ptype_prop,
json.dumps(
{'message': typed_data.get('message', {})},
separators=(',', ':')), 2)

@expect(eth_proto.EthereumMessageSignature)
def ethereum_sign_message(self, n, message):
n = self._convert_prime(n)
Expand Down Expand Up @@ -1722,10 +1755,21 @@ def solana_get_address(self, address_n, show_display=False):
)

@expect(solana_proto.SolanaSignedTx)
def solana_sign_tx(self, address_n, raw_tx):
return self.call(
solana_proto.SolanaSignTx(address_n=address_n, raw_tx=raw_tx)
)
def solana_sign_tx(self, address_n, raw_tx, token_info=None,
token_recipient_owner=None):
"""Sign a Solana transaction with optional display metadata.

``token_recipient_owner`` contains candidate 32-byte SPL token-account
owners (for example an x402 ``payTo`` address). Firmware only displays
a candidate after deriving its associated token account and matching
the destination present in the signed TransferChecked instruction.
"""
return self.call(solana_proto.SolanaSignTx(
address_n=address_n,
raw_tx=raw_tx,
token_info=token_info or [],
token_recipient_owner=token_recipient_owner or [],
))

@expect(solana_proto.SolanaMessageSignature)
def solana_sign_message(self, address_n, message, show_display=False):
Expand Down
54 changes: 41 additions & 13 deletions keepkeylib/messages_solana_pb2.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 21 additions & 7 deletions scripts/generate-test-report.py
Original file line number Diff line number Diff line change
Expand Up @@ -860,13 +860,17 @@ def _arg_shown(a):
'Contract function call', 'Generic contract call signing.', []),
('E16', 'test_sign_typed_data', 'test_ethereum_sign_typed_data_hash',
'EIP-712 typed-data hash signing (legacy, no on-device display)',
'KNOWN GAP, disclosed rather than hidden: EIP-712 (the standard behind wallet permits, '
'OpenSea listings, and DAO votes — a daily-driver format) is only supported at the '
'domain-separator-hash + message-hash level. The device signs two host-computed 32-byte '
'hashes; it does NOT parse or display the typed-data domain or message fields, so this '
'path shows the user no readable WHO/WHAT — it is effectively a blind hash-sign, not a '
'clear-sign. Full structured EIP-712 display is a firmware feature, not yet built.',
[]),
'The legacy endpoint receives two host-computed 32-byte hashes, so firmware keeps it '
'behind AdvancedMode and cannot show readable WHO/WHAT. Structured formats such as '
'x402 EIP-3009 use the separate device-parsed path proven by E16b.',
[]),
('E16b', 'test_sign_typed_data', 'test_ethereum_sign_x402_eip3009',
'x402 EVM EIP-3009 payment clear-signs structured data',
'The device computes the EIP-712 hashes itself and displays the Base Sepolia USDC '
'domain plus every TransferWithAuthorization field: payer, recipient, exact value, '
'validity window and nonce. AdvancedMode stays OFF; the facilitator pays gas but '
'cannot alter the signed destination or amount.',
['USDC domain fields', 'TransferWithAuthorization fields']),
('E17', 'test_msg_ethereum_erc20_uniswap_liquidity', 'test_sign_uni_approve_liquidity_ETH',
'Uniswap V2 add-liquidity approve (pending)',
'PENDING, disclosed: known emulator limitation — an approve to an unknown (non-registry) '
Expand Down Expand Up @@ -1713,6 +1717,16 @@ def _arg_shown(a):
'Lookup-table accounts cannot be resolved on-device, so the tx routes to the '
'blind-sign gate.',
[]),
('S25', 'test_msg_solana_signtx',
'test_solana_sign_x402_zero_lut_usdc_payment',
'x402 zero-LUT v0 USDC payment is hardware verified',
'The sponsor pays fees while the KeepKey key authorizes TransferChecked. The device '
'renders 0.002 USDC from firmware-owned mint metadata, derives ATA(payTo, mint) '
'offline, and displays the merchant owner only after it matches the signed '
'destination token account. The required x402 uniqueness memo is also displayed; '
'AdvancedMode stays OFF.',
['Compute budget', 'Known USDC mint', 'Verified recipient owner',
'0.002 USDC', 'x402 memo']),
]),

('T', 'TRON', '7.14.0',
Expand Down
23 changes: 23 additions & 0 deletions tests/test_message_signing_protocol_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,29 @@

class TestMessageSigningProtocolBindings(unittest.TestCase):

def test_solana_recipient_owner_hint_is_additive_field_12(self):
field = solana_proto.SolanaSignTx.DESCRIPTOR.fields_by_name[
'token_recipient_owner'
]
self.assertEqual(field.number, 12)
# protobuf 6 removed the public ``label`` accessor in favor of the
# semantic predicates; generated bindings must remain testable with
# both the release toolchain and current developer environments.
if hasattr(field, 'label'):
self.assertEqual(field.label, field.LABEL_REPEATED)
else:
self.assertTrue(field.is_repeated)
self.assertEqual(field.type, field.TYPE_BYTES)

owner = bytes(range(32))
encoded = solana_proto.SolanaSignTx(
address_n=[0x8000002c, 0x800001f5, 0x80000000, 0x80000000],
raw_tx=b'\x80x402',
token_recipient_owner=[owner],
).SerializeToString()
decoded = solana_proto.SolanaSignTx.FromString(encoded)
self.assertEqual(list(decoded.token_recipient_owner), [owner])

def test_solana_offchain_messages_are_mapped(self):
self.assertEqual(proto.MessageType_SolanaSignOffchainMessage, 756)
self.assertEqual(proto.MessageType_SolanaOffchainMessageSignature, 757)
Expand Down
75 changes: 72 additions & 3 deletions tests/test_msg_solana_signtx.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ def test_solana_sign_token_transfer_with_metadata(self):
0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a,
0x3d, 0x65, 0xf3, 0x6a, 0xab, 0xc9, 0x74, 0x31,
0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, 0xe0, 0xe4,
0x7c, 0xa6, 0x02, 0x03, 0x45, 0x20, 0x23, 0x34,
0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61,
])

# SPL Token Transfer instruction: opcode=3 (u8) + amount (LE u64)
Expand Down Expand Up @@ -687,7 +687,7 @@ def test_solana_sign_token_transfer_checked(self):
0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a,
0x3d, 0x65, 0xf3, 0x6a, 0xab, 0xc9, 0x74, 0x31,
0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, 0xe0, 0xe4,
0x7c, 0xa6, 0x02, 0x03, 0x45, 0x20, 0x23, 0x34,
0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61,
])

# TransferChecked: opcode=12 (u8) + amount (LE u64) + decimals (u8);
Expand Down Expand Up @@ -747,7 +747,7 @@ def test_solana_sign_token_transfer_checked_attested_symbol(self):
0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a,
0x3d, 0x65, 0xf3, 0x6a, 0xab, 0xc9, 0x74, 0x31,
0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, 0xe0, 0xe4,
0x7c, 0xa6, 0x02, 0x03, 0x45, 0x20, 0x23, 0x34,
0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61,
])
decimals = 6
symbol = "USDC"
Expand Down Expand Up @@ -893,6 +893,75 @@ def test_solana_sign_versioned_v0_static_verified(self):
self.assertEqual(len(resp.signature), 64)
self.assertFalse(all(b == 0 for b in resp.signature))

def test_solana_sign_x402_zero_lut_usdc_payment(self):
"""Official x402 SVM shape clear-signs without blind signing.

The sponsor is fee payer, the KeepKey key is the token authority, the
payment is TransferChecked, and payTo is supplied separately so the
device must derive and verify its associated token account itself.
"""
self.requires_firmware("7.15.0")
self.requires_fullFeature()
self.setup_mnemonic_allallall()

authority = self._get_from_pubkey()
sponsor = b'\x10' * 32
source = b'\x30' * 32
pay_to = bytes([
0xea, 0x4a, 0x6c, 0x63, 0xe2, 0x9c, 0x52, 0x0a,
0xbe, 0xf5, 0x50, 0x7b, 0x13, 0x2e, 0xc5, 0xf9,
0x95, 0x47, 0x76, 0xae, 0xbe, 0xbe, 0x7b, 0x92,
0x42, 0x1e, 0xea, 0x69, 0x14, 0x46, 0xd2, 0x2c,
])
destination_ata = bytes([
0x67, 0x30, 0x2e, 0x49, 0x18, 0x94, 0xd7, 0x49,
0x2e, 0xa6, 0xbe, 0x4f, 0x91, 0x4e, 0xa4, 0xf4,
0x5f, 0xa1, 0x42, 0xe6, 0x45, 0x86, 0x7c, 0x91,
0x64, 0xa2, 0x76, 0xd5, 0xdd, 0x76, 0xf0, 0x76,
])
usdc_mint = bytes([
0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a,
0x3d, 0x65, 0xf3, 0x6a, 0xab, 0xc9, 0x74, 0x31,
0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, 0xe0, 0xe4,
0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61,
])

accounts = [
sponsor, authority, source, destination_ata, usdc_mint,
self.COMPUTE_BUDGET_PROGRAM, self.TOKEN_PROGRAM,
self.MEMO_PROGRAM,
]
raw_tx = bytearray([0x80, 2, 0, 3, len(accounts)])
for account in accounts:
raw_tx.extend(account)
raw_tx.extend(b'\xbb' * 32)
raw_tx.append(4)

# ComputeBudget::SetComputeUnitLimit(120000)
raw_tx.extend(bytes([5, 0, 5, 2]))
raw_tx.extend(struct.pack('<I', 120000))
# ComputeBudget::SetComputeUnitPrice(1000 micro-lamports)
raw_tx.extend(bytes([5, 0, 9, 3]))
raw_tx.extend(struct.pack('<Q', 1000))
# SPL TransferChecked(source, mint, destination ATA, authority)
raw_tx.extend(bytes([6, 4, 2, 4, 3, 1, 10, 12]))
raw_tx.extend(struct.pack('<Q', 2000))
raw_tx.append(6)
# Required x402 uniqueness memo: a 16-byte nonce encoded as hex.
memo = b'00112233445566778899aabbccddeeff'
raw_tx.extend(bytes([7, 1, 1, len(memo)]))
raw_tx.extend(memo)
raw_tx.append(0) # zero address-lookup tables

token_info = messages.SolanaTokenInfo(
mint=usdc_mint, symbol="USDC", decimals=6)
self.client.apply_policy('AdvancedMode', False)
response = self.client.solana_sign_tx(
parse_path("m/44'/501'/0'/0'"), bytes(raw_tx),
token_info=[token_info], token_recipient_owner=[pay_to])
self.assertEqual(len(response.signature), 64)
self.assertFalse(all(b == 0 for b in response.signature))

def test_solana_sign_versioned_v0_opaque(self):
"""Versioned v0 transaction whose instruction reaches into an address
lookup table (an account index at or beyond the static account
Expand Down
Loading
Loading