Author SHA1 Message Date
clawbot c40c7432a5 fix: sign and export with each address's own derivation index (closes #510)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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 18:22:12 +02:00
clawbot 8e20a4e21c fix: the connection prompt shows the shared address whole (closes #517)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The element holding the address on the connection prompt was a flex row,
so the title line and the address that renderAddressHtml() returns sat
side by side and the address got only the width left over, which hid its
last nine characters. It is now a plain block, as on the signature
screen, so the address has a row of its own. The Chrome browser suite's
connection test now measures that row with the check the other screens
already use.

Model: opus-5-5
2026-10-14 17:11:34 +02:00
clawbot f283a5bb91 fix: the typed data screen shows only the message fields that are signed (closes #539)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
ethers signs only the fields a type declares and drops any other key, but
the Message section listed every key the site sent, so an added key read
as signed. The Message section now follows the signed type, at every level
of nesting and in the type's order. Typed data with other keys is not
refused: those keys are listed after the message under "Not part of the
signature", by their place in the message. README SignApproval says so.
The operator/spender test now also checks the Message section, and new
tests cover an added key in a nested struct and in a list item.

Model: opus-5-5
2026-10-14 16:41:13 +02:00
clawbot cb27281b21 fix: the transaction detail screen decodes a token transfer against the token contract (closes #515)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The screen decoded a transaction's data against the history entry's `to`,
which for a token transfer row is the transfer's recipient. The decoded
Token line named the recipient, and the amount read "decimals unknown" for
a token the wallet tracks. It now decodes against the address the
explorer's answer for that transaction says it was sent to, which is the
right address for every kind of row. tests/tokenTransferDetail.test.js
opens a row made by the real history parser and checks the Token,
Recipient and Amount lines.

Model: opus-5-5
2026-10-14 16:37:55 +02:00
clawbot 00d1fecc36 fix: eth_sign's warning and the README say what it signs (closes #409)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
eth_sign is signed as an EIP-191 personal message over its data, which is
what the Ethereum JSON-RPC specification defines it to return. Its warning
on the signature prompt said the site was asking to sign a raw hash that
could drain funds, and the README said the same; neither was true. The
warning now says the site used the older eth_sign method and that the data
will be signed as a personal message, and the README's SignApproval section
says so too. Tests pin that the background reads eth_sign address first and
data second, and that the sign screen answers it with the personal message
signature over the data.

Model: opus-5-5
2026-10-14 15:41:22 +02:00
clawbot dfc3a35fe3 fix: Sign & Send refuses a transfer reviewed on another network (closes #524)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Settings is reachable from the confirmation screen, and Back from it only
unhides the screen as it was drawn. Sign & Send took the network at the
click, so a transfer reviewed on Sepolia went out on mainnet after a switch
in between. show() now records the chain id of the network it draws for.
Sign & Send refuses with a message on the screen when the active network
differs, and the send carries that chain id, so ethers will not populate it
for a provider on any other network.

Model: opus-5-5
2026-10-14 15:31:50 +02:00
22 changed files with 1419 additions and 58 deletions
+41 -3
View File
@@ -1192,7 +1192,16 @@ The core hierarchy is **Wallets → Addresses**:
of its stored `xpub` by `src/shared/walletDefects.js`, explained in the of its stored `xpub` by `src/shared/walletDefects.js`, explained in the
wallet list, and blocked from signing, sending and private-key export. It wallet list, and blocked from signing, sending and private-key export. It
is never deleted or rewritten. is never deleted or rewritten.
- An **address** holds ETH and ERC-20 tokens. - An **address** holds ETH and ERC-20 tokens. In an HD or xprv wallet each
address record also stores `index`, the derivation index it was made at: 0 for
the first address, the index the import scan found it at, or `nextIndex` when
"+" added it. An address's `index` and its position in the wallet's list
differ once the scan has passed over an unused index or an address has been
removed. Sending, site transactions, site signatures and private-key export
derive the key at `index`, and refuse it unless it is that address's own key;
an HD or xprv address with no valid `index` is refused before anything is
derived (`getSignerForAddress()` in `src/shared/wallet.js`). Each refusal is a
full sentence naming the address.
- The user can have multiple wallets, each with multiple addresses (HD) or a - The user can have multiple wallets, each with multiple addresses (HD) or a
single address (key). single address (key).
@@ -1518,6 +1527,9 @@ view would leave a wallet one click from deletion.
change) change)
- "Reveal" (wrong password) → full-sentence error on the error line, nothing - "Reveal" (wrong password) → full-sentence error on the error line, nothing
revealed (no screen change) revealed (no screen change)
- "Reveal" (correct password, but this address's key is refused, see Data
Model) → full-sentence error saying why, nothing revealed (no screen
change)
- "Back" → previous screen (AddressDetail) - "Back" → previous screen (AddressDetail)
- Settings gear → **Settings**, whose "Back" goes to AddressDetail: leaving - Settings gear → **Settings**, whose "Back" goes to AddressDetail: leaving
drops the address the screen was showing, so it also takes the screen off drops the address the screen was showing, so it also takes the screen off
@@ -1645,8 +1657,21 @@ view would leave a wallet one click from deletion.
the contract's own answer is read at signing time only to be compared with the contract's own answer is read at signing time only to be compared with
it, and a disagreement is a refusal rather than a preference for either it, and a disagreement is a refusal rather than a preference for either
value (`src/shared/transferAmount.js`) value (`src/shared/transferAmount.js`)
- "Sign & Send" after the active network was switched in Settings while this
screen was open → nothing is signed; "The active network changed after
this transaction was reviewed, so it was not sent. Please go back and
review it again." in the errors box, no screen change. The transaction
carries the chain id of the network the screen was shown for, so it cannot
be signed for another
- "Sign & Send" (wrong password) → "Wrong password." on the password error - "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change line, no screen change
- "Sign & Send" signs as the From address above, not as whichever address is
selected: the two are saved separately, so the popup can reopen onto this
screen with another address selected. When the selected wallet does not
hold the From address → full-sentence error on the password error line,
nothing sent (no screen change)
- "Sign & Send" (correct password, but the From address's key is refused,
see Data Model) → nothing is signed → **ErrorTx** saying why
- "Back" → **Send** - "Back" → **Send**
#### WaitTx (`wait-tx`) #### WaitTx (`wait-tx`)
@@ -2112,6 +2137,9 @@ view would leave a wallet one click from deletion.
shown, exactly as shown, hands the signed transaction to the background to shown, exactly as shown, hands the signed transaction to the background to
broadcast, then → **WaitTx** in the same popup window broadcast, then → **WaitTx** in the same popup window
- "Confirm" (wrong password) → error line, no screen change - "Confirm" (wrong password) → error line, no screen change
- "Confirm" (correct password, but the From address's key is refused, see
Data Model) → full-sentence error on the error line saying why, nothing
signed (no screen change)
- "Reject" → closes popup (returns rejection to background) - "Reject" → closes popup (returns rejection to background)
- Popup window closed without answering → the request is rejected with - Popup window closed without answering → the request is rejected with
EIP-1193 code 4001 EIP-1193 code 4001
@@ -2130,7 +2158,12 @@ view would leave a wallet one click from deletion.
blocklist) blocklist)
- Site origin (bold, scheme and port included) + "wants you to sign a - Site origin (bold, scheme and port included) + "wants you to sign a
message" message"
- Danger warning box (shown for `eth_sign`, which signs a raw hash) - Warning box, shown for `eth_sign`: "This site used the older eth_sign
method. The data will be signed as a personal message." An `eth_sign`
request names the address first and the data second. Its data is signed
exactly as a `personal_sign` message is, as an EIP-191 personal message,
which is what the Ethereum JSON-RPC specification defines `eth_sign` to
return.
- Type: "Personal message" or "Typed data (EIP-712)" - Type: "Personal message" or "Typed data (EIP-712)"
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Message: for `personal_sign` and `eth_sign`, the text the message's bytes - Message: for `personal_sign` and `eth_sign`, the text the message's bytes
@@ -2145,7 +2178,12 @@ view would leave a wallet one click from deletion.
Bytes that are not UTF-8 are shown as "This message is not text." For Bytes that are not UTF-8 are shown as "This message is not text." For
typed data, formatted domain/type/message fields (EIP-712). The primary typed data, formatted domain/type/message fields (EIP-712). The primary
type shown is the one ethers signs, derived from the typed data's `types`, type shown is the one ethers signs, derived from the typed data's `types`,
not the type the site states. not the type the site states. The message shows only the fields that type
declares, at every level of nesting and in the type's order: ethers signs
those and drops any other key. Typed data carrying other keys is not
refused; each such key is listed after the message under "Not part of the
signature", by its place in the message (`details.note`,
`details[1].note`) and its value.
- Token permission warning, at the top of the message (typed data whose - Token permission warning, at the top of the message (typed data whose
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
+2 -3
View File
@@ -988,9 +988,8 @@ async function handleRpc(method, params, origin) {
if (method === "eth_sign") { if (method === "eth_sign") {
signParams.dangerWarning = signParams.dangerWarning =
"\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " + "This site used the older eth_sign method. " +
"This can be used to sign transactions that drain your funds. " + "The data will be signed as a personal message.";
"Only proceed if you fully understand what you are signing.";
} }
if (findPendingApproval(origin, "sign")) { if (findPendingApproval(origin, "sign")) {
+1 -4
View File
@@ -1716,10 +1716,7 @@
<div class="text-xs text-muted mb-1"> <div class="text-xs text-muted mb-1">
Address that will be shared: Address that will be shared:
</div> </div>
<div <div id="approve-address" class="text-xs mb-2"></div>
id="approve-address"
class="text-xs flex items-center mb-2"
></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label <label
+14 -2
View File
@@ -184,7 +184,12 @@ async function importMnemonic(ctx) {
encryptedSecret: encrypted, encryptedSecret: encrypted,
nextIndex: 1, nextIndex: 1,
addresses: [ addresses: [
{ address: firstAddress, balance: "0.0000", tokenBalances: [] }, {
address: firstAddress,
index: 0,
balance: "0.0000",
tokenBalances: [],
},
], ],
}; };
state.wallets.push(wallet); state.wallets.push(wallet);
@@ -205,6 +210,7 @@ async function importMnemonic(ctx) {
if (scan.addresses.length > 1) { if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({ wallet.addresses = scan.addresses.map((a) => ({
address: a.address, address: a.address,
index: a.index,
balance: "0.0000", balance: "0.0000",
tokenBalances: [], tokenBalances: [],
})); }));
@@ -299,7 +305,12 @@ async function importXprvKey(ctx) {
encryptedSecret: encrypted, encryptedSecret: encrypted,
nextIndex: 1, nextIndex: 1,
addresses: [ addresses: [
{ address: firstAddress, balance: "0.0000", tokenBalances: [] }, {
address: firstAddress,
index: 0,
balance: "0.0000",
tokenBalances: [],
},
], ],
}; };
state.wallets.push(wallet); state.wallets.push(wallet);
@@ -320,6 +331,7 @@ async function importXprvKey(ctx) {
if (scan.addresses.length > 1) { if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({ wallet.addresses = scan.addresses.map((a) => ({
address: a.address, address: a.address,
index: a.index,
balance: "0.0000", balance: "0.0000",
tokenBalances: [], tokenBalances: [],
})); }));
+1 -1
View File
@@ -266,7 +266,7 @@ function init(_ctx) {
// This address's private key can be derived from the stored key, // This address's private key can be derived from the stored key,
// but export goes through getSignerForAddress, which refuses a key // but export goes through getSignerForAddress, which refuses a key
// that is not a master key. Without this the export screen would // that is not a master key. Without this the export screen would
// take a password and then report that refusal as a wrong password. // take a password and then refuse.
const defect = selectedWalletDefect(); const defect = selectedWalletDefect();
if (defect) { if (defect) {
showFlash(defect.shortMessage); showFlash(defect.shortMessage);
+58 -12
View File
@@ -626,10 +626,38 @@ function permitWarningHtml(types, primaryType, domain, message) {
return html; return html;
} }
// What ethers signs of a message value of type `type`: a struct keeps only
// the fields its type declares, in the type's order, and a list is read item
// by item. ethers drops every other key, so each one is added to `notSigned`
// with its place in the message and its value, for the screen to list apart.
function signedValue(types, type, value, path, notSigned) {
const itemType = type.replace(/\[\d*\]$/, "");
if (itemType !== type) {
if (!Array.isArray(value)) return value;
return value.map((item, i) =>
signedValue(types, itemType, item, `${path}[${i}]`, notSigned),
);
}
const fields = types[type];
if (!fields || !value || typeof value !== "object") return value;
const at = (name) => (path ? `${path}.${name}` : name);
for (const [key, val] of Object.entries(value)) {
if (!fields.some((f) => f.name === key)) notSigned.push([at(key), val]);
}
return Object.fromEntries(
fields.map((f) => [
f.name,
signedValue(types, f.type, value[f.name], at(f.name), notSigned),
]),
);
}
// The typed data as the screen shows it. The primary type shown is the one // The typed data as the screen shows it. The primary type shown is the one
// ethers signs, never the page's word for it; typedDataRefusal() keeps the two // ethers signs, never the page's word for it; typedDataRefusal() keeps the two
// from differing on anything that can be signed. Only typed data that cannot // from differing on anything that can be signed. The Message section shows
// be read at all is shown as raw text, and typedDataRefusal() refuses it. // only what is signed, and any other key the page sent is listed after it as
// not part of the signature. Only typed data that cannot be read at all is
// shown as raw text, and typedDataRefusal() refuses it.
function formatTypedDataHtml(jsonStr) { function formatTypedDataHtml(jsonStr) {
let data; let data;
let primaryType; let primaryType;
@@ -663,12 +691,30 @@ function formatTypedDataHtml(jsonStr) {
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`; html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
html += `<div class="font-bold">${escapeHtml(primaryType)}</div></div>`; html += `<div class="font-bold">${escapeHtml(primaryType)}</div></div>`;
const lineHtml = (key, val) =>
`<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
if (data.message) { if (data.message) {
const notSigned = [];
const message = signedValue(
data.types,
primaryType,
data.message,
"",
notSigned,
);
html += `<div class="mb-2"><div class="text-muted">Message</div>`; html += `<div class="mb-2"><div class="text-muted">Message</div>`;
for (const [key, val] of Object.entries(data.message)) { for (const [key, val] of Object.entries(message)) {
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`; html += lineHtml(key, val);
} }
html += `</div>`; html += `</div>`;
if (notSigned.length > 0) {
html += `<div class="mb-2"><div class="text-muted">Not part of the signature</div>`;
for (const [key, val] of notSigned) {
html += lineHtml(key, val);
}
html += `</div>`;
}
} }
return html; return html;
@@ -725,7 +771,7 @@ function showSignApproval(details) {
"This message is plain text, not hex, so it cannot be signed."; "This message is plain text, not hex, so it cannot be signed.";
} }
// Display danger warning for eth_sign (raw hash signing) // The warning the background attaches to an eth_sign request.
const warningEl = $("approve-sign-danger-warning"); const warningEl = $("approve-sign-danger-warning");
if (warningEl) { if (warningEl) {
if (sp.dangerWarning) { if (sp.dangerWarning) {
@@ -867,15 +913,15 @@ function gateOnWalletDefect(errorId, buttonId, address) {
return true; return true;
} }
// Locate the wallet and the address index owning an address. Returns null when // Locate the wallet and the stored address record owning an address. Returns
// no wallet holds it. Approvals look up the address they were raised for, not // null when no wallet holds it. Approvals look up the address they were raised
// whichever address is active now: the approval named one account, and signing // for, not whichever address is active now: the approval named one account,
// with another is what verification refuses. // and signing with another is what verification refuses.
function findWalletFor(address) { function findWalletFor(address) {
for (const wallet of state.wallets) { for (const wallet of state.wallets) {
for (let i = 0; i < wallet.addresses.length; i++) { for (let i = 0; i < wallet.addresses.length; i++) {
if (wallet.addresses[i].address === address) { if (wallet.addresses[i].address === address) {
return { wallet, addrIndex: i }; return { wallet, addr: wallet.addresses[i] };
} }
} }
} }
@@ -1002,7 +1048,7 @@ function init(_ctx) {
try { try {
const signer = getSignerForAddress( const signer = getSignerForAddress(
active.wallet, active.wallet,
active.addrIndex, active.addr,
decryptedSecret, decryptedSecret,
); );
// Sign the approved transaction exactly as it was displayed. The // Sign the approved transaction exactly as it was displayed. The
@@ -1130,7 +1176,7 @@ function init(_ctx) {
try { try {
const signer = getSignerForAddress( const signer = getSignerForAddress(
active.wallet, active.wallet,
active.addrIndex, active.addr,
decryptedSecret, decryptedSecret,
); );
const sp = pendingSignParams; const sp = pendingSignParams;
+35 -10
View File
@@ -101,6 +101,10 @@ function show(txInfo) {
feeStatus = FEE_PENDING; feeStatus = FEE_PENDING;
feeWei = null; feeWei = null;
maxSendFees = null; maxSendFees = null;
// The network this screen is drawn for, and the only one it sends on:
// Settings can switch the active network while the screen is open. The
// wait, success and error screens label the amount by it too.
txInfo.chainId = currentNetwork().chainId;
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the // The raw symbol is the price-table key; the capped one is what the
@@ -490,7 +494,12 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
); );
request = await contract.transfer.populateTransaction(tx.to, amount); request = await contract.transfer.populateTransaction(tx.to, amount);
} }
const populated = await connectedSigner.populateTransaction(request); // The chain id of the network the screen was drawn for: ethers refuses to
// populate it for a provider on any other network.
const populated = await connectedSigner.populateTransaction({
...request,
chainId: tx.chainId,
});
assertWithinCeilings(populated); assertWithinCeilings(populated);
return connectedSigner.sendTransaction(populated); return connectedSigner.sendTransaction(populated);
} }
@@ -547,6 +556,30 @@ function init(_ctx) {
return; return;
} }
// Back from Settings only unhides this screen, still drawn for the
// network that was active when it was shown.
if (currentNetwork().chainId !== pendingTx.chainId) {
showSendError(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
return;
}
// Sign as the From address this screen shows. The selection is saved
// apart from this screen, so the popup can reopen onto it with another
// address selected.
const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
if (!addr) {
showError(
"confirm-tx-password-error",
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
);
return;
}
const password = $("confirm-tx-password").value; const password = $("confirm-tx-password").value;
if (!password) { if (!password) {
showError( showError(
@@ -575,17 +608,9 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true; $("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted"); $("btn-confirm-send").classList.add("text-muted");
// The network it is sent on. The wait, success and error screens
// label its amount by this, not by the network active when they draw.
pendingTx.chainId = currentNetwork().chainId;
let tx; let tx;
try { try {
const signer = getSignerForAddress( const signer = getSignerForAddress(wallet, addr, decryptedSecret);
wallet,
state.selectedAddress,
decryptedSecret,
);
const provider = getProvider(state.rpcUrl, state.networkId); const provider = getProvider(state.rpcUrl, state.networkId);
const connectedSigner = signer.connect(provider); const connectedSigner = signer.connect(provider);
+12 -1
View File
@@ -135,7 +135,18 @@ async function reveal() {
// decrypt ran, the wipe has already happened, so the key is not // decrypt ran, the wipe has already happened, so the key is not
// even derived, let alone written. // even derived, let alone written.
if (!isCurrentReveal(generation)) return; if (!isCurrentReveal(generation)) return;
const signer = getSignerForAddress(wallet, addressIndex, secret); let signer;
try {
signer = getSignerForAddress(
wallet,
wallet.addresses[addressIndex],
secret,
);
} catch (e) {
// The password was right; it is the key that was refused.
showError("export-privkey-password-error", e.message);
return;
}
$("export-privkey-password").value = ""; $("export-privkey-password").value = "";
$("export-privkey-password-section").classList.add("hidden"); $("export-privkey-password-section").classList.add("hidden");
$("export-privkey-value").textContent = signer.privateKey; $("export-privkey-value").textContent = signer.privateKey;
+1
View File
@@ -354,6 +354,7 @@ function render(ctx) {
); );
wallet.addresses.push({ wallet.addresses.push({
address: newAddr, address: newAddr,
index: wallet.nextIndex,
balance: "0.0000", balance: "0.0000",
tokenBalances: [], tokenBalances: [],
}); });
+6 -3
View File
@@ -183,7 +183,7 @@ function render() {
if (el) el.classList.add("hidden"); if (el) el.classList.add("hidden");
} }
loadFullTxDetails(tx.hash, tx.to, tx.chainId); loadFullTxDetails(tx.hash, tx.chainId);
const isoStr = isoDate(tx.timestamp); const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML = $("tx-detail-time").innerHTML =
@@ -291,7 +291,7 @@ function populateOnChainDetails(txData, chainId) {
} }
} }
async function loadFullTxDetails(txHash, toAddress, chainId) { async function loadFullTxDetails(txHash, chainId) {
const section = $("tx-detail-calldata-section"); const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action"); const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details"); const detailsEl = $("tx-detail-calldata-details");
@@ -313,7 +313,10 @@ async function loadFullTxDetails(txHash, toAddress, chainId) {
const inputData = txData.raw_input || txData.input || null; const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return; if (!inputData || inputData === "0x") return;
const decoded = decodeCalldata(inputData, toAddress || ""); // Decoded against the address the transaction was sent to, as the
// explorer reports it, not the history entry's `to`: for a token
// transfer that is the transfer's recipient, not the token contract.
const decoded = decodeCalldata(inputData, txData.to?.hash || "");
if (decoded) { if (decoded) {
// Render decoded calldata matching approval view style // Render decoded calldata matching approval view style
actionEl.textContent = decoded.name; actionEl.textContent = decoded.name;
+32 -6
View File
@@ -110,16 +110,33 @@ function addressFromPrivateKey(key) {
return w.address; return w.address;
} }
function getSignerForAddress(walletData, addrIndex, decryptedSecret) { // The signer for `addr`, one of the wallet's stored address records. An HD or
// xprv address is derived at its own `index`, never at its position in the
// wallet's list: the two differ once an import scan passes over an unused
// index, or once an address that is not the last is removed. An HD or xprv
// record without a valid `index` is refused before anything is derived.
// Whatever the wallet type, the key is refused unless it is the key of
// `addr.address`.
function getSignerForAddress(walletData, addr, decryptedSecret) {
if (
(walletData.type === "hd" || walletData.type === "xprv") &&
!(Number.isInteger(addr.index) && addr.index >= 0)
) {
throw new Error(
"No derivation index is stored for " +
addr.address +
", so its key cannot be derived.",
);
}
let signer;
if (walletData.type === "hd") { if (walletData.type === "hd") {
const node = HDNodeWallet.fromPhrase( const node = HDNodeWallet.fromPhrase(
decryptedSecret, decryptedSecret,
"", "",
BIP44_ETH_PATH, BIP44_ETH_PATH,
); );
return node.deriveChild(addrIndex); signer = node.deriveChild(addr.index);
} } else if (walletData.type === "xprv") {
if (walletData.type === "xprv") {
// Checked here rather than through masterXprvOrThrow so the message // Checked here rather than through masterXprvOrThrow so the message
// fits the situation: nobody is importing anything at signing time, // fits the situation: nobody is importing anything at signing time,
// and this wallet is already in storage. src/shared/walletDefects.js // and this wallet is already in storage. src/shared/walletDefects.js
@@ -137,9 +154,18 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
"is not a master key, so it cannot sign.", "is not a master key, so it cannot sign.",
); );
} }
return node.derivePath(BIP44_ETH_PATH).deriveChild(addrIndex); signer = node.derivePath(BIP44_ETH_PATH).deriveChild(addr.index);
} else {
signer = new Wallet(decryptedSecret);
} }
return new Wallet(decryptedSecret); if (signer.address.toLowerCase() !== addr.address.toLowerCase()) {
throw new Error(
"The key this wallet holds for " +
addr.address +
" belongs to a different address, so it was not used.",
);
}
return signer;
} }
function isValidMnemonic(mnemonic) { function isValidMnemonic(mnemonic) {
+490
View File
@@ -0,0 +1,490 @@
// Sending, a site's transaction and signature requests and private key export
// each use the key of the address they name: the key at the index that address
// was derived at, not the one at its position in the wallet's list. The two
// differ after an import whose scan passed over an unused index, and after an
// address that is not the last is removed
// (https://git.eeqj.de/sneak/AutistMask/issues/510). A send is signed by the
// From address its confirmation screen shows, whichever address is selected.
//
// Driven through the real import, "+" button, address removal, confirmation
// screen, transaction and signature approval screens and export screen, with
// the node, the vault and the DOM stubbed.
"use strict";
// The addresses the stub node reports a balance for, and the signed
// transactions it was handed.
const mockNode = { funded: new Set(), broadcast: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
async getBalance(address) {
return mockNode.funded.has(address) ? 1n : 0n;
}
async getTransactionCount() {
return 0;
}
async estimateGas() {
return 21000n;
}
async getFeeData() {
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 10000000000n,
};
}
async getNetwork() {
return { chainId: 1n };
}
async getCode() {
return "0x";
}
async lookupAddress() {
return null;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push(signed);
return { hash: "0x" + "ab".repeat(32) };
}
}
return { ...actual, JsonRpcProvider: StubProvider };
});
// No password is hashed: what the wallet stores is the phrase itself.
jest.mock("../src/shared/vault", () => ({
encryptWithPassword: async (secret) => secret,
decryptWithPassword: async (secret) => secret,
}));
// The wait screen polls for a receipt; these tests stop at the broadcast.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
jest.mock("ethereum-blockies-base64", () => () => "data:image/png;base64,x");
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const {
HDNodeWallet,
Mnemonic,
Transaction,
getBytes,
verifyMessage,
} = require("ethers");
// Publicly known development phrase; never fund it.
const PHRASE = "test test test test test test test test test test test junk";
// PHRASE's master extended private key, as the xprv import takes it.
const XPRV = HDNodeWallet.fromSeed(
Mnemonic.fromPhrase(PHRASE).computeSeed(),
).extendedKey;
const PASSWORD = "correct horse battery staple";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// "Hello", as the hex a page sends.
const MESSAGE = "0x48656c6c6f";
// The address and key at derivation index `index` of PHRASE, worked out with
// ethers directly rather than through the wallet's own code.
function derived(index) {
return HDNodeWallet.fromPhrase(PHRASE, "", "m/44'/60'/0'/0/" + index);
}
// ------------------------------------------------------------ DOM stub
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
title: "",
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"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(element) {
const event = { stopPropagation: () => {} };
return Promise.all((element.listeners.click || []).map((fn) => fn(event)));
}
// show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
// ------------------------------------------------------------ harness
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const { removeAddressFromState } = require("../src/shared/walletDelete");
const addWallet = require("../src/popup/views/addWallet");
const home = require("../src/popup/views/home");
const confirmTx = require("../src/popup/views/confirmTx");
const approval = require("../src/popup/views/approval");
const exportPrivkey = require("../src/popup/views/exportPrivkey");
const txStatus = require("../src/popup/views/txStatus");
// What the background answers when the approval window asks which approval it
// was opened for, and every message the popup sent it.
let approvalDetails;
let sent;
// The "+" button of the wallet list's last render. Each render attaches the
// handler to the buttons it drew, as the browser's fresh list would have.
let plusButton;
beforeEach(() => {
approvalDetails = null;
sent = [];
mockNode.broadcast = [];
globalThis.document = makeDocument();
globalThis.window = { close: () => {} };
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
sent.push(msg);
if (!reply) return;
reply(
msg.type === "AUTISTMASK_GET_APPROVAL"
? approvalDetails
: null,
);
},
},
};
node("wallet-list").querySelectorAll = (selector) => {
if (selector !== ".btn-add-address") return [];
plusButton = makeElement("btn-add-address");
plusButton.dataset.wallet = "0";
return [plusButton];
};
// The explorer and price lookups these screens start fail offline, and
// say so; that is not under test.
for (const method of ["log", "warn", "error"]) {
jest.spyOn(console, method).mockImplementation(() => {});
}
txStatus.showError.mockClear();
state.wallets = [];
state.rpcUrl = "https://rpc.example.invalid";
state.networkId = "mainnet";
state.viewStack = [];
state.currentView = "main";
addWallet.init({
renderWalletList: () => {},
doRefreshAndRender: () => {},
pageClosed: new AbortController().signal,
});
confirmTx.init({});
approval.init({});
exportPrivkey.init();
});
afterEach(() => {
jest.restoreAllMocks();
});
// Import PHRASE on the add-wallet screen, with the stub node reporting a
// balance at each of `fundedIndices`, and wait for the address scan.
async function importPhrase(fundedIndices) {
mockNode.funded = new Set(fundedIndices.map((i) => derived(i).address));
await click(node("tab-mnemonic"));
node("wallet-mnemonic").value = PHRASE;
node("add-wallet-password").value = PASSWORD;
node("add-wallet-password-confirm").value = PASSWORD;
await click(node("btn-add-wallet-confirm"));
}
// The same, importing XPRV on the add-wallet screen's xprv tab.
async function importXprv(fundedIndices) {
mockNode.funded = new Set(fundedIndices.map((i) => derived(i).address));
await click(node("tab-xprv"));
node("import-xprv-key").value = XPRV;
node("add-wallet-password").value = PASSWORD;
node("add-wallet-password-confirm").value = PASSWORD;
await click(node("btn-add-wallet-confirm"));
}
// Press the wallet's "+" button on Home.
async function addAddress() {
home.render({
doRefreshAndRender: () => {},
pageClosed: new AbortController().signal,
});
await click(plusButton);
}
// The key the export screen reveals for the address at `position`.
async function exportedKey(position) {
exportPrivkey.show(0, position);
node("export-privkey-password").value = PASSWORD;
await click(node("btn-export-privkey-confirm"));
return node("export-privkey-value").textContent;
}
// Confirm a send whose From line shows `from`, with the address at position
// `selected` selected; resolves to the signed transaction the node was handed,
// or undefined when none was.
async function confirmSend(from, selected) {
state.selectedWallet = 0;
state.selectedAddress = selected;
confirmTx.show({
from,
to: RECIPIENT,
amount: "0.5",
token: "ETH",
balance: "1.0",
});
await settle();
node("confirm-tx-password").value = PASSWORD;
await click(node("btn-confirm-send"));
return mockNode.broadcast.pop();
}
// Send from the address at `position`, with that address selected, as the Send
// button leaves it.
function sendFrom(position) {
return confirmSend(state.wallets[0].addresses[position].address, position);
}
// A site asks the address at `position` to sign MESSAGE; resolves to the
// response the approval window sent the background.
async function signFor(position) {
const address = state.wallets[0].addresses[position].address;
approvalDetails = {
type: "sign",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: address,
signParams: {
method: "personal_sign",
message: MESSAGE,
from: address,
},
};
await approval.show(1);
node("approve-sign-password").value = PASSWORD;
await click(node("btn-approve-sign"));
return sent.find((msg) => msg.type === "AUTISTMASK_SIGN_RESPONSE");
}
// A site asks the address at `position` to send RECIPIENT a transaction of no
// value; resolves to the response the approval window sent the background.
async function transactFor(position) {
const address = state.wallets[0].addresses[position].address;
approvalDetails = {
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: address,
approvedTx: {
type: 2,
from: address,
chainId: "0x1",
nonce: "0x0",
gasLimit: "0x5208",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: RECIPIENT,
value: "0x0",
data: "0x",
accessList: [],
},
};
await approval.show(1);
node("approve-tx-password").value = PASSWORD;
await click(node("btn-approve-tx"));
return sent.find((msg) => msg.type === "AUTISTMASK_TX_RESPONSE");
}
describe.each([
[
"a recovery phrase import whose scan found only the first address",
async () => {
await importPhrase([0]);
},
// The address the import stores before its scan.
{ position: 0, index: 0 },
],
[
"an xprv import whose scan found only the first address",
async () => {
await importXprv([0]);
},
{ position: 0, index: 0 },
],
[
"an import whose scan passed over index 3",
async () => {
await importPhrase([0, 1, 2, 4]);
},
// Address 4 on Home, derived at index 4.
{ position: 3, index: 4 },
],
[
"an xprv import whose scan passed over index 3",
async () => {
await importXprv([0, 1, 2, 4]);
},
{ position: 3, index: 4 },
],
[
"removing the middle one of three addresses",
async () => {
await importPhrase([0]);
await addAddress();
await addAddress();
removeAddressFromState(state, 0, 1);
},
// Address 2 on Home after the removal, derived at index 2.
{ position: 1, index: 2 },
],
])("after %s", (_name, setUp, { position, index }) => {
beforeEach(setUp);
test("the wallet lists the address derived at that index there", () => {
expect(state.wallets[0].addresses[position].address).toBe(
derived(index).address,
);
});
test("export reveals that address's key", async () => {
expect(await exportedKey(position)).toBe(derived(index).privateKey);
});
test("a send is signed by that address", async () => {
const signed = await sendFrom(position);
expect(Transaction.from(signed).from).toBe(derived(index).address);
});
test("a site's signature is made by that address", async () => {
const response = await signFor(position);
expect(verifyMessage(getBytes(MESSAGE), response.signature)).toBe(
derived(index).address,
);
});
test("a site's transaction is signed by that address", async () => {
const response = await transactFor(position);
expect(Transaction.from(response.rawSignedTx).from).toBe(
derived(index).address,
);
});
});
// The selection and the confirmation screen are saved apart, so the popup can
// reopen onto the screen with another address selected than its From line
// shows.
describe("a confirmation screen whose From is not the selected address", () => {
beforeEach(async () => {
await importPhrase([0, 1]);
});
test("the send is signed by the From address shown", async () => {
const signed = await confirmSend(derived(1).address, 0);
expect(Transaction.from(signed).from).toBe(derived(1).address);
});
test("nothing is sent when the selected wallet does not hold it", async () => {
expect(await confirmSend(derived(5).address, 0)).toBeUndefined();
expect(node("confirm-tx-password-error").textContent).toBe(
"The selected wallet does not hold the From address above, " +
"so nothing was sent. Please go back and send again.",
);
});
});
describe("an address whose stored index derives a different address", () => {
function refusal() {
return (
"The key this wallet holds for " +
derived(0).address +
" belongs to a different address, so it was not used."
);
}
beforeEach(async () => {
await importPhrase([0]);
state.wallets[0].addresses[0].index = 1;
});
test("export reveals nothing and says why", async () => {
expect(await exportedKey(0)).toBe("");
expect(node("export-privkey-password-error").textContent).toBe(
refusal(),
);
});
test("nothing is sent", async () => {
expect(await sendFrom(0)).toBeUndefined();
expect(txStatus.showError).toHaveBeenCalledWith(
expect.anything(),
null,
refusal(),
);
});
test("nothing is signed", async () => {
const response = await signFor(0);
expect(response.signature).toBeUndefined();
expect(response.error).toBe(refusal());
});
});
+3 -1
View File
@@ -136,7 +136,9 @@ async function openTxApproval(approvedTx) {
xpub: "xpub-wallet-1", xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1", encryptedSecret: "encrypted-secret-1",
nextIndex: 1, nextIndex: 1,
addresses: [{ address: FROM, balance: "0", tokenBalances: [] }], addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances: [] },
],
}, },
]; ];
approval.init({}); approval.init({});
+10 -2
View File
@@ -1454,7 +1454,11 @@ describe("background population to popup signing to verification", () => {
// What the popup does with it: signs it as given, populating nothing. // What the popup does with it: signs it as given, populating nothing.
async function popupSigns(approvedTx) { async function popupSigns(approvedTx) {
const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY); const localSigner = getSignerForAddress(
walletData,
{ address: signer.address },
SIGNER_KEY,
);
return localSigner.signTransaction({ ...approvedTx }); return localSigner.signTransaction({ ...approvedTx });
} }
@@ -1549,7 +1553,11 @@ describe("background population to popup signing to verification", () => {
// address the user has switched to. // address the user has switched to.
test("the approved from stops the popup signing with another key", async () => { test("the approved from stops the popup signing with another key", async () => {
const approvedTx = await backgroundPrepares(TX_PARAMS); const approvedTx = await backgroundPrepares(TX_PARAMS);
const otherSigner = getSignerForAddress(walletData, 0, OTHER_KEY); const otherSigner = getSignerForAddress(
walletData,
{ address: other.address },
OTHER_KEY,
);
await expect( await expect(
otherSigner.signTransaction({ ...approvedTx }), otherSigner.signTransaction({ ...approvedTx }),
).rejects.toThrow(/from address mismatch/); ).rejects.toThrow(/from address mismatch/);
+49
View File
@@ -1606,6 +1606,55 @@ describe("what the approval is verified against", () => {
}); });
}); });
// eth_sign names the address first and the data second, the reverse of
// personal_sign, and is answered as the JSON-RPC specification defines it:
// with an EIP-191 personal message signature over the data, the same
// signature personal_sign returns
// (https://git.eeqj.de/sneak/AutistMask/issues/409). The signature below is
// made by the test; that the sign screen makes the same one is tested in
// tests/ethSign.test.js.
describe("eth_sign", () => {
test("is read address first, and returns a personal message signature over the data", async () => {
const bg = loadBackground();
const page = bg.send(
{
type: "AUTISTMASK_RPC",
method: "eth_sign",
params: [signer.address, MESSAGE],
},
{ origin: ORIGIN },
);
await settle();
const id = approvalIdIn(bg.created.map((c) => c.url));
const details = bg.send(
{ type: "AUTISTMASK_GET_APPROVAL", id },
{ url: bg.fromPopup.url },
);
const { signParams } = details.sendResponse.mock.calls[0][0];
expect(signParams).toMatchObject({
method: "eth_sign",
message: MESSAGE,
from: signer.address,
});
expect(signParams.dangerWarning).toBe(
"This site used the older eth_sign method. " +
"The data will be signed as a personal message.",
);
const signature = await signer.signMessage(
Buffer.from(MESSAGE.slice(2), "hex"),
);
bg.send(
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: true, signature },
{ url: bg.fromPopup.url },
);
await settle();
expect(page.sendResponse).toHaveBeenCalledWith({ result: signature });
});
});
// The interlock must not cost the retry the approval exists to allow. // The interlock must not cost the retry the approval exists to allow.
describe("the interlock releases a failed attempt", () => { describe("the interlock releases a failed attempt", () => {
test("a retryable failure before the broadcast leaves the approval usable", async () => { test("a retryable failure before the broadcast leaves the approval usable", async () => {
+259
View File
@@ -0,0 +1,259 @@
// A transfer is sent only on the network its confirmation screen was drawn for
// (https://git.eeqj.de/sneak/AutistMask/issues/524).
//
// Settings is reachable from the confirmation screen, and Back from it only
// unhides the screen, still labelled for the network it was drawn for. Sign &
// Send used to take the network at the moment of the click, so 1 SepoliaETH
// reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in
// Settings in between.
//
// Driven through the real confirmation screen, signer and chain switch, with
// only the node and the DOM stubbed. The stub node is on the network its
// provider was built for, so a broadcast records the network it went to.
"use strict";
// The signed transactions the stub node was handed, with its network's chain id.
const mockNode = { broadcast: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
constructor(url, network) {
this.network = network;
}
async getNetwork() {
return this.network;
}
async getFeeData() {
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 10000000000n,
};
}
async estimateGas() {
return 21000n;
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async lookupAddress() {
return null;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push({ chainId: this.network.chainId, signed });
return { hash: "0x" + "ab".repeat(32) };
}
}
return { ...actual, JsonRpcProvider: StubProvider };
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ items: [] }),
})),
urlOrigin: () => "",
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The wait screen polls for a receipt; these tests stop at the broadcast.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const { BaseWallet, Transaction, Wallet } = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
const { NETWORKS } = require("../src/shared/networks");
const txStatus = require("../src/popup/views/txStatus");
const confirmTx = require("../src/popup/views/confirmTx");
const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const PASSWORD = "correct horse battery staple";
function el(id) {
return global.document.getElementById(id);
}
let encryptedSecret;
let signTransaction;
beforeAll(async () => {
await loadState();
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
});
beforeEach(() => {
elements.clear();
mockNode.broadcast = [];
txStatus.showWait.mockClear();
txStatus.showError.mockClear();
signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction");
});
afterEach(() => {
signTransaction.mockRestore();
});
// Show the confirmation screen for 1 of the native token on `networkId`, as
// Review on the Send screen does, with its fee estimate settled.
async function review(networkId) {
await onChainSwitch(networkId);
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: HOLDER, balance: "5.0" }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
confirmTx.init({});
confirmTx.show({
from: HOLDER,
to: RECIPIENT,
ensName: null,
amount: "1",
token: "ETH",
balance: "5.0",
tokenSymbol: null,
tokenBalance: null,
tokenDecimals: null,
max: false,
});
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
async function signAndSend() {
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
}
test("a transfer reviewed on Sepolia and sent goes out on Sepolia", async () => {
await review("sepolia");
expect(el("confirm-type").textContent).toBe("Native SepoliaETH transfer");
expect(el("btn-confirm-send").disabled).toBe(false);
await signAndSend();
expect(mockNode.broadcast).toHaveLength(1);
expect(mockNode.broadcast[0].chainId).toBe(11155111n);
expect(Transaction.from(mockNode.broadcast[0].signed).chainId).toBe(
11155111n,
);
expect(txStatus.showWait).toHaveBeenCalledTimes(1);
});
describe.each([
["sepolia", "mainnet", "SepoliaETH"],
["mainnet", "sepolia", "ETH"],
])(
"a transfer reviewed on %s, with %s chosen in Settings before Sign & Send",
(reviewedOn, switchedTo, symbol) => {
test("is neither signed nor sent, and the screen says why", async () => {
await review(reviewedOn);
expect(el("confirm-type").textContent).toBe(
"Native " + symbol + " transfer",
);
// Settings' network select, then Back, which only unhides the
// screen as it was drawn.
await onChainSwitch(switchedTo);
await signAndSend();
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(el("confirm-errors").style.visibility).toBe("visible");
expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
});
},
);
// The check in the click handler tells the user; the send itself carries the
// chain id the screen was drawn for, so a signer on another network's node
// cannot sign it even if the network changes after that check.
test("the send is refused by a signer on another network's node", async () => {
const { getProvider } = require("../src/shared/balances");
const signer = new Wallet(PRIVATE_KEY).connect(
getProvider("https://rpc.example.invalid", "mainnet"),
);
await expect(
confirmTx.populateVerifyAndSend(signer, {
token: "ETH",
to: RECIPIENT,
amount: "1",
chainId: NETWORKS.sepolia.chainId,
}),
).rejects.toThrow("transaction chainId mismatch");
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
});
+3
View File
@@ -3857,6 +3857,9 @@ test("eth_requestAccounts approved returns the selected address (#183)", async (
shown.toLowerCase().includes(env.expectedAddress.toLowerCase()), shown.toLowerCase().includes(env.expectedAddress.toLowerCase()),
"the site prompt shows the wrong address: " + JSON.stringify(shown), "the site prompt shows the wrong address: " + JSON.stringify(shown),
); );
// The text above is there even when the row is too narrow to show it
// all; this measures that the whole address is visible (#517).
await assertAddressesFit(popup, "the connection prompt");
// Remembered, so the connection survives a background worker that MV3 // Remembered, so the connection survives a background worker that MV3
// terminates after 30 seconds idle. The in-memory connectedSites map // terminates after 30 seconds idle. The in-memory connectedSites map
+102
View File
@@ -0,0 +1,102 @@
// The sign screen signs an eth_sign request the way it signs personal_sign: as
// an EIP-191 personal message over the bytes of the data
// (https://git.eeqj.de/sneak/AutistMask/issues/409). Booted through the real
// popup entry point as the approval window. The background's half, which reads
// the address first and the data second and checks the signature, is tested in
// tests/backgroundApproval.test.js.
// Mocked so that no password has to be hashed: the stored secret decrypts to
// the test key below.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
encryptWithPassword: jest.fn(),
}));
const { Wallet, getBytes } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
// A published test key, never used for real funds.
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
// "Hello AutistMask", as the hex a page sends.
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
const ORIGIN = "https://dapp.example";
afterEach(cleanupPopup);
test("the sign screen answers eth_sign with a personal message signature over the data", async () => {
const sent = [];
const page = await bootPopup(
unversionedValidProfile({
wallets: [
{
type: "key",
name: "Wallet 1",
encryptedSecret: "encrypted-key",
addresses: [
{
address: signer.address,
balance: "0.0000",
tokenBalances: [],
},
],
},
],
activeAddress: signer.address,
allowedSites: { [signer.address]: [ORIGIN] },
}),
{
search: "?approval=approval-1",
runtime: {
connect: () => ({ postMessage: () => {} }),
// Answered as the background answers: with the eth_sign
// approval this window was opened for, and by handing back
// the signature the popup sends.
sendMessage: (msg, reply) => {
sent.push(msg);
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
reply({
type: "sign",
origin: ORIGIN,
isPhishingDomain: false,
approvedFrom: signer.address,
signParams: {
method: "eth_sign",
message: MESSAGE,
from: signer.address,
},
});
} else if (msg.type === "AUTISTMASK_SIGN_RESPONSE") {
reply({ signature: msg.signature });
} else {
reply(undefined);
}
},
},
},
);
globalThis.window.close = jest.fn();
const { decryptWithPassword } = require("../src/shared/vault");
decryptWithPassword.mockResolvedValue(SIGNER_KEY);
expect(page.visibleViews()).toEqual(["approve-sign"]);
page.node("approve-sign-password").value = "any password";
await page.click("btn-approve-sign");
expect(
sent.filter((msg) => msg.type === "AUTISTMASK_SIGN_RESPONSE"),
).toEqual([
{
type: "AUTISTMASK_SIGN_RESPONSE",
id: "approval-1",
approved: true,
signature: await signer.signMessage(getBytes(MESSAGE)),
},
]);
expect(globalThis.window.close).toHaveBeenCalled();
});
+176
View File
@@ -0,0 +1,176 @@
// The decoded details on the transaction detail screen of a token transfer
// (https://git.eeqj.de/sneak/AutistMask/issues/515).
//
// A history row for a token transfer holds the transfer's recipient as its
// `to`, not the contract the transaction was sent to. The screen used to decode
// the transaction's data against that `to`, so its Token line named the
// recipient, and the amount read "decimals unknown" for a token the wallet
// tracks. It decodes against the address the explorer reports the transaction
// was sent to.
//
// The row is the one the real history parser makes from the explorer's answer,
// opened through transactionDetail.show(), which is what a click on the row
// calls. Driven against a minimal DOM stub in the shape
// tests/contractCreation.test.js uses.
const { Interface } = require("ethers");
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: async (url) => ({
ok: true,
json: async () => mockExplorerAnswer(url),
}),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const transactionDetail = require("../src/popup/views/transactionDetail");
const EXPLORER = "https://explorer.example/api/v2";
const HOLDER = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// A token the wallet tracks and the bundled token list does not name.
const TOKEN = "0x74de5d4FCbf63E00296fd95d33236B9794016631";
const TX_HASH =
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
// HOLDER sends 1234.5 of the 6-decimal token to RECIPIENT.
const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
// The explorer's answers: the transfer in HOLDER's token transfers, and the
// transaction itself, sent to the token contract.
function mockExplorerAnswer(url) {
if (
url ===
EXPLORER + "/addresses/" + HOLDER + "/token-transfers?type=ERC-20"
) {
return {
items: [
{
transaction_hash: TX_HASH,
block_number: 100,
timestamp: "2026-10-01T00:00:00Z",
from: { hash: HOLDER },
to: { hash: RECIPIENT },
total: { value: "1234500000", decimals: "6" },
token: {
address_hash: TOKEN,
symbol: "TDOL",
holders_count: "5000",
},
},
],
};
}
if (url === EXPLORER + "/transactions/" + TX_HASH) {
return { to: { hash: TOKEN }, raw_input: TRANSFER_DATA };
}
return { items: [] };
}
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: () => false,
},
addEventListener: () => {},
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);
}
// One line of the decoded details: its label and what is drawn under it.
function decodedLine(label) {
return node("tx-detail-calldata-details")
.innerHTML.split('<div class="mb-2">')
.find((line) =>
line.startsWith(`<div class="text-muted">${label}</div>`),
);
}
beforeEach(() => {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.blockscoutUrl = EXPLORER;
state.wallets = [
{
name: "Main",
type: "key",
addresses: [{ address: HOLDER, balance: "0.0000" }],
},
];
state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }];
state.viewData = {};
state.viewStack = [];
state.currentView = null;
});
test("a token transfer's decoded details name the token contract and the amount at its scale", async () => {
const [row] = await fetchRecentTransactions(HOLDER, EXPLORER, 1);
expect(row.to).toBe(RECIPIENT);
expect(row.contractAddress).toBe(TOKEN.toLowerCase());
transactionDetail.show(row);
// The decoded details are drawn once the explorer has answered.
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
expect(node("tx-detail-calldata-action").textContent).toBe(
"Token Transfer",
);
const token = decodedLine("Token");
expect(token).toContain('<div class="font-bold">TDOL</div>');
expect(token).toContain(TOKEN);
expect(token).not.toContain(RECIPIENT);
expect(decodedLine("Recipient")).toContain(RECIPIENT);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
+67 -1
View File
@@ -233,6 +233,26 @@ function warningOf(data) {
return html.slice(0, html.indexOf(">Domain<")); return html.slice(0, html.indexOf(">Domain<"));
} }
const NOT_SIGNED = ">Not part of the signature<";
// The Message section alone, without the keys listed after it as not part of
// the signature.
function messageOf(data) {
const html = formatTypedDataHtml(JSON.stringify(data));
const end = html.indexOf(NOT_SIGNED);
return html.slice(
html.indexOf(">Message<"),
end === -1 ? html.length : end,
);
}
// The keys listed as not part of the signature, or "" when there are none.
function notSignedOf(data) {
const html = formatTypedDataHtml(JSON.stringify(data));
const start = html.indexOf(NOT_SIGNED);
return start === -1 ? "" : html.slice(start);
}
function request(data) { function request(data) {
return { method: "eth_signTypedData_v4", typedData: JSON.stringify(data) }; return { method: "eth_signTypedData_v4", typedData: JSON.stringify(data) };
} }
@@ -312,7 +332,8 @@ describe("the warning reads only the fields the signed type declares", () => {
}); });
// A Permit whose type names its spender `operator`; the page adds a // A Permit whose type names its spender `operator`; the page adds a
// `spender` key the type does not declare. // `spender` key the type does not declare. The Message section names
// only the operator, and the added key is listed apart as not signed.
test("an added key is not named as the spender", () => { test("an added key is not named as the spender", () => {
const data = { const data = {
...PERMIT, ...PERMIT,
@@ -331,6 +352,10 @@ describe("the warning reads only the fields the signed type declares", () => {
const warning = warningOf(data); const warning = warningOf(data);
expect(warning).toContain(WARNING); expect(warning).toContain(WARNING);
expect(warning).not.toContain(DECOY); expect(warning).not.toContain(DECOY);
expect(messageOf(data)).toContain(SPENDER);
expect(messageOf(data)).not.toContain(DECOY);
expect(notSignedOf(data)).toContain("spender:");
expect(notSignedOf(data)).toContain(DECOY);
}); });
// The permit for a Uniswap v3 position NFT: a `Permit` with no amount // The permit for a Uniswap v3 position NFT: a `Permit` with no amount
@@ -371,6 +396,47 @@ describe("the warning reads only the fields the signed type declares", () => {
}); });
}); });
// ethers signs only the declared fields at every level of nesting, so an
// added key inside a struct is not signed either. Such typed data is not
// refused; the added key is listed apart, with where it sits in the message.
describe("the Message section shows only the fields that are signed", () => {
const NOTE = "This signature only signs you in";
test("an added key inside a nested struct is listed as not signed", () => {
const data = {
...PERMIT_SINGLE,
message: {
...PERMIT_SINGLE.message,
details: { ...PERMIT_SINGLE.message.details, note: NOTE },
},
};
expect(messageOf(data)).toContain("expiration");
expect(messageOf(data)).not.toContain(NOTE);
expect(notSignedOf(data)).toContain("details.note:");
expect(notSignedOf(data)).toContain(NOTE);
expect(typedDataRefusal(request(data))).toBeNull();
});
test("an added key inside a list item is listed with its index", () => {
const [first, second] = PERMIT_BATCH.message.details;
const data = {
...PERMIT_BATCH,
message: {
...PERMIT_BATCH.message,
details: [first, { ...second, note: NOTE }],
},
};
expect(messageOf(data)).not.toContain(NOTE);
expect(notSignedOf(data)).toContain("details[1].note:");
expect(notSignedOf(data)).toContain(NOTE);
});
test("nothing is listed apart when every key is declared", () => {
expect(notSignedOf(PERMIT_SINGLE)).toBe("");
expect(notSignedOf(LOGIN)).toBe("");
});
});
describe("the primary type shown is the one ethers signs", () => { describe("the primary type shown is the one ethers signs", () => {
// A drain stated as a sign-in: the page says Login, the types say // A drain stated as a sign-in: the page says Login, the types say
// PermitSingle, and ethers would sign PermitSingle. // PermitSingle, and ethers would sign PermitSingle.
+49 -5
View File
@@ -416,7 +416,7 @@ describe("extended key depth", () => {
expect(() => expect(() =>
wallet.getSignerForAddress( wallet.getSignerForAddress(
{ type: "xprv" }, { type: "xprv" },
0, { address: VECTOR_ADDRESSES[0], index: 0 },
accountXprv(VECTOR_PHRASE), accountXprv(VECTOR_PHRASE),
), ),
).toThrow(/master/i); ).toThrow(/master/i);
@@ -504,7 +504,7 @@ describe("getSignerForAddress", () => {
test.each([0, 1, 2])("hd wallet, address index %i", (index) => { test.each([0, 1, 2])("hd wallet, address index %i", (index) => {
const signer = wallet.getSignerForAddress( const signer = wallet.getSignerForAddress(
{ type: "hd" }, { type: "hd" },
index, { address: VECTOR_ADDRESSES[index], index },
VECTOR_PHRASE, VECTOR_PHRASE,
); );
@@ -515,7 +515,7 @@ describe("getSignerForAddress", () => {
test.each([0, 1, 2])("xprv wallet, address index %i", (index) => { test.each([0, 1, 2])("xprv wallet, address index %i", (index) => {
const signer = wallet.getSignerForAddress( const signer = wallet.getSignerForAddress(
{ type: "xprv" }, { type: "xprv" },
index, { address: VECTOR_ADDRESSES[index], index },
masterXprv(VECTOR_PHRASE), masterXprv(VECTOR_PHRASE),
); );
@@ -527,7 +527,7 @@ describe("getSignerForAddress", () => {
for (const index of [0, 1, 2]) { for (const index of [0, 1, 2]) {
const signer = wallet.getSignerForAddress( const signer = wallet.getSignerForAddress(
{ type: "privkey" }, { type: "privkey" },
index, { address: VECTOR_ADDRESSES[1], index },
VECTOR_PRIVATE_KEYS[1], VECTOR_PRIVATE_KEYS[1],
); );
@@ -535,10 +535,54 @@ describe("getSignerForAddress", () => {
} }
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/510: an address whose stored
// index does not derive it, or a private key that is not its key.
test.each([
["hd", { address: VECTOR_ADDRESSES[2], index: 1 }, VECTOR_PHRASE],
[
"xprv",
{ address: VECTOR_ADDRESSES[2], index: 1 },
masterXprv(VECTOR_PHRASE),
],
["privkey", { address: VECTOR_ADDRESSES[2] }, VECTOR_PRIVATE_KEYS[1]],
])(
"%s wallet refuses a key that is not the address's own",
(type, addr, secret) => {
expect(() =>
wallet.getSignerForAddress({ type }, addr, secret),
).toThrow(
"The key this wallet holds for " +
VECTOR_ADDRESSES[2] +
" belongs to a different address, so it was not used.",
);
},
);
// An HD or xprv address record without a usable `index` is refused in a
// sentence, not with what ethers says about the value it was handed.
test.each([
["hd", VECTOR_PHRASE],
["xprv", masterXprv(VECTOR_PHRASE)],
])("%s wallet refuses an address with no valid index", (type, secret) => {
for (const index of [undefined, null, -1, 1.5, "1"]) {
expect(() =>
wallet.getSignerForAddress(
{ type },
{ address: VECTOR_ADDRESSES[1], index },
secret,
),
).toThrow(
"No derivation index is stored for " +
VECTOR_ADDRESSES[1] +
", so its key cannot be derived.",
);
}
});
test("the returned signer signs recoverably as the expected address", async () => { test("the returned signer signs recoverably as the expected address", async () => {
const signer = wallet.getSignerForAddress( const signer = wallet.getSignerForAddress(
{ type: "hd" }, { type: "hd" },
1, { address: VECTOR_ADDRESSES[1], index: 1 },
VECTOR_PHRASE, VECTOR_PHRASE,
); );
const message = "AutistMask derivation test"; const message = "AutistMask derivation test";
+8 -4
View File
@@ -55,7 +55,7 @@ function healthyXprvWallet(name = "Master") {
xpub, xpub,
nextIndex: 1, nextIndex: 1,
encryptedSecret: "irrelevant-to-these-tests", encryptedSecret: "irrelevant-to-these-tests",
addresses: [{ address: firstAddress, balance: "0.0000" }], addresses: [{ address: firstAddress, index: 0, balance: "0.0000" }],
}; };
} }
@@ -74,7 +74,11 @@ function brokenXprvWallet(name = "Imported xprv") {
nextIndex: 1, nextIndex: 1,
encryptedSecret: "irrelevant-to-these-tests", encryptedSecret: "irrelevant-to-these-tests",
addresses: [ addresses: [
{ address: node.deriveChild(0).address, balance: "0.0000" }, {
address: node.deriveChild(0).address,
index: 0,
balance: "0.0000",
},
], ],
}; };
} }
@@ -570,7 +574,7 @@ describe("no path throws an unhandled error for a defective wallet", () => {
try { try {
wallet.getSignerForAddress( wallet.getSignerForAddress(
{ type: "xprv" }, { type: "xprv" },
0, brokenXprvWallet().addresses[0],
accountXprv(VECTOR_PHRASE), accountXprv(VECTOR_PHRASE),
); );
} catch (e) { } catch (e) {
@@ -585,7 +589,7 @@ describe("no path throws an unhandled error for a defective wallet", () => {
test("a healthy xprv wallet signs as it always did", () => { test("a healthy xprv wallet signs as it always did", () => {
const signer = wallet.getSignerForAddress( const signer = wallet.getSignerForAddress(
{ type: "xprv" }, { type: "xprv" },
0, healthyXprvWallet().addresses[0],
masterXprv(VECTOR_PHRASE), masterXprv(VECTOR_PHRASE),
); );