harden: verify the signed transaction against what the popup displayed (closes #216) #269
Reference in New Issue
Block a user
Delete Branch "harden/issue-216-verify-displayed-values"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #216. Option (a): populate in the background before the approval screen renders, and verify the artifact against that populated-and-displayed object.
The defect
verifySignedTxcompared the artifact with the dApp's request. For every field the dApp omitted — normallynonce,gasLimitand every fee field, becausepopulateTransaction()filled them in the popup — there was no approved value, so the comparison was skipped and the number the user read on screen was held only to the absolute ceilings. A bare 21,000-gas transfer atMAX_FEE_PER_GAShands the validator 2.1 ETH. The ceilings were not the defect; the thing verified was not the thing approved.Where population lives, and what happens when it fails
New
src/shared/approvalTx.js.prepareApprovalTx(provider, from, txParams)runsVoidSigner(from, provider).populateTransaction()— the same sequence the popup ran, so nonce, gas, fee and chain id are populated identically — and serializes the result to exactly the fields its type serializes (SERIALIZED_FIELDS), as hex quantities, plusfrom. Extension messaging is JSON, which has no bigint; a field that did not survive the trip would be a field displayed and never compared, so the round trip is asserted.The background calls it in
eth_sendTransactionbeforerequestTxApproval(). A pending approval therefore only ever exists fully populated.On failure there is no approval and no window. The error goes back to the requesting page — where the user's click came from — for an unreachable node, a reverting estimate, a populated type outside
ALLOWED_TX_TYPES, a fee or gas limit past the ceilings, or a 20-second timeout (POPULATE_TIMEOUT_MS; ethers' own request timeout is minutes long, which is not a wait anyone sits through with nothing on screen).The cost is real and deliberate: nothing is displayed during the RPC round trip, so the wallet window appears a beat after the click, and an estimate failure is reported by the site rather than by the wallet. The alternative — open the window first, populate behind a spinner — requires a pending approval that exists before it can be displayed or signed, and a half-initialised approval record is precisely the state shape the settle interlock from #205 exists to keep out of that record. The failure also lands earlier than it used to, not later: the same estimate previously failed after the user had typed their password.
How
fromis pinned to approval timerequestTxApproval()/requestSignApproval()storeapprovedFrom, the address active when the approval was raised. That — notgetActiveAddress()— is theexpectedFrompassed toverifySignedTx()/verifySignature(). On top of that:approvedFrom, as anApprovalMismatchError, so the approval is spent rather than retried;fromis not the active address is refused up front (4100), on the transaction path and on both message-signing paths;fromstays on the object it signs, so ethers' owntransaction from address mismatchcatches it popup-side too.The message-signing path had the identical defect (
expectedFromread from current state,signParams.fromnever checked) and is fixed the same way; a permit signed by an account the approval did not name spends that account's tokens.Verification
verifySignedTx(rawSignedTx, approvedTx, expectedFrom, selectedChainId)now compares field by field overSERIALIZED_FIELDS[parsed.type]through one comparator table, plus an exacttypecomparison. A quantity the approval does not fix is a refusal, not a skipped comparison — there is no "the approval did not say" branch left. A new exhaustiveness test pins the comparator table againstSERIALIZED_FIELDSin both directions.The ceilings remain, documented as a backstop: equality with the screen cannot bound what the RPC node talks the wallet into putting on the screen, so
assertWithinCeilings()runs at population as well as at verification.The #205 interlock is untouched
delete pendingApprovals[...]and oneapproval.resolve(...), both insidesettleApproval(); every new refusal path settles through it or returns before an approval exists;claimApproval()still setsattemptInFlightsynchronously before the firstawait;releaseApproval()unchanged;broadcastAccountsChanged()still skipswindowsApi.remove()when the settle is refused;ALLOWED_TX_TYPES = [0, 1, 2], theauthorizationListrefusal,FORBIDDEN_FIELDS,assertNoForbiddenFields,assertNothingUnchecked,assertCanonicalBytes,normalizeValuedelegating tonormalizeQuantity,TX_STAGE_INFLIGHTand theTransaction.prototypetripwire all unchanged and still tested;One comment corrected rather than left misleading: a retry no longer re-populates at a fresh nonce, so the reason a failed broadcast stays terminal is now stated as "the node may have taken it and the page already has its outcome", not "the retry would send a second transaction". The behaviour is unchanged.
UI
The approval screen shows what it now vouches for: network, gas limit, fee per gas, maximum total fee (ETH + USD) and nonce. The popup signs the object it was given — no
populateTransaction(), no provider.Verification run
make checkgreen on the rebased branch: 26 suites, 613 tests,test-verify-build18 cases,prettier --checkclean.make buildemits both bundles withDEBUGoff. New tests cover an address switch in both directions (popup signs as the new address; wallet moves under a correctly signed artifact), a switch during population, a fee and a nonce differing from the displayed value at both the module and the background-wiring level, and the population module against a stub provider (ceilings, refused types, dropped page fields, timeout, JSON round trip).One pre-existing quirk left alone and noted here rather than fixed drive-by: a dApp's
gasfield is dropped by ethers'copyRequestand the wallet estimates instead, exactly as before this change.FAIL —
needs-rebaseThe change itself passes adversarial review. One blocking mechanical defect.
1.
TODO.md— conflicts with currentorigin/nextTODO.md, first bullet under# Completed Steps.5af89a1;origin/nextis now18b47cd("test: close the empty-batch hole in the e2e unstubbed-request guard (closes #187)"), which added its own bullet at the top of the same section.git rebase origin/nexton91b36d7stops withCONFLICT (content): Merge conflict in TODO.md. Nothing else conflicts — the source and test files rebase cleanly.origin/next, keeping BOTH bullets — the#216bullet first, the#187bullet immediately after it — and re-runmake fmtso the wrapped prose stays prettier-clean. No landed entry may be dropped.Verified and passing (adversarial, by mutation and probe, not by reading)
assertFieldMatchesto skip one key at a time via an env switch and ran the full suite for each of the ten comparators. Every single one is individually pinned —chainId2 fail,nonce6,gasLimit3,gasPrice1,maxFeePerGas6,maxPriorityFeePerGas2,to6,value2,data1,accessList2. No field can be dropped with the suite green.APPROVED_FIELDSis pinned bidirectionally againstSERIALIZED_FIELDS, and theTransaction.prototypegetter tripwire still binds.claimApproval()(src/background/index.js:167); three tests die, and the decisive one is "the same artifact sent twice broadcasts once", where the two artifacts are byte-identical and both verify. It fails onbroadcastTransactioncalled 2 times, not on any verification message. The claim is the only thing standing between one approval and two broadcasts, and the suite proves it.frompinning: disabling the signing-handler switch guard turns the suite red (2 tests). Both directions of an address switch are covered, plus a switch during population, plus the message-signing path, plus the up-front4100refusals.serializeApprovedTx->JSONround trip -> the popup's exactsignTransaction({...approvedTx})->verifySignedTx, for nonce 0, gas limit atMAX_GAS_LIMIT, fee atMAX_FEE_PER_GAS,maxPriorityFeePerGas0, absent vs zerovalue, absent vs"0x"data, contract creation, type 0, type 1 with a populated access list, type 2 with[]. All fourteen round-trip and verify. Tampering the fee, the nonce or the access list on the signed side is refused with the right message.pendingApprovalsentry, so every failure between the request andrequestTxApproval()returns a single{error}to the page. The timeout racespopulateTransaction()and clears its timer infinally; a provider that resolves late has nothing to resolve into.#205chokepoint is byte-for-byte as claimed; popup does nopopulateTransaction, nogetProvider, no re-estimation; ceilings kept and documented as a backstop and applied at population;script/checkgreen (26 suites, 613 tests,test-verify-build18 cases,prettier --checkclean, exit 0, all executed, no cached markers);make test-e2egreen (27/27, exit 0); single commit, author and committerclawbot, title ends(closes #216), basenext, no attribution trailers, no competitor named.Noted, not counted against this PR
populateTransaction()when the user clicked Confirm, so a second transaction picked up a fresh nonce. Now both are populated before either window opens, so two concurrenteth_sendTransactioncalls — or one approval window left open while another transaction goes out — are populated at the same nonce, and the second broadcast fails terminally with the "may still have reached the network" wording. This is inherent to option (a) and fails closed, but it is a behaviour change the PR body does not mention. Worth a follow-up issue rather than a rework.return { error: { message: e.message } }atsrc/background/index.js:625and:658carries no EIP-1193code. That matches the existing shape at:308,:507,:556,:591,:662in the same file, so it is left alone.expectedFromback togetActiveAddress()while leaving the handler's switch guard in place does NOT turn the suite red — the guard has already established the two are equal at that point, so no test can distinguish them. Disclosed because it was asked for specifically; it is layering, not a hole. The suite goes red as soon as both are removed.gasdropped bycopyRequest), #262 (orphaned-approval liveness), #220 (tracker CI status).91b36d7d5dto993afbc5da