harden: verify the signed transaction against what the popup displayed (closes #216)
All checks were successful
check / check (push) Successful in 32s
All checks were successful
check / check (push) Successful in 32s
The signed artifact was compared with the dApp's request object, so every field the dApp left out — normally the nonce, the gas limit and every fee field, because populateTransaction() filled them in the popup — was checked by nothing but the absolute ceilings. A bare transfer at the fee ceiling hands the validator 2.1 ETH. The ceilings were never the defect: the thing being verified was not the thing the user approved. The transaction is now populated in the background, before the approval window opens, and that populated object is what is displayed, what the popup signs, and what the artifact is verified against. Every consequential field is compared exactly. - src/shared/approvalTx.js populates the request through a VoidSigner over the configured RPC and serializes the result to the fields its type serializes, as hex quantities that survive the JSON messaging boundary. Fields the wallet does not act on are dropped before ethers sees the page's object. - Population failure raises no approval and opens no window: the error goes back to the requesting page, bounded by a 20-second timeout. A half-initialised approval record would be exactly the state the settle interlock exists to keep out of that record, and the same estimate previously failed after the user had typed their password. - verifySignedTx compares the artifact field by field over SERIALIZED_FIELDS[type], plus the type itself. A quantity the approval does not fix is a refusal rather than a skipped comparison. The ceilings stay as a documented backstop and now also apply at population, where they bound what an RPC node can talk the wallet into displaying. - The approval pins the address it was raised for. Verification uses that address, not getActiveAddress(), and an address switch between approval and signing refuses rather than signing from an account the screen never named — including a switch during population, and on the message-signing path. A request naming an address that is not the active one is refused outright. - The approval screen shows the network, gas limit, fee per gas, maximum fee and nonce it now vouches for, and the popup signs the object it was given with no provider and no population of its own. The settle chokepoint is untouched: one delete of pendingApprovals and one approval.resolve(), both inside settleApproval(), the claim taken synchronously before the first await, and a refused settle still leaving the approval window standing.
This commit is contained in:
@@ -7,6 +7,13 @@
|
||||
// the signer from the artifact and checks it against the approval it is
|
||||
// holding before acting on it. All recovery is delegated to ethers.
|
||||
//
|
||||
// What the artifact is checked against is the transaction the background
|
||||
// populated and the popup displayed (see approvalTx.js), not the request the
|
||||
// dApp made. The two differ in every field a dApp normally leaves out — nonce,
|
||||
// gas limit, fees — and those are the fields the user reads off the approval
|
||||
// screen, so comparing against the request would leave the numbers on screen
|
||||
// vouched for by nothing.
|
||||
//
|
||||
// The check is an allowlist, in both directions, because a denylist cannot be
|
||||
// correct against a transaction format that keeps gaining fields:
|
||||
//
|
||||
@@ -31,14 +38,12 @@
|
||||
// never a warning: what the user approved is what gets broadcast, or nothing
|
||||
// does.
|
||||
//
|
||||
// Fields the approval does not carry are not treated as zero. The popup
|
||||
// populates nonce, gas limit, fee and chain id through populateTransaction()
|
||||
// when the requesting page did not fix them, so there is no approved value to
|
||||
// compare against; treating absent as zero would refuse every legitimate
|
||||
// transaction. Those fields are instead held to the absolute ceilings below,
|
||||
// and the chain id is always checked against the selected network rather than
|
||||
// against the approval alone, which is what makes a cross-chain replay
|
||||
// impossible.
|
||||
// The approved transaction is required to fix every field its type serializes,
|
||||
// so there is no "the approval did not say" branch to fall through: a quantity
|
||||
// the approval does not carry is a refusal, because an artifact that cannot be
|
||||
// compared with what was displayed has not been checked. The chain id is
|
||||
// checked against the selected network as well as against the approval, which
|
||||
// is what makes a cross-chain replay impossible.
|
||||
//
|
||||
// Every failure message is a full sentence, because these strings are shown to
|
||||
// the user and returned to the dApp.
|
||||
@@ -113,6 +118,17 @@ const FORBIDDEN_FIELDS = [
|
||||
},
|
||||
];
|
||||
|
||||
// Absolute ceilings — a BACKSTOP, not the primary control.
|
||||
//
|
||||
// The primary control is equality: every field of the artifact is compared
|
||||
// with the populated transaction the user was shown, so nothing the popup
|
||||
// signs can differ from the screen. What equality cannot bound is the
|
||||
// populated transaction itself, which is built from what the configured RPC
|
||||
// node answered — a node that reports an absurd fee gets that fee displayed,
|
||||
// and a user who does not read the fee line would approve it. These ceilings
|
||||
// bound that, and they are therefore applied where the transaction is
|
||||
// populated (approvalTx.js) as well as here.
|
||||
//
|
||||
// Above the block gas limit of every supported network (see networks.js), so
|
||||
// no transaction that could ever be included is refused by it.
|
||||
const MAX_GAS_LIMIT = 100000000n;
|
||||
@@ -224,40 +240,151 @@ function normalizeData(v) {
|
||||
return String(v).toLowerCase();
|
||||
}
|
||||
|
||||
// Quantity fields the requesting page may fix in the approval. Each is
|
||||
// compared exactly when the approval carries it, and left to the ceilings
|
||||
// above when it does not.
|
||||
const APPROVED_QUANTITIES = [
|
||||
{
|
||||
key: "nonce",
|
||||
// How each field of an approved transaction is compared with the artifact.
|
||||
// There is an entry here for every field any allowed type serializes — a test
|
||||
// pins that against SERIALIZED_FIELDS — so the comparison loop covers the
|
||||
// whole of what gets signed and cannot silently skip a field for want of a
|
||||
// comparator.
|
||||
//
|
||||
// `kind` decides how the two sides are made comparable. A `quantity` must be
|
||||
// fixed by the approval: it is one of the numbers on the approval screen, and
|
||||
// an absent one means the artifact cannot be checked against what was
|
||||
// displayed. `to`, `value`, `data` and `accessList` have canonical absent
|
||||
// forms — contract creation, zero, "0x" and the empty list — so they are
|
||||
// normalized on both sides instead.
|
||||
const APPROVED_FIELDS = {
|
||||
chainId: {
|
||||
kind: "quantity",
|
||||
label: "network",
|
||||
message:
|
||||
"The signed transaction is for a different network than the one that was approved.",
|
||||
},
|
||||
nonce: {
|
||||
kind: "quantity",
|
||||
label: "nonce",
|
||||
message: "The signed transaction does not carry the approved nonce.",
|
||||
},
|
||||
{
|
||||
key: "gasLimit",
|
||||
gasLimit: {
|
||||
kind: "quantity",
|
||||
label: "gas limit",
|
||||
message:
|
||||
"The signed transaction does not carry the approved gas limit.",
|
||||
},
|
||||
{
|
||||
key: "gasPrice",
|
||||
gasPrice: {
|
||||
kind: "quantity",
|
||||
label: "gas price",
|
||||
message:
|
||||
"The signed transaction does not carry the approved gas price.",
|
||||
},
|
||||
{
|
||||
key: "maxFeePerGas",
|
||||
maxFeePerGas: {
|
||||
kind: "quantity",
|
||||
label: "maximum fee per gas",
|
||||
message:
|
||||
"The signed transaction does not carry the approved maximum fee per gas.",
|
||||
},
|
||||
{
|
||||
key: "maxPriorityFeePerGas",
|
||||
maxPriorityFeePerGas: {
|
||||
kind: "quantity",
|
||||
label: "maximum priority fee per gas",
|
||||
message:
|
||||
"The signed transaction does not carry the approved maximum priority fee per gas.",
|
||||
},
|
||||
];
|
||||
to: {
|
||||
kind: "address",
|
||||
label: "recipient",
|
||||
message:
|
||||
"The signed transaction does not go to the approved recipient.",
|
||||
},
|
||||
value: {
|
||||
kind: "value",
|
||||
label: "value",
|
||||
message: "The signed transaction does not carry the approved value.",
|
||||
},
|
||||
data: {
|
||||
kind: "data",
|
||||
label: "call data",
|
||||
message:
|
||||
"The signed transaction does not carry the approved call data.",
|
||||
},
|
||||
accessList: {
|
||||
kind: "accessList",
|
||||
label: "access list",
|
||||
message:
|
||||
"The signed transaction does not carry the approved access list.",
|
||||
},
|
||||
};
|
||||
|
||||
// Compare one field of the artifact with the approved transaction. A field
|
||||
// with no entry in the table above is refused rather than skipped: the loop
|
||||
// below runs over the fields the type serializes, so an unmatched key means
|
||||
// something that gets signed has no comparator at all.
|
||||
function assertFieldMatches(key, parsed, approvedTx) {
|
||||
const field = APPROVED_FIELDS[key];
|
||||
if (!field) {
|
||||
throw refuse(
|
||||
"The signed transaction carries a field this wallet cannot compare with the approval.",
|
||||
);
|
||||
}
|
||||
switch (field.kind) {
|
||||
case "quantity": {
|
||||
if (!present(approvedTx[key])) {
|
||||
throw refuse(
|
||||
"The approved transaction fixes no " +
|
||||
field.label +
|
||||
", so the signed transaction cannot be checked" +
|
||||
" against what was shown.",
|
||||
);
|
||||
}
|
||||
const approved = normalizeQuantity(approvedTx[key], field.label);
|
||||
if (normalizeQuantity(parsed[key], field.label) !== approved) {
|
||||
throw refuse(field.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "address":
|
||||
if (!sameAddress(parsed[key], approvedTx[key])) {
|
||||
throw refuse(field.message);
|
||||
}
|
||||
return;
|
||||
case "value":
|
||||
if (normalizeValue(parsed[key]) !== normalizeValue(approvedTx[key]))
|
||||
throw refuse(field.message);
|
||||
return;
|
||||
case "data":
|
||||
if (normalizeData(parsed[key]) !== normalizeData(approvedTx[key]))
|
||||
throw refuse(field.message);
|
||||
return;
|
||||
default:
|
||||
if (
|
||||
normalizeAccessList(parsed[key]) !==
|
||||
normalizeAccessList(approvedTx[key])
|
||||
) {
|
||||
throw refuse(field.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The ceilings, applied to a transaction that is either about to be displayed
|
||||
// or about to be broadcast. See MAX_GAS_LIMIT above for what they are for:
|
||||
// they bound what the RPC node can talk this wallet into showing the user,
|
||||
// which is the one thing comparing the artifact with the screen cannot do.
|
||||
function assertWithinCeilings(tx) {
|
||||
if (
|
||||
present(tx.gasLimit) &&
|
||||
normalizeQuantity(tx.gasLimit, "gas limit") > MAX_GAS_LIMIT
|
||||
) {
|
||||
throw refuse(
|
||||
"The signed transaction sets a gas limit no network this wallet supports can accept.",
|
||||
);
|
||||
}
|
||||
for (const key of ["gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) {
|
||||
if (!present(tx[key])) continue;
|
||||
if (normalizeQuantity(tx[key], "fee per gas") > MAX_FEE_PER_GAS) {
|
||||
throw refuse(
|
||||
"The signed transaction sets a fee per gas far above any plausible value.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refuse a field only a transaction type this wallet does not sign can carry.
|
||||
// The type allowlist keeps these unreachable in production, which is exactly
|
||||
@@ -317,10 +444,28 @@ function assertCanonicalBytes(parsed, rawSignedTx) {
|
||||
// signed by the address the approval was raised for, on the network that is
|
||||
// selected. Returns the parsed ethers Transaction on success, throws
|
||||
// otherwise.
|
||||
function verifySignedTx(rawSignedTx, txParams, expectedFrom, selectedChainId) {
|
||||
//
|
||||
// `approvedTx` is the populated transaction the approval screen displayed, and
|
||||
// `expectedFrom` is the address that was active when the approval was raised —
|
||||
// not whichever address is active now. An address switch between approval and
|
||||
// signing therefore refuses here rather than producing a transaction from an
|
||||
// account the approval did not name.
|
||||
function verifySignedTx(
|
||||
rawSignedTx,
|
||||
approvedTx,
|
||||
expectedFrom,
|
||||
selectedChainId,
|
||||
) {
|
||||
if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) {
|
||||
throw refuse("The signed transaction is missing or malformed.");
|
||||
}
|
||||
// Nothing to compare against is a refusal like any other: an approval that
|
||||
// does not carry the transaction it displayed cannot vouch for one.
|
||||
if (!approvedTx || typeof approvedTx !== "object") {
|
||||
throw refuse(
|
||||
"There is no approved transaction to check the signed transaction against.",
|
||||
);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
@@ -360,46 +505,15 @@ function verifySignedTx(rawSignedTx, txParams, expectedFrom, selectedChainId) {
|
||||
"The signed transaction is for a different network than the one that is selected.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
present(txParams.chainId) &&
|
||||
parsed.chainId !== normalizeQuantity(txParams.chainId, "network")
|
||||
) {
|
||||
throw refuse(
|
||||
"The signed transaction is for a different network than the one that was approved.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!sameAddress(parsed.to, txParams.to)) {
|
||||
throw refuse(
|
||||
"The signed transaction does not go to the approved recipient.",
|
||||
);
|
||||
}
|
||||
if (normalizeValue(parsed.value) !== normalizeValue(txParams.value)) {
|
||||
throw refuse(
|
||||
"The signed transaction does not carry the approved value.",
|
||||
);
|
||||
}
|
||||
if (normalizeData(parsed.data) !== normalizeData(txParams.data)) {
|
||||
throw refuse(
|
||||
"The signed transaction does not carry the approved call data.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
normalizeAccessList(parsed.accessList) !==
|
||||
normalizeAccessList(txParams.accessList)
|
||||
) {
|
||||
throw refuse(
|
||||
"The signed transaction does not carry the approved access list.",
|
||||
);
|
||||
}
|
||||
|
||||
// An approval that fixed EIP-1559 fees must not be signed as a legacy
|
||||
// transaction, and vice versa: the fee the user agreed to is only
|
||||
// meaningful under the mechanism it was quoted in.
|
||||
// The approved fee mechanism, named before the type comparison below
|
||||
// subsumes it: the fee the user agreed to is only meaningful under the
|
||||
// mechanism it was quoted in, and saying so is more use than "a different
|
||||
// transaction type".
|
||||
const approvedEip1559 =
|
||||
present(txParams.maxFeePerGas) ||
|
||||
present(txParams.maxPriorityFeePerGas);
|
||||
const approvedLegacy = present(txParams.gasPrice);
|
||||
present(approvedTx.maxFeePerGas) ||
|
||||
present(approvedTx.maxPriorityFeePerGas);
|
||||
const approvedLegacy = present(approvedTx.gasPrice);
|
||||
const signedEip1559 = parsed.type === 2;
|
||||
if (
|
||||
(approvedEip1559 && !signedEip1559) ||
|
||||
@@ -410,28 +524,33 @@ function verifySignedTx(rawSignedTx, txParams, expectedFrom, selectedChainId) {
|
||||
);
|
||||
}
|
||||
|
||||
for (const field of APPROVED_QUANTITIES) {
|
||||
if (!present(txParams[field.key])) continue;
|
||||
const approved = normalizeQuantity(txParams[field.key], field.label);
|
||||
if (normalizeQuantity(parsed[field.key], field.label) !== approved) {
|
||||
throw refuse(field.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.gasLimit > MAX_GAS_LIMIT) {
|
||||
// The type decides which fields are compared, so it is compared first and
|
||||
// against the approval, not merely checked for membership of the
|
||||
// allowlist above.
|
||||
if (!present(approvedTx.type)) {
|
||||
throw refuse(
|
||||
"The signed transaction sets a gas limit no network this wallet supports can accept.",
|
||||
"The approved transaction fixes no transaction type, so the signed transaction cannot be checked against what was shown.",
|
||||
);
|
||||
}
|
||||
for (const key of ["gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) {
|
||||
const fee = parsed[key];
|
||||
if (fee !== null && fee !== undefined && fee > MAX_FEE_PER_GAS) {
|
||||
throw refuse(
|
||||
"The signed transaction sets a fee per gas far above any plausible value.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
BigInt(parsed.type) !==
|
||||
normalizeQuantity(approvedTx.type, "transaction type")
|
||||
) {
|
||||
throw refuse(
|
||||
"The signed transaction does not use the approved transaction type.",
|
||||
);
|
||||
}
|
||||
|
||||
// Every field this type serializes, compared with the transaction the user
|
||||
// was shown. Driving the loop off SERIALIZED_FIELDS is what keeps this
|
||||
// exhaustive: the same table decides what assertNothingUnchecked() rebuilds
|
||||
// from, so a field that gets signed and is not compared here cannot exist.
|
||||
for (const key of SERIALIZED_FIELDS[parsed.type]) {
|
||||
assertFieldMatches(key, parsed, approvedTx);
|
||||
}
|
||||
|
||||
assertWithinCeilings(parsed);
|
||||
|
||||
assertNothingUnchecked(parsed);
|
||||
assertCanonicalBytes(parsed, rawSignedTx);
|
||||
|
||||
@@ -504,10 +623,10 @@ function errorText(err) {
|
||||
// approval. Anything else failed before the check ran and is retryable.
|
||||
// - broadcast: always terminal. A broadcast that throws after the node
|
||||
// accepted the transaction is routine (a timeout, a dropped response, a
|
||||
// node answering "already known"), and the popup's retry does not
|
||||
// re-broadcast these bytes — it re-runs populateTransaction() and signs
|
||||
// again at a freshly fetched pending-tag nonce. Retrying would therefore
|
||||
// put a second transaction on the chain for one approval.
|
||||
// node answering "already known"), so the wallet cannot tell a transaction
|
||||
// that never left from one that is already in the mempool. The approval is
|
||||
// spent and the requesting page has been given its outcome; a second
|
||||
// attempt against it would report a second outcome for one request.
|
||||
function describeTxFailure(stage, err) {
|
||||
const error = errorText(err);
|
||||
const retryable =
|
||||
@@ -553,6 +672,7 @@ module.exports = {
|
||||
assertNoForbiddenFields,
|
||||
assertNothingUnchecked,
|
||||
assertCanonicalBytes,
|
||||
assertWithinCeilings,
|
||||
sameAddress,
|
||||
failureIsRetryable,
|
||||
describeTxFailure,
|
||||
@@ -561,6 +681,7 @@ module.exports = {
|
||||
ALLOWED_TX_TYPES,
|
||||
SERIALIZED_FIELDS,
|
||||
FORBIDDEN_FIELDS,
|
||||
APPROVED_FIELDS,
|
||||
TX_STAGE_SIGN,
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
|
||||
Reference in New Issue
Block a user