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 "+"), and getSignerForAddress() derives at that index and refuses any key whose address differs from the record's. The export screen reports that refusal instead of calling the password wrong. Model: opus-5-5
170 lines
5.7 KiB
JavaScript
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);
|
|
});
|