Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a456170145 |
@@ -1188,7 +1188,14 @@ 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
|
||||||
|
(`getSignerForAddress()` in `src/shared/wallet.js`).
|
||||||
- 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).
|
||||||
|
|
||||||
@@ -1514,6 +1521,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 the key derived is not this address's own,
|
||||||
|
see Data Model) → full-sentence error saying so, 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
|
||||||
|
|||||||
@@ -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: [],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -863,15 +863,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] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -998,7 +998,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
|
||||||
@@ -1126,7 +1126,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;
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ function init(_ctx) {
|
|||||||
try {
|
try {
|
||||||
const signer = getSignerForAddress(
|
const signer = getSignerForAddress(
|
||||||
wallet,
|
wallet,
|
||||||
state.selectedAddress,
|
wallet.addresses[state.selectedAddress],
|
||||||
decryptedSecret,
|
decryptedSecret,
|
||||||
);
|
);
|
||||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: [],
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-6
@@ -110,16 +110,21 @@ 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. Whatever the
|
||||||
|
// wallet type, the key is refused unless it is the key of `addr.address`.
|
||||||
|
function getSignerForAddress(walletData, addr, decryptedSecret) {
|
||||||
|
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 +142,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) {
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
// 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).
|
||||||
|
//
|
||||||
|
// 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,
|
||||||
|
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";
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send from the address at `position`; resolves to the signed transaction the
|
||||||
|
// node was handed, or undefined when none was.
|
||||||
|
async function sendFrom(position) {
|
||||||
|
state.selectedWallet = 0;
|
||||||
|
state.selectedAddress = position;
|
||||||
|
confirmTx.show({
|
||||||
|
from: state.wallets[0].addresses[position].address,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
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({});
|
||||||
|
|||||||
@@ -1455,7 +1455,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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1550,7 +1554,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/);
|
||||||
|
|||||||
+28
-5
@@ -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,33 @@ 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.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
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";
|
||||||
|
|||||||
@@ -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" }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,7 +558,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) {
|
||||||
@@ -573,7 +573,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),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user