Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4770849ff8 | ||
|
|
f1a13a50a6 | ||
|
|
b4059af827 | ||
|
|
8c08b3d467 | ||
|
|
fd5921f62e | ||
|
|
83f3549d37 | ||
|
|
eb012b9eb3 | ||
|
|
adff1e0b9d | ||
|
|
8e52528f8b | ||
|
|
b8b13ef5cd | ||
|
|
ff05bd50f7 |
@@ -239,8 +239,12 @@ provide:
|
||||
`make check` stays non-mutating. Linting runs only in the container, because
|
||||
an ESLint result that depends on whichever ESLint the host happens to have is
|
||||
not a result, so docker is required to lint.
|
||||
- `script/fmt` — format all files (writes), on the host
|
||||
- `script/fmt-check` — check formatting (read-only), on the host
|
||||
- `script/fmt` — format all files (writes), on the host, with the `prettier`
|
||||
that `script/bootstrap` installs in `node_modules/`; when that one is missing
|
||||
it fails and says to run `script/bootstrap`, instead of using a `prettier`
|
||||
from the `PATH`
|
||||
- `script/fmt-check` — check formatting (read-only), on the host, with the same
|
||||
`prettier` and the same refusal
|
||||
- `script/check` — run `script/test`, `script/lint` and `script/fmt-check`
|
||||
- `script/check-censored` — assert the competitor name RULES.md bars appears
|
||||
nowhere in the working tree or under `dist/` outside its documented
|
||||
@@ -422,11 +426,12 @@ captured at `eth_sendRawTransaction` rather than against anything the extension
|
||||
reported, rejecting each prompt is required to return a rejection to the page
|
||||
rather than hang or resolve, a network switch request is required to leave the
|
||||
stored network unchanged and send no `chainChanged` until it is approved, a
|
||||
prompt raised while another approval window has focus is required to open a
|
||||
window of its own, and the password is required to be absent from every message
|
||||
the approval window sends to the background — with the message that would carry
|
||||
it required to be present, so that check cannot pass by observing nothing. That
|
||||
last one is the standing floor under
|
||||
network switch in Settings is required to send the page `chainChanged` with the
|
||||
new chain id, a prompt raised while another approval window has focus is
|
||||
required to open a window of its own, and the password is required to be absent
|
||||
from every message the approval window sends to the background — with the
|
||||
message that would carry it required to be present, so that check cannot pass by
|
||||
observing nothing. That last one is the standing floor under
|
||||
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
|
||||
|
||||
The limits of that coverage and of the rest of the Chrome suite, none of them
|
||||
@@ -1187,7 +1192,16 @@ The core hierarchy is **Wallets → Addresses**:
|
||||
of its stored `xpub` by `src/shared/walletDefects.js`, explained in the
|
||||
wallet list, and blocked from signing, sending and private-key export. It
|
||||
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
|
||||
single address (key).
|
||||
|
||||
@@ -1513,6 +1527,9 @@ view would leave a wallet one click from deletion.
|
||||
change)
|
||||
- "Reveal" (wrong password) → full-sentence error on the error line, nothing
|
||||
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)
|
||||
- Settings gear → **Settings**, whose "Back" goes to AddressDetail: leaving
|
||||
drops the address the screen was showing, so it also takes the screen off
|
||||
@@ -1642,6 +1659,13 @@ view would leave a wallet one click from deletion.
|
||||
value (`src/shared/transferAmount.js`)
|
||||
- "Sign & Send" (wrong password) → "Wrong password." on the password error
|
||||
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**
|
||||
|
||||
#### WaitTx (`wait-tx`)
|
||||
@@ -1789,7 +1813,8 @@ view would leave a wallet one click from deletion.
|
||||
network changes only here, or when the user approves a site's request on
|
||||
**NetworkApproval**; either way, switching restores the RPC and Blockscout
|
||||
endpoints last used on that network, or that network's defaults if it has
|
||||
none
|
||||
none, and sends `chainChanged` with the new chain id to every open tab.
|
||||
Choosing the network already active changes nothing and sends nothing
|
||||
- Ethereum RPC: endpoint URL input + "Save" button (validated against
|
||||
`eth_chainId` before being saved)
|
||||
- Blockscout API: endpoint URL input + "Save" button (validated against
|
||||
@@ -2104,6 +2129,9 @@ view would leave a wallet one click from deletion.
|
||||
shown, exactly as shown, hands the signed transaction to the background to
|
||||
broadcast, then → **WaitTx** in the same popup window
|
||||
- "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)
|
||||
- Popup window closed without answering → the request is rejected with
|
||||
EIP-1193 code 4001
|
||||
@@ -2158,6 +2186,13 @@ view would leave a wallet one click from deletion.
|
||||
- Typed data that states no primary type, or one other than the type it
|
||||
would be signed as, or that cannot be read → shown with the error line
|
||||
saying so and "Sign" disabled; only "Reject" remains
|
||||
- Typed data whose domain `chainId` names a chain other than the active
|
||||
network's → shown with the error line naming both chain ids and "Sign"
|
||||
disabled; only "Reject" remains. EIP-712 asks for this, because the
|
||||
signature is valid on the chain the domain names. Typed data with no
|
||||
`chainId` is not refused. The background refuses such a signature too,
|
||||
against the network active when the signature reaches it, and answers the
|
||||
site with the same sentence
|
||||
- A `personal_sign` or `eth_sign` message that is not hex (`0x` or `0X` and
|
||||
an even number of hex digits, the form ethers' `getBytes` reads when
|
||||
signing) → shown as plain text, with the error line "This message is plain
|
||||
@@ -2650,7 +2685,8 @@ plaintext list of anyone's domain names.
|
||||
When a dApp on a blocklisted domain requests a wallet connection, transaction
|
||||
approval, or signature, the approval popup displays a prominent red warning
|
||||
banner alerting the user. The domain checker matches exact hostnames and all
|
||||
parent domains (subdomain matching).
|
||||
parent domains (subdomain matching), and checks a hostname written with a
|
||||
trailing dot (`name.`) as `name`.
|
||||
|
||||
#### Transaction Decoding
|
||||
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
- `git pull` `next` and cut a branch from it — one branch per issue, named
|
||||
`issue-<N>-<slug>`. Never branch from `main`.
|
||||
- Do the work as one commit whose title ends with ` (closes #N)`, with the
|
||||
`TODO.md` update in that same commit.
|
||||
- Move Next Step to the top of Completed Steps; move the top item of Future
|
||||
Steps into Next Step.
|
||||
- Do the work as one commit whose title ends with ` (closes #N)`.
|
||||
- Add no entry to Completed Steps. What landed is recorded on the tracker and in
|
||||
`git log`, where each commit title names the issue it closes.
|
||||
- Run `make fmt`, then `make check`. A feature branch may be red; `next` and
|
||||
`main` may not.
|
||||
- Rebase onto current `next` immediately before pushing — other branches land on
|
||||
@@ -44,6 +43,25 @@ then continue tagging as milestones land.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-08: The browser suites no longer read a screen before the page has
|
||||
shown it ([#502](https://git.eeqj.de/sneak/AutistMask/issues/502)). Their wait
|
||||
for a screen used to pass as soon as the element laid out, which every view
|
||||
does until the page's stylesheet has applied, so an approval test could read
|
||||
the prompt's fields before the page's script had filled them. `visible()` in
|
||||
`tests/e2e/harness.js` and `waitVisible()` in `tests/e2e/firefox/driver.js`
|
||||
now also wait for the page to finish loading and for neither the element nor
|
||||
anything around it to carry the `hidden` class that `showView()` puts on every
|
||||
view but the current one. No caller changed.
|
||||
|
||||
- 2026-10-08: Switching the network in Settings now tells open pages
|
||||
([#500](https://git.eeqj.de/sneak/AutistMask/issues/500)). Once the switch is
|
||||
saved, Settings asks the background to send `chainChanged` with the new chain
|
||||
id to every open tab, through the same function an approved site request uses.
|
||||
Choosing the network already active changes nothing and sends nothing. Only
|
||||
the extension's own pages can ask for this. `tests/chainSwitchGate.test.js`
|
||||
drives the real Settings view against the background, and the Chrome suite
|
||||
checks that the test page hears both switches.
|
||||
|
||||
- 2026-10-07: A site can no longer switch the wallet's network by itself
|
||||
([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)). A connected site's
|
||||
`wallet_switchEthereumChain` request for the other supported network opens a
|
||||
|
||||
@@ -25,6 +25,13 @@ run_yarn() {
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
# Without the repo's own prettier, yarn runs whatever prettier is on
|
||||
# the PATH, which may be a version that formats files differently.
|
||||
if [ ! -x node_modules/.bin/prettier ]; then
|
||||
echo "fmt: this repo's prettier is not installed in node_modules/," \
|
||||
"so run script/bootstrap first." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Formatting..."
|
||||
run_yarn run fmt
|
||||
}
|
||||
|
||||
@@ -26,6 +26,13 @@ run_yarn() {
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
# Without the repo's own prettier, yarn runs whatever prettier is on
|
||||
# the PATH, which may be a version that formats files differently.
|
||||
if [ ! -x node_modules/.bin/prettier ]; then
|
||||
echo "fmt-check: this repo's prettier is not installed in" \
|
||||
"node_modules/, so run script/bootstrap first." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Checking formatting..."
|
||||
run_yarn run fmt-check
|
||||
}
|
||||
|
||||
+31
-4
@@ -22,6 +22,7 @@ const { debugFetch, log } = require("../shared/log");
|
||||
const {
|
||||
verifySignedTx,
|
||||
verifySignature,
|
||||
typedDataChainRefusal,
|
||||
failureIsRetryable,
|
||||
describeTxFailure,
|
||||
sameAddress,
|
||||
@@ -632,8 +633,9 @@ function isExtensionSender(sender) {
|
||||
// had refused.
|
||||
//
|
||||
// TX and sign approvals do not decide here. They stay pending across a
|
||||
// disconnect — the user can reopen the toolbar popup — and are rejected by the
|
||||
// windows.onRemoved listener below.
|
||||
// disconnect. When their window closes, the windows.onRemoved listener below
|
||||
// rejects them, unless an approved attempt is still running, in which case
|
||||
// that attempt answers the page.
|
||||
runtime.onConnect.addListener((port) => {
|
||||
if (port.name.startsWith("approval:")) {
|
||||
const id = port.name.split(":")[1];
|
||||
@@ -661,7 +663,9 @@ runtime.onConnect.addListener((port) => {
|
||||
const approval = pendingApprovals[id];
|
||||
if (approval) {
|
||||
if (approval.type === "tx" || approval.type === "sign") {
|
||||
// Keep pending — user can reopen the toolbar popup
|
||||
// When its window closes, windows.onRemoved rejects it,
|
||||
// unless an approved attempt is still running, in which
|
||||
// case that attempt answers the page.
|
||||
return;
|
||||
}
|
||||
settleApproval(id, { approved: false, remember: false });
|
||||
@@ -1471,6 +1475,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
"AUTISTMASK_ADDRESSES_REMOVED",
|
||||
"AUTISTMASK_GET_CONNECTED_SITES",
|
||||
"AUTISTMASK_REMOVE_SITE",
|
||||
"AUTISTMASK_NETWORK_CHANGED",
|
||||
];
|
||||
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
|
||||
sendResponse({ error: "Unauthorized sender" });
|
||||
@@ -1763,7 +1768,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const activeAddress = await getActiveAddress();
|
||||
const s = await getState();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
// Same as the transaction path: the address the approval named
|
||||
// is the one that must have signed, and a switch since then is
|
||||
// a refusal rather than a signature from another account.
|
||||
@@ -1782,6 +1788,20 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
signature,
|
||||
approval.approvedFrom,
|
||||
);
|
||||
// The sign screen refuses typed data whose domain names
|
||||
// another chain. A signature for it that arrives anyway is
|
||||
// refused here, against the network active now.
|
||||
const sp = approval.signParams;
|
||||
if (
|
||||
sp.method === "eth_signTypedData_v4" ||
|
||||
sp.method === "eth_signTypedData"
|
||||
) {
|
||||
const refusal = typedDataChainRefusal(
|
||||
JSON.parse(sp.typedData).domain,
|
||||
networkById(s.networkId),
|
||||
);
|
||||
if (refusal) throw new ApprovalMismatchError(refusal);
|
||||
}
|
||||
settleApproval(msg.id, { signature }, { holdsClaim: true });
|
||||
sendResponse({ signature });
|
||||
} catch (e) {
|
||||
@@ -1854,6 +1874,13 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
broadcastSiteRemoved(msg.origin);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Settings switched the network and has saved it. Open tabs are told the
|
||||
// new chain id the same way as after a site's approved switch request.
|
||||
if (msg.type === "AUTISTMASK_NETWORK_CHANGED") {
|
||||
broadcastChainChanged(msg.chainId);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = { PROXY_METHODS };
|
||||
|
||||
+12
-8
@@ -7,9 +7,10 @@
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<!-- Chrome gives extension pages a stylesheet of its own that sets the
|
||||
font on body, and a Tailwind class beats it only when marked
|
||||
important: hence font-mono! rather than font-mono. -->
|
||||
<body class="bg-bg text-fg font-mono! text-sm">
|
||||
font and its size on body, and a Tailwind class beats it only when
|
||||
marked important: hence font-mono! and text-sm! rather than
|
||||
font-mono and text-sm. -->
|
||||
<body class="bg-bg text-fg font-mono! text-sm!">
|
||||
<div id="app" class="p-2 pr-5 overflow-x-hidden">
|
||||
<!-- ============ GLOBAL TITLE BAR ============ -->
|
||||
<div
|
||||
@@ -87,11 +88,14 @@
|
||||
|
||||
<!-- Mnemonic form section -->
|
||||
<div id="add-wallet-section-mnemonic">
|
||||
<p class="mb-2">
|
||||
Enter your 12 or 24 word recovery phrase below, or click
|
||||
the button to roll the die for a new one.
|
||||
</p>
|
||||
<div class="mb-1 flex justify-end">
|
||||
<!-- The die button shares the instruction's row: on a row
|
||||
of its own it pushes Import below the fold at
|
||||
360x600. -->
|
||||
<div class="mb-2 flex justify-between items-start gap-2">
|
||||
<p>
|
||||
Enter your 12 or 24 word recovery phrase below, or
|
||||
click the button to roll the die for a new one.
|
||||
</p>
|
||||
<button
|
||||
id="btn-generate-phrase"
|
||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"
|
||||
|
||||
@@ -184,7 +184,12 @@ async function importMnemonic(ctx) {
|
||||
encryptedSecret: encrypted,
|
||||
nextIndex: 1,
|
||||
addresses: [
|
||||
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
|
||||
{
|
||||
address: firstAddress,
|
||||
index: 0,
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
state.wallets.push(wallet);
|
||||
@@ -205,6 +210,7 @@ async function importMnemonic(ctx) {
|
||||
if (scan.addresses.length > 1) {
|
||||
wallet.addresses = scan.addresses.map((a) => ({
|
||||
address: a.address,
|
||||
index: a.index,
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
}));
|
||||
@@ -299,7 +305,12 @@ async function importXprvKey(ctx) {
|
||||
encryptedSecret: encrypted,
|
||||
nextIndex: 1,
|
||||
addresses: [
|
||||
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
|
||||
{
|
||||
address: firstAddress,
|
||||
index: 0,
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
state.wallets.push(wallet);
|
||||
@@ -320,6 +331,7 @@ async function importXprvKey(ctx) {
|
||||
if (scan.addresses.length > 1) {
|
||||
wallet.addresses = scan.addresses.map((a) => ({
|
||||
address: a.address,
|
||||
index: a.index,
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
}));
|
||||
|
||||
+15
-11
@@ -12,7 +12,7 @@ const {
|
||||
formatFee,
|
||||
tokenLabel,
|
||||
} = require("./helpers");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const {
|
||||
networkById,
|
||||
networkByChainId,
|
||||
@@ -46,7 +46,10 @@ const {
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
const { walletDefect } = require("../../shared/walletDefects");
|
||||
const { describeSigningFailure } = require("../../shared/approvalVerify");
|
||||
const {
|
||||
describeSigningFailure,
|
||||
typedDataChainRefusal,
|
||||
} = require("../../shared/approvalVerify");
|
||||
const txStatus = require("./txStatus");
|
||||
const uniswap = require("../../shared/uniswap");
|
||||
const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
|
||||
@@ -443,7 +446,8 @@ function signedPrimaryType(types) {
|
||||
// Why a signature request cannot be signed, as a sentence for the error line,
|
||||
// or null when it can. Only typed data is refused: the `primaryType` the page
|
||||
// states has to be the type ethers will sign, or this screen would name one
|
||||
// message while another is signed.
|
||||
// message while another is signed. Its domain may name no chain but the active
|
||||
// network's.
|
||||
function typedDataRefusal(sp) {
|
||||
if (sp.method === "personal_sign" || sp.method === "eth_sign") return null;
|
||||
let data;
|
||||
@@ -466,7 +470,7 @@ function typedDataRefusal(sp) {
|
||||
", so it cannot be signed."
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return typedDataChainRefusal(data.domain, currentNetwork());
|
||||
}
|
||||
|
||||
// The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats
|
||||
@@ -863,15 +867,15 @@ function gateOnWalletDefect(errorId, buttonId, address) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Locate the wallet and the address index owning an address. Returns null when
|
||||
// no wallet holds it. Approvals look up the address they were raised for, not
|
||||
// whichever address is active now: the approval named one account, and signing
|
||||
// with another is what verification refuses.
|
||||
// Locate the wallet and the stored address record owning an address. Returns
|
||||
// null when no wallet holds it. Approvals look up the address they were raised
|
||||
// for, not whichever address is active now: the approval named one account,
|
||||
// and signing with another is what verification refuses.
|
||||
function findWalletFor(address) {
|
||||
for (const wallet of state.wallets) {
|
||||
for (let i = 0; i < wallet.addresses.length; i++) {
|
||||
if (wallet.addresses[i].address === address) {
|
||||
return { wallet, addrIndex: i };
|
||||
return { wallet, addr: wallet.addresses[i] };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -998,7 +1002,7 @@ function init(_ctx) {
|
||||
try {
|
||||
const signer = getSignerForAddress(
|
||||
active.wallet,
|
||||
active.addrIndex,
|
||||
active.addr,
|
||||
decryptedSecret,
|
||||
);
|
||||
// Sign the approved transaction exactly as it was displayed. The
|
||||
@@ -1126,7 +1130,7 @@ function init(_ctx) {
|
||||
try {
|
||||
const signer = getSignerForAddress(
|
||||
active.wallet,
|
||||
active.addrIndex,
|
||||
active.addr,
|
||||
decryptedSecret,
|
||||
);
|
||||
const sp = pendingSignParams;
|
||||
|
||||
@@ -547,6 +547,19 @@ function init(_ctx) {
|
||||
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;
|
||||
if (!password) {
|
||||
showError(
|
||||
@@ -581,11 +594,7 @@ function init(_ctx) {
|
||||
|
||||
let tx;
|
||||
try {
|
||||
const signer = getSignerForAddress(
|
||||
wallet,
|
||||
state.selectedAddress,
|
||||
decryptedSecret,
|
||||
);
|
||||
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const connectedSigner = signer.connect(provider);
|
||||
|
||||
|
||||
@@ -135,7 +135,18 @@ async function reveal() {
|
||||
// decrypt ran, the wipe has already happened, so the key is not
|
||||
// even derived, let alone written.
|
||||
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-section").classList.add("hidden");
|
||||
$("export-privkey-value").textContent = signer.privateKey;
|
||||
|
||||
@@ -354,6 +354,7 @@ function render(ctx) {
|
||||
);
|
||||
wallet.addresses.push({
|
||||
address: newAddr,
|
||||
index: wallet.nextIndex,
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
});
|
||||
|
||||
@@ -316,7 +316,11 @@ function init(ctx) {
|
||||
const networkSelect = $("settings-network");
|
||||
networkSelect.addEventListener("change", async () => {
|
||||
const newId = networkSelect.value;
|
||||
if (newId === state.networkId) return;
|
||||
const net = await onChainSwitch(newId);
|
||||
// Open pages are told by the background, as after a site's approved
|
||||
// switch request.
|
||||
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
|
||||
$("settings-rpc").value = state.rpcUrl;
|
||||
$("settings-blockscout").value = state.blockscoutUrl;
|
||||
showFlash("Switched to " + net.name + ".");
|
||||
|
||||
@@ -53,6 +53,7 @@ const {
|
||||
accessListify,
|
||||
formatEther,
|
||||
getAddress,
|
||||
getBigInt,
|
||||
getBytes,
|
||||
toQuantity,
|
||||
verifyMessage,
|
||||
@@ -635,6 +636,33 @@ function verifySignature(signParams, signature, expectedFrom) {
|
||||
return recovered;
|
||||
}
|
||||
|
||||
// Why typed data cannot be signed while `network` is the active network, as a
|
||||
// sentence, or null when it can. EIP-712 has a wallet refuse typed data whose
|
||||
// domain names another chain, because the signature is valid on that chain.
|
||||
// ethers signs no chain id when the domain's is null or absent, and reads any
|
||||
// other with getBigInt(), so 1, "1" and "0x1" are the same chain here too.
|
||||
function typedDataChainRefusal(domain, network) {
|
||||
const chainId = domain?.chainId;
|
||||
if (chainId === undefined || chainId === null) return null;
|
||||
let named;
|
||||
try {
|
||||
named = getBigInt(chainId);
|
||||
} catch {
|
||||
return "This typed data cannot be read, so it cannot be signed.";
|
||||
}
|
||||
const active = getBigInt(network.chainId);
|
||||
if (named === active) return null;
|
||||
return (
|
||||
"This typed data is for chain id " +
|
||||
named +
|
||||
", but the active network is " +
|
||||
network.name +
|
||||
" (chain id " +
|
||||
active +
|
||||
"), so it cannot be signed."
|
||||
);
|
||||
}
|
||||
|
||||
// The stage a transaction approval failed at. Which stage it is decides
|
||||
// whether the approval survives the failure.
|
||||
const TX_STAGE_SIGN = "sign";
|
||||
@@ -796,6 +824,7 @@ function describeSigningFailure(response, fallbackMessage) {
|
||||
module.exports = {
|
||||
verifySignedTx,
|
||||
verifySignature,
|
||||
typedDataChainRefusal,
|
||||
assertNoForbiddenFields,
|
||||
assertNothingUnchecked,
|
||||
assertCanonicalBytes,
|
||||
|
||||
@@ -110,11 +110,14 @@ function hashListed(hash) {
|
||||
* Generate hostname variants for subdomain matching.
|
||||
* "sub.evil.com" yields ["sub.evil.com", "evil.com"].
|
||||
*
|
||||
* A trailing dot is dropped: "evil.com." is the fully qualified spelling of
|
||||
* "evil.com", and the browser loads the same site from either.
|
||||
*
|
||||
* @param {string} hostname
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function hostnameVariants(hostname) {
|
||||
const h = hostname.toLowerCase();
|
||||
const h = hostname.toLowerCase().replace(/\.$/, "");
|
||||
const variants = [h];
|
||||
const parts = h.split(".");
|
||||
// Parent domains: a.b.c.d -> b.c.d, c.d
|
||||
|
||||
+32
-6
@@ -110,16 +110,33 @@ function addressFromPrivateKey(key) {
|
||||
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") {
|
||||
const node = HDNodeWallet.fromPhrase(
|
||||
decryptedSecret,
|
||||
"",
|
||||
BIP44_ETH_PATH,
|
||||
);
|
||||
return node.deriveChild(addrIndex);
|
||||
}
|
||||
if (walletData.type === "xprv") {
|
||||
signer = node.deriveChild(addr.index);
|
||||
} else if (walletData.type === "xprv") {
|
||||
// Checked here rather than through masterXprvOrThrow so the message
|
||||
// fits the situation: nobody is importing anything at signing time,
|
||||
// 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.",
|
||||
);
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -36,11 +36,13 @@ const DEFECTS = {
|
||||
"derivation path beneath that key instead of from a master " +
|
||||
"key, so the addresses listed here are not the ones that key " +
|
||||
"produces under the standard path.",
|
||||
"Signing and sending are disabled for this wallet. The addresses " +
|
||||
"do descend from the extended private key you imported, so " +
|
||||
"anything they hold is still reachable by software that " +
|
||||
"repeats the same non-standard derivation. Check them in a " +
|
||||
"block explorer before deciding what to do.",
|
||||
"Signing, sending and private key export are disabled for this " +
|
||||
"wallet. Anything these addresses hold can be reached outside " +
|
||||
"this wallet with the extended private key you imported, if " +
|
||||
"you kept a copy of it, or with the master extended private " +
|
||||
"key or recovery phrase it came from, in software that " +
|
||||
"repeats the same non-standard derivation. Check the " +
|
||||
"addresses in a block explorer before deciding what to do.",
|
||||
"To see the addresses this key produces under the standard path, " +
|
||||
"import the master extended private key, or the recovery " +
|
||||
"phrase it came from, as a new wallet. Nothing here has been " +
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
// Sending, a site's signature request 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, signature approval screen 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");
|
||||
}
|
||||
|
||||
describe.each([
|
||||
[
|
||||
"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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 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());
|
||||
});
|
||||
});
|
||||
@@ -136,7 +136,9 @@ async function openTxApproval(approvedTx) {
|
||||
xpub: "xpub-wallet-1",
|
||||
encryptedSecret: "encrypted-secret-1",
|
||||
nextIndex: 1,
|
||||
addresses: [{ address: FROM, balance: "0", tokenBalances: [] }],
|
||||
addresses: [
|
||||
{ address: FROM, index: 0, balance: "0", tokenBalances: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
approval.init({});
|
||||
|
||||
@@ -1455,7 +1455,11 @@ describe("background population to popup signing to verification", () => {
|
||||
|
||||
// What the popup does with it: signs it as given, populating nothing.
|
||||
async function popupSigns(approvedTx) {
|
||||
const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY);
|
||||
const localSigner = getSignerForAddress(
|
||||
walletData,
|
||||
{ address: signer.address },
|
||||
SIGNER_KEY,
|
||||
);
|
||||
return localSigner.signTransaction({ ...approvedTx });
|
||||
}
|
||||
|
||||
@@ -1550,7 +1554,11 @@ describe("background population to popup signing to verification", () => {
|
||||
// address the user has switched to.
|
||||
test("the approved from stops the popup signing with another key", async () => {
|
||||
const approvedTx = await backgroundPrepares(TX_PARAMS);
|
||||
const otherSigner = getSignerForAddress(walletData, 0, OTHER_KEY);
|
||||
const otherSigner = getSignerForAddress(
|
||||
walletData,
|
||||
{ address: other.address },
|
||||
OTHER_KEY,
|
||||
);
|
||||
await expect(
|
||||
otherSigner.signTransaction({ ...approvedTx }),
|
||||
).rejects.toThrow(/from address mismatch/);
|
||||
|
||||
@@ -397,7 +397,13 @@ function loadBackground(options) {
|
||||
rpcResult = r;
|
||||
},
|
||||
);
|
||||
return { result: () => rpcResult };
|
||||
return {
|
||||
id: () =>
|
||||
new URL(created[created.length - 1].url).searchParams.get(
|
||||
"approval",
|
||||
),
|
||||
result: () => rpcResult,
|
||||
};
|
||||
}
|
||||
|
||||
// A dApp asking to connect. The origin defaults to one the persisted
|
||||
@@ -1484,6 +1490,55 @@ describe("what the approval is verified against", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Typed data whose domain names another chain is refused on the sign
|
||||
// screen; a signature for it that reaches the background anyway is not
|
||||
// handed to the page (https://git.eeqj.de/sneak/AutistMask/issues/519).
|
||||
// TYPED_DATA names mainnet.
|
||||
async function answerTypedData(bg, pending) {
|
||||
const { domain, types, message } = JSON.parse(TYPED_DATA);
|
||||
delete types.EIP712Domain;
|
||||
const signature = await signer.signTypedData(domain, types, message);
|
||||
const answer = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||
id: pending.id(),
|
||||
approved: true,
|
||||
signature,
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
return { answer, signature };
|
||||
}
|
||||
|
||||
test("typed data for the active chain is handed back signed", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTypedData();
|
||||
await settle();
|
||||
|
||||
const { answer, signature } = await answerTypedData(bg, pending);
|
||||
|
||||
expect(answer.sendResponse).toHaveBeenCalledWith({ signature });
|
||||
expect(pending.result()).toEqual({ result: signature });
|
||||
});
|
||||
|
||||
test("typed data for another chain is refused, not handed back", async () => {
|
||||
const bg = loadBackground();
|
||||
bg.setNetwork(SEPOLIA);
|
||||
const pending = bg.requestTypedData();
|
||||
await settle();
|
||||
|
||||
const { answer } = await answerTypedData(bg, pending);
|
||||
|
||||
const refusal =
|
||||
"This typed data is for chain id 1, but the active network is Sepolia Testnet (chain id 11155111), so it cannot be signed.";
|
||||
expect(answer.sendResponse).toHaveBeenCalledWith({
|
||||
error: refusal,
|
||||
retryable: false,
|
||||
});
|
||||
expect(pending.result()).toEqual({ error: { message: refusal } });
|
||||
});
|
||||
|
||||
test("a sign request from another address raises no approval at all", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestSign(other.address);
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
//
|
||||
// The endpoint half of #308 lives in tests/networkEndpoints.test.js, which
|
||||
// covers the popup's chain switch; this file covers the background's, which
|
||||
// goes through storage rather than the shared state singleton.
|
||||
// goes through storage rather than the shared state singleton. The last block
|
||||
// covers what the background tells open tabs when the user switches the
|
||||
// network in Settings.
|
||||
|
||||
const { networkById } = require("../src/shared/networks");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
@@ -225,6 +227,14 @@ function loadBackground() {
|
||||
answerPrompt,
|
||||
closePrompt,
|
||||
opened,
|
||||
// A message to the background from `sender`, and its answer.
|
||||
send: (msg, sender) => {
|
||||
let reply = null;
|
||||
messageListener(msg, sender, (r) => {
|
||||
reply = r;
|
||||
});
|
||||
return reply;
|
||||
},
|
||||
walletState: () => storage.read("autistmask"),
|
||||
chainChangedEvents: () =>
|
||||
toTabs.filter((m) => m.eventName === "chainChanged"),
|
||||
@@ -390,3 +400,109 @@ describe("only the user switches the network", () => {
|
||||
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
|
||||
});
|
||||
});
|
||||
|
||||
// Switching the network in Settings tells open pages, as an approved site
|
||||
// request does (https://git.eeqj.de/sneak/AutistMask/issues/500). These drive
|
||||
// the real Settings view over the background's storage, with what it sends
|
||||
// delivered to the background from the extension's own page.
|
||||
describe("switching the network in Settings tells every open tab", () => {
|
||||
const POPUP = { url: EXT_URL + "src/popup/index.html" };
|
||||
|
||||
// A stand-in for one DOM node: enough of an element for init() to set
|
||||
// properties on it and hang listeners off it.
|
||||
function fakeElement() {
|
||||
return {
|
||||
value: "",
|
||||
checked: false,
|
||||
textContent: "",
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: { add() {}, remove() {} },
|
||||
listeners: {},
|
||||
addEventListener(event, handler) {
|
||||
this.listeners[event] = handler;
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
// Settings, opened the way the popup opens it. Returns its network
|
||||
// selector.
|
||||
async function openSettings(bg) {
|
||||
const elements = {};
|
||||
const element = (id) => (elements[id] ||= fakeElement());
|
||||
jest.doMock("../src/popup/views/helpers", () => ({
|
||||
$: element,
|
||||
showView: () => {},
|
||||
updateDebugBanner: () => {},
|
||||
showFlash: () => {},
|
||||
escapeHtml: (s) => s,
|
||||
flashCopyFeedback: () => {},
|
||||
goBack: () => {},
|
||||
pushCurrentView: () => {},
|
||||
onViewLeave: () => {},
|
||||
VIEWS: [],
|
||||
}));
|
||||
global.chrome.runtime.sendMessage = (msg) => {
|
||||
bg.send(msg, POPUP);
|
||||
};
|
||||
await require("../src/shared/state").loadState();
|
||||
require("../src/popup/views/settings").init({
|
||||
pageClosed: new AbortController().signal,
|
||||
});
|
||||
const select = element("settings-network");
|
||||
select.value = "mainnet";
|
||||
return select;
|
||||
}
|
||||
|
||||
// The user picks `networkId` in the selector.
|
||||
async function choose(select, networkId) {
|
||||
select.value = networkId;
|
||||
await select.listeners.change();
|
||||
await settle();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.dontMock("../src/popup/views/helpers");
|
||||
});
|
||||
|
||||
test("switching to the other network sends chainChanged once, with its chain id", async () => {
|
||||
const bg = loadBackground();
|
||||
const select = await openSettings(bg);
|
||||
|
||||
await choose(select, "sepolia");
|
||||
|
||||
expect(bg.walletState().networkId).toBe("sepolia");
|
||||
expect(bg.chainChangedEvents()).toEqual([
|
||||
{
|
||||
type: "AUTISTMASK_EVENT",
|
||||
eventName: "chainChanged",
|
||||
data: SEPOLIA.chainId,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("choosing the network already active changes nothing and sends nothing", async () => {
|
||||
const bg = loadBackground();
|
||||
const select = await openSettings(bg);
|
||||
const before = bg.walletState();
|
||||
|
||||
await choose(select, "mainnet");
|
||||
|
||||
expect(bg.walletState()).toEqual(before);
|
||||
expect(bg.chainChangedEvents()).toEqual([]);
|
||||
});
|
||||
|
||||
test("a page cannot make the background send chainChanged", async () => {
|
||||
const bg = loadBackground();
|
||||
|
||||
const reply = bg.send(
|
||||
{ type: "AUTISTMASK_NETWORK_CHANGED", chainId: SEPOLIA.chainId },
|
||||
{ url: CONNECTED_ORIGIN + "/" },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(reply).toEqual({ error: "Unauthorized sender" });
|
||||
expect(bg.chainChangedEvents()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -279,12 +279,18 @@ class Driver {
|
||||
|
||||
// Shown means shown: in the popup a view is switched by toggling a
|
||||
// "hidden" class, and an element that is present but collapsed is not
|
||||
// the thing a test means by visible.
|
||||
// the thing a test means by visible. Until the page's stylesheet has
|
||||
// applied that class hides nothing and every view lays out, so the page
|
||||
// must also have finished loading, which it does only after its
|
||||
// stylesheet, and neither the element nor anything enclosing it may
|
||||
// carry the class (https://git.eeqj.de/sneak/AutistMask/issues/502).
|
||||
async waitVisible(selector, timeout = DEFAULT_WAIT_MS) {
|
||||
return this.waitFor(
|
||||
"selector " + selector + " to be visible",
|
||||
`const el = document.querySelector(arguments[0]);
|
||||
if (!el) return false;
|
||||
if (document.readyState !== "complete" || !el) return false;
|
||||
if (el.closest(".hidden")) return false;
|
||||
if (getComputedStyle(el).visibility !== "visible") return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;`,
|
||||
[selector],
|
||||
|
||||
@@ -128,6 +128,18 @@ step("the popup is drawn in the monospace font it declares", async (env) => {
|
||||
);
|
||||
});
|
||||
|
||||
// The same check as the Chrome suite's (#456), so both browsers are held to
|
||||
// the same size.
|
||||
step("the popup's text is drawn at the 14px text-sm declares", async (env) => {
|
||||
const size = await env.driver.execute(
|
||||
"return getComputedStyle(document.body).fontSize;",
|
||||
);
|
||||
assert(
|
||||
size === "14px",
|
||||
"the popup's text is drawn at " + size + ", not at the 14px of text-sm",
|
||||
);
|
||||
});
|
||||
|
||||
// The recovery screen (#361): the Chrome suite's four cases, run before any
|
||||
// wallet exists for the same reason. With no wallet nothing saves on a timer,
|
||||
// so no save can write a good record over the unreadable one. The last of them
|
||||
|
||||
+26
-1
@@ -333,8 +333,33 @@ async function launch(routeOpts) {
|
||||
|
||||
const PASSWORD = "e2e-harness-password";
|
||||
|
||||
// Waits until the page shows `selector`, not only until it lays out. Until the
|
||||
// page's stylesheet has applied, the `hidden` class that showView() keeps on
|
||||
// every view but the current one hides nothing and every view lays out, so a
|
||||
// test could read a screen before the page's script had filled it
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/502). So the page must also
|
||||
// have finished loading, which it does only after its stylesheet, and neither
|
||||
// the element nor anything enclosing it may carry `hidden`. Polled on a timer:
|
||||
// animation frames are not guaranteed to a window that is not in front.
|
||||
async function visible(page, selector, timeout = 15000) {
|
||||
await page.waitForSelector(selector, { state: "visible", timeout });
|
||||
await page
|
||||
.waitForFunction(
|
||||
(sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (document.readyState !== "complete" || !el) return false;
|
||||
if (el.closest(".hidden")) return false;
|
||||
if (getComputedStyle(el).visibility !== "visible") return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
},
|
||||
selector,
|
||||
{ polling: 50, timeout },
|
||||
)
|
||||
.catch((e) => {
|
||||
throw new Error(
|
||||
"the page did not show " + selector + ": " + e.message,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// An empty WebAssembly module: magic number and version header, no
|
||||
|
||||
@@ -113,6 +113,18 @@ test("the popup is drawn in the monospace font it declares (#418)", async (env)
|
||||
);
|
||||
});
|
||||
|
||||
// The same stylesheet sets the size on body to 75%, which would draw the
|
||||
// popup at 12px.
|
||||
test("the popup's text is drawn at the 14px text-sm declares (#456)", async (env) => {
|
||||
const size = await env.page.evaluate(
|
||||
() => getComputedStyle(document.body).fontSize,
|
||||
);
|
||||
assert(
|
||||
size === "14px",
|
||||
"the popup's text is drawn at " + size + ", not at the 14px of text-sm",
|
||||
);
|
||||
});
|
||||
|
||||
// The empirical half of #182. The manifest change is only a claim about
|
||||
// what the CSP permits; this is the observation. Two things have to hold
|
||||
// together, and the run covers both: the popup realm compiles WASM (here),
|
||||
@@ -4538,6 +4550,42 @@ test("a site's network switch changes nothing until the user approves it (#408)"
|
||||
);
|
||||
});
|
||||
|
||||
// Switching the network in Settings tells the page too, as an approved site
|
||||
// request does (https://git.eeqj.de/sneak/AutistMask/issues/500). The wallet
|
||||
// goes back to mainnet the same way at the end.
|
||||
test("a network switch in Settings tells the page (#500)", async (env) => {
|
||||
const { mainnet, sepolia } = NETWORKS;
|
||||
const before = await storedNetwork(env.page);
|
||||
assert(
|
||||
before.networkId === "mainnet",
|
||||
"this test starts on mainnet, not on " + before.networkId,
|
||||
);
|
||||
const eventsBefore = (await chainChangedEvents(env.dapp)).length;
|
||||
await openSettings(env.page);
|
||||
|
||||
await env.page.selectOption("#settings-network", "sepolia");
|
||||
let events = await chainChangedEvents(env.dapp, eventsBefore + 1);
|
||||
assert(
|
||||
events.length === eventsBefore + 1 &&
|
||||
events[events.length - 1].data === sepolia.chainId,
|
||||
"the page was not told of the switch to Sepolia: " +
|
||||
JSON.stringify(events),
|
||||
);
|
||||
|
||||
await env.page.selectOption("#settings-network", "mainnet");
|
||||
events = await chainChangedEvents(env.dapp, eventsBefore + 2);
|
||||
assert(
|
||||
events.length === eventsBefore + 2 &&
|
||||
events[events.length - 1].data === mainnet.chainId,
|
||||
"the page was not told of the switch back to mainnet: " +
|
||||
JSON.stringify(events),
|
||||
);
|
||||
assert(
|
||||
isDeepStrictEqual(await storedNetwork(env.page), before),
|
||||
"switching back did not restore the mainnet network and endpoints",
|
||||
);
|
||||
});
|
||||
|
||||
// The closing pass over both boundaries at once. Every message the section
|
||||
// put on either channel is re-read here and required to be free of the
|
||||
// password — and required to be there at all, method by method, so the
|
||||
|
||||
@@ -89,6 +89,12 @@ describe("vendored blocklist", () => {
|
||||
expect(isPhishingDomain(LISTED[0].toUpperCase())).toBe(true);
|
||||
});
|
||||
|
||||
test("a hostname ending in a dot is the same host", () => {
|
||||
expect(isPhishingDomain(LISTED[1] + ".")).toBe(true);
|
||||
expect(isPhishingDomain("wallet." + LISTED[1] + ".")).toBe(true);
|
||||
expect(isPhishingDomain(CLEAN[0] + ".")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for an empty or missing hostname", () => {
|
||||
expect(isPhishingDomain("")).toBe(false);
|
||||
expect(isPhishingDomain(null)).toBe(false);
|
||||
@@ -133,6 +139,13 @@ describe("hostnameVariants", () => {
|
||||
test("lowercases hostnames", () => {
|
||||
expect(hostnameVariants("Evil.COM")).toEqual(["evil.com"]);
|
||||
});
|
||||
|
||||
test("drops the trailing dot of a fully qualified hostname", () => {
|
||||
expect(hostnameVariants("sub.evil.com.")).toEqual([
|
||||
"sub.evil.com",
|
||||
"evil.com",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("domain hashing", () => {
|
||||
|
||||
@@ -240,6 +240,7 @@ function request(data) {
|
||||
beforeEach(() => {
|
||||
state.trackedTokens = [];
|
||||
state.wallets = [];
|
||||
state.networkId = "mainnet";
|
||||
});
|
||||
|
||||
describe("a token permission is warned about, naming spender and amount", () => {
|
||||
@@ -532,3 +533,66 @@ describe("the sign screen refuses a mismatched primary type", () => {
|
||||
expect(node("btn-approve-sign").disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// EIP-712: a wallet refuses typed data whose domain names a chain other than
|
||||
// the active one, because the signature is valid on the chain it names
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/519). The wallet is on Sepolia
|
||||
// and the site asks for a mainnet USDC Permit.
|
||||
describe("the sign screen refuses typed data for another chain", () => {
|
||||
const REFUSAL =
|
||||
"This typed data is for chain id 1, but the active network is Sepolia Testnet (chain id 11155111), so it cannot be signed.";
|
||||
|
||||
function withChainId(chainId) {
|
||||
return { ...PERMIT, domain: { ...PERMIT.domain, chainId } };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
decryptWithPassword.mockClear();
|
||||
state.networkId = "sepolia";
|
||||
});
|
||||
|
||||
test("another chain shows the error, with Sign disabled", async () => {
|
||||
await openSignScreen(PERMIT);
|
||||
expect(node("approve-sign-error").textContent).toBe(REFUSAL);
|
||||
expect(node("btn-approve-sign").disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("Sign clicked anyway decrypts and signs nothing", async () => {
|
||||
const sent = await openSignScreen(PERMIT);
|
||||
node("approve-sign-password").value = "any password";
|
||||
await click("btn-approve-sign");
|
||||
|
||||
expect(decryptWithPassword).not.toHaveBeenCalled();
|
||||
expect(sent.map((msg) => msg.type)).not.toContain(
|
||||
"AUTISTMASK_SIGN_RESPONSE",
|
||||
);
|
||||
expect(node("approve-sign-error").textContent).toBe(REFUSAL);
|
||||
});
|
||||
|
||||
// ethers signs 1, "1" and "0x1" as the same chain id.
|
||||
test("the chain id is refused in each form ethers signs", () => {
|
||||
for (const chainId of [1, "1", "0x1"]) {
|
||||
expect(typedDataRefusal(request(withChainId(chainId)))).toBe(
|
||||
REFUSAL,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a chain id that is not a number is refused", () => {
|
||||
expect(typedDataRefusal(request(withChainId("mainnet")))).toBe(
|
||||
"This typed data cannot be read, so it cannot be signed.",
|
||||
);
|
||||
});
|
||||
|
||||
test("the active chain leaves Sign enabled", async () => {
|
||||
await openSignScreen(withChainId("0xaa36a7"));
|
||||
expect(node("approve-sign-error").textContent).toBe("");
|
||||
expect(node("btn-approve-sign").disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("no chain id leaves Sign enabled", async () => {
|
||||
await openSignScreen(withChainId(undefined));
|
||||
expect(node("approve-sign-error").textContent).toBe("");
|
||||
expect(node("btn-approve-sign").disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+49
-5
@@ -416,7 +416,7 @@ describe("extended key depth", () => {
|
||||
expect(() =>
|
||||
wallet.getSignerForAddress(
|
||||
{ type: "xprv" },
|
||||
0,
|
||||
{ address: VECTOR_ADDRESSES[0], index: 0 },
|
||||
accountXprv(VECTOR_PHRASE),
|
||||
),
|
||||
).toThrow(/master/i);
|
||||
@@ -504,7 +504,7 @@ describe("getSignerForAddress", () => {
|
||||
test.each([0, 1, 2])("hd wallet, address index %i", (index) => {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "hd" },
|
||||
index,
|
||||
{ address: VECTOR_ADDRESSES[index], index },
|
||||
VECTOR_PHRASE,
|
||||
);
|
||||
|
||||
@@ -515,7 +515,7 @@ describe("getSignerForAddress", () => {
|
||||
test.each([0, 1, 2])("xprv wallet, address index %i", (index) => {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "xprv" },
|
||||
index,
|
||||
{ address: VECTOR_ADDRESSES[index], index },
|
||||
masterXprv(VECTOR_PHRASE),
|
||||
);
|
||||
|
||||
@@ -527,7 +527,7 @@ describe("getSignerForAddress", () => {
|
||||
for (const index of [0, 1, 2]) {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "privkey" },
|
||||
index,
|
||||
{ address: VECTOR_ADDRESSES[1], index },
|
||||
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 () => {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "hd" },
|
||||
1,
|
||||
{ address: VECTOR_ADDRESSES[1], index: 1 },
|
||||
VECTOR_PHRASE,
|
||||
);
|
||||
const message = "AutistMask derivation test";
|
||||
|
||||
@@ -55,7 +55,7 @@ function healthyXprvWallet(name = "Master") {
|
||||
xpub,
|
||||
nextIndex: 1,
|
||||
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,
|
||||
encryptedSecret: "irrelevant-to-these-tests",
|
||||
addresses: [
|
||||
{ address: node.deriveChild(0).address, balance: "0.0000" },
|
||||
{
|
||||
address: node.deriveChild(0).address,
|
||||
index: 0,
|
||||
balance: "0.0000",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -153,6 +157,18 @@ describe("the explanatory copy", () => {
|
||||
expect(all).not.toMatch(/\blost\b|\bstolen\b|\bgone\b/i);
|
||||
});
|
||||
|
||||
// The wallet holds the imported key but will not export it, so the copy
|
||||
// must name every key the user may hold that reaches the addresses
|
||||
// outside it.
|
||||
test("it names the keys that reach the addresses outside this wallet", () => {
|
||||
const body = defect.paragraphs.join(" ");
|
||||
|
||||
expect(body).toMatch(/private key export are disabled/);
|
||||
expect(body).toMatch(
|
||||
/with the extended private key you imported, if you kept a copy of it, or with the master extended private key or recovery phrase it came from, in software that repeats the same non-standard derivation/,
|
||||
);
|
||||
});
|
||||
|
||||
test("it says the wallet is not deleted and what the user can do", () => {
|
||||
const body = defect.paragraphs.join(" ");
|
||||
|
||||
@@ -558,7 +574,7 @@ describe("no path throws an unhandled error for a defective wallet", () => {
|
||||
try {
|
||||
wallet.getSignerForAddress(
|
||||
{ type: "xprv" },
|
||||
0,
|
||||
brokenXprvWallet().addresses[0],
|
||||
accountXprv(VECTOR_PHRASE),
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -573,7 +589,7 @@ describe("no path throws an unhandled error for a defective wallet", () => {
|
||||
test("a healthy xprv wallet signs as it always did", () => {
|
||||
const signer = wallet.getSignerForAddress(
|
||||
{ type: "xprv" },
|
||||
0,
|
||||
healthyXprvWallet().addresses[0],
|
||||
masterXprv(VECTOR_PHRASE),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user