security: announce a fresh EIP-6963 provider UUID per page load (closes #398)
check / check (push) Successful in 1m4s
e2e / e2e-chrome (push) Successful in 1m47s
e2e / e2e-firefox (push) Successful in 33s

The provider UUID was generated once, kept in extension storage and
announced to every page on every load, so any site could read it as a
stable identifier for the install across sites and browser restarts.

inpage.js now generates one UUID per page load, shared by every
announcement in that load, and stores nothing. The eip6963Uuid storage
key and the AUTISTMASK_PROVIDER_UUID content-script message are removed.
The key was never part of the versioned autistmask profile, so the state
schema is untouched. Two inpage.js message listeners lose the leftover
name onUuid.

The test posts each load the same stored UUID the way the old content
script did, and asserts that no load announces it and that two loads
announce different UUIDs.

Model: opus-5-5
This commit was merged in pull request #412.
This commit is contained in:
2026-10-03 16:26:39 +02:00
parent 598de3ff1a
commit add11e57de
5 changed files with 143 additions and 44 deletions
+11
View File
@@ -143,6 +143,17 @@ but the review is broader than any of them.
both routes take, so they behave the same and the button is live for the next both routes take, so they behave the same and the button is live for the next
delete. delete.
- 2026-09-21: The EIP-6963 provider UUID is generated fresh on each page load
and never persisted ([#398](https://git.eeqj.de/sneak/AutistMask/issues/398)).
It was created once and stored, then announced verbatim to every page on every
load and across restarts, so any site — connected or not — could read it as a
stable cross-site, cross-session identifier for the install, contradicting the
"no tracking" promise. inpage.js now announces a per-load
`crypto.randomUUID()` and the `eip6963Uuid` storage key and the
`AUTISTMASK_PROVIDER_UUID` content-script message are gone. That key was a
standalone storage entry, never part of the versioned `autistmask` profile, so
the state schema is untouched and no existing profile is affected.
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the - 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
wallet list was the reported case: the address shared one row with the wallet list was the reported case: the address shared one row with the
-26
View File
@@ -5,8 +5,6 @@ const {
hasBrowserNamespace, hasBrowserNamespace,
runtimeApi, runtimeApi,
sendMessage, sendMessage,
storageGet,
storageSet,
} = require("../shared/browserApi"); } = require("../shared/browserApi");
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared // In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
@@ -21,30 +19,6 @@ if (hasBrowserNamespace()) {
(document.head || document.documentElement).appendChild(script); (document.head || document.documentElement).appendChild(script);
} }
// Send the persisted EIP-6963 provider UUID to the inpage script.
// Generated once at install time and stored in extension storage.
(async function sendProviderUuid() {
let uuid = null;
try {
const items = await storageGet("eip6963Uuid");
uuid = items?.eip6963Uuid;
if (!uuid) {
uuid = crypto.randomUUID();
await storageSet({ eip6963Uuid: uuid });
}
} catch {
// Storage was unavailable or refused the write. The announcement
// still has to go out — a provider that never announces is invisible
// to every EIP-6963 dApp — so it goes under a fresh uuid that this
// page load will not outlive.
if (!uuid) uuid = crypto.randomUUID();
}
window.postMessage(
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
location.origin,
);
})();
// Relay requests from the page to the background script // Relay requests from the page to the background script
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {
if (event.source !== window) return; if (event.source !== window) return;
+9 -13
View File
@@ -45,7 +45,7 @@
} }
// Listen for responses from the content script // Listen for responses from the content script
window.addEventListener("message", function onUuid(event) { window.addEventListener("message", (event) => {
if (event.source !== window) return; if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_RESPONSE") return; if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
const { id, result, error } = event.data; const { id, result, error } = event.data;
@@ -60,7 +60,7 @@
}); });
// Listen for events pushed from the extension // Listen for events pushed from the extension
window.addEventListener("message", function onUuid(event) { window.addEventListener("message", (event) => {
if (event.source !== window) return; if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_EVENT") return; if (event.data?.type !== "AUTISTMASK_EVENT") return;
const { eventName, data } = event.data; const { eventName, data } = event.data;
@@ -204,7 +204,13 @@
"</svg>", "</svg>",
); );
let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives // EIP-6963 asks for one UUIDv4 per provider for the life of the page: one
// per page load, shared by every announcement in that load. It is
// generated here and never stored: announcing one persisted value to every
// site, on every load and across restarts, turned it into a stable
// cross-site, cross-session tracking identifier any page could read
// (https://git.eeqj.de/sneak/AutistMask/issues/398).
const providerUuid = crypto.randomUUID();
function buildProviderInfo() { function buildProviderInfo() {
return { return {
@@ -226,16 +232,6 @@
); );
} }
// Listen for the persisted UUID from the content script
function onProviderUuid(event) {
if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_PROVIDER_UUID") return;
window.removeEventListener("message", onProviderUuid);
providerUuid = event.data.uuid;
announceProvider();
}
window.addEventListener("message", onProviderUuid);
window.addEventListener("eip6963:requestProvider", announceProvider); window.addEventListener("eip6963:requestProvider", announceProvider);
announceProvider(); announceProvider();
+5 -5
View File
@@ -372,10 +372,10 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
STEP_TIMEOUT_MS, STEP_TIMEOUT_MS,
); );
// EIP-6963, asked of the provider itself. The announcement carries the // EIP-6963, asked of the provider itself. The announcement carries a
// uuid src/content/index.js reads out of extension storage — call site 1 // UUIDv4 inpage.js generates fresh for this page load (nothing persists
// in the issue — and it has to name this extension and hand back the very // it — see issue #398) and has to name this extension and hand back the
// object on window.ethereum. // very object on window.ethereum.
const announced = await d.executeAsync( const announced = await d.executeAsync(
`const done = arguments[arguments.length - 1]; `const done = arguments[arguments.length - 1];
const onAnnounce = (e) => { const onAnnounce = (e) => {
@@ -402,7 +402,7 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
); );
assert( assert(
typeof announced.uuid === "string" && announced.uuid.length === 36, typeof announced.uuid === "string" && announced.uuid.length === 36,
"the announcement carries no stored provider uuid: " + "the announcement carries no provider uuid: " +
JSON.stringify(announced.uuid), JSON.stringify(announced.uuid),
); );
+118
View File
@@ -0,0 +1,118 @@
// The EIP-6963 provider UUID inpage.js announces (src/content/inpage.js).
//
// The bug this pins down (issue #398): the UUID used to be generated once,
// persisted in extension storage, and announced verbatim to every page on
// every load and across browser restarts, so any site — connected or not —
// could read a stable cross-site, cross-session identifier for the install.
// EIP-6963 asks for one UUIDv4 per page load, shared by every announcement in
// that load. The fix generates it per page load and stores nothing.
//
// The stored UUID reached inpage.js as an AUTISTMASK_PROVIDER_UUID page
// message from the content script, and inpage.js then announced it. Each load
// below is posted the same stored UUID that way, so on the old code both loads
// announce it and the last two tests fail.
//
// inpage.js is a bare IIFE injected into the page's JS context, not a module;
// see tests/inpageErrors.test.js for why it is evaluated against a stub window
// rather than imported. Here the stub captures the CustomEvent that carries
// the announcement, so the UUID this file reads is the one a real dApp's
// eip6963:announceProvider listener would see.
const fs = require("fs");
const path = require("path");
const { webcrypto } = require("crypto");
const SOURCE = fs.readFileSync(
path.join(__dirname, "..", "src", "content", "inpage.js"),
"utf8",
);
const loadInto = new Function(
"window",
"self",
"crypto",
"Event",
"CustomEvent",
SOURCE,
);
class StubEvent {
constructor(type) {
this.type = type;
}
}
class StubCustomEvent extends StubEvent {
constructor(type, init) {
super(type);
this.detail = init && init.detail;
}
}
// What the old content script read out of extension storage and posted to
// every page load.
const STORED_UUID = "11111111-2222-4333-8444-555555555555";
// Evaluate inpage.js once against a fresh stub window, post it STORED_UUID the
// way the old content script did, then dispatch a `requestProvider` event so a
// re-announcement is observed as well as the announcement at load. Returns
// every UUID the load announced, in order.
function announcedUuids() {
const listeners = {};
const uuids = [];
const win = {
addEventListener(type, fn) {
(listeners[type] || (listeners[type] = [])).push(fn);
},
removeEventListener(type, fn) {
const fns = listeners[type];
if (!fns) return;
const i = fns.indexOf(fn);
if (i !== -1) fns.splice(i, 1);
},
postMessage() {},
dispatchEvent(event) {
if (event.type === "eip6963:announceProvider") {
uuids.push(event.detail.info.uuid);
}
for (const fn of (listeners[event.type] || []).slice()) fn(event);
return true;
},
};
win.window = win;
loadInto(win, win, webcrypto, StubEvent, StubCustomEvent);
win.dispatchEvent({
type: "message",
source: win,
data: { type: "AUTISTMASK_PROVIDER_UUID", uuid: STORED_UUID },
});
win.dispatchEvent(new StubEvent("eip6963:requestProvider"));
return uuids;
}
const UUID_V4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
describe("the EIP-6963 provider UUID is fresh per page load", () => {
test("a load announces a UUIDv4", () => {
const uuids = announcedUuids();
expect(uuids.length).toBeGreaterThan(0);
expect(uuids[0]).toMatch(UUID_V4);
});
test("every announcement within one load carries the same UUID", () => {
const uuids = announcedUuids();
expect(uuids.length).toBeGreaterThan(1);
expect(new Set(uuids).size).toBe(1);
});
test("two page loads announce different UUIDs, neither the stored one", () => {
const first = announcedUuids();
const second = announcedUuids();
expect(first).not.toContain(STORED_UUID);
expect(second).not.toContain(STORED_UUID);
expect(second[0]).not.toBe(first[0]);
});
});