Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
59400e0
test(zcash): sign real note ciphertexts; refuse a corrupted one
BitHighlander Oct 7, 2026
c0345cc
deps: device-protocol with the tron-trc20-review capability
BitHighlander Oct 7, 2026
5dee326
test(tron): USDT clear-signs; unknown TRC-20 contracts need AdvancedMode
BitHighlander Oct 7, 2026
4db0aff
oled_text: find an address as the QR address screens draw it
BitHighlander Oct 7, 2026
af7689f
Assert every shown address and key is on screen in full
BitHighlander Oct 7, 2026
44ab6c6
Assert Cosmos staking screens show the whole validator address
BitHighlander Oct 7, 2026
3e62deb
Read address screens only on 7.15.0 and later
BitHighlander Oct 7, 2026
c04ad72
test(solana): attest exactly the accounts the lookup table loads
BitHighlander Oct 7, 2026
ccd5b69
test: read address screens on 7.14.3 too
BitHighlander Oct 7, 2026
ff86b16
test: show a native SegWit address on bitcoin-only firmware too
BitHighlander Oct 7, 2026
d33e560
test(zcash): check and show the user's address on a shielded output
BitHighlander Oct 7, 2026
d0c6e17
test(eip712): real-world documents must sign on the device
BitHighlander Oct 7, 2026
7726cc8
test(eth): unlimited approvals and permits sign after a warning
BitHighlander Oct 7, 2026
5670d8f
test(eip712): one test per corpus document, and larger real documents
BitHighlander Oct 7, 2026
586c626
test(erc7730): an embedded unlimited approve signs after a warning
BitHighlander Oct 7, 2026
c4e0727
test(zcash): a real NU6.3 transaction with a fabricated output signs
BitHighlander Oct 7, 2026
b56cbc1
test(zcash): a zero-value padding output is not shown
BitHighlander Oct 7, 2026
fe14695
test(zcash): sign Orchard to Ironwood migration transactions
BitHighlander Oct 7, 2026
bae8ced
test(zcash): change is one total and a memo-only send is shown
BitHighlander Oct 7, 2026
1d3f92c
test(thorchain): sign deposits on the BSC, Base and Arbitrum routers
BitHighlander Oct 7, 2026
bc92255
test(zcash): an all-uppercase user_address is accepted and shown in l…
BitHighlander Oct 7, 2026
05938bf
test(eip712): sign three real UniswapX V3 Dutch orders from Arbitrum
BitHighlander Oct 7, 2026
b82ab6a
test(zcash): sign ZIP 320 TEX payments
BitHighlander Oct 7, 2026
4bc241e
fix(eos): send a derived authority key as K1
BitHighlander Oct 7, 2026
deb58bb
feat(eip712): send typed-data values over 1 KB in chunks
BitHighlander Oct 7, 2026
3a91473
test(display): distinguish pipe and quote substitutions in signed mes…
BitHighlander Oct 8, 2026
b858639
test(eip712): distinguish colliding text across streamed values
BitHighlander Oct 8, 2026
57f2749
test(ethereum): verify normalized integer signatures against canonica…
BitHighlander Oct 8, 2026
e55a1b5
fix(report): require the active dice frequency threshold test
BitHighlander Oct 8, 2026
405cc45
test(eip712): sign a real Safe MultiSend with an unlimited approve
BitHighlander Oct 8, 2026
cbc5e64
test(eip712): sign Ethermint domains and 16-point UniswapX V3 curves
BitHighlander Oct 8, 2026
6793539
test(storage): retry the emulator boot when its UDP port is taken
BitHighlander Oct 8, 2026
f2a2292
test(thorchain,maya): the sign screen shows the fee in whole coins
BitHighlander Oct 8, 2026
5c678ed
test(solana): stake authorize and withdraw fit one page
BitHighlander Oct 8, 2026
ff3a341
report: match 7.15 behaviour in the K, E, V and L sections
BitHighlander Oct 8, 2026
0601dda
report: keep the staged PIN-KDF controls; render them withheld
BitHighlander Oct 8, 2026
8d62850
report: describe E16 and E16b as 7.15 behaves
BitHighlander Oct 8, 2026
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
35 changes: 25 additions & 10 deletions keepkeylib/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2068,7 +2068,9 @@ def zcash_sign_pczt(self, address_n, actions, account=None,
expiry_height=None, transparent_outputs=None,
transparent_inputs=None,
expected_seed_fingerprint=None,
return_transparent_signatures=False):
return_transparent_signatures=False,
ironwood_actions=None, ironwood_flags=None,
ironwood_value_balance=None):
"""Sign a Zcash Orchard-family shielded transaction via PCZT protocol.

Streams transparent outputs, then transparent inputs, then shielded
Expand All @@ -2089,14 +2091,19 @@ def zcash_sign_pczt(self, address_n, actions, account=None,
orchard_digest: 32-byte orchard digest
shielded_pool: ZcashShieldedPool value (Orchard by default)
ironwood_digest: 32-byte Ironwood digest for transaction v6
ironwood_actions: for a ZIP 318 pool-crossing v6 transaction, the
Ironwood actions, streamed after the Orchard `actions`, with
ironwood_flags and ironwood_value_balance for their bundle
orchard_flags: bundle flags byte (enables digest verification)
orchard_value_balance: signed i64 value balance
orchard_anchor: 32-byte anchor
tx_version: transaction version used to verify header_digest
version_group_id: transaction version group ID
lock_time: transaction lock time
expiry_height: transaction expiry height
transparent_outputs: output dicts matching ZcashTransparentOutput
transparent_outputs: output dicts matching ZcashTransparentOutput;
is_tex shows a P2PKH output as a ZIP 320 tex1 address, and
address_n marks the account's own one-time address
transparent_inputs: input dicts matching ZcashTransparentInput;
host-provided per-input sighashes are rejected by RC18
return_transparent_signatures: when true, return a tuple of
Expand All @@ -2106,11 +2113,15 @@ def zcash_sign_pczt(self, address_n, actions, account=None,
ZcashSignedPCZT with compact Orchard signatures and optional txid,
or a tuple including transparent signatures when requested.
"""
n_actions = len(actions)
if n_actions == 0:
raise ValueError("Must have at least one action")

for idx, action in enumerate(actions):
# No action is a transparent-only transaction (ZIP 320 TEX step 2),
# which needs a transparent input to sign.
if len(actions) == 0 and not transparent_inputs:
raise ValueError("Must have at least one action or transparent input")
# Orchard actions first, then any Ironwood ones; indices count on.
stream = list(actions) + list(ironwood_actions or [])
n_actions = len(stream)

for idx, action in enumerate(stream):
if 'is_spend' not in action or not isinstance(action['is_spend'], bool):
raise ValueError(
"Orchard action %d must explicitly set boolean is_spend" % idx)
Expand All @@ -2127,7 +2138,7 @@ def zcash_sign_pczt(self, address_n, actions, account=None,
# explicitly if the caller passed it.
kwargs = dict(
address_n=address_n,
n_actions=n_actions,
n_actions=len(actions),
total_amount=total_amount,
fee=fee,
branch_id=branch_id,
Expand Down Expand Up @@ -2166,6 +2177,10 @@ def zcash_sign_pczt(self, address_n, actions, account=None,
kwargs['n_transparent_inputs'] = len(transparent_inputs)
if expected_seed_fingerprint is not None:
kwargs['expected_seed_fingerprint'] = expected_seed_fingerprint
if ironwood_actions:
kwargs['n_ironwood_actions'] = len(ironwood_actions)
kwargs['ironwood_flags'] = ironwood_flags
kwargs['ironwood_value_balance'] = ironwood_value_balance

resp = self.call(zcash_proto.ZcashSignPCZT(**kwargs))

Expand Down Expand Up @@ -2231,7 +2246,7 @@ def zcash_sign_pczt(self, address_n, actions, account=None,
# include an index. The device controls stream order, just as it
# does for transparent inputs and outputs above, so discard any
# caller copy before supplying the requested index.
action = dict(actions[idx])
action = dict(stream[idx])
action.pop('index', None)
resp = self.call(zcash_proto.ZcashPCZTAction(index=idx, **action))
sent_actions.add(idx)
Expand Down Expand Up @@ -2269,7 +2284,7 @@ def zcash_sign_pczt(self, address_n, actions, account=None,
if not signature:
raise Exception("Device returned an empty transparent signature")

expected_signatures = sum(1 for action in actions if action['is_spend'])
expected_signatures = sum(1 for action in stream if action['is_spend'])
if len(resp.signatures) != expected_signatures:
raise Exception(
"Device returned %d Orchard signatures for %d real spends"
Expand Down
78 changes: 67 additions & 11 deletions keepkeylib/eip712_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,24 @@
STRUCT = DataType.STRUCT

# EthereumTypedDataValueAck.value max_size in messages-ethereum.options, and
# EIP712_MAX_LEAF on the device.
# EIP712_MAX_LEAF on the device: one ValueAck, and one chunk of a longer value.
MAX_LEAF_BYTES = 1024
# EIP712_MAX_VALUE: the longest `bytes` or `string` value the device takes in
# chunks.
MAX_VALUE_BYTES = 1024 * 1024
# The Features capability (as common.capability_names() spells it) of
# firmware that takes chunked values.
CHUNKED_VALUES = 'eip712-chunked-values'
MAX_IDENTIFIER_BYTES = 31
# Struct type names (EIP712_MAX_STRUCT_NAME - 1 on the device) may also use
# ':' after the first character, as Hyperliquid's
# "HyperliquidTransaction:ApproveBuilderFee" does.
MAX_TYPE_NAME_BYTES = 47

_ARRAY_GROUP = re.compile(r'\[([0-9]*)\]')
_CANONICAL_DIGITS = re.compile(r'^[1-9][0-9]*$')
_IDENTIFIER = re.compile(r'^[A-Za-z_$][A-Za-z0-9_$]*$')
_TYPE_NAME = re.compile(r'^[A-Za-z_$][A-Za-z0-9_$:]*$')


class Eip712Error(Exception):
Expand Down Expand Up @@ -116,7 +127,7 @@ def parse_solidity_type(type_str):
'array_levels': levels,
}

if not _IDENTIFIER.match(base) or len(base) > MAX_IDENTIFIER_BYTES:
if not _TYPE_NAME.match(base) or len(base) > MAX_TYPE_NAME_BYTES:
raise Eip712Error('Unparseable EIP-712 type: %s' % type_str)
return {'data_type': STRUCT, 'struct_name': base, 'array_levels': levels}

Expand Down Expand Up @@ -146,13 +157,14 @@ def _hex_bytes(value, what):
return bytes(bytearray.fromhex(h))


def encode_value(field, value):
def encode_value(field, value, max_bytes=MAX_LEAF_BYTES):
"""One leaf, as the exact bytes the device will hash and display.

Raw big-endian at the declared width, never a decimal string: the device
does no number parsing at all, which is what removes the old path's
2**63-1 ceiling and any chance of the two sides disagreeing about what a
decimal meant.
decimal meant. A dynamic value may be up to `max_bytes` long; past
MAX_LEAF_BYTES only value_ack() can put it on the wire.
"""
dt = field['data_type']

Expand Down Expand Up @@ -197,18 +209,18 @@ def encode_value(field, value):
if len(b) != size:
raise Eip712Error('bytes%d must be %d bytes, got %d' % (size, size, len(b)))
return b
if len(b) > MAX_LEAF_BYTES:
raise Eip712Error('bytes value is %d bytes, over the %d-byte wire limit'
% (len(b), MAX_LEAF_BYTES))
if len(b) > max_bytes:
raise Eip712Error('bytes value is %d bytes, over the %d-byte limit'
% (len(b), max_bytes))
return b

if dt == STRING:
if not isinstance(value, str):
raise Eip712Error('string field must be a string')
b = value.encode('utf-8')
if len(b) > MAX_LEAF_BYTES:
raise Eip712Error('string value is %d bytes, over the %d-byte wire limit'
% (len(b), MAX_LEAF_BYTES))
if len(b) > max_bytes:
raise Eip712Error('string value is %d bytes, over the %d-byte limit'
% (len(b), max_bytes))
return b

raise Eip712Error('Cannot encode data type %r as a leaf' % (dt,))
Expand All @@ -227,7 +239,7 @@ def struct_members(typed_data, name):
Order is part of the signature: it sets both encodeType and the order
encodeData concatenates members.
"""
if not isinstance(name, str) or not _IDENTIFIER.match(name) or len(name) > MAX_IDENTIFIER_BYTES:
if not isinstance(name, str) or not _TYPE_NAME.match(name) or len(name) > MAX_TYPE_NAME_BYTES:
raise Eip712Error('Struct name is not a canonical EIP-712 identifier')
members = typed_data['types'].get(name)
if members is None:
Expand Down Expand Up @@ -320,3 +332,47 @@ def build_struct_ack(members):
for lvl in m['type']['array_levels']:
entry.type.array_levels.append(lvl)
return ack


def value_ack(typed_data, request, chunked=False):
"""The EthereumTypedDataValueAck that answers a device ValueRequest.

A value of up to MAX_LEAF_BYTES goes whole. A longer `bytes` or `string`
goes in MAX_LEAF_BYTES chunks: the first carries value_total_length, and
each later request names a value_offset that its answer echoes. The
device may read a string twice from offset 0 (it counts and checks it
before showing it), so every chunk is served by its offset alone.

`chunked` must be True only when the firmware reports CHUNKED_VALUES.
Older firmware skips the chunk fields and would hash the first chunk
alone, so without the capability a long value is refused here.
"""
resolved = resolve_member_path(typed_data, list(request.member_path))
ack = eth_proto.EthereumTypedDataValueAck()
if resolved[0] == 'length':
if request.HasField('value_offset'):
raise Eip712Error('Device asked for an array length in chunks')
ack.value = encode_array_length(resolved[1])
return ack
value = encode_value(resolved[1], resolved[2], MAX_VALUE_BYTES)
if len(value) > MAX_LEAF_BYTES and request.member_path[0] == 0:
raise Eip712Error('A domain value of %d bytes cannot be signed; the '
'device takes at most %d' % (len(value), MAX_LEAF_BYTES))
if request.HasField('value_offset'):
offset = request.value_offset
if len(value) <= MAX_LEAF_BYTES or offset >= len(value):
raise Eip712Error('Device asked for offset %d of a %d-byte value'
% (offset, len(value)))
ack.value = value[offset:offset + MAX_LEAF_BYTES]
ack.value_offset = offset
return ack
if len(value) <= MAX_LEAF_BYTES:
ack.value = value
return ack
if not chunked:
raise Eip712Error('A %d-byte value needs firmware that takes chunked '
'values (%s); this one takes at most %d bytes'
% (len(value), CHUNKED_VALUES, MAX_LEAF_BYTES))
ack.value = value[:MAX_LEAF_BYTES]
ack.value_total_length = len(value)
return ack
2 changes: 1 addition & 1 deletion keepkeylib/eos.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ def parse_authorization(data):

keys.append(
proto.EosAuthorizationKey(
type=1,
type=0, # K1: the key derived from address_n
address_n=address_n,
weight=int(key['weight'])
)
Expand Down
Loading
Loading