All checks were successful
check / check (push) Successful in 32s
libsodium ships a WASM build and a wasm2js translation in one file, tries WASM first, and silently falls back if instantiation throws. Under a plain script-src 'self' the fallback was taken on every popup load, announced by nothing but an uncaught CompileError. Measured on the vault's own Argon2id parameters (OPSLIMIT_INTERACTIVE, MEMLIMIT_INTERACTIVE), node 22: WASM 141-198ms per derivation, wasm2js 3204-3660ms. The work factor is identical either way — it is set by the ops and memory parameters, not by wall time — so the fallback bought no security and cost about 3.5s on every operation that asks for the password, which is every signature. Both manifests now declare script-src 'self' 'wasm-unsafe-eval'; object-src 'self' for extension pages: an object under content_security_policy.extension_pages for Chrome MV3, a bare string for Firefox MV2. The keyword permits compiling WebAssembly and nothing else — not eval() of strings, not inline script, not remote script — and reaching it requires already executing script in an extension page. 'unsafe-eval' is not granted. The silence is what made this dangerous, so the fallback is now loud at three levels: tests/manifest.test.js pins both policies to exactly that token set, failing make check if the grant is dropped or if anything is added beside it; tests/vaultBackend.test.js asserts the unit tests exercise the WASM backend, with a self-validating check that libsodium never swapped its fallback in; and the e2e suite compiles a WebAssembly module inside the real popup under the real manifest, with the harness allowlist entry that used to excuse the CompileError now deleted. The runtime fallback itself is kept — a wallet that refuses to decrypt is worse than a slow one — but vault.js now reports the backend and logs an error when it is not WASM.
549 lines
21 KiB
JavaScript
549 lines
21 KiB
JavaScript
// End-to-end suite entrypoint. Run via script/test-e2e (which builds
|
|
// dist/chrome/ and starts the pinned container); running it directly
|
|
// requires a Chromium that playwright-core can find.
|
|
//
|
|
// A plain runner rather than jest on purpose: jest's default testMatch
|
|
// would pull these files into script/test, and browser tests do not fit
|
|
// inside the 20-second cap REPO_POLICIES.md puts on make test. Nothing
|
|
// here is named *.test.js for the same reason.
|
|
|
|
"use strict";
|
|
|
|
const {
|
|
PASSWORD,
|
|
createWallet,
|
|
launch,
|
|
openAddressDetail,
|
|
openPopup,
|
|
pageCompilesWasm,
|
|
visible,
|
|
} = require("./harness");
|
|
const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
|
|
|
|
const TEST_TIMEOUT_MS = 120000;
|
|
|
|
// How long to keep collecting after the final test returns; see the
|
|
// trailing drain in main().
|
|
const TRAILING_WATCH_MS = 1500;
|
|
|
|
const tests = [];
|
|
|
|
function test(name, fn) {
|
|
tests.push({ name, fn });
|
|
}
|
|
|
|
function assert(cond, message) {
|
|
if (!cond) throw new Error(message);
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function withTimeout(promise, name) {
|
|
let timer;
|
|
const timeout = new Promise((_, reject) => {
|
|
timer = setTimeout(
|
|
() =>
|
|
reject(new Error("timed out after " + TEST_TIMEOUT_MS + "ms")),
|
|
TEST_TIMEOUT_MS,
|
|
);
|
|
});
|
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
}
|
|
|
|
// ----------------------------------------------------------------- tests
|
|
|
|
test("popup loads and reaches the welcome view", async (env) => {
|
|
env.page = await openPopup(env.ctx, env.popupUrl);
|
|
await visible(env.page, "#view-welcome");
|
|
const title = await env.page.title();
|
|
assert(title === "AutistMask", "unexpected popup title: " + title);
|
|
});
|
|
|
|
// 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),
|
|
// and no WASM refusal or abort is recorded anywhere in the run — the
|
|
// harness allowlist that used to excuse exactly that error is now empty,
|
|
// so a recurrence fails whichever test it lands in rather than being
|
|
// tolerated. Since libsodium's WASM module is embedded in the bundle and
|
|
// needs no fetch, a realm that compiles WASM is a realm where libsodium
|
|
// takes the WASM path, and the next test drives a real vault encryption
|
|
// through it.
|
|
test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env) => {
|
|
const ok = await pageCompilesWasm(env.page);
|
|
assert(
|
|
ok,
|
|
"the popup refused to compile WebAssembly. The shipped manifest CSP " +
|
|
"has lost 'wasm-unsafe-eval', so libsodium is back on its wasm2js " +
|
|
"fallback and every password derivation costs roughly 20x what it " +
|
|
"should — see the backend note in src/shared/vault.js",
|
|
);
|
|
});
|
|
|
|
test("wallet creation through the UI reaches the main view", async (env) => {
|
|
env.phrase = await createWallet(env.page);
|
|
assert(
|
|
env.phrase.split(/\s+/).length >= 12,
|
|
"wallet creation did not yield a recovery phrase",
|
|
);
|
|
const addrCount = await env.page
|
|
.locator("#wallet-list .btn-addr-info")
|
|
.count();
|
|
assert(addrCount > 0, "no addresses rendered in the wallet list");
|
|
});
|
|
|
|
test("add token screen opens from address detail (#150)", async (env) => {
|
|
await openAddressDetail(env.page);
|
|
await env.page.click("#btn-add-token");
|
|
await visible(env.page, "#view-add-token");
|
|
const quickPicks = await env.page
|
|
.locator("#common-token-list .common-token")
|
|
.count();
|
|
assert(quickPicks > 0, "no common-token quick-pick buttons rendered");
|
|
});
|
|
|
|
test("transaction detail renders an ERC-20 transfer (#151)", async (env) => {
|
|
// Serve the stubbed token transfer from here on, then reload so the
|
|
// address detail screen refetches its transaction list.
|
|
env.routeOpts.seedTokenTransfer = true;
|
|
await env.page.reload();
|
|
await openAddressDetail(env.page);
|
|
|
|
await visible(env.page, "#tx-list .tx-row");
|
|
const rowText = await env.page
|
|
.locator("#tx-list .tx-row")
|
|
.first()
|
|
.innerText();
|
|
assert(
|
|
rowText.includes(STUB_TOKEN.symbol),
|
|
"token transfer row missing symbol " +
|
|
STUB_TOKEN.symbol +
|
|
", got: " +
|
|
JSON.stringify(rowText),
|
|
);
|
|
|
|
await env.page.locator("#tx-list .tx-row").first().click();
|
|
await visible(env.page, "#view-transaction");
|
|
|
|
const hash = await env.page.locator("#tx-detail-hash").innerText();
|
|
assert(
|
|
hash.includes(STUB_TX_HASH),
|
|
"transaction detail shows the wrong hash: " + hash,
|
|
);
|
|
|
|
// The token contract row is the field that crashes when
|
|
// addressDotHtml is not imported: it renders only for transfers with
|
|
// a contractAddress, which is every ERC-20 transfer.
|
|
await visible(env.page, "#tx-detail-token-contract-section");
|
|
const contract = env.page.locator("#tx-detail-token-contract");
|
|
const contractText = await contract.innerText();
|
|
assert(
|
|
contractText.toLowerCase().includes(STUB_TOKEN.address),
|
|
"token contract row missing the contract address, got: " +
|
|
JSON.stringify(contractText),
|
|
);
|
|
const dots = await contract.locator('span[style*="border-radius"]').count();
|
|
assert(dots > 0, "token contract row rendered without its colour dot");
|
|
});
|
|
|
|
// -------------------------------------------- recovery phrase (#161)
|
|
|
|
// The gear toggles, so pressing it while Settings is already up leaves it.
|
|
async function openSettings(page) {
|
|
if (!(await page.isVisible("#view-settings"))) {
|
|
await page.click("#btn-settings");
|
|
}
|
|
await visible(page, "#view-settings");
|
|
}
|
|
|
|
// Everything the recovery phrase screen is holding, read straight out of
|
|
// the DOM whether or not that screen is the one on top. Reading it while it
|
|
// is hidden is the point: "cleared on leave" means the node is empty, not
|
|
// merely off-screen.
|
|
async function phraseScreenState(page) {
|
|
return page.evaluate(() => ({
|
|
value: document.getElementById("show-phrase-value").textContent,
|
|
error: document.getElementById("show-phrase-flash").textContent,
|
|
html: document.getElementById("view-show-phrase").innerHTML,
|
|
resultHidden: document
|
|
.getElementById("show-phrase-result")
|
|
.classList.contains("hidden"),
|
|
viewHidden: document
|
|
.getElementById("view-show-phrase")
|
|
.classList.contains("hidden"),
|
|
}));
|
|
}
|
|
|
|
async function openPhraseScreen(page) {
|
|
await openSettings(page);
|
|
await page.click("#settings-wallet-list .btn-show-phrase");
|
|
await visible(page, "#view-show-phrase");
|
|
}
|
|
|
|
async function revealPhrase(page) {
|
|
await page.fill("#show-phrase-password", PASSWORD);
|
|
await page.click("#btn-show-phrase-reveal");
|
|
await visible(page, "#show-phrase-result", 60000);
|
|
}
|
|
|
|
function assertWiped(st, phrase, where) {
|
|
assert(st.value === "", "phrase still in the DOM " + where);
|
|
assert(st.resultHidden, "result section still shown " + where);
|
|
assert(
|
|
!st.html.includes(phrase),
|
|
"the recovery phrase is still somewhere in the screen markup " + where,
|
|
);
|
|
}
|
|
|
|
test("only an HD wallet is offered the recovery phrase action (#161)", async (env) => {
|
|
await openSettings(env.page);
|
|
const offered = await env.page
|
|
.locator("#settings-wallet-list .btn-show-phrase")
|
|
.count();
|
|
const wallets = await env.page
|
|
.locator("#settings-wallet-list .btn-delete-wallet")
|
|
.count();
|
|
assert(wallets === 1, "expected exactly one wallet row, got " + wallets);
|
|
assert(
|
|
offered === 1,
|
|
"the HD wallet was not offered the recovery phrase action",
|
|
);
|
|
});
|
|
|
|
// The other half of the gate, against the real UI: a wallet holding a bare
|
|
// private key has no phrase to show, so no row of it may offer the action.
|
|
// The key is generated here rather than committed — the repo holds no
|
|
// private keys, test ones included.
|
|
test("a key wallet is not offered the recovery phrase action (#161)", async (env) => {
|
|
const { Wallet } = require("ethers");
|
|
|
|
await openSettings(env.page);
|
|
await env.page.click("#btn-main-add-wallet");
|
|
await visible(env.page, "#view-add-wallet");
|
|
await env.page.click("#tab-privkey");
|
|
await env.page.fill(
|
|
"#import-private-key",
|
|
Wallet.createRandom().privateKey,
|
|
);
|
|
await env.page.fill("#add-wallet-password", PASSWORD);
|
|
await env.page.fill("#add-wallet-password-confirm", PASSWORD);
|
|
await env.page.click("#btn-add-wallet-confirm");
|
|
await visible(env.page, "#view-main", 60000);
|
|
|
|
await openSettings(env.page);
|
|
const wallets = await env.page
|
|
.locator("#settings-wallet-list .btn-delete-wallet")
|
|
.count();
|
|
const offered = await env.page
|
|
.locator("#settings-wallet-list .btn-show-phrase")
|
|
.count();
|
|
assert(wallets === 2, "expected two wallet rows, got " + wallets);
|
|
assert(
|
|
offered === 1,
|
|
"the key wallet was offered the recovery phrase action",
|
|
);
|
|
});
|
|
|
|
test("the recovery phrase screen holds nothing before the password (#161)", async (env) => {
|
|
await openPhraseScreen(env.page);
|
|
const st = await phraseScreenState(env.page);
|
|
assertWiped(st, env.phrase, "before any password was entered");
|
|
const passwordShown = await env.page.isVisible(
|
|
"#show-phrase-password-section",
|
|
);
|
|
assert(passwordShown, "the password prompt is not shown");
|
|
});
|
|
|
|
test("a wrong password reveals nothing (#161)", async (env) => {
|
|
await env.page.fill("#show-phrase-password", "not-the-password");
|
|
await env.page.click("#btn-show-phrase-reveal");
|
|
await env.page.waitForFunction(
|
|
() =>
|
|
document.getElementById("show-phrase-flash").textContent.length > 0,
|
|
null,
|
|
{ timeout: 60000 },
|
|
);
|
|
|
|
const st = await phraseScreenState(env.page);
|
|
assertWiped(st, env.phrase, "after a wrong password");
|
|
assert(
|
|
/^[A-Z].*\.$/.test(st.error.trim()),
|
|
"the wrong-password error is not a full sentence: " +
|
|
JSON.stringify(st.error),
|
|
);
|
|
});
|
|
|
|
test("the correct password reveals the full phrase, and nothing logs it (#161)", async (env) => {
|
|
const console_ = [];
|
|
const listener = (msg) => console_.push(msg.text());
|
|
env.page.on("console", listener);
|
|
try {
|
|
await revealPhrase(env.page);
|
|
|
|
const st = await phraseScreenState(env.page);
|
|
assert(
|
|
st.value === env.phrase,
|
|
"the displayed phrase is not the wallet's phrase, verbatim",
|
|
);
|
|
const promptShown = await env.page.isVisible(
|
|
"#show-phrase-password-section",
|
|
);
|
|
assert(!promptShown, "the password prompt is still shown after unlock");
|
|
|
|
// Full Identifiers Policy: shown whole, and copyable.
|
|
const title = await env.page.getAttribute(
|
|
"#show-phrase-value",
|
|
"title",
|
|
);
|
|
assert(title === "Click to copy", "the phrase is not click-to-copy");
|
|
|
|
const leaked = console_.filter((line) => line.includes(env.phrase));
|
|
assert(
|
|
leaked.length === 0,
|
|
"the recovery phrase reached the console: " +
|
|
JSON.stringify(leaked),
|
|
);
|
|
} finally {
|
|
env.page.off("console", listener);
|
|
}
|
|
});
|
|
|
|
test('"Back" wipes the revealed phrase (#161)', async (env) => {
|
|
await env.page.click("#btn-show-phrase-back");
|
|
await visible(env.page, "#view-settings");
|
|
const st = await phraseScreenState(env.page);
|
|
assert(st.viewHidden, "the recovery phrase screen is still on top");
|
|
assertWiped(st, env.phrase, "after Back");
|
|
});
|
|
|
|
// The settings gear leaves the screen without touching its Back button. A
|
|
// clear wired only to Back would pass the test above and leak here.
|
|
test("leaving by the settings gear wipes it too (#161)", async (env) => {
|
|
await openPhraseScreen(env.page);
|
|
await revealPhrase(env.page);
|
|
await env.page.click("#btn-settings");
|
|
await visible(env.page, "#view-settings");
|
|
const st = await phraseScreenState(env.page);
|
|
assertWiped(st, env.phrase, "after leaving via the settings gear");
|
|
});
|
|
|
|
// The same leave, but taken while the decrypt is still running. Both
|
|
// clicks are dispatched inside one page task on purpose: "Reveal" runs its
|
|
// handler up to the await, the gear then runs the leave — and the wipe with
|
|
// it — to completion, and the decrypt's continuation resumes afterwards.
|
|
// Without a liveness check that continuation writes the phrase into the
|
|
// hidden screen after the wipe, and nothing is left to wipe it again.
|
|
//
|
|
// A human cannot produce this interleaving by hand once libsodium's wasm is
|
|
// warm, because crypto_pwhash is synchronous and the only suspension point
|
|
// is a microtask; the window a user can actually hit is a still-pending
|
|
// sodium.ready on the first vault use of a page load. Forcing it here is
|
|
// the only way to test the guard deterministically.
|
|
test("leaving while the decrypt is in flight reveals nothing (#161)", async (env) => {
|
|
await openPhraseScreen(env.page);
|
|
await env.page.fill("#show-phrase-password", PASSWORD);
|
|
await env.page.evaluate(() => {
|
|
document.getElementById("btn-show-phrase-reveal").click();
|
|
document.getElementById("btn-settings").click();
|
|
});
|
|
await visible(env.page, "#view-settings");
|
|
|
|
// The Reveal button is disabled for exactly the duration of the
|
|
// decrypt and re-enabled in the same continuation that would have
|
|
// written the phrase, so waiting for it to come back is a precise
|
|
// "the decrypt has settled and its handler has finished" signal
|
|
// rather than a guess at a duration.
|
|
await env.page.waitForFunction(
|
|
() => !document.getElementById("btn-show-phrase-reveal").disabled,
|
|
null,
|
|
{ timeout: 60000 },
|
|
);
|
|
await sleep(2000);
|
|
|
|
const st = await phraseScreenState(env.page);
|
|
// Printed on every run, pass or fail: "the phrase is not there" is
|
|
// worth more as a measurement than as a silent assertion, and the
|
|
// same line read from a build without the guard is what this test
|
|
// exists to prevent.
|
|
console.log(
|
|
"# probe: len=" +
|
|
st.value.length +
|
|
" equalsPhrase=" +
|
|
(st.value === env.phrase) +
|
|
" resultHidden=" +
|
|
st.resultHidden +
|
|
" viewHidden=" +
|
|
st.viewHidden,
|
|
);
|
|
assert(st.viewHidden, "the recovery phrase screen is still on top");
|
|
assertWiped(st, env.phrase, "after leaving mid-decrypt");
|
|
});
|
|
|
|
// Closing and reopening the page rather than reloading it: that is what
|
|
// the toolbar popup actually does, and the persisted currentView is
|
|
// "show-phrase" at the moment it happens, which is precisely the state
|
|
// RESTORABLE_VIEWS has to refuse.
|
|
test("reopening the popup never lands on the phrase screen (#161)", async (env) => {
|
|
await openPhraseScreen(env.page);
|
|
await revealPhrase(env.page);
|
|
|
|
await env.page.close();
|
|
env.page = await openPopup(env.ctx, env.popupUrl);
|
|
await visible(env.page, "#view-main");
|
|
|
|
const st = await phraseScreenState(env.page);
|
|
assert(st.viewHidden, "the popup reopened onto the recovery phrase screen");
|
|
assertWiped(st, env.phrase, "after reopening the popup");
|
|
});
|
|
|
|
// ---------------------------------------------------------------- runner
|
|
|
|
async function main() {
|
|
// A suite that runs nothing must never report success. If a refactor
|
|
// drops the registrations above, or a require() of this file stops
|
|
// reaching them, the only honest outcome is a red run — reporting
|
|
// "0/0 passed" and exiting 0 is the same vacuous-check failure this
|
|
// whole harness exists to prevent.
|
|
if (tests.length === 0) {
|
|
console.log("1..0");
|
|
console.log("# FAILED: the e2e suite registered no tests");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const routeOpts = { seedTokenTransfer: false };
|
|
|
|
let session;
|
|
try {
|
|
session = await launch(routeOpts);
|
|
} catch (e) {
|
|
// Never skip and report success: a browser we cannot start, or
|
|
// one whose network interception is not in force, is a failure of
|
|
// the suite, not an absent one.
|
|
console.error("e2e: cannot run the suite: " + e.message);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
console.log("# extension id: " + session.extensionId);
|
|
console.log("1.." + tests.length);
|
|
|
|
const env = {
|
|
ctx: session.ctx,
|
|
popupUrl: session.popupUrl,
|
|
routeOpts,
|
|
page: null,
|
|
// The recovery phrase of the wallet created in test 2, so later
|
|
// tests can assert on the real secret rather than its shape.
|
|
phrase: null,
|
|
};
|
|
|
|
// Attribution of collected errors is total. session.errors has no
|
|
// window API at all: take() always drains everything outstanding, so
|
|
// successive takes partition the whole stream, and the phases below
|
|
// cover the entire life of the run. Nothing the collector holds can
|
|
// go unread.
|
|
//
|
|
// launch .. end of test 1 -> test 1 (so the worker's startup
|
|
// fetches land on a test, not
|
|
// nowhere)
|
|
// end of test k .. end of k+1 -> test k+1
|
|
// last test .. teardown -> the suite, via the trailing drain
|
|
//
|
|
// Those three phases cover the entire life of the browser context.
|
|
// There is no fourth: once the context is closed nothing can record,
|
|
// because the route handler and the console listeners died with it.
|
|
// Traffic that a test defers past the trailing drain is therefore
|
|
// never observed at all — a real limit of this design, stated in the
|
|
// README, and not one any post-teardown hook could close.
|
|
//
|
|
// Two green-but-vacuous runs on this harness were the same shape: a
|
|
// record falling outside somebody's window and being dropped. First
|
|
// the mark started after test 1, discarding launch-time records;
|
|
// then the tail after the last test was never read. Patching a
|
|
// second boundary would have invited a third, so the window concept
|
|
// is gone rather than fixed.
|
|
let failed = 0;
|
|
let n = 0;
|
|
for (const t of tests) {
|
|
n += 1;
|
|
let failure = null;
|
|
try {
|
|
await withTimeout(t.fn(env), t.name);
|
|
} catch (e) {
|
|
failure = e.message;
|
|
}
|
|
|
|
// Any uncaught page error, console.error or unstubbed request
|
|
// fails the test that provoked it, whether or not its assertions
|
|
// passed. This is the mechanism that caught #150.
|
|
const newErrors = session.errors.take();
|
|
if (!failure && newErrors.length > 0) {
|
|
failure = "uncaught browser errors during this test";
|
|
}
|
|
|
|
if (failure) {
|
|
failed += 1;
|
|
console.log("not ok " + n + " - " + t.name);
|
|
console.log(" " + failure);
|
|
for (const line of newErrors) {
|
|
console.log(" " + line);
|
|
}
|
|
} else {
|
|
console.log("ok " + n + " - " + t.name);
|
|
}
|
|
}
|
|
|
|
// Keep watching after the last test returns, before tearing the
|
|
// browser down. A request a test fires without awaiting is still in
|
|
// flight when its function resolves; measured here it reaches the
|
|
// route handler about 10ms later, but closing the context does not
|
|
// wait for it — with no window at all the request dies unobserved
|
|
// and the run goes green, which is exactly how escaping traffic
|
|
// stays invisible.
|
|
//
|
|
// A fixed bounded window rather than a quiescence poll on purpose:
|
|
// the collector being quiet is not evidence, because a request that
|
|
// has not been dispatched yet has recorded nothing to be quiet
|
|
// about. Playwright offers no "is anything in flight" question to
|
|
// ask either — the route handler is the only observation point — so
|
|
// a grace period is the mechanism available, and this one is ~150x
|
|
// the measured latency for 1.5s on a ~25s suite.
|
|
await new Promise((resolve) => setTimeout(resolve, TRAILING_WATCH_MS));
|
|
|
|
await session.close();
|
|
|
|
// The tail. These cannot be blamed on any single test, so they are
|
|
// reported against the suite rather than guessed at — but they are
|
|
// reported, and they fail the run.
|
|
const trailing = session.errors.take();
|
|
|
|
console.log(
|
|
"# " + (tests.length - failed) + "/" + tests.length + " tests passed",
|
|
);
|
|
|
|
if (trailing.length > 0) {
|
|
console.log(
|
|
"# " +
|
|
trailing.length +
|
|
" browser error(s) recorded after the last test finished, " +
|
|
"not attributable to any single test:",
|
|
);
|
|
for (const line of trailing) {
|
|
console.log("# " + line);
|
|
}
|
|
}
|
|
|
|
if (failed > 0 || trailing.length > 0) {
|
|
console.log("# FAILED");
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("e2e: " + (e && e.stack ? e.stack : e));
|
|
process.exitCode = 1;
|
|
});
|