harden: verify the signed transaction against what the popup displayed (closes #216)
Some checks failed
check / check (push) Has been cancelled
Some checks failed
check / check (push) Has been cancelled
Verification compared the signed artifact against the dApp's request object. For every field the dApp omitted -- normally nonce, gas limit and all the fee fields, since the popup filled them in -- the number the user actually read on screen was verified by nothing, and only absolute ceilings stood behind it. The transaction is now populated in the background before the approval window opens, and that populated object is both what the popup displays and what the signed artifact is verified against. Every consequential field becomes an equality comparison; the ceilings remain as a backstop. Population failing means no approval and no window, and the error goes to the requesting page -- earlier than before, where the same estimate failed after the password had been typed. The account is pinned too: `from` is compared against the address named at approval time rather than whichever address is active at signing, so switching accounts mid-flow refuses instead of signing from an account the approval did not name. The message-signing path had the same defect and gets the same fix. Nonce selection moves earlier as a consequence; the concurrent-approval case that follows from it is tracked at #271.
This commit was merged in pull request #269.
This commit is contained in:
@@ -18,11 +18,14 @@ const {
|
||||
verifySignature,
|
||||
failureIsRetryable,
|
||||
describeTxFailure,
|
||||
sameAddress,
|
||||
ApprovalMismatchError,
|
||||
TX_STAGE_SIGN,
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
TX_STAGE_INFLIGHT,
|
||||
} = require("../shared/approvalVerify");
|
||||
const { prepareApprovalTx } = require("../shared/approvalTx");
|
||||
const {
|
||||
isPhishingDomain,
|
||||
refreshPhishingListOnSchedule,
|
||||
@@ -77,6 +80,14 @@ async function getActiveAddress() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Whether a request names a signing address other than the active one. Such a
|
||||
// request is refused rather than quietly signed as whichever address happens
|
||||
// to be active: the page asked for account A and would otherwise be handed
|
||||
// something from account B.
|
||||
function namesAnotherAddress(requested, activeAddress) {
|
||||
return !!requested && !sameAddress(requested, activeAddress);
|
||||
}
|
||||
|
||||
async function getRpcUrl() {
|
||||
const s = await getState();
|
||||
return s.rpcUrl || DEFAULT_RPC_URL;
|
||||
@@ -225,13 +236,21 @@ function requestApproval(origin, hostname) {
|
||||
// Uses windows.create() directly because tx approvals are triggered programmatically
|
||||
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
||||
// unreliable in this context.
|
||||
function requestTxApproval(origin, hostname, txParams) {
|
||||
//
|
||||
// `approvedTx` is the fully populated transaction (see approvalTx.js): the
|
||||
// object the popup displays, the object it signs, and the object the artifact
|
||||
// is verified against. `approvedFrom` is the address that is active now, and
|
||||
// it is pinned here rather than read again at signing time — an address switch
|
||||
// between approval and signing must refuse, not sign from an account this
|
||||
// screen never named.
|
||||
function requestTxApproval(origin, hostname, approvedTx, approvedFrom) {
|
||||
return new Promise((resolve) => {
|
||||
const id = crypto.randomUUID();
|
||||
pendingApprovals[id] = {
|
||||
origin,
|
||||
hostname,
|
||||
txParams,
|
||||
approvedTx,
|
||||
approvedFrom,
|
||||
resolve,
|
||||
type: "tx",
|
||||
};
|
||||
@@ -244,13 +263,14 @@ function requestTxApproval(origin, hostname, txParams) {
|
||||
// Uses windows.create() directly because sign approvals are triggered programmatically
|
||||
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
||||
// unreliable in this context.
|
||||
function requestSignApproval(origin, hostname, signParams) {
|
||||
function requestSignApproval(origin, hostname, signParams, approvedFrom) {
|
||||
return new Promise((resolve) => {
|
||||
const id = crypto.randomUUID();
|
||||
pendingApprovals[id] = {
|
||||
origin,
|
||||
hostname,
|
||||
signParams,
|
||||
approvedFrom,
|
||||
resolve,
|
||||
type: "sign",
|
||||
};
|
||||
@@ -502,6 +522,16 @@ async function handleRpc(method, params, origin) {
|
||||
? { method, message: params[0], from: params[1] }
|
||||
: { method, message: params[1], from: params[0] };
|
||||
|
||||
if (namesAnotherAddress(signParams.from, activeAddress)) {
|
||||
return {
|
||||
error: {
|
||||
code: 4100,
|
||||
message:
|
||||
"This site asked to sign as an address that is not the active one.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (method === "eth_sign") {
|
||||
signParams.dangerWarning =
|
||||
"\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
|
||||
@@ -513,6 +543,7 @@ async function handleRpc(method, params, origin) {
|
||||
origin,
|
||||
hostname,
|
||||
signParams,
|
||||
activeAddress,
|
||||
);
|
||||
if (decision.error) return { error: decision.error };
|
||||
return { result: decision.signature };
|
||||
@@ -534,10 +565,20 @@ async function handleRpc(method, params, origin) {
|
||||
}
|
||||
|
||||
const signParams = { method, typedData: params[1], from: params[0] };
|
||||
if (namesAnotherAddress(signParams.from, activeAddress)) {
|
||||
return {
|
||||
error: {
|
||||
code: 4100,
|
||||
message:
|
||||
"This site asked to sign as an address that is not the active one.",
|
||||
},
|
||||
};
|
||||
}
|
||||
const decision = await requestSignApproval(
|
||||
origin,
|
||||
hostname,
|
||||
signParams,
|
||||
activeAddress,
|
||||
);
|
||||
if (decision.error) return { error: decision.error };
|
||||
return { result: decision.signature };
|
||||
@@ -559,7 +600,51 @@ async function handleRpc(method, params, origin) {
|
||||
}
|
||||
|
||||
const txParams = params?.[0] || {};
|
||||
const decision = await requestTxApproval(origin, hostname, txParams);
|
||||
if (namesAnotherAddress(txParams.from, activeAddress)) {
|
||||
return {
|
||||
error: {
|
||||
code: 4100,
|
||||
message:
|
||||
"This site asked to send from an address that is not the active one.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Populate here, before any window opens, so that the transaction the
|
||||
// user is shown is a complete one and is the same object the signed
|
||||
// artifact is checked against. A failure raises no approval at all and
|
||||
// is reported to the requesting page; see approvalTx.js.
|
||||
let approvedTx;
|
||||
try {
|
||||
approvedTx = await prepareApprovalTx(
|
||||
getProvider(await getRpcUrl()),
|
||||
activeAddress,
|
||||
txParams,
|
||||
);
|
||||
} catch (e) {
|
||||
return { error: { message: e.message } };
|
||||
}
|
||||
|
||||
// Population is a network round trip, and the user can switch address
|
||||
// during it. Raising the approval anyway would put an account on the
|
||||
// screen that the wallet is no longer on, and it could never be signed
|
||||
// — the signing handler refuses exactly that. Refuse it here instead,
|
||||
// while the page is still waiting and nothing has been displayed.
|
||||
if (!sameAddress(await getActiveAddress(), activeAddress)) {
|
||||
return {
|
||||
error: {
|
||||
message:
|
||||
"The active address changed while this transaction was being prepared, so it was not sent.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const decision = await requestTxApproval(
|
||||
origin,
|
||||
hostname,
|
||||
approvedTx,
|
||||
activeAddress,
|
||||
);
|
||||
if (decision.error) return { error: decision.error };
|
||||
return { result: decision.txHash };
|
||||
}
|
||||
@@ -810,11 +895,16 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
};
|
||||
if (approval.type === "tx") {
|
||||
resp.type = "tx";
|
||||
resp.txParams = approval.txParams;
|
||||
// The populated transaction, and the address it was raised
|
||||
// for. The popup displays and signs exactly this and does not
|
||||
// populate or re-read anything itself.
|
||||
resp.approvedTx = approval.approvedTx;
|
||||
resp.approvedFrom = approval.approvedFrom;
|
||||
}
|
||||
if (approval.type === "sign") {
|
||||
resp.type = "sign";
|
||||
resp.signParams = approval.signParams;
|
||||
resp.approvedFrom = approval.approvedFrom;
|
||||
}
|
||||
// Flag if the requesting domain is on the phishing blocklist.
|
||||
resp.isPhishingDomain = isPhishingDomain(approval.hostname);
|
||||
@@ -888,14 +978,27 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
try {
|
||||
await loadState();
|
||||
const activeAddress = await getActiveAddress();
|
||||
// An address switch between approval and signing refuses. The
|
||||
// approval named one account; signing from whichever account
|
||||
// is active now would send funds from an account this screen
|
||||
// never showed. A switch normally rejects every pending
|
||||
// approval on its way through broadcastAccountsChanged(), so
|
||||
// this is the case where that did not reach the approval —
|
||||
// and it is a refusal, not a retry, because the transaction
|
||||
// the user saw is no longer the transaction that would go out.
|
||||
if (!sameAddress(activeAddress, approval.approvedFrom)) {
|
||||
throw new ApprovalMismatchError(
|
||||
"The active address changed after this transaction was approved, so it was not sent.",
|
||||
);
|
||||
}
|
||||
// The popup holds the secret, but the background stays the
|
||||
// authority on what is broadcast: the raw transaction must be
|
||||
// the approved one, signed by the approved address, on the
|
||||
// network that is selected.
|
||||
// the transaction that was displayed, signed by the address
|
||||
// the approval named, on the network that is selected.
|
||||
verifySignedTx(
|
||||
msg.rawSignedTx,
|
||||
approval.txParams,
|
||||
activeAddress,
|
||||
approval.approvedTx,
|
||||
approval.approvedFrom,
|
||||
currentNetwork().chainId,
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -932,10 +1035,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
sendResponse({ txHash: tx.hash });
|
||||
} catch (e) {
|
||||
// Terminal, never retried: the node may have accepted the
|
||||
// transaction and still failed to answer, and the popup's
|
||||
// retry re-signs at a freshly fetched nonce rather than
|
||||
// re-broadcasting these bytes. Retrying would send the
|
||||
// approved transfer a second time.
|
||||
// transaction and still failed to answer, so the wallet cannot
|
||||
// tell a transaction that never left from one already in the
|
||||
// mempool. The page has been given its outcome for this
|
||||
// request; a second attempt would report a second one.
|
||||
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
|
||||
settleApproval(
|
||||
msg.id,
|
||||
@@ -998,12 +1101,24 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
(async () => {
|
||||
try {
|
||||
const activeAddress = await getActiveAddress();
|
||||
// Same as the transaction path: the address the approval named
|
||||
// is the one that must have signed, and a switch since then is
|
||||
// a refusal rather than a signature from another account.
|
||||
if (!sameAddress(activeAddress, approval.approvedFrom)) {
|
||||
throw new ApprovalMismatchError(
|
||||
"The active address changed after this request was approved, so it was not signed.",
|
||||
);
|
||||
}
|
||||
// The popup holds the secret, but the background stays the
|
||||
// authority on what is handed back to the page: the signature
|
||||
// must cover the approved payload and recover to the approved
|
||||
// address.
|
||||
// must cover the approved payload and recover to the address
|
||||
// the approval named.
|
||||
const signature = msg.signature;
|
||||
verifySignature(approval.signParams, signature, activeAddress);
|
||||
verifySignature(
|
||||
approval.signParams,
|
||||
signature,
|
||||
approval.approvedFrom,
|
||||
);
|
||||
settleApproval(msg.id, { signature }, { holdsClaim: true });
|
||||
sendResponse({ signature });
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user