Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 60d2b24cd1 security: announce a fresh EIP-6963 provider UUID per page load (closes #398)
e2e / e2e-chrome (push) Failing after 0s
e2e / e2e-firefox (push) Failing after 0s
check / check (push) Successful in 44s
The provider UUID was generated once, stored in extension storage, and
announced verbatim to every page on every load and across restarts, so any
site — connected or not — could read a stable cross-site, cross-session
identifier for the install: a supercookie contradicting the "no tracking"
promise. EIP-6963 wants a fresh UUIDv4 per announcement instead.

inpage.js now announces a per-load crypto.randomUUID() and persists nothing;
the eip6963Uuid storage key and the AUTISTMASK_PROVIDER_UUID content-script
message are removed. That key was a standalone top-level storage entry, never
part of the versioned autistmask profile, so stateSchema.js and the
persisted-field harness are untouched and no existing profile is affected.

A jest test asserts two loads announce different UUIDv4s and that one load
reuses a single UUID across re-announcements.

Model: opus-4-8
2026-09-21 23:35:57 +00:00
5 changed files with 127 additions and 42 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;
+8 -11
View File
@@ -204,7 +204,14 @@
"</svg>", "</svg>",
); );
let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives // EIP-6963 wants a fresh UUIDv4 per page load — it identifies one
// announcement, so a provider can be told apart from another instance of
// itself in the same page. 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 +233,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),
); );
+103
View File
@@ -0,0 +1,103 @@
// 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 wants a fresh UUIDv4 per announcement instead. The fix generates
// it per page load and stores nothing.
//
// 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;
}
}
// Evaluate inpage.js once against a fresh stub window and return every UUID it
// announced. A `requestProvider` event is dispatched too, so a re-announcement
// within one load is observed as well as the announcement at load.
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(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, unprompted, with nothing delivered", () => {
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", () => {
const first = announcedUuids()[0];
const second = announcedUuids()[0];
expect(first).toMatch(UUID_V4);
expect(second).toMatch(UUID_V4);
expect(second).not.toBe(first);
});
});