harden: verify all approval fields and make failed signing retryable (closes #174)
All checks were successful
check / check (push) Successful in 35s
All checks were successful
check / check (push) Successful in 35s
verifySignedTx compared only from, to, value and data, so a signed transaction could differ from the approval in chain id, nonce, gas limit or any fee field and still be broadcast. Worse, it named the fields it checked and so admitted every field it did not name: a type 4 artifact carrying an EIP-7702 authorization passed verification, paying the approved amount to the approved recipient and, in the same transaction, permanently installing another contract's code at the signer's own account. The check is now an allowlist in both directions. The transaction type must be 0, 1 or 2 — the only types this wallet signs — so no later EIP-2718 type can bring a field along; authorizationList, blobs, blob commitments and blob gas fees are refused by name; and the access list is compared with the approval. Every consequential field is compared and any mismatch refuses outright: the chain id against the selected network (and against the approval when the page fixed one), plus nonce, gas limit, gasPrice, maxFeePerGas and maxPriorityFeePerGas wherever the approval carries a value, together with the fee mechanism the approval implies. Fields the approval does not carry are populated locally by the popup and have no approved value to compare against, so they are held to absolute ceilings instead. Verification then closes by rebuilding the transaction from exactly those checked fields and comparing the unsigned bytes, so an artifact carrying anything this module does not account for is refused without having to be named first. An approved value that is not a number now refuses like every other quantity rather than escaping as a raw BigInt conversion error, which was reported as retryable and left a live button that could never succeed. A failed signing attempt also left a button that could not succeed: the background deleted the approval before it broadcast, so a retry found nothing to sign. The approval is now retired once the request has an outcome, and the background tells the popup which stage failed. A popup that could not sign is retryable; a mismatch spends the approval; a failed broadcast is terminal, because 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 the same bytes, which would send the approved transfer twice. Keeping the approval alive for that retry cost it its single use: the handler read it, then verified and broadcast asynchronously, so a second AUTISTMASK_TX_RESPONSE carrying the same id started an independent verify and broadcast instead of finding nothing. With the ordinary dApp approval shape the page fixes no nonce, so two artifacts signed at different nonces both verify and the approved transfer goes out twice; a reloaded approval window during a slow broadcast is enough to send it, since the only guard was popup-local button state. The approval is now claimed synchronously, before the first await, and released only when an attempt fails in a way the user may retry. Same interlock on AUTISTMASK_SIGN_RESPONSE. Surviving the whole verify-and-broadcast window put the approval within reach of every other path that retires one, and those paths did not consult the claim. Closing the approval popup, switching the active address, or a reject arriving late each resolved the waiting promise 4001 while the attempt behind it ran to completion; the attempt's own resolve then landed on a settled promise, so the transaction reached the chain and the page was told the user rejected it. The user's natural response is to redo the transfer from the site, which re-signs at a fresh nonce and sends it twice — the outcome this change exists to prevent, reached without an adversary, since the popup stays open across the broadcast and a user closing an apparently-hung window is enough. Every settlement now goes through one function. settleApproval() is the only place an approval is resolved or removed, and it refuses a claimed approval unless the caller holds the claim, so a path added later inherits the interlock instead of having to remember it. The active- address switch also leaves a claimed approval's window standing rather than force-closing the window the attempt is reporting into. The duplicate refusal on the sign path now carries a stage of its own, so the popup stops telling the user to start again from the site while a first attempt may still succeed. Verification also compared only the decode against itself: both sides of the closing byte comparison derive from one Transaction.from(), while what is broadcast is the artifact string. An artifact re-encoded with a leading zero byte on an RLP quantity therefore decoded to the approved transaction, passed, and broadcast different bytes. The artifact is now required to be the canonical encoding of its own decode, which is what makes the claim that it *is* the approved transaction true. The background's approval wiring had no tests, which is where these defects lived. It has them now, driven through the real message listener from eth_sendTransaction to broadcast, with windows.onRemoved captured rather than stubbed away: each retirement path is asserted to leave a mid-broadcast attempt alone and to still reject an approval no attempt holds.
This commit is contained in:
@@ -1,8 +1,28 @@
|
||||
const { Network, Transaction, Wallet } = require("ethers");
|
||||
const {
|
||||
Network,
|
||||
Transaction,
|
||||
Wallet,
|
||||
decodeRlp,
|
||||
encodeRlp,
|
||||
} = require("ethers");
|
||||
const {
|
||||
verifySignedTx,
|
||||
verifySignature,
|
||||
assertNoForbiddenFields,
|
||||
assertNothingUnchecked,
|
||||
assertCanonicalBytes,
|
||||
sameAddress,
|
||||
failureIsRetryable,
|
||||
describeTxFailure,
|
||||
describeSigningFailure,
|
||||
ALLOWED_TX_TYPES,
|
||||
SERIALIZED_FIELDS,
|
||||
FORBIDDEN_FIELDS,
|
||||
TX_STAGE_SIGN,
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
MAX_GAS_LIMIT,
|
||||
MAX_FEE_PER_GAS,
|
||||
} = require("../src/shared/approvalVerify");
|
||||
const { getSignerForAddress } = require("../src/shared/wallet");
|
||||
|
||||
@@ -18,6 +38,10 @@ const other = new Wallet(OTHER_KEY);
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||
|
||||
// The chain id of the selected network, as networks.js carries it.
|
||||
const SELECTED = "0x1";
|
||||
const SEPOLIA = "0xaa36a7";
|
||||
|
||||
// Approved parameters as a dApp would supply them over eth_sendTransaction.
|
||||
const TX_PARAMS = {
|
||||
from: signer.address,
|
||||
@@ -27,25 +51,38 @@ const TX_PARAMS = {
|
||||
gas: "0x5208",
|
||||
};
|
||||
|
||||
// The values populateTransaction() fills in when the dApp fixed none of them.
|
||||
const POPULATED = {
|
||||
chainId: 1,
|
||||
nonce: 7,
|
||||
gasLimit: 100000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
type: 2,
|
||||
};
|
||||
|
||||
// Build a signable transaction from approved params. The popup does the same
|
||||
// thing through populateTransaction(); here the fields are fixed so the test
|
||||
// needs no provider.
|
||||
function txFor(params) {
|
||||
// needs no provider. `overrides` stands in for what a tampered or misbuilt
|
||||
// popup would put on the wire.
|
||||
function txFor(params, overrides) {
|
||||
return {
|
||||
chainId: 1,
|
||||
nonce: 7,
|
||||
gasLimit: 100000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
type: 2,
|
||||
...POPULATED,
|
||||
to: params.to,
|
||||
value: params.value === undefined ? 0n : BigInt(params.value),
|
||||
data: params.data || "0x",
|
||||
...(overrides || {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function signedFor(params, withWallet) {
|
||||
return (withWallet || signer).signTransaction(txFor(params));
|
||||
async function signedFor(params, withWallet, overrides) {
|
||||
return (withWallet || signer).signTransaction(txFor(params, overrides));
|
||||
}
|
||||
|
||||
// Sign the approved transaction with one field changed from what was
|
||||
// populated, which is the shape of every tamper case below.
|
||||
async function signedWith(overrides) {
|
||||
return signedFor(TX_PARAMS, signer, overrides);
|
||||
}
|
||||
|
||||
describe("sameAddress", () => {
|
||||
@@ -71,7 +108,7 @@ describe("sameAddress", () => {
|
||||
describe("verifySignedTx", () => {
|
||||
test("accepts the approved transaction signed by the approved address", async () => {
|
||||
const raw = await signedFor(TX_PARAMS);
|
||||
const parsed = verifySignedTx(raw, TX_PARAMS, signer.address);
|
||||
const parsed = verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||
expect(parsed.from).toBe(signer.address);
|
||||
expect(parsed.hash).toBe(Transaction.from(raw).hash);
|
||||
});
|
||||
@@ -79,14 +116,16 @@ describe("verifySignedTx", () => {
|
||||
test("accepts a contract creation with no recipient", async () => {
|
||||
const params = { to: undefined, value: "0x0", data: "0x600160005500" };
|
||||
const raw = await signedFor(params);
|
||||
expect(() => verifySignedTx(raw, params, signer.address)).not.toThrow();
|
||||
expect(() =>
|
||||
verifySignedTx(raw, params, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts an absent value as zero", async () => {
|
||||
const approved = { to: RECIPIENT, data: "0x" };
|
||||
const raw = await signedFor(approved);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address),
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -94,7 +133,7 @@ describe("verifySignedTx", () => {
|
||||
const approved = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" };
|
||||
const raw = await signedFor(approved);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address),
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -103,9 +142,9 @@ describe("verifySignedTx", () => {
|
||||
...TX_PARAMS,
|
||||
to: OTHER_RECIPIENT,
|
||||
});
|
||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
||||
/approved recipient/,
|
||||
);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/approved recipient/);
|
||||
});
|
||||
|
||||
test("rejects an inflated value", async () => {
|
||||
@@ -113,48 +152,48 @@ describe("verifySignedTx", () => {
|
||||
...TX_PARAMS,
|
||||
value: "0x4563918244f40000",
|
||||
});
|
||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
||||
/approved value/,
|
||||
);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/approved value/);
|
||||
});
|
||||
|
||||
test("rejects substituted call data", async () => {
|
||||
const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" });
|
||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
||||
/approved call data/,
|
||||
);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/approved call data/);
|
||||
});
|
||||
|
||||
test("rejects a transaction signed by a different address", async () => {
|
||||
const raw = await signedFor(TX_PARAMS, other);
|
||||
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
|
||||
/different address/,
|
||||
);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/different address/);
|
||||
});
|
||||
|
||||
test("rejects an unsigned transaction", () => {
|
||||
const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized;
|
||||
expect(() =>
|
||||
verifySignedTx(unsigned, TX_PARAMS, signer.address),
|
||||
verifySignedTx(unsigned, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/no valid signature/);
|
||||
});
|
||||
|
||||
test("rejects a missing or malformed payload", () => {
|
||||
expect(() =>
|
||||
verifySignedTx(undefined, TX_PARAMS, signer.address),
|
||||
verifySignedTx(undefined, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/missing or malformed/);
|
||||
expect(() => verifySignedTx("nope", TX_PARAMS, signer.address)).toThrow(
|
||||
/missing or malformed/,
|
||||
);
|
||||
expect(() =>
|
||||
verifySignedTx("0xc0ffee", TX_PARAMS, signer.address),
|
||||
verifySignedTx("nope", TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/missing or malformed/);
|
||||
expect(() =>
|
||||
verifySignedTx("0xc0ffee", TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/could not be decoded/);
|
||||
});
|
||||
|
||||
test("every rejection message is a full sentence", async () => {
|
||||
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
||||
try {
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address);
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
||||
@@ -162,6 +201,606 @@ describe("verifySignedTx", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// One case per consequential field: the field alone differs from what was
|
||||
// approved, and that alone must refuse the signature.
|
||||
describe("verifySignedTx field comparison", () => {
|
||||
test("rejects a chain id that is not the selected network", async () => {
|
||||
const raw = await signedWith({ chainId: 11155111 });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/different network than the one that is selected/);
|
||||
});
|
||||
|
||||
test("rejects a chain id that is not the approved one", async () => {
|
||||
// Selected network and signed chain id agree; the dApp asked for a
|
||||
// different chain, so the artifact is not what was approved.
|
||||
const approved = { ...TX_PARAMS, chainId: SEPOLIA };
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/different network than the one that was approved/);
|
||||
});
|
||||
|
||||
test("refuses when the selected network is unknown", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, undefined),
|
||||
).toThrow(/selected network is unknown/);
|
||||
});
|
||||
|
||||
test("rejects a substituted nonce", async () => {
|
||||
const approved = { ...TX_PARAMS, nonce: 7 };
|
||||
const raw = await signedWith({ nonce: 8 });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved nonce/);
|
||||
});
|
||||
|
||||
test("rejects a substituted gas limit", async () => {
|
||||
const approved = { ...TX_PARAMS, gasLimit: "0x186a0" };
|
||||
const raw = await signedWith({ gasLimit: 250000n });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved gas limit/);
|
||||
});
|
||||
|
||||
test("rejects a substituted maximum fee per gas", async () => {
|
||||
const approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" };
|
||||
const raw = await signedWith({ maxFeePerGas: 900000000000n });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved maximum fee per gas/);
|
||||
});
|
||||
|
||||
test("rejects a substituted maximum priority fee per gas", async () => {
|
||||
const approved = { ...TX_PARAMS, maxPriorityFeePerGas: "0x3b9aca00" };
|
||||
const raw = await signedWith({ maxPriorityFeePerGas: 1500000000n });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved maximum priority fee per gas/);
|
||||
});
|
||||
|
||||
test("rejects a substituted legacy gas price", async () => {
|
||||
const approved = { ...TX_PARAMS, gasPrice: "0x77359400" };
|
||||
const legacy = {
|
||||
type: 0,
|
||||
gasPrice: 9000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
};
|
||||
const raw = await signedWith(legacy);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved gas price/);
|
||||
});
|
||||
|
||||
test("rejects an approved legacy fee signed as an EIP-1559 fee", async () => {
|
||||
const approved = { ...TX_PARAMS, gasPrice: "0x77359400" };
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved fee mechanism/);
|
||||
});
|
||||
|
||||
test("rejects an approved EIP-1559 fee signed as a legacy fee", async () => {
|
||||
const approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" };
|
||||
const raw = await signedWith({
|
||||
type: 0,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).toThrow(/approved fee mechanism/);
|
||||
});
|
||||
|
||||
test("rejects a gas limit above anything a supported network accepts", async () => {
|
||||
const raw = await signedWith({ gasLimit: MAX_GAS_LIMIT + 1n });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/gas limit no network this wallet supports/);
|
||||
});
|
||||
|
||||
test("rejects an absurd fee per gas the approval never fixed", async () => {
|
||||
const raw = await signedWith({
|
||||
maxFeePerGas: MAX_FEE_PER_GAS + 1n,
|
||||
maxPriorityFeePerGas: MAX_FEE_PER_GAS + 1n,
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/fee per gas far above any plausible value/);
|
||||
});
|
||||
|
||||
test("every field mismatch is a refusal, not a warning", async () => {
|
||||
const raw = await signedWith({ nonce: 8 });
|
||||
try {
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, nonce: 7 },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
expect(e.approvalMismatch).toBe(true);
|
||||
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The transaction type decides which fields exist, so an artifact of a type
|
||||
// this wallet does not sign carries consequences the approval cannot describe
|
||||
// and none of the field comparisons can see. The approval used here is the
|
||||
// ordinary dApp shape with no fee fields — the common case, since
|
||||
// populateTransaction() fills them — which is exactly the case the
|
||||
// fee-mechanism check cannot catch by accident.
|
||||
describe("verifySignedTx transaction type", () => {
|
||||
const BARE_APPROVAL = {
|
||||
from: signer.address,
|
||||
to: RECIPIENT,
|
||||
value: "0x2386f26fc10000",
|
||||
data: "0x",
|
||||
};
|
||||
|
||||
// An EIP-7702 artifact that pays the approved amount to the approved
|
||||
// recipient and, in the same transaction, installs the attacker's code at
|
||||
// the signer's own account for good. Every field the approval screen shows
|
||||
// matches; only the type and the authorization list do not.
|
||||
test("refuses a type 4 artifact that delegates the signer's own account", async () => {
|
||||
const authorization = await signer.authorize({
|
||||
address: OTHER_RECIPIENT,
|
||||
chainId: 1,
|
||||
nonce: 8,
|
||||
});
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||
type: 4,
|
||||
authorizationList: [authorization],
|
||||
});
|
||||
const parsed = Transaction.from(raw);
|
||||
expect(parsed.type).toBe(4);
|
||||
expect(parsed.authorizationList[0].address).toBe(OTHER_RECIPIENT);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
|
||||
).toThrow(/type this wallet does not sign/);
|
||||
});
|
||||
|
||||
test("refuses a type 3 blob artifact", async () => {
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||
type: 3,
|
||||
maxFeePerBlobGas: 1000000000n,
|
||||
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
|
||||
});
|
||||
expect(Transaction.from(raw).type).toBe(3);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
|
||||
).toThrow(/type this wallet does not sign/);
|
||||
});
|
||||
|
||||
test("refuses every type outside the allowlist, not just the known ones", async () => {
|
||||
for (const type of [3, 4]) {
|
||||
expect(ALLOWED_TX_TYPES).not.toContain(type);
|
||||
}
|
||||
expect(ALLOWED_TX_TYPES).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
test("a type refusal is a refusal, not a warning", async () => {
|
||||
const authorization = await signer.authorize({
|
||||
address: OTHER_RECIPIENT,
|
||||
chainId: 1,
|
||||
nonce: 8,
|
||||
});
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||
type: 4,
|
||||
authorizationList: [authorization],
|
||||
});
|
||||
try {
|
||||
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
expect(e.approvalMismatch).toBe(true);
|
||||
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts a legacy type 0 transaction", async () => {
|
||||
const approved = { ...BARE_APPROVAL, gasPrice: "0x77359400" };
|
||||
const raw = await signedFor(approved, signer, {
|
||||
type: 0,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts a type 1 transaction whose access list is the approved one", async () => {
|
||||
const accessList = [{ address: OTHER_RECIPIENT, storageKeys: [] }];
|
||||
const approved = {
|
||||
...BARE_APPROVAL,
|
||||
gasPrice: "0x77359400",
|
||||
accessList,
|
||||
};
|
||||
const raw = await signedFor(approved, signer, {
|
||||
type: 1,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
accessList,
|
||||
});
|
||||
expect(Transaction.from(raw).type).toBe(1);
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("refuses an access list the approval never carried", async () => {
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {
|
||||
accessList: [{ address: OTHER_RECIPIENT, storageKeys: [] }],
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
|
||||
).toThrow(/approved access list/);
|
||||
});
|
||||
|
||||
test("treats an absent access list and an empty one as the same thing", async () => {
|
||||
const approved = { ...BARE_APPROVAL, accessList: [] };
|
||||
const raw = await signedFor(BARE_APPROVAL, signer, {});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// The allowlist is only exhaustive while it accounts for every field an
|
||||
// artifact can carry. These tests are what makes that claim checkable rather
|
||||
// than asserted.
|
||||
describe("verifySignedTx exhaustiveness", () => {
|
||||
// Every accessor ethers exposes on a parsed transaction, and where this
|
||||
// module deals with it. If an ethers upgrade adds a transaction field,
|
||||
// this fails and forces a decision about it instead of letting it default
|
||||
// to unchecked.
|
||||
test("every field ethers can parse is accounted for", () => {
|
||||
const derived = [
|
||||
// Recovered from the signature or computed from the payload, not
|
||||
// independent content: covered by the signer check and by the
|
||||
// fields below.
|
||||
"from",
|
||||
"fromPublicKey",
|
||||
"hash",
|
||||
"serialized",
|
||||
"signature",
|
||||
"type",
|
||||
"typeName",
|
||||
"unsignedHash",
|
||||
"unsignedSerialized",
|
||||
// Blob sidecar machinery, meaningful only alongside `blobs`,
|
||||
// which is refused outright.
|
||||
"kzg",
|
||||
"blobWrapperVersion",
|
||||
];
|
||||
const accounted = new Set([
|
||||
...derived,
|
||||
...FORBIDDEN_FIELDS.map((f) => f.key),
|
||||
...Object.values(SERIALIZED_FIELDS).flat(),
|
||||
]);
|
||||
const exposed = Object.getOwnPropertyNames(Transaction.prototype)
|
||||
.filter((name) => {
|
||||
const d = Object.getOwnPropertyDescriptor(
|
||||
Transaction.prototype,
|
||||
name,
|
||||
);
|
||||
return d && typeof d.get === "function";
|
||||
})
|
||||
.sort();
|
||||
expect(exposed.filter((name) => !accounted.has(name))).toEqual([]);
|
||||
});
|
||||
|
||||
// The two layers behind the type allowlist. Nothing reachable through
|
||||
// verifySignedTx can trip either of them while the allowlist holds — that
|
||||
// is what they are for — so they are exercised directly rather than taken
|
||||
// on trust.
|
||||
test("a forbidden field is refused even on an allowed type", async () => {
|
||||
const authorization = await signer.authorize({
|
||||
address: OTHER_RECIPIENT,
|
||||
chainId: 1,
|
||||
nonce: 8,
|
||||
});
|
||||
const carriers = {
|
||||
authorizationList: [authorization],
|
||||
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
|
||||
blobs: ["0x00"],
|
||||
maxFeePerBlobGas: 1n,
|
||||
};
|
||||
for (const key of Object.keys(carriers)) {
|
||||
expect(FORBIDDEN_FIELDS.map((f) => f.key)).toContain(key);
|
||||
let thrown;
|
||||
try {
|
||||
assertNoForbiddenFields({ type: 2, [key]: carriers[key] });
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown.approvalMismatch).toBe(true);
|
||||
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
|
||||
}
|
||||
expect(() => assertNoForbiddenFields({ type: 2 })).not.toThrow();
|
||||
});
|
||||
|
||||
// Stands in for a future ethers that parses a field this module does not
|
||||
// know about onto an allowed type: every field the module checks is
|
||||
// identical, and the bytes are not.
|
||||
test("an artifact carrying more than the checked fields is refused", async () => {
|
||||
const parsed = Transaction.from(await signedWith({}));
|
||||
const smuggled = { type: parsed.type };
|
||||
for (const key of SERIALIZED_FIELDS[parsed.type]) {
|
||||
smuggled[key] = parsed[key];
|
||||
}
|
||||
smuggled.unsignedSerialized = parsed.unsignedSerialized + "ff";
|
||||
expect(() => assertNothingUnchecked(smuggled)).toThrow(
|
||||
/beyond the fields that were checked/,
|
||||
);
|
||||
expect(() => assertNothingUnchecked(parsed)).not.toThrow();
|
||||
});
|
||||
|
||||
// The closing check rebuilds the artifact from the fields the module
|
||||
// compared and compares the bytes, so an artifact carrying anything else
|
||||
// is refused without the module having to name it. Assert the rebuild is
|
||||
// faithful for every accepted shape, since a rebuild that dropped a
|
||||
// legitimate field would refuse honest transactions.
|
||||
test("an accepted artifact of each allowed type rebuilds byte for byte", async () => {
|
||||
const shapes = [
|
||||
{
|
||||
approved: { ...TX_PARAMS, gasPrice: "0x77359400" },
|
||||
overrides: {
|
||||
type: 0,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
approved: {
|
||||
...TX_PARAMS,
|
||||
gasPrice: "0x77359400",
|
||||
accessList: [
|
||||
{
|
||||
address: RECIPIENT,
|
||||
storageKeys: ["0x" + "11".repeat(32)],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: {
|
||||
type: 1,
|
||||
gasPrice: 2000000000n,
|
||||
maxFeePerGas: null,
|
||||
maxPriorityFeePerGas: null,
|
||||
accessList: [
|
||||
{
|
||||
address: RECIPIENT,
|
||||
storageKeys: ["0x" + "11".repeat(32)],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ approved: TX_PARAMS, overrides: {} },
|
||||
];
|
||||
for (const shape of shapes) {
|
||||
const raw = await signedFor(
|
||||
shape.approved,
|
||||
signer,
|
||||
shape.overrides,
|
||||
);
|
||||
const parsed = verifySignedTx(
|
||||
raw,
|
||||
shape.approved,
|
||||
signer.address,
|
||||
SELECTED,
|
||||
);
|
||||
const fields = { type: parsed.type };
|
||||
for (const key of SERIALIZED_FIELDS[parsed.type]) {
|
||||
fields[key] = parsed[key];
|
||||
}
|
||||
expect(Transaction.from(fields).unsignedSerialized).toBe(
|
||||
parsed.unsignedSerialized,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Every comparison above runs against the decode, but the string that is
|
||||
// handed to broadcastTransaction() is the artifact. An encoding the decoder
|
||||
// normalizes away therefore checks as one transaction and broadcasts as
|
||||
// different bytes, so the artifact must be the canonical encoding of itself.
|
||||
describe("verifySignedTx canonical encoding", () => {
|
||||
// Re-encode a signed type-2 artifact with a leading zero byte on the RLP
|
||||
// value field. It decodes to exactly the approved transaction — same
|
||||
// value, same signer, same everything the field comparisons look at — and
|
||||
// it is not the same string.
|
||||
async function nonCanonical() {
|
||||
const raw = await signedWith({});
|
||||
const items = decodeRlp("0x" + raw.slice(4));
|
||||
// type 2 payload order: chainId, nonce, maxPriorityFeePerGas,
|
||||
// maxFeePerGas, gasLimit, to, value, data, accessList, then the
|
||||
// signature.
|
||||
const padded = items.slice();
|
||||
padded[6] = "0x00" + items[6].slice(2);
|
||||
return "0x02" + encodeRlp(padded).slice(2);
|
||||
}
|
||||
|
||||
test("the mutation decodes to the approved transaction and is not it", async () => {
|
||||
const raw = await signedWith({});
|
||||
const mutated = await nonCanonical();
|
||||
const parsed = Transaction.from(mutated);
|
||||
expect(mutated).not.toBe(raw);
|
||||
expect(mutated.length).toBeGreaterThan(raw.length);
|
||||
expect(parsed.value).toBe(BigInt(TX_PARAMS.value));
|
||||
expect(parsed.from).toBe(signer.address);
|
||||
expect(parsed.serialized).not.toBe(mutated);
|
||||
});
|
||||
|
||||
test("refuses an artifact that is not its own canonical encoding", async () => {
|
||||
const mutated = await nonCanonical();
|
||||
expect(() =>
|
||||
verifySignedTx(mutated, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/not encoded canonically/);
|
||||
});
|
||||
|
||||
test("assertCanonicalBytes accepts what ethers itself produced", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
assertCanonicalBytes(Transaction.from(raw), raw),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("hex case is not part of the encoding", async () => {
|
||||
const raw = await signedWith({});
|
||||
const upper = "0x" + raw.slice(2).toUpperCase();
|
||||
expect(() =>
|
||||
verifySignedTx(upper, TX_PARAMS, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// The approval and the artifact spell the same values differently. None of
|
||||
// these differences is tampering, so none may refuse the signature.
|
||||
describe("verifySignedTx normalization", () => {
|
||||
test("accepts a decimal chain id against a hex selected network", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, 1),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, "1"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts an approved chain id written in hex", async () => {
|
||||
const raw = await signedWith({});
|
||||
const approved = { ...TX_PARAMS, chainId: "0x1" };
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts a hex nonce against a numeric one", async () => {
|
||||
const raw = await signedWith({ nonce: 7 });
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, nonce: "0x7" },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts a decimal gas limit against a hex one", async () => {
|
||||
const raw = await signedWith({ gasLimit: 100000n });
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, gasLimit: "100000" },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts fee fields spelled as hex, decimal, number and bigint", async () => {
|
||||
const raw = await signedWith({});
|
||||
for (const maxFee of [
|
||||
"0x77359400",
|
||||
"2000000000",
|
||||
2000000000,
|
||||
2000000000n,
|
||||
]) {
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, maxFeePerGas: maxFee },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts an approval that fixes no nonce, gas or fee at all", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts an approval whose recipient case differs", async () => {
|
||||
const raw = await signedWith({});
|
||||
const approved = { ...TX_PARAMS, to: RECIPIENT.toLowerCase() };
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("accepts absent call data against 0x", async () => {
|
||||
const approved = { to: RECIPIENT, value: "0x0" };
|
||||
const raw = await signedFor({ ...approved, data: "0x" });
|
||||
expect(() =>
|
||||
verifySignedTx(raw, approved, signer.address, SELECTED),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("refuses an approved quantity that is not a number", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, maxFeePerGas: "cheap" },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).toThrow(/is not a number/);
|
||||
});
|
||||
|
||||
// The value is page-controlled. A refusal is correct; a raw BigInt
|
||||
// conversion error is not, because it is not a mismatch, so it would be
|
||||
// reported retryable and leave the approval unspent behind a live button
|
||||
// that can never succeed.
|
||||
test("refuses an approved value that is not a number, as a mismatch", async () => {
|
||||
const raw = await signedWith({});
|
||||
for (const value of ["cheap", 1.5, "1e18", {}]) {
|
||||
let thrown;
|
||||
try {
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, value },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown.approvalMismatch).toBe(true);
|
||||
expect(thrown.message).toMatch(/approved value is not a number/);
|
||||
expect(failureIsRetryable(thrown)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses an approved access list that is not an access list", async () => {
|
||||
const raw = await signedWith({});
|
||||
expect(() =>
|
||||
verifySignedTx(
|
||||
raw,
|
||||
{ ...TX_PARAMS, accessList: ["nope"] },
|
||||
signer.address,
|
||||
SELECTED,
|
||||
),
|
||||
).toThrow(/not a valid access list/);
|
||||
});
|
||||
});
|
||||
|
||||
const TYPED_DATA = JSON.stringify({
|
||||
domain: {
|
||||
name: "AutistMask Test",
|
||||
@@ -281,6 +920,147 @@ describe("verifySignature", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// What happens after a signing attempt fails: the background keeps the
|
||||
// approval for anything the user can correct, and the popup only offers the
|
||||
// button again when it did.
|
||||
describe("signing failure and retry", () => {
|
||||
test("a failure that is not a mismatch leaves the approval retryable", () => {
|
||||
expect(failureIsRetryable(new Error("The node is unreachable."))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(failureIsRetryable(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test("a mismatch spends the approval", async () => {
|
||||
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
||||
try {
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||
throw new Error("expected a rejection");
|
||||
} catch (e) {
|
||||
expect(failureIsRetryable(e)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("a retryable failure keeps the button usable and says only what failed", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
{ error: "The node rejected the transaction.", retryable: true },
|
||||
"The transaction could not be sent.",
|
||||
);
|
||||
expect(outcome.retryable).toBe(true);
|
||||
expect(outcome.message).toBe("The node rejected the transaction.");
|
||||
});
|
||||
|
||||
test("a refusal tells the user to start again from the site", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
{
|
||||
error: "The signed transaction does not go to the approved recipient.",
|
||||
retryable: false,
|
||||
},
|
||||
"The transaction could not be sent.",
|
||||
);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.message).toMatch(/start it again from the site\.$/);
|
||||
});
|
||||
|
||||
test("a refusal for an attempt already running does not say to start again", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
{
|
||||
error: "This request is already being signed.",
|
||||
retryable: false,
|
||||
stage: "inflight",
|
||||
},
|
||||
"The message could not be signed.",
|
||||
);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.message).not.toMatch(/start it again from the site/);
|
||||
expect(outcome.message).toMatch(/first attempt is still running/);
|
||||
});
|
||||
|
||||
test("a response the background never sent is treated as a spent approval", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
undefined,
|
||||
"The transaction could not be sent.",
|
||||
);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.message).toMatch(/^The transaction could not be sent\./);
|
||||
});
|
||||
|
||||
test("every failure message is a full sentence", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
{ error: "The node is on fire", retryable: true },
|
||||
"The transaction could not be sent.",
|
||||
);
|
||||
expect(outcome.message).toMatch(/^[A-Z].*\.$/);
|
||||
});
|
||||
|
||||
test("a popup that could not sign leaves the approval standing", () => {
|
||||
const outcome = describeTxFailure(
|
||||
TX_STAGE_SIGN,
|
||||
"That password is incorrect. Please try again.",
|
||||
);
|
||||
expect(outcome.retryable).toBe(true);
|
||||
expect(outcome.spendApproval).toBe(false);
|
||||
expect(outcome.error).toMatch(/password is incorrect/);
|
||||
});
|
||||
|
||||
test("a mismatch found at verification spends the approval", async () => {
|
||||
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
|
||||
let outcome;
|
||||
try {
|
||||
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
|
||||
} catch (e) {
|
||||
outcome = describeTxFailure(TX_STAGE_VERIFY, e);
|
||||
}
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.spendApproval).toBe(true);
|
||||
});
|
||||
|
||||
test("a failure before the check ran is still retryable", () => {
|
||||
const outcome = describeTxFailure(
|
||||
TX_STAGE_VERIFY,
|
||||
new Error("The wallet state could not be read."),
|
||||
);
|
||||
expect(outcome.retryable).toBe(true);
|
||||
expect(outcome.spendApproval).toBe(false);
|
||||
});
|
||||
|
||||
// A broadcast that throws after the node took the transaction is routine:
|
||||
// a timeout, a dropped response, a node answering "already known". The
|
||||
// popup's retry does not re-broadcast the same bytes — it re-populates and
|
||||
// re-signs at a freshly fetched nonce — so a retryable broadcast failure
|
||||
// would put the approved transfer on the chain twice.
|
||||
test("a failed broadcast is terminal, whatever the node said", () => {
|
||||
for (const message of [
|
||||
"already known",
|
||||
"timeout of 30000ms exceeded",
|
||||
"could not coalesce error",
|
||||
"replacement transaction underpriced",
|
||||
]) {
|
||||
const outcome = describeTxFailure(
|
||||
TX_STAGE_BROADCAST,
|
||||
new Error(message),
|
||||
);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.spendApproval).toBe(true);
|
||||
expect(outcome.error).toBe(message);
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed broadcast does not tell the user to send it again", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
{
|
||||
error: "The node did not answer.",
|
||||
retryable: false,
|
||||
stage: TX_STAGE_BROADCAST,
|
||||
},
|
||||
"The transaction could not be sent.",
|
||||
);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.message).toMatch(/may still have reached the network/);
|
||||
expect(outcome.message).not.toMatch(/start it again from the site/);
|
||||
});
|
||||
});
|
||||
|
||||
// End-to-end over the messaging boundary, without a browser: run the exact
|
||||
// sequence the approval popup runs, then hand the artifact to the exact check
|
||||
// the background runs before it broadcasts or resolves. Only what the popup
|
||||
@@ -314,7 +1094,12 @@ describe("popup signing sequence to background verification", () => {
|
||||
|
||||
test("a populated, signed transaction is accepted and broadcastable", async () => {
|
||||
const rawSignedTx = await popupSignsTx(TX_PARAMS);
|
||||
const parsed = verifySignedTx(rawSignedTx, TX_PARAMS, signer.address);
|
||||
const parsed = verifySignedTx(
|
||||
rawSignedTx,
|
||||
TX_PARAMS,
|
||||
signer.address,
|
||||
SELECTED,
|
||||
);
|
||||
expect(parsed.nonce).toBe(7);
|
||||
expect(parsed.chainId).toBe(1n);
|
||||
expect(parsed.gasLimit).toBe(21000n);
|
||||
@@ -349,7 +1134,14 @@ describe("popup signing sequence to background verification", () => {
|
||||
to: OTHER_RECIPIENT,
|
||||
});
|
||||
expect(() =>
|
||||
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address),
|
||||
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SELECTED),
|
||||
).toThrow(/approved recipient/);
|
||||
});
|
||||
|
||||
test("the background rejects a transaction populated on another network", async () => {
|
||||
const rawSignedTx = await popupSignsTx(TX_PARAMS);
|
||||
expect(() =>
|
||||
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SEPOLIA),
|
||||
).toThrow(/different network than the one that is selected/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user