Skip to content

fix(abstract-eth): verify EVM TSS bytes against intent before signing - #10121

Draft
dpkjnr wants to merge 1 commit into
masterfrom
CECHO-2460-evm-tss-fail-closed
Draft

dpkjnr wants to merge 1 commit into
masterfrom
CECHO-2460-evm-tss-fail-closed

Conversation

@dpkjnr

@dpkjnr dpkjnr commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

What

Makes EVM TSS signing fail closed. Before the user key share signs, verifyTssTransaction decodes the transaction bytes once and compares to / value / data with the user intent, on both signing paths: direct send (the caller's txParams) and pending approval (the stored txRequest.intent).

  • Wiring (sdk-core): the ECDSA and MPCv2 signing paths pass the stored intent, the caller's untouched txParams (callerTxParams) and the chain name into verifyTransaction. Stored kebab-case DeFi intents (defi-approve, defi-deposit, defi-withdraw) pass the missing-recipients guard so their calldata gets checked. defi-redeem has no handler and stays rejected, as on master.
  • Source of truth: when the caller declares a type or recipients, only its own params count. The stored intent is used only when the caller declared nothing, so server data never fills gaps in a caller's intent. The asset is resolved from local identity only.
  • One flow (abstract-eth): the bytes are decoded once, and the intent type picks small checks that each accept a closed set of shapes: native send, ERC-20 / ERC-7984 transfer, exact dApp call, batcher batch / batchTransferFrom, tokenApproval to the batcher for MAX, fillNonce, consolidation to the base address, and the DeFi calldata checks. contractCall / customTx throw.
  • One policy step: checks return findings. Fatal findings (the checks that threw before) still throw, with the same messages. Every other finding goes to console.warn and the new onVerificationWarning callback, or throws when the client sets the new client-side strictTssVerification option on BitGoAPI.
  • Consolidation gap: an ERC-20-shaped consolidation must carry no native value. Before, transfer(baseAddress, 0) sent to any contract passed the recipient check and could move the native balance.
  • Legacy chains: EvmCoin.verifyLegacyTssTransaction, which returned true without decoding, is removed, so kavaevm and prividiumeth run the parent verifier.
  • The verifier helpers stay internal to @bitgo/abstract-eth.

Why

TOB-BITGOEDMPC-2 / INFOSEC-671: verifyTssTransaction trusted the server-declared txParams.type. A compromised server could label approve(attacker, MAX) or any contract call as a payment, or redirect a payment on the pending-approval path, and the key share would sign it.

Case master this PR (default / strictTssVerification)
approve(attacker, MAX) under a native payment intent signs warns / throws
ERC-20 transfer on a different token contract than declared signs warns / throws
contractCall / customTx intent signs throws / throws
Native redirect or inflated value on pending approval signs throws / throws
Token consolidation that carries native value signs warns / throws (always throws with consolidationToBaseAddress)
Stored defi-redeem intent rejected rejected
Legitimate native send or ERC-20 transfer signs signs

Test plan

  • Unit suites on Node 24.22.1: sdk-core 1064, sdk-coin-eth 517, abstract-eth 188, sdk-coin-evm 134, sdk-coin-polygon 71, sdk-coin-xdc 65, sdk-coin-flr 61, sdk-coin-bsc 56, sdk-coin-arbeth 56, sdk-coin-opeth 56, sdk-coin-coredao 9.
  • End to end: the real verifier inside EcdsaUtils.signTxRequest (kavaevm). Rejected bytes never reach the signing round.
  • Prettier and ESLint clean on all changed files (0 errors).
  • BitGo SDK CI on this PR.
  • Release gate: prod backtest over ~563k EVM TSS payments from the last 90 days, covering direct sends and pending approvals, the legacy chains, every intent type, and tokens missing from the shipped statics. Expect no unexplained rejection of legitimate payments (tracked on CECHO-2460).

Open items

  • Rollout: the default warns and strictTssVerification opts into throwing, as agreed on CECHO-2460. Whether that closes TOB-BITGOEDMPC-2 / INFOSEC-671 is still open with security (INFOSEC-691).
  • Unresolved tokens are findings, so they warn by default; security asked for a failure. A lookup through the coin factory, which also knows AMS tokens, is not in this PR.
  • BitGo Express can't pass strictTssVerification yet; it builds BitGo from a fixed option list.
  • Interface: adds an optional callerTxParams to VerifyTransactionOptions. Out-of-repo verifyTransaction implementations won't get the caller / stored split until they read it.

Supersedes #9972, #9973, #9975, #10114.

Ticket: CECHO-2460

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

CECHO-2460

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ Compatibility checks are failing on Node.js 27-nightly (experimental, non-blocking). This does not block merge, but it signals an incompatibility to fix before this release becomes supported.

View run

verifyTssTransaction trusted the server-declared txParams.type and only decoded two calldata
shapes under transfer. A compromised server could label approve(attacker, MAX) or any contract
call as a payment, or redirect a payment on the pending-approval path, and the user key share
would sign it (TOB-BITGOEDMPC-2 / INFOSEC-671).

The ecdsa and ecdsaMPCv2 signing paths now pass the stored txRequest.intent, the caller's
untouched txParams (callerTxParams) and the chain name into verifyTransaction. When the caller
declares a type or recipients, only its own params count. The stored intent is used only when
the caller declared nothing (pending approval), so server data never fills gaps in a caller's
intent. The asset comes from local identity only; unresolvable or conflicting identity is a
finding.

The verifier decodes the bytes once and runs small checks picked by the intent type, each
accepting a closed set of shapes: native send, ERC-20 or ERC-7984 transfer, exact dApp call,
batcher batch / batchTransferFrom, tokenApproval to the batcher for MAX, fillNonce,
consolidation to the base address, and the DeFi calldata checks. contractCall and customTx
throw. A token consolidation must carry no native value, which closes a path where
transfer(baseAddress, 0) sent to any contract moved the native balance there.

Checks return findings and one policy step decides warn or throw. Fatal findings, the checks
that threw before, still throw with the same messages. Every other finding goes to
console.warn and the new onVerificationWarning callback, or throws when the client sets the
new client-side strictTssVerification BitGoAPI option, which is never read from server data.

Stored kebab-case DeFi intents (defi-approve, defi-deposit, defi-withdraw) now pass the
missing-recipients guard so their calldata is checked. defi-redeem has no handler and stays
rejected. EvmCoin.verifyLegacyTssTransaction, which returned true without decoding, is removed
so kavaevm and prividiumeth run the parent verifier. The verifier helpers stay internal to
abstract-eth.

Tests cover the audit cases on kavaevm, hbarevm and tbsc (batcher), and an end-to-end case
runs the real verifier inside EcdsaUtils.signTxRequest to show rejected bytes never reach the
signing round.

Ticket: CECHO-2460
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dpkjnr
dpkjnr force-pushed the CECHO-2460-evm-tss-fail-closed branch from fd8d18d to 90aceaa Compare November 4, 2026 06:25

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant