There was no packaging target anywhere, no artifact, and no `key` in `manifest/chrome.json` — so an unpacked Chrome load derived its extension id, and therefore its `chrome.storage.local` partition, from the absolute checkout path. Moving or re-cloning the checkout presented an empty wallet, with no error and nothing in the UI to say so. `manifest/chrome.json` now carries a fixed `key`: the public half of an RSA keypair, which pins the extension id to `gipbhkogfopeahplcjhipkgpcimdpkip`. The private half is a credential and is not in this repo; no target generates one into the working tree, and `tests/extensionId.test.js` fails if a `.pem` is ever committed. Changing `key` changes the id and orphans every wallet stored under the old one. `make package` (script/package) runs `make build` — the only audited path to a release build — and writes one self-contained, versioned archive per browser into `release/`, plus `SHA256SUMS`. The archives are deterministic: entries sorted, timestamps fixed, compression level fixed, so two builds of one commit are byte-identical. Self-containment is checked rather than assumed: every path the manifests and the popup HTML reference is resolved and required to be inside the archive, a reference that climbs out of the extension root is a hard failure, and files left at the `dist/` root — `dist/styles.css`, which build.js copies into each browser directory — are reported as deliberately not shipped rather than dropped by a glob. The archive is then read back off disk and compared member by member against the directory it was built from. The zip writer and reader are stdlib zlib in `script/lib/zip.js`; no new dependency, and nothing unpinned. One version, enforced rather than generated. `script/lib/version.js` requires `package.json`, `manifest/chrome.json` and `manifest/firefox.json` to agree and fails the build naming each file and what it said, instead of reading from one of the three. `BUILD_COMMIT` now carries `-dirty` when the working tree does not match `HEAD`, and `-unknown` when git cannot say; the full hash behind the About screen's commit link stays clean so the link still resolves. Two real-browser observations, both run through the pinned harnesses: - `tests/e2e/storagePartition.js` loads the build from two different paths in one Chrome profile. With `key`: same id, and the second load reads the first load's storage. Without `key`: different ids, and the second load sees an empty partition. Loading both keyed copies at once yields one id, not two. - `tests/e2e/firefox/reinstall.js` installs the packaged XPI in a real Firefox, creates a wallet, quits the browser, restarts on the same profile, adds the add-on again, and decrypts the vault back to the original recovery phrase. It then observes that an explicit uninstall DESTROYS that storage — correct browser behaviour, but for a wallet it means Remove is irreversible except from the recovery phrase, so README.md says so. Firefox ships an UNSIGNED XPI. README.md states plainly that release Firefox and ESR will refuse it, that Developer Edition, Nightly or an Unbranded build is required, and that a temporary add-on does not survive a browser restart. AMO signing, CRX packing, tagging and any upload are deliberately out of scope.
304 lines
11 KiB
JavaScript
304 lines
11 KiB
JavaScript
// Where does chrome.storage.local live, and what moves it?
|
|
//
|
|
// The finding behind https://git.eeqj.de/sneak/AutistMask/issues/310: an
|
|
// unpacked Chrome extension with no `key` in its manifest gets an extension id
|
|
// derived from the ABSOLUTE PATH it was loaded from, and chrome.storage.local
|
|
// is partitioned by that id. README.md documents Load unpacked from
|
|
// dist/chrome/ as the install route, so moving the checkout, re-cloning it, or
|
|
// loading a second copy from anywhere else means a different id, a different
|
|
// storage partition, and a wallet that reads as empty — with no error, no
|
|
// prompt and nothing in the UI to say what happened.
|
|
//
|
|
// So this OBSERVES the behaviour rather than restating it. Two unpacked loads
|
|
// from two different directories, in one profile, with the shipped manifest
|
|
// and again with `key` stripped out, and it reports what each pair actually
|
|
// does. The assertions are on what was seen when this was written and are
|
|
// annotated as such; if Chrome's derivation ever changes, this says so instead
|
|
// of passing.
|
|
//
|
|
// Run through script/test-e2e, which builds the extension and the pinned
|
|
// container.
|
|
//
|
|
// node tests/e2e/storagePartition.js
|
|
//
|
|
// A separate program from run.js because every test there shares one browser
|
|
// and one extension load, and the whole subject here is what happens across
|
|
// two of each.
|
|
//
|
|
// The extension's own UI is deliberately not driven. What is under test is the
|
|
// storage partition, so a sentinel key the extension never reads or writes is
|
|
// written and read back directly: a wallet would prove the same thing more
|
|
// slowly, and would confuse an empty partition with a UI that failed to
|
|
// render.
|
|
|
|
"use strict";
|
|
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
|
|
const { chromium } = require("playwright-core");
|
|
|
|
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
const DIST_CHROME = path.join(REPO_ROOT, "dist", "chrome");
|
|
|
|
// Never touched by the extension: src/shared/state.js reads and writes the
|
|
// single key "autistmask" and nothing else.
|
|
const SENTINEL_KEY = "e2e-storage-partition-sentinel";
|
|
const SENTINEL_VALUE = "written-by-the-first-load";
|
|
|
|
const checks = [];
|
|
let failed = 0;
|
|
const scratch = [];
|
|
|
|
function check(name, cond, detail) {
|
|
checks.push(name);
|
|
if (cond) {
|
|
console.log("ok " + checks.length + " - " + name);
|
|
} else {
|
|
failed += 1;
|
|
console.log("not ok " + checks.length + " - " + name);
|
|
if (detail) console.log(" " + detail);
|
|
}
|
|
}
|
|
|
|
function tmpdir(tag) {
|
|
const dir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "autistmask-" + tag + "-"),
|
|
);
|
|
scratch.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
// A copy of the built extension at a fresh absolute path. `withKey: false`
|
|
// strips the manifest's `key`, which is the pre-fix state of this repo and the
|
|
// control the whole program is built around.
|
|
function extensionCopy(tag, withKey) {
|
|
const dir = path.join(tmpdir(tag), "chrome");
|
|
fs.cpSync(DIST_CHROME, dir, { recursive: true });
|
|
const manifestPath = path.join(dir, "manifest.json");
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
if (withKey) {
|
|
if (!manifest.key) {
|
|
throw new Error(
|
|
"dist/chrome/manifest.json has no `key`, so this program has " +
|
|
"nothing to observe. That field is what pins the " +
|
|
"extension id; see tests/extensionId.test.js.",
|
|
);
|
|
}
|
|
} else {
|
|
delete manifest.key;
|
|
}
|
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 4));
|
|
return dir;
|
|
}
|
|
|
|
async function launch(profileDir, extensionDirs) {
|
|
const ctx = await chromium.launchPersistentContext(profileDir, {
|
|
// See the note in harness.js: the default headless shell silently
|
|
// refuses to load extensions.
|
|
channel: "chromium",
|
|
headless: true,
|
|
args: [
|
|
"--disable-extensions-except=" + extensionDirs.join(","),
|
|
"--load-extension=" + extensionDirs.join(","),
|
|
"--no-sandbox",
|
|
// Nothing here should reach the network; this makes sure it
|
|
// cannot.
|
|
"--host-resolver-rules=MAP * ~NOTFOUND",
|
|
],
|
|
});
|
|
return ctx;
|
|
}
|
|
|
|
// Every extension id Chrome ended up with in this context, from the service
|
|
// worker urls. MV3 registers one worker per loaded extension.
|
|
async function extensionIds(ctx, expected) {
|
|
const deadline = Date.now() + 30000;
|
|
for (;;) {
|
|
const ids = [
|
|
...new Set(ctx.serviceWorkers().map((w) => new URL(w.url()).host)),
|
|
].sort();
|
|
if (ids.length >= expected) return ids;
|
|
if (Date.now() > deadline) return ids;
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
}
|
|
}
|
|
|
|
// Open one extension's popup and run `fn` in it. The popup page is used rather
|
|
// than the service worker because Chrome stops an idle MV3 worker, and a
|
|
// handle to a stopped worker cannot be evaluated in.
|
|
async function inExtension(ctx, id, fn, arg) {
|
|
const page = await ctx.newPage();
|
|
try {
|
|
await page.goto("chrome-extension://" + id + "/src/popup/index.html");
|
|
return await page.evaluate(fn, arg);
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
}
|
|
|
|
const writeSentinel = ([key, value]) =>
|
|
new Promise((resolve) => {
|
|
chrome.storage.local.set({ [key]: value }, () => resolve(true));
|
|
});
|
|
|
|
const readSentinel = (key) =>
|
|
new Promise((resolve) => {
|
|
chrome.storage.local.get(key, (r) => resolve(r[key] ?? null));
|
|
});
|
|
|
|
// Load `firstDir` in a fresh profile, write the sentinel, close; load
|
|
// `secondDir` in the SAME profile, read it back. Returns both ids and what the
|
|
// second load saw.
|
|
async function acrossTwoPaths(tag, firstDir, secondDir) {
|
|
const profile = tmpdir(tag + "-profile");
|
|
|
|
const first = await launch(profile, [firstDir]);
|
|
let firstId;
|
|
try {
|
|
[firstId] = await extensionIds(first, 1);
|
|
if (!firstId) throw new Error("no extension loaded from " + firstDir);
|
|
await inExtension(first, firstId, writeSentinel, [
|
|
SENTINEL_KEY,
|
|
SENTINEL_VALUE,
|
|
]);
|
|
} finally {
|
|
await first.close();
|
|
}
|
|
|
|
const second = await launch(profile, [secondDir]);
|
|
let secondId;
|
|
let seen;
|
|
try {
|
|
[secondId] = await extensionIds(second, 1);
|
|
if (!secondId) throw new Error("no extension loaded from " + secondDir);
|
|
seen = await inExtension(second, secondId, readSentinel, SENTINEL_KEY);
|
|
} finally {
|
|
await second.close();
|
|
}
|
|
|
|
return { firstId, secondId, seen };
|
|
}
|
|
|
|
async function main() {
|
|
if (!fs.existsSync(path.join(DIST_CHROME, "manifest.json"))) {
|
|
console.error(
|
|
"storagePartition: no unpacked build at " +
|
|
DIST_CHROME +
|
|
" — run make build first",
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// --- the shipped manifest, which carries `key` ----------------------
|
|
const keyedA = extensionCopy("keyed-a", true);
|
|
const keyedB = extensionCopy("keyed-b", true);
|
|
const keyed = await acrossTwoPaths("keyed", keyedA, keyedB);
|
|
console.log("# with `key`: " + keyedA + " -> " + keyed.firstId);
|
|
console.log("# with `key`: " + keyedB + " -> " + keyed.secondId);
|
|
console.log("# with `key`: sentinel read back: " + keyed.seen);
|
|
|
|
check(
|
|
"with `key`, two different paths produce the SAME extension id",
|
|
keyed.firstId === keyed.secondId,
|
|
keyed.firstId + " != " + keyed.secondId,
|
|
);
|
|
check(
|
|
"with `key`, the second path reads the first path's storage",
|
|
keyed.seen === SENTINEL_VALUE,
|
|
"the second load read " +
|
|
JSON.stringify(keyed.seen) +
|
|
" instead of the value the first load wrote. The storage " +
|
|
"partition did not follow the extension across the move, " +
|
|
"which is the wallet silently reading as empty.",
|
|
);
|
|
|
|
// --- the same build with `key` removed: the control -----------------
|
|
const barePath = extensionCopy("bare-a", false);
|
|
const bareB = extensionCopy("bare-b", false);
|
|
const bare = await acrossTwoPaths("bare", barePath, bareB);
|
|
console.log("# without `key`: " + barePath + " -> " + bare.firstId);
|
|
console.log("# without `key`: " + bareB + " -> " + bare.secondId);
|
|
console.log("# without `key`: sentinel read back: " + bare.seen);
|
|
|
|
// Observed, not assumed. If Chrome ever stops deriving the id from
|
|
// the load path these two fail, and the right response is to record
|
|
// what it does now — not to delete them.
|
|
check(
|
|
"without `key`, two different paths produce DIFFERENT ids " +
|
|
"(observed Chrome behaviour, the defect this fixes)",
|
|
bare.firstId !== bare.secondId,
|
|
"both loads got " +
|
|
bare.firstId +
|
|
", so the id no longer follows the load path on this Chrome",
|
|
);
|
|
check(
|
|
"without `key`, the second path sees an EMPTY partition " +
|
|
"(observed Chrome behaviour, the defect this fixes)",
|
|
bare.seen === null,
|
|
"the second load read " +
|
|
JSON.stringify(bare.seen) +
|
|
" from a different id's partition",
|
|
);
|
|
|
|
// --- both copies loaded at once, in one profile ---------------------
|
|
// The literal shape of the question, kept because "two unpacked loads
|
|
// in one profile" is what a user does when they forget to remove the
|
|
// old one. With `key`, both copies claim the same id.
|
|
const profile = tmpdir("simultaneous-profile");
|
|
const ctx = await launch(profile, [keyedA, keyedB]);
|
|
let ids = [];
|
|
try {
|
|
ids = await extensionIds(ctx, 2);
|
|
} finally {
|
|
await ctx.close();
|
|
}
|
|
console.log(
|
|
"# both keyed copies loaded at once: " +
|
|
ids.length +
|
|
" extension id(s): " +
|
|
ids.join(", "),
|
|
);
|
|
check(
|
|
"loading both keyed copies at once yields one id, not two " +
|
|
"(observed: Chrome does not load a second copy of an id it " +
|
|
"already has)",
|
|
ids.length === 1 && ids[0] === keyed.firstId,
|
|
"saw " + JSON.stringify(ids) + ", expected exactly one id",
|
|
);
|
|
} catch (e) {
|
|
failed += 1;
|
|
console.log("# ERROR: " + (e && e.stack ? e.stack : e));
|
|
} finally {
|
|
for (const dir of scratch) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
console.log("1.." + checks.length);
|
|
if (checks.length === 0) {
|
|
console.log("# FAILED: this program asserted nothing");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
console.log(
|
|
"# " +
|
|
(checks.length - failed) +
|
|
"/" +
|
|
checks.length +
|
|
" checks passed",
|
|
);
|
|
if (failed > 0) {
|
|
console.log("# FAILED");
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("storagePartition: " + (e && e.stack ? e.stack : e));
|
|
process.exitCode = 1;
|
|
});
|