harden: verify all approval fields and make failed signing retryable (closes #174)
All checks were successful
check / check (push) Successful in 30s
All checks were successful
check / check (push) Successful in 30s
approvalVerify now compares every field of the signed artifact against the approval, not a subset. Transaction types are allowlisted to 0/1/2 and any field the module does not check is refused outright, so a future transaction type cannot smuggle consequential fields past verification -- an EIP-7702 type-4 artifact that delegates the signer's own EOA while matching every displayed field was accepted before this change. The serialized bytes handed to broadcastTransaction are compared against the parsed artifact, so the guarantee covers the bytes that actually go to the node. Signing failures in the popup are retryable again. To make that safe, an approval is claimed synchronously before the first await and every path that resolves or removes one goes through a single chokepoint that refuses a claimed approval. Without it, closing the approval window, switching the active address or a late reject would report "User rejected the request." to the dApp while the broadcast completed -- the user then redoes the transfer at a fresh nonce and it sends twice. Failure copy distinguishes the stage reached, so a user is never told to start again from the site when the first attempt may already have reached the network.
This commit was merged in pull request #205.
This commit is contained in:
679
tests/backgroundApproval.test.js
Normal file
679
tests/backgroundApproval.test.js
Normal file
@@ -0,0 +1,679 @@
|
||||
// The background's approval message wiring, driven end to end: a dApp
|
||||
// eth_sendTransaction raises a pending approval, and the popup answers it with
|
||||
// AUTISTMASK_TX_RESPONSE / AUTISTMASK_SIGN_RESPONSE.
|
||||
//
|
||||
// What this exists for is the duplicate response. The handler verifies and
|
||||
// broadcasts asynchronously, and the approval deliberately survives a
|
||||
// retryable failure so the user can try again with the transaction they
|
||||
// already saw — which means the entry being present is not by itself proof
|
||||
// that no attempt is running. A second response carrying the same id (a
|
||||
// reloaded approval window re-rendering a live Approve button, a popup that
|
||||
// emits the message twice) must not start a second verify and broadcast: with
|
||||
// the ordinary dApp approval shape the page fixes no nonce, so two artifacts
|
||||
// signed at different nonces both verify, and the approved transfer would go
|
||||
// out twice.
|
||||
|
||||
const { Wallet } = require("ethers");
|
||||
|
||||
const SIGNER_KEY =
|
||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
||||
const signer = new Wallet(SIGNER_KEY);
|
||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
const ORIGIN = "https://dapp.example";
|
||||
const HOSTNAME = "dapp.example";
|
||||
const EXT_URL = "chrome-extension://autistmask/";
|
||||
|
||||
// What the dApp asks for: no nonce, no gas, no fees. This is the shape that
|
||||
// makes a duplicate broadcast possible at all.
|
||||
const TX_PARAMS = {
|
||||
from: signer.address,
|
||||
to: RECIPIENT,
|
||||
value: "0x2386f26fc10000",
|
||||
data: "0x",
|
||||
};
|
||||
|
||||
// The fields the popup's populateTransaction() would fill in. The nonce is a
|
||||
// parameter because the duplicate case turns on the two artifacts differing
|
||||
// in exactly the field nothing constrains.
|
||||
function populated(nonce) {
|
||||
return {
|
||||
type: 2,
|
||||
chainId: 1,
|
||||
nonce,
|
||||
gasLimit: 100000n,
|
||||
maxFeePerGas: 2000000000n,
|
||||
maxPriorityFeePerGas: 1000000000n,
|
||||
to: TX_PARAMS.to,
|
||||
value: BigInt(TX_PARAMS.value),
|
||||
data: TX_PARAMS.data,
|
||||
};
|
||||
}
|
||||
|
||||
function signedAtNonce(nonce) {
|
||||
return signer.signTransaction(populated(nonce));
|
||||
}
|
||||
|
||||
// A promise whose settlement the test controls, so a broadcast can be held in
|
||||
// flight while the second response arrives.
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
// Load the background worker against stubbed browser and network APIs and
|
||||
// return the handles the tests drive it through. Everything that would touch
|
||||
// the network or the browser's own schedulers is mocked; the approval
|
||||
// verification is the real module, because that is what the handler under
|
||||
// test is wired to.
|
||||
function loadBackground(options) {
|
||||
const opts = options || {};
|
||||
jest.resetModules();
|
||||
|
||||
const broadcastTransaction = jest.fn();
|
||||
const loadState = jest.fn(opts.loadState || (async () => {}));
|
||||
|
||||
jest.doMock("../src/shared/state", () => ({
|
||||
state: { rpcUrl: "https://rpc.invalid", wallets: [] },
|
||||
loadState,
|
||||
saveState: jest.fn(async () => {}),
|
||||
currentNetwork: () => ({ chainId: "0x1" }),
|
||||
}));
|
||||
jest.doMock("../src/shared/balances", () => ({
|
||||
getProvider: () => ({ broadcastTransaction }),
|
||||
refreshBalances: jest.fn(async () => {}),
|
||||
}));
|
||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||
isPhishingDomain: () => false,
|
||||
refreshPhishingListOnSchedule: jest.fn(async () => {}),
|
||||
initPhishingList: jest.fn(async () => {}),
|
||||
}));
|
||||
jest.doMock("../src/shared/alarms", () => ({
|
||||
BALANCE_REFRESH_ALARM: "balance",
|
||||
PHISHING_REFRESH_ALARM: "phishing",
|
||||
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
||||
ensureRecurringAlarms: jest.fn(async () => {}),
|
||||
registerAlarmHandlers: jest.fn(),
|
||||
}));
|
||||
|
||||
const persisted = {
|
||||
wallets: [
|
||||
{ name: "Wallet 1", type: "hd", addresses: [signer.address] },
|
||||
],
|
||||
rpcUrl: "https://rpc.invalid",
|
||||
activeAddress: signer.address,
|
||||
allowedSites: { [signer.address]: [HOSTNAME] },
|
||||
deniedSites: {},
|
||||
};
|
||||
|
||||
let messageListener = null;
|
||||
let windowRemovedListener = null;
|
||||
const created = [];
|
||||
const removed = [];
|
||||
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(async () => ({ autistmask: persisted })),
|
||||
set: jest.fn(async () => {}),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
getURL: (path) => EXT_URL + path,
|
||||
onMessage: {
|
||||
addListener: (fn) => {
|
||||
messageListener = fn;
|
||||
},
|
||||
},
|
||||
onConnect: { addListener: () => {} },
|
||||
lastError: null,
|
||||
},
|
||||
windows: {
|
||||
getLastFocused: (cb) => cb(null),
|
||||
create: (options2, cb) => {
|
||||
created.push(options2);
|
||||
cb({ id: created.length });
|
||||
},
|
||||
remove: (id, cb) => {
|
||||
removed.push(id);
|
||||
if (cb) cb();
|
||||
},
|
||||
// Captured, not swallowed: closing the approval window is the
|
||||
// event that used to retire an approval out from under a live
|
||||
// broadcast, and a no-op stub here hides exactly that.
|
||||
onRemoved: {
|
||||
addListener: (fn) => {
|
||||
windowRemovedListener = fn;
|
||||
},
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
query: (q, cb) => cb([]),
|
||||
sendMessage: () => {},
|
||||
},
|
||||
action: { setPopup: () => {} },
|
||||
};
|
||||
|
||||
require("../src/background/index");
|
||||
|
||||
// Send a message the way the browser would, and hand back whatever the
|
||||
// handler passed to sendResponse.
|
||||
function send(msg, sender) {
|
||||
const sendResponse = jest.fn();
|
||||
const kept = messageListener(msg, sender || {}, sendResponse);
|
||||
return { sendResponse, kept };
|
||||
}
|
||||
|
||||
// Raise a pending transaction approval the way a dApp does, and dig the
|
||||
// approval id back out of the popup URL the background opened.
|
||||
function requestTx() {
|
||||
let rpcResult = null;
|
||||
const sendResponse = jest.fn((r) => {
|
||||
rpcResult = r;
|
||||
});
|
||||
messageListener(
|
||||
{
|
||||
type: "AUTISTMASK_RPC",
|
||||
method: "eth_sendTransaction",
|
||||
params: [TX_PARAMS],
|
||||
},
|
||||
{ origin: ORIGIN },
|
||||
sendResponse,
|
||||
);
|
||||
return {
|
||||
id: () => new URL(created[0].url).searchParams.get("approval"),
|
||||
result: () => rpcResult,
|
||||
};
|
||||
}
|
||||
|
||||
// The user closes the approval popup. `created` is index-aligned with the
|
||||
// ids the window stub hands back, so window 1 is the first popup opened.
|
||||
function closeWindow(windowId) {
|
||||
windowRemovedListener(windowId);
|
||||
}
|
||||
|
||||
return {
|
||||
send,
|
||||
requestTx,
|
||||
closeWindow,
|
||||
broadcastTransaction,
|
||||
loadState,
|
||||
created,
|
||||
removed,
|
||||
fromPopup: { url: EXT_URL + "src/popup/index.html" },
|
||||
};
|
||||
}
|
||||
|
||||
// Let the handler's promise chain run to the next suspension point.
|
||||
async function settle() {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete global.chrome;
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe("one approval, one broadcast", () => {
|
||||
test("a second AUTISTMASK_TX_RESPONSE for the same id does not broadcast again", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
expect(id).toBeTruthy();
|
||||
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
|
||||
// The popup answers. Verification passes and the broadcast is held
|
||||
// open, which is the whole window the second message arrives in.
|
||||
const first = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A reloaded approval window signs the same approval again. Nothing
|
||||
// in the approval fixes a nonce, so this artifact verifies just as
|
||||
// well as the first one.
|
||||
const second = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(8),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(second.sendResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.stringMatching(/already being sent/),
|
||||
retryable: false,
|
||||
}),
|
||||
);
|
||||
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("the same artifact sent twice broadcasts once", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
const raw = await signedAtNonce(7);
|
||||
const msg = {
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: raw,
|
||||
};
|
||||
|
||||
bg.send(msg, { url: bg.fromPopup.url });
|
||||
bg.send(msg, { url: bg.fromPopup.url });
|
||||
await settle();
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a response arriving after the broadcast finished finds nothing to send", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
const late = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(8),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(late.sendResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a second AUTISTMASK_SIGN_RESPONSE for the same id is refused", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
// Hold the transaction approval in flight, then answer it a second
|
||||
// time as if it were a sign approval: the sign handler must apply the
|
||||
// same interlock rather than running its own verification.
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
const second = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
signature: "0x00",
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(second.sendResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.stringMatching(/already being signed/),
|
||||
retryable: false,
|
||||
}),
|
||||
);
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// The interlock must not cost the retry the approval exists to allow.
|
||||
describe("the interlock releases a failed attempt", () => {
|
||||
test("a retryable failure before the broadcast leaves the approval usable", async () => {
|
||||
let failNext = true;
|
||||
const bg = loadBackground({
|
||||
loadState: async () => {
|
||||
if (failNext) {
|
||||
failNext = false;
|
||||
throw new Error("storage unavailable");
|
||||
}
|
||||
},
|
||||
});
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const first = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
||||
expect(first.sendResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ retryable: true }),
|
||||
);
|
||||
|
||||
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
||||
const retry = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(retry.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
});
|
||||
|
||||
test("a mismatched artifact spends the approval outright", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
// Signed for a different recipient than the one that was approved.
|
||||
const wrong = await signer.signTransaction({
|
||||
...populated(7),
|
||||
to: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
|
||||
});
|
||||
const first = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: wrong,
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(first.sendResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ retryable: false, stage: "verify" }),
|
||||
);
|
||||
|
||||
const retry = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
||||
expect(retry.sendResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// The claim is what makes one approval one broadcast, so it has to hold
|
||||
// against everything else that retires an approval, not just against a second
|
||||
// AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting
|
||||
// promise 4001 while the attempt behind it ran to completion: the transaction
|
||||
// reached the chain and the page was told the user rejected it, which invites
|
||||
// the user to send it a second time at a fresh nonce.
|
||||
describe("a claimed approval outlives every other retirement path", () => {
|
||||
// The approval popup stays open across the broadcast it is waiting on, so
|
||||
// a user closing an apparently-hung window needs no adversary at all.
|
||||
test("closing the approval window mid-broadcast still reports the result", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
const first = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The user closes the window while the broadcast is still open.
|
||||
bg.closeWindow(1);
|
||||
await settle();
|
||||
expect(pending.result()).toBeNull();
|
||||
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("switching the active address mid-broadcast still reports the result", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The user switches account in the toolbar popup, which rejects and
|
||||
// force-closes every pending approval.
|
||||
bg.send(
|
||||
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(pending.result()).toBeNull();
|
||||
// The window an in-flight attempt reports into is left standing too.
|
||||
expect(bg.removed).toEqual([]);
|
||||
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a reject arriving mid-broadcast is refused, not honoured", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
const reject = bg.send(
|
||||
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(pending.result()).toBeNull();
|
||||
expect(reject.sendResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
retryable: false,
|
||||
stage: "broadcast",
|
||||
}),
|
||||
);
|
||||
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// The refusals above must not cost the rejection its ordinary meaning.
|
||||
test("with no attempt running, closing the window still rejects", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
|
||||
bg.closeWindow(1);
|
||||
await settle();
|
||||
|
||||
expect(pending.result()).toEqual({
|
||||
error: { code: 4001, message: "User rejected the request." },
|
||||
});
|
||||
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("with no attempt running, an active-address switch still rejects and closes", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
|
||||
bg.send(
|
||||
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(pending.result()).toEqual({
|
||||
error: { code: 4001, message: "User rejected the request." },
|
||||
});
|
||||
expect(bg.removed).toEqual([1]);
|
||||
});
|
||||
|
||||
// A sign approval held by a running verification is the same shape, and
|
||||
// the refusal must not tell the user to start again from the site while
|
||||
// the first attempt may still hand back a signature.
|
||||
test("a reject during a sign attempt is refused with the in-flight stage", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const inFlight = deferred();
|
||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
const reject = bg.send(
|
||||
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: false },
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(reject.sendResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ retryable: false, stage: "inflight" }),
|
||||
);
|
||||
|
||||
inFlight.resolve({ hash: "0xfeed" });
|
||||
await settle();
|
||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("popup-only messages", () => {
|
||||
test("a page sender cannot answer an approval", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
const id = pending.id();
|
||||
|
||||
const spoof = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id,
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(7),
|
||||
},
|
||||
{ url: ORIGIN + "/index.html" },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
||||
expect(spoof.sendResponse).toHaveBeenCalledWith({
|
||||
error: "Unauthorized sender",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user