fix(evm): bind transformERC20 clear-signing to full calldata - #468
Conversation
Require the full calldata to be present before any contract decoder can claim a transaction. This removes the transformERC20 exception that allowed a streamed tail to be signed without disclosure.\n\nAdd regression coverage for complete and streamed calldata.\n\nFixes #444.
|
Reviewed, and pushed one commit to this branch: The gate itself is correctHoisting The added test negative-controls: rebuilt against the pre-fix What was missing: the warningFalling through is only half the policy. Never block someone from their own funds, but never let them approve bytes the device did not interpret without saying so. The AdvancedMode gate does the first half. The second half did not exist on this path. Every sibling chain that cannot interpret its payload says so in a screen of its own — TRON (
That warning is a formatted body whose length grows with the byte count, and The disclosure gap this PR hadThe BREAKING table was not updated. This is a user-facing behaviour change: Worth stating plainly for reviewers: the old exemption was reasoned, not an oversight. The four displayed values are static head words and were always correct, and the argument was that they bound the outcome. That argument rests on the 0x Exchange Proxy enforcing it — and the Exchange Proxy is upgradeable, so it is not a guarantee this firmware can make. Verified on this branch headARM cross-compile in the pinned builder image; full One procedural noteThis PR branched at |
0fd57fc to
a1c4fcc
Compare
|
Correcting my earlier review: this does not close #444, and the The gate proves the calldata was RECEIVED, not DISPLAYED
So for any calldata that fits one chunk: At the 1024-byte chunk limit that is up to 860 bytes of attacker-controlled And the cost is realThe repo's own recorded mainnet fixture ( That means this PR pays most of the user-facing cost of a full fix while leaving the hole open below the chunk boundary. (One thing I got wrong and want on the record: I initially flagged that this breaks The actual choiceThe four displayed values hide the tail at every size, not just above 1024 bytes. So:
This PR is between the two and gets both costs. My recommendation is (2) if the bounded-outcome argument is not trusted, (1) if it is — but not the midpoint. If you merge as-is, change the trailer to |
a1c4fcc to
d50f4f9
Compare
|
Two corrections to my own numbers above, both found by re-checking rather than by anyone disputing them. The fixture is 1480 bytes, not 1564. My first count swept up the signature hex literals alongside the "It is not a refusal" was wrong, and I had written that into the release notes. That is still the intended policy — never block someone from their own funds, never present uninterpreted bytes as though the device understood them — but the default answer for a 0x swap becomes "turn on AdvancedMode", and the notes have to say that rather than implying the signing path is unchanged.
The substantive question from my previous comment stands: the four displayed values hide the tail at every size, so this gate is not a disclosure boundary. Either the bounded-outcome argument is trusted (revert, close #444 as accepted-with-reasoning) or it is not (drop the decoder, raw hex for all |
The gate commit changes what a default-configuration device will do and did not say so in the release notes. Measured, not estimated: the recorded mainnet transaction in deps/python-keepkey/tests/test_msg_ethereum_erc20_0x_signtx.py (etherscan 0xcf94f79d...) carries 1480 bytes of calldata, and its second transformation (PayTakerTransformer) starts at byte 1060 -- past the 1024-byte data_initial_chunk limit. Dropping that transformation would strand the funds in the Exchange Proxy, so no host emits a shorter form. This is production 0x traffic, not an edge case. State the consequence accurately: on a default device this is a REFUSAL, not a degraded screen. AdvancedMode is off by default (policy.h:34) and ethereum.c:808 answers with fsm_sendFailure(ActionCancelled). Enabling AdvancedMode signs it and shows the raw-hex disclosure, which is the intended policy -- never block someone from their own funds, never present uninterpreted bytes as though the device understood them. Scoping, so the trailers mean what they say: #444 was about the streamed tail beyond the first chunk, and this closes it. The adjacent in-buffer weakness -- up to 860 bytes of transformations[] inside one chunk that the decoder never reads -- is untouched by this commit and belongs to #414. And why the old exemption's safety argument cannot simply be restored: besides resting on the upgradeable Exchange Proxy, it assumes the screen shows amounts. TokenType.chain_id and tokenByChainAddress() are uint8_t, so Base (8453->5), Arbitrum (42161->177) and Avalanche (43114->106) truncate, miss the table, and render "Unknown token value" for both operands. On half the allowlisted chains the bound is not displayed at all. Tracked as #455.
d50f4f9 to
7aae356
Compare
|
Correcting myself: my refutation above was wrong. I wrote that this PR does not break
def test__sign_transformERC20(self):
self.requires_fullFeature()
self.requires_firmware("7.1.5")
self.setup_mnemonic_nopin_nopassphrase()
sig_v, sig_r, sig_s = self.client.ethereum_sign_tx(...) # no apply_policyNo Verified by controlled experimentSame pinned test file (
The mechanism is exactly this PR's: the recorded mainnet payload is 1480 bytes, so it no longer clear-signs and falls through to the generic contract-data path, which is AdvancedMode-gated and refuses on a default device. What this does and does not changeIt does not change whether the fix is right — it is, and master's copy of the test already accounts for it by enabling AdvancedMode. It changes the accounting: this is one of the 22 failures now visible via #477, and it belongs in the "intentional change, stale pinned expectation" bucket that the Recording it because "I checked and it doesn't break anything" was stated with more confidence than the evidence supported, and the distinction between the pinned submodule and the working tree is exactly the sort of thing that should not be discovered later. |
Summary
Fail closed when an Ethereum contract decoder does not have the complete calldata in
data_initial_chunk.transformERC20was checked before the existing completeness gate. A host could therefore supply a valid 132-byte 0x prefix, stream an additional calldata tail throughEthereumTxAck, and have that tail included in the signature without the raw-data disclosure path.This moves the completeness gate ahead of every decoder, including
transformERC20.Behavior
transformERC20calldata remains clear-signable.Regression coverage
The new test asserts both sides of the boundary:
data_total == data_initial_chunk.sizeis handled.data_total > data_initial_chunk.sizeis not handled.The red-team negative control demonstrated that the pre-fix build signed a transaction carrying an appended 2 KiB tail with the same two confirmations as the honest call. The fixed build refuses that transaction under the default raw-data policy.
Verification
cppcheckpassed.git diff --checkand source formatting passed.Closes #444.