Files
AutistMask/tests/approvalNonce.test.js
T
sneak 4770849ff8
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: sign and export with each address's own derivation index (closes #510)
Sending, site transactions, site signatures and private key export
derived the key at the address's position in the wallet's list. After
an import whose scan passed over an unused index, or after removing an
address that was not the last, that position named another address's
key.

Each HD and xprv address record now stores the index it was derived at
(at wallet creation, by the import scan, by "+"). getSignerForAddress()
derives at that index, refuses a record without one, and refuses any key
whose address differs from the record's. The confirmation screen signs
as the From address it shows, not the selected one. The export screen
reports a refusal instead of calling the password wrong.

Model: opus-5-5
2026-10-14 10:56:43 +00:00

170 lines
5.7 KiB
JavaScript

// A nonce the page supplies is not used
// (https://git.eeqj.de/sneak/AutistMask/issues/404). With it a page could
// replace one of the user's pending transactions (the same nonce at a higher
// fee) or leave the new one stuck behind a gap, so the transaction is always
// given the account's next nonce from the network.
//
// Driven through the preparation the background runs on a page's
// eth_sendTransaction (src/shared/approvalTx.js) and the real approval screen,
// password and Confirm included, against a minimal DOM stub in the shape
// tests/approvalOrigin.test.js uses. The vault is mocked so that no password
// has to be hashed.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Network, Transaction } = require("ethers");
const { state } = require("../src/shared/state");
const { decryptWithPassword } = require("../src/shared/vault");
const { prepareApprovalTx } = require("../src/shared/approvalTx");
const approval = require("../src/popup/views/approval");
// A well-known test phrase, and its first address.
const PHRASE = "test test test test test test test test test test test junk";
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// The account's next nonce, as the network reports it.
const NETWORK_NONCE = 7;
const network = {
getNetwork: async () => Network.from(1),
getTransactionCount: async () => NETWORK_NONCE,
estimateGas: async () => 21000n,
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
}),
};
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(id) {
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
}
// Open the transaction approval screen the way the popup does: the background
// hands over the populated transaction and show() draws it. Returns every
// message the screen sends to the background. The background's answer to the
// signed transaction does not matter here; a retryable refusal keeps the
// screen where it is.
async function openTxApproval(approvedTx) {
const sent = [];
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
sent.push(msg);
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
return reply({ error: "Not sent.", retryable: true });
}
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx,
});
},
};
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances: [] },
],
},
];
approval.init({});
await approval.show(1);
return sent;
}
test("a page's nonce is replaced by the network's, on screen and in the signed transaction", async () => {
// What the background does with the page's request before it opens the
// approval window.
const approvedTx = await prepareApprovalTx(network, FROM, {
from: FROM,
to: RECIPIENT,
value: "0x0",
data: "0x",
nonce: "0x2",
});
const sent = await openTxApproval(approvedTx);
expect(node("approve-tx-nonce").textContent).toBe("7");
decryptWithPassword.mockResolvedValue(PHRASE);
node("approve-tx-password").value = "any password";
await click("btn-approve-tx");
const response = sent.find((msg) => msg.type === "AUTISTMASK_TX_RESPONSE");
expect(Transaction.from(response.rawSignedTx).nonce).toBe(NETWORK_NONCE);
});