// 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; });