commit a058d78c207fee9c32ea0d4589afdbe1845f27fa Author: sal Date: Sat Feb 28 22:59:46 2026 -0600 Initial release — per-site container isolation with unique device fingerprints Automatic per-domain containers with hardened fingerprint spoofing: canvas, WebGL, audio, navigator, screen, timezone, WebRTC, fonts, ClientRects, plugins, battery, and connection APIs. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8fe5a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.xpi +*.zip +icons/icon.svg diff --git a/background.js b/background.js new file mode 100644 index 0000000..fe06ec7 --- /dev/null +++ b/background.js @@ -0,0 +1,391 @@ +// ContainSite — Background Script +// Every site gets its own container. Auth redirects stay in the originating container. + +const registeredScripts = {}; // cookieStoreId -> RegisteredContentScript +let injectSourceCache = null; +let domainMap = {}; // baseDomain -> cookieStoreId +let pendingTabs = {}; // tabId -> true (tabs being redirected) +let tabOrigins = {}; // tabId -> cookieStoreId (tracks which container a tab was assigned to) + +const CONTAINER_COLORS = ["blue", "turquoise", "green", "yellow", "orange", "red", "pink", "purple"]; +const CONTAINER_ICONS = ["fingerprint", "fence", "briefcase", "cart", "circle", "gift", "tree", "chill"]; + +// --- Domain Extraction --- + +function extractDomain(url) { + try { + const u = new URL(url); + if (u.protocol !== "http:" && u.protocol !== "https:") return null; + // Skip localhost and local IPs + const h = u.hostname; + if (h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".local")) return null; + return h; + } catch(e) { + return null; + } +} + +function getBaseDomain(hostname) { + const parts = hostname.split("."); + if (parts.length <= 2) return hostname; + + const twoPartTLDs = ["co.uk", "co.jp", "co.kr", "com.au", "com.br", "co.nz", "co.in", "org.uk", "net.au"]; + const lastTwo = parts.slice(-2).join("."); + if (twoPartTLDs.includes(lastTwo) && parts.length > 2) { + return parts.slice(-3).join("."); + } + + return parts.slice(-2).join("."); +} + +// --- Seed Management --- + +function generateSeed() { + const arr = new Uint32Array(1); + crypto.getRandomValues(arr); + return arr[0]; +} + +// --- Inject Source Loading --- + +async function getInjectSource() { + if (!injectSourceCache) { + const resp = await fetch(browser.runtime.getURL("inject.js")); + injectSourceCache = await resp.text(); + } + return injectSourceCache; +} + +// --- Per-Container Script Registration --- + +async function registerForContainer(cookieStoreId, profile) { + if (registeredScripts[cookieStoreId]) { + try { await registeredScripts[cookieStoreId].unregister(); } catch(e) {} + delete registeredScripts[cookieStoreId]; + } + + const injectSource = await getInjectSource(); + + // Set config then run inject.js — both execute in ISOLATED world + // inject.js uses exportFunction/wrappedJSObject to modify page context (bypasses CSP) + const configCode = `window.__csConfig = ${JSON.stringify(profile)};`; + + registeredScripts[cookieStoreId] = await browser.contentScripts.register({ + matches: [""], + js: [{ code: configCode }, { code: injectSource }], + runAt: "document_start", + allFrames: true, + cookieStoreId: cookieStoreId + }); +} + +async function registerAllKnownContainers() { + const stored = await browser.storage.local.get(["containerSeeds", "containerSettings"]); + const seeds = stored.containerSeeds || {}; + const settings = stored.containerSettings || {}; + + for (const [cid, script] of Object.entries(registeredScripts)) { + try { await script.unregister(); } catch(e) {} + } + for (const key of Object.keys(registeredScripts)) { + delete registeredScripts[key]; + } + + for (const [cookieStoreId, seed] of Object.entries(seeds)) { + const cfg = settings[cookieStoreId] || { enabled: true }; + if (!cfg.enabled) continue; + const profile = generateFingerprintProfile(seed); + await registerForContainer(cookieStoreId, profile); + } +} + +// --- Storage --- + +async function loadDomainMap() { + const stored = await browser.storage.local.get("domainMap"); + domainMap = stored.domainMap || {}; +} + +async function saveDomainMap() { + await browser.storage.local.set({ domainMap }); +} + +// --- Auto-Containment --- + +async function getOrCreateContainerForDomain(baseDomain) { + if (domainMap[baseDomain]) { + return domainMap[baseDomain]; + } + + const colorIndex = Object.keys(domainMap).length % CONTAINER_COLORS.length; + const iconIndex = Object.keys(domainMap).length % CONTAINER_ICONS.length; + + const container = await browser.contextualIdentities.create({ + name: baseDomain, + color: CONTAINER_COLORS[colorIndex], + icon: CONTAINER_ICONS[iconIndex] + }); + + const cookieStoreId = container.cookieStoreId; + domainMap[baseDomain] = cookieStoreId; + await saveDomainMap(); + + const stored = await browser.storage.local.get("containerSeeds"); + const seeds = stored.containerSeeds || {}; + seeds[cookieStoreId] = generateSeed(); + await browser.storage.local.set({ containerSeeds: seeds }); + + const profile = generateFingerprintProfile(seeds[cookieStoreId]); + await registerForContainer(cookieStoreId, profile); + + return cookieStoreId; +} + +// Set of tabIds we just created — skip these entirely to prevent loops +const createdByUs = {}; + +// Handle a tab that needs to be in a container for a given domain +async function assignTabToContainer(tabId, url, baseDomain) { + // Skip tabs we just created + if (createdByUs[tabId]) return; + if (pendingTabs[tabId]) return; + pendingTabs[tabId] = true; + + try { + const tab = await browser.tabs.get(tabId); + + // If the tab is in ANY non-default container, leave it alone. + // Either it's one of ours, or the user put it there intentionally. + if (tab.cookieStoreId !== "firefox-default") { + delete pendingTabs[tabId]; + return; + } + + // Tab is in the default (uncontained) context — assign it to the right container + const cookieStoreId = await getOrCreateContainerForDomain(baseDomain); + + if (tab.cookieStoreId === cookieStoreId) { + delete pendingTabs[tabId]; + return; + } + + const newTab = await browser.tabs.create({ + url: url, + cookieStoreId: cookieStoreId, + index: tab.index + 1, + active: tab.active + }); + // Mark the new tab so we never redirect it again + createdByUs[newTab.id] = true; + setTimeout(() => { delete createdByUs[newTab.id]; }, 5000); + + await browser.tabs.remove(tabId); + } catch(e) {} + delete pendingTabs[tabId]; +} + +// Intercept new navigations +browser.webRequest.onBeforeRequest.addListener( + function(details) { + if (details.type !== "main_frame") return {}; + if (details.tabId === -1) return {}; + + const domain = extractDomain(details.url); + if (!domain) return {}; + + const baseDomain = getBaseDomain(domain); + + // Trigger async container assignment + assignTabToContainer(details.tabId, details.url, baseDomain); + return {}; + }, + { urls: [""] }, + ["blocking"] +); + +// Handle in-tab navigations (address bar, link clicks) +browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (!changeInfo.url) return; + if (pendingTabs[tabId]) return; + + const domain = extractDomain(changeInfo.url); + if (!domain) return; + + const baseDomain = getBaseDomain(domain); + await assignTabToContainer(tabId, changeInfo.url, baseDomain); +}); + +// Clean up tab tracking when tabs close +browser.tabs.onRemoved.addListener((tabId) => { + delete pendingTabs[tabId]; + delete tabOrigins[tabId]; +}); + +// --- Message Handling (from popup) --- + +browser.runtime.onMessage.addListener((message, sender) => { + if (message.type === "getContainerList") { + return handleGetContainerList(); + } + if (message.type === "toggleContainer") { + return handleToggle(message.cookieStoreId, message.enabled); + } + if (message.type === "regenerateFingerprint") { + return handleRegenerate(message.cookieStoreId); + } + if (message.type === "regenerateAll") { + return handleRegenerateAll(); + } + if (message.type === "resetAll") { + return handleResetAll(); + } + if (message.type === "pruneContainers") { + return handlePruneContainers(); + } +}); + +async function handleGetContainerList() { + const containers = await browser.contextualIdentities.query({}); + const stored = await browser.storage.local.get(["containerSeeds", "containerSettings"]); + const seeds = stored.containerSeeds || {}; + const settings = stored.containerSettings || {}; + + const reverseDomainMap = {}; + for (const [domain, cid] of Object.entries(domainMap)) { + reverseDomainMap[cid] = domain; + } + + return containers.map(c => ({ + name: c.name, + cookieStoreId: c.cookieStoreId, + color: c.color, + icon: c.icon, + domain: reverseDomainMap[c.cookieStoreId] || null, + enabled: (settings[c.cookieStoreId]?.enabled !== false), + hasSeed: !!seeds[c.cookieStoreId] + })); +} + +async function handleToggle(cookieStoreId, enabled) { + const stored = await browser.storage.local.get("containerSettings"); + const settings = stored.containerSettings || {}; + settings[cookieStoreId] = { ...settings[cookieStoreId], enabled }; + await browser.storage.local.set({ containerSettings: settings }); + + if (!enabled) { + if (registeredScripts[cookieStoreId]) { + try { await registeredScripts[cookieStoreId].unregister(); } catch(e) {} + delete registeredScripts[cookieStoreId]; + } + } else { + const seedStored = await browser.storage.local.get("containerSeeds"); + const seeds = seedStored.containerSeeds || {}; + if (seeds[cookieStoreId]) { + const profile = generateFingerprintProfile(seeds[cookieStoreId]); + await registerForContainer(cookieStoreId, profile); + } + } + return { ok: true }; +} + +async function handleRegenerate(cookieStoreId) { + const stored = await browser.storage.local.get(["containerSeeds", "containerSettings"]); + const seeds = stored.containerSeeds || {}; + const settings = stored.containerSettings || {}; + + seeds[cookieStoreId] = generateSeed(); + await browser.storage.local.set({ containerSeeds: seeds }); + + const cfg = settings[cookieStoreId] || { enabled: true }; + if (cfg.enabled) { + const profile = generateFingerprintProfile(seeds[cookieStoreId]); + await registerForContainer(cookieStoreId, profile); + } + return { ok: true }; +} + +async function handleRegenerateAll() { + const stored = await browser.storage.local.get("containerSeeds"); + const seeds = stored.containerSeeds || {}; + + for (const cid of Object.keys(seeds)) { + seeds[cid] = generateSeed(); + } + await browser.storage.local.set({ containerSeeds: seeds }); + await registerAllKnownContainers(); + return { ok: true }; +} + +async function handleResetAll() { + // Unregister all content scripts + for (const [cid, script] of Object.entries(registeredScripts)) { + try { await script.unregister(); } catch(e) {} + } + for (const key of Object.keys(registeredScripts)) { + delete registeredScripts[key]; + } + + // Remove all ContainSite-managed containers + const containers = await browser.contextualIdentities.query({}); + const ourContainerIds = new Set(Object.values(domainMap)); + for (const c of containers) { + if (ourContainerIds.has(c.cookieStoreId)) { + try { await browser.contextualIdentities.remove(c.cookieStoreId); } catch(e) {} + } + } + + // Clear all storage + domainMap = {}; + pendingTabs = {}; + tabOrigins = {}; + await browser.storage.local.clear(); + + return { ok: true }; +} + +async function handlePruneContainers() { + // Remove containers that have no open tabs + const containers = await browser.contextualIdentities.query({}); + const ourContainerIds = new Set(Object.values(domainMap)); + const tabs = await browser.tabs.query({}); + + // Collect cookieStoreIds that have open tabs + const activeContainers = new Set(tabs.map(t => t.cookieStoreId)); + + let pruned = 0; + for (const c of containers) { + if (ourContainerIds.has(c.cookieStoreId) && !activeContainers.has(c.cookieStoreId)) { + try { + await browser.contextualIdentities.remove(c.cookieStoreId); + pruned++; + } catch(e) {} + // domainMap cleanup happens via the onRemoved listener + } + } + return { pruned }; +} + +// --- Container Lifecycle --- + +browser.contextualIdentities.onRemoved.addListener(async ({ contextualIdentity }) => { + const cid = contextualIdentity.cookieStoreId; + if (registeredScripts[cid]) { + try { await registeredScripts[cid].unregister(); } catch(e) {} + delete registeredScripts[cid]; + } + for (const [domain, cookieStoreId] of Object.entries(domainMap)) { + if (cookieStoreId === cid) { + delete domainMap[domain]; + } + } + await saveDomainMap(); +}); + +// --- Init --- + +async function init() { + await loadDomainMap(); + await registerAllKnownContainers(); +} + +init(); diff --git a/icons/icon-48.png b/icons/icon-48.png new file mode 100644 index 0000000..04e7eb4 Binary files /dev/null and b/icons/icon-48.png differ diff --git a/icons/icon-96.png b/icons/icon-96.png new file mode 100644 index 0000000..7e6ee94 Binary files /dev/null and b/icons/icon-96.png differ diff --git a/inject.js b/inject.js new file mode 100644 index 0000000..3e1749f --- /dev/null +++ b/inject.js @@ -0,0 +1,448 @@ +// ContainSite — Hardened fingerprint overrides +// Uses Firefox exportFunction/wrappedJSObject APIs (bypasses CSP) + +(function() { + "use strict"; + + const CONFIG = window.__csConfig; + if (!CONFIG) return; + delete window.__csConfig; + + const pageWindow = window.wrappedJSObject; + + // --- PRNG (Mulberry32) --- + function mulberry32(seed) { + return function() { + seed |= 0; + seed = seed + 0x6D2B79F5 | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = t + Math.imul(t ^ (t >>> 7), 61 | t) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + + // ========================================================================= + // CANVAS SPOOFING + // ========================================================================= + + const origGetImageData = window.CanvasRenderingContext2D.prototype.getImageData; + const origPutImageData = window.CanvasRenderingContext2D.prototype.putImageData; + + function addCanvasNoise(ctx, canvas) { + try { + const w = canvas.width, h = canvas.height; + if (w <= 0 || h <= 0) return; + const imgData = origGetImageData.call(ctx, 0, 0, w, h); + const data = imgData.data; + const rng = mulberry32(CONFIG.canvasSeed); + for (let i = 0; i < data.length; i += 4) { + if (rng() < 0.1) { + const ch = (rng() * 3) | 0; + const delta = rng() < 0.5 ? -1 : 1; + data[i + ch] = Math.max(0, Math.min(255, data[i + ch] + delta)); + } + } + origPutImageData.call(ctx, imgData, 0, 0); + } catch(e) {} + } + + const origToDataURL = window.HTMLCanvasElement.prototype.toDataURL; + exportFunction(function(...args) { + try { + const ctx = this.getContext("2d"); + if (ctx) addCanvasNoise(ctx, this); + } catch(e) {} + return origToDataURL.apply(this, args); + }, pageWindow.HTMLCanvasElement.prototype, { defineAs: "toDataURL" }); + + const origToBlob = window.HTMLCanvasElement.prototype.toBlob; + exportFunction(function(callback, ...args) { + try { + const ctx = this.getContext("2d"); + if (ctx) addCanvasNoise(ctx, this); + } catch(e) {} + return origToBlob.call(this, callback, ...args); + }, pageWindow.HTMLCanvasElement.prototype, { defineAs: "toBlob" }); + + exportFunction(function(...args) { + const imgData = origGetImageData.apply(this, args); + const data = imgData.data; + const rng = mulberry32(CONFIG.canvasSeed); + for (let i = 0; i < data.length; i += 4) { + if (rng() < 0.1) { + const ch = (rng() * 3) | 0; + const delta = rng() < 0.5 ? -1 : 1; + data[i + ch] = Math.max(0, Math.min(255, data[i + ch] + delta)); + } + } + return imgData; + }, pageWindow.CanvasRenderingContext2D.prototype, { defineAs: "getImageData" }); + + // ========================================================================= + // WEBGL SPOOFING + // ========================================================================= + + const UNMASKED_VENDOR = 0x9245; + const UNMASKED_RENDERER = 0x9246; + + function patchWebGL(protoName) { + const pageProto = pageWindow[protoName]; + if (!pageProto) return; + const origProto = window[protoName]; + if (!origProto) return; + + const origGetParam = origProto.prototype.getParameter; + exportFunction(function(pname) { + if (pname === UNMASKED_VENDOR) return CONFIG.webgl.vendor; + if (pname === UNMASKED_RENDERER) return CONFIG.webgl.renderer; + return origGetParam.call(this, pname); + }, pageProto.prototype, { defineAs: "getParameter" }); + } + + patchWebGL("WebGLRenderingContext"); + patchWebGL("WebGL2RenderingContext"); + + // ========================================================================= + // AUDIO SPOOFING + // ========================================================================= + + const origGetFloatFreq = window.AnalyserNode.prototype.getFloatFrequencyData; + exportFunction(function(array) { + origGetFloatFreq.call(this, array); + const rng = mulberry32(CONFIG.audioSeed); + for (let i = 0; i < array.length; i++) { + if (array[i] !== 0) array[i] += (rng() - 0.5) * 0.0001; + } + }, pageWindow.AnalyserNode.prototype, { defineAs: "getFloatFrequencyData" }); + + const origGetByteFreq = window.AnalyserNode.prototype.getByteFrequencyData; + exportFunction(function(array) { + origGetByteFreq.call(this, array); + const rng = mulberry32(CONFIG.audioSeed); + for (let i = 0; i < array.length; i++) { + if (array[i] !== 0 && rng() < 0.05) { + array[i] = Math.max(0, Math.min(255, array[i] + (rng() < 0.5 ? -1 : 1))); + } + } + }, pageWindow.AnalyserNode.prototype, { defineAs: "getByteFrequencyData" }); + + const origGetChannelData = window.AudioBuffer.prototype.getChannelData; + exportFunction(function(channel) { + const data = origGetChannelData.call(this, channel); + const rng = mulberry32(CONFIG.audioSeed); + for (let i = 0; i < data.length; i++) { + if (data[i] !== 0) data[i] += (rng() - 0.5) * 0.0001; + } + return data; + }, pageWindow.AudioBuffer.prototype, { defineAs: "getChannelData" }); + + // ========================================================================= + // NAVIGATOR SPOOFING + // ========================================================================= + + const navOverrides = { + hardwareConcurrency: CONFIG.nav.hardwareConcurrency, + platform: CONFIG.nav.platform, + deviceMemory: CONFIG.nav.deviceMemory, + maxTouchPoints: CONFIG.nav.maxTouchPoints + }; + + for (const [prop, value] of Object.entries(navOverrides)) { + if (value !== undefined) { + Object.defineProperty(pageWindow.Navigator.prototype, prop, { + get: exportFunction(function() { return value; }, pageWindow), + configurable: true, + enumerable: true + }); + } + } + + const frozenLangs = CONFIG.nav.languages; + Object.defineProperty(pageWindow.Navigator.prototype, "languages", { + get: exportFunction(function() { + return cloneInto(frozenLangs, pageWindow, { freeze: true }); + }, pageWindow), + configurable: true, + enumerable: true + }); + + Object.defineProperty(pageWindow.Navigator.prototype, "language", { + get: exportFunction(function() { return frozenLangs[0]; }, pageWindow), + configurable: true, + enumerable: true + }); + + // Spoof plugins and mimeTypes as empty + Object.defineProperty(pageWindow.Navigator.prototype, "plugins", { + get: exportFunction(function() { + return cloneInto([], pageWindow); + }, pageWindow), + configurable: true, + enumerable: true + }); + + Object.defineProperty(pageWindow.Navigator.prototype, "mimeTypes", { + get: exportFunction(function() { + return cloneInto([], pageWindow); + }, pageWindow), + configurable: true, + enumerable: true + }); + + // Spoof connection info + if (pageWindow.navigator.connection) { + try { + Object.defineProperty(pageWindow.Navigator.prototype, "connection", { + get: exportFunction(function() { + return cloneInto({ + effectiveType: "4g", + downlink: 10, + rtt: 50, + saveData: false + }, pageWindow); + }, pageWindow), + configurable: true, + enumerable: true + }); + } catch(e) {} + } + + // Block Battery API + if (pageWindow.navigator.getBattery) { + exportFunction(function() { + return new pageWindow.Promise(exportFunction(function(resolve) { + resolve(cloneInto({ + charging: true, + chargingTime: 0, + dischargingTime: Infinity, + level: 1.0, + addEventListener: function() {}, + removeEventListener: function() {} + }, pageWindow, { cloneFunctions: true })); + }, pageWindow)); + }, pageWindow.Navigator.prototype, { defineAs: "getBattery" }); + } + + // ========================================================================= + // SCREEN SPOOFING + // ========================================================================= + + const screenOverrides = { + width: CONFIG.screen.width, + height: CONFIG.screen.height, + availWidth: CONFIG.screen.width, + availHeight: CONFIG.screen.height - 40, + colorDepth: CONFIG.screen.colorDepth, + pixelDepth: CONFIG.screen.colorDepth + }; + + for (const [prop, value] of Object.entries(screenOverrides)) { + Object.defineProperty(pageWindow.Screen.prototype, prop, { + get: exportFunction(function() { return value; }, pageWindow), + configurable: true, + enumerable: true + }); + } + + Object.defineProperty(pageWindow, "outerWidth", { + get: exportFunction(function() { return CONFIG.screen.width; }, pageWindow), + configurable: true + }); + Object.defineProperty(pageWindow, "outerHeight", { + get: exportFunction(function() { return CONFIG.screen.height; }, pageWindow), + configurable: true + }); + Object.defineProperty(pageWindow, "innerWidth", { + get: exportFunction(function() { return CONFIG.screen.width; }, pageWindow), + configurable: true + }); + Object.defineProperty(pageWindow, "innerHeight", { + get: exportFunction(function() { return CONFIG.screen.height - 80; }, pageWindow), + configurable: true + }); + + // ========================================================================= + // TIMEZONE SPOOFING + // ========================================================================= + + if (CONFIG.timezone) { + const tzName = CONFIG.timezone.name; + const tzOffset = CONFIG.timezone.offset; + + // Override getTimezoneOffset + const origGetTimezoneOffset = window.Date.prototype.getTimezoneOffset; + exportFunction(function() { + return tzOffset; + }, pageWindow.Date.prototype, { defineAs: "getTimezoneOffset" }); + + // Override Intl.DateTimeFormat.prototype.resolvedOptions to report spoofed timezone + const OrigDateTimeFormat = window.Intl.DateTimeFormat; + const origResolvedOptions = OrigDateTimeFormat.prototype.resolvedOptions; + exportFunction(function() { + const opts = origResolvedOptions.call(this); + try { opts.timeZone = tzName; } catch(e) {} + return opts; + }, pageWindow.Intl.DateTimeFormat.prototype, { defineAs: "resolvedOptions" }); + + // Override Date.prototype.toString and toTimeString to reflect spoofed timezone + const origToString = window.Date.prototype.toString; + const origToTimeString = window.Date.prototype.toTimeString; + + function formatTzAbbrev(tzName) { + // Generate a plausible timezone abbreviation + const abbrevMap = { + "America/New_York": "EST", "America/Chicago": "CST", + "America/Denver": "MST", "America/Los_Angeles": "PST", + "Europe/London": "GMT", "Europe/Berlin": "CET", + "Europe/Paris": "CET", "Asia/Tokyo": "JST", + "Australia/Sydney": "AEST", "America/Toronto": "EST", + "America/Phoenix": "MST" + }; + return abbrevMap[tzName] || "UTC"; + } + + function buildTzString(date) { + try { + const fmt = new OrigDateTimeFormat("en-US", { + timeZone: tzName, + weekday: "short", year: "numeric", month: "short", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false + }); + const parts = fmt.format(date); + const sign = tzOffset <= 0 ? "+" : "-"; + const absOff = Math.abs(tzOffset); + const h = String(Math.floor(absOff / 60)).padStart(2, "0"); + const m = String(absOff % 60).padStart(2, "0"); + const abbrev = formatTzAbbrev(tzName); + return `${parts} GMT${sign}${h}${m} (${abbrev})`; + } catch(e) { + return origToString.call(date); + } + } + + exportFunction(function() { + return buildTzString(this); + }, pageWindow.Date.prototype, { defineAs: "toString" }); + + exportFunction(function() { + try { + const fmt = new OrigDateTimeFormat("en-US", { + timeZone: tzName, + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false + }); + const parts = fmt.format(this); + const sign = tzOffset <= 0 ? "+" : "-"; + const absOff = Math.abs(tzOffset); + const h = String(Math.floor(absOff / 60)).padStart(2, "0"); + const m = String(absOff % 60).padStart(2, "0"); + const abbrev = formatTzAbbrev(tzName); + return `${parts} GMT${sign}${h}${m} (${abbrev})`; + } catch(e) { + return origToTimeString.call(this); + } + }, pageWindow.Date.prototype, { defineAs: "toTimeString" }); + } + + // ========================================================================= + // WEBRTC LEAK PROTECTION + // ========================================================================= + + if (CONFIG.webrtc && CONFIG.webrtc.blockLocal) { + // Wrap RTCPeerConnection to prevent local IP leaks + if (pageWindow.RTCPeerConnection) { + const OrigRTC = window.RTCPeerConnection; + const wrappedRTC = exportFunction(function(config, constraints) { + // Force TURN-only to prevent local candidate leaks + if (config && config.iceServers) { + config.iceTransportPolicy = "relay"; + } + const pc = new OrigRTC(config, constraints); + return pc; + }, pageWindow); + + try { + wrappedRTC.prototype = pageWindow.RTCPeerConnection.prototype; + pageWindow.RTCPeerConnection = wrappedRTC; + } catch(e) {} + } + + if (pageWindow.webkitRTCPeerConnection) { + try { + pageWindow.webkitRTCPeerConnection = pageWindow.RTCPeerConnection; + } catch(e) {} + } + } + + // ========================================================================= + // FONT FINGERPRINT PROTECTION + // ========================================================================= + + if (CONFIG.fontSeed) { + const fontRng = mulberry32(CONFIG.fontSeed); + + // Add subtle noise to measureText to prevent font enumeration + const origMeasureText = window.CanvasRenderingContext2D.prototype.measureText; + exportFunction(function(text) { + const metrics = origMeasureText.call(this, text); + + // Add deterministic sub-pixel noise to width + const noise = (fontRng() - 0.5) * 0.3; + const origWidth = metrics.width; + + try { + Object.defineProperty(metrics, "width", { + get: function() { return origWidth + noise; }, + configurable: true + }); + } catch(e) {} + + return metrics; + }, pageWindow.CanvasRenderingContext2D.prototype, { defineAs: "measureText" }); + } + + // ========================================================================= + // CLIENTRECTS FINGERPRINT PROTECTION + // ========================================================================= + + if (CONFIG.rectSeed) { + const rectRng = mulberry32(CONFIG.rectSeed); + + function addRectNoise(rect) { + const noise = (rectRng() - 0.5) * 0.1; + try { + const origX = rect.x, origY = rect.y; + const origW = rect.width, origH = rect.height; + const origT = rect.top, origL = rect.left; + const origB = rect.bottom, origR = rect.right; + + Object.defineProperties(rect, { + x: { get: () => origX + noise, configurable: true }, + y: { get: () => origY + noise, configurable: true }, + width: { get: () => origW + noise, configurable: true }, + height: { get: () => origH + noise, configurable: true }, + top: { get: () => origT + noise, configurable: true }, + left: { get: () => origL + noise, configurable: true }, + bottom: { get: () => origB + noise, configurable: true }, + right: { get: () => origR + noise, configurable: true } + }); + } catch(e) {} + return rect; + } + + const origGetBCR = window.Element.prototype.getBoundingClientRect; + exportFunction(function() { + const rect = origGetBCR.call(this); + return addRectNoise(rect); + }, pageWindow.Element.prototype, { defineAs: "getBoundingClientRect" }); + + const origGetCR = window.Element.prototype.getClientRects; + exportFunction(function() { + const rects = origGetCR.call(this); + for (let i = 0; i < rects.length; i++) { + addRectNoise(rects[i]); + } + return rects; + }, pageWindow.Element.prototype, { defineAs: "getClientRects" }); + } + +})(); diff --git a/lib/fingerprint-gen.js b/lib/fingerprint-gen.js new file mode 100644 index 0000000..9ab76fa --- /dev/null +++ b/lib/fingerprint-gen.js @@ -0,0 +1,122 @@ +// Deterministic fingerprint profile generator +// Given the same seed, always produces the same device identity + +// Real hardware values — spoofed values must NEVER match these +const REAL_HARDWARE = { + hardwareConcurrency: 4, // 2 cores / 4 threads + screenWidth: 1920, + screenHeight: 1080 +}; + +function generateFingerprintProfile(masterSeed) { + const rng = mulberry32(masterSeed); + + function pick(arr) { + return arr[Math.floor(rng() * arr.length)]; + } + + // Pick from array, but never the excluded value. Rerolls if needed. + function pickExcluding(arr, exclude) { + const filtered = arr.filter(v => { + if (typeof exclude === "object" && exclude !== null) { + return Object.keys(exclude).some(k => v[k] !== exclude[k]); + } + return v !== exclude; + }); + return filtered.length > 0 ? pick(filtered) : pick(arr); + } + + function subSeed() { + return (rng() * 0xFFFFFFFF) >>> 0; + } + + const platforms = ["Win32", "Linux x86_64", "MacIntel"]; + + const vendors = [ + "Google Inc. (NVIDIA)", + "Google Inc. (AMD)", + "Google Inc. (Intel)", + "Google Inc." + ]; + + const renderers = [ + "ANGLE (NVIDIA GeForce GTX 1060 Direct3D11 vs_5_0 ps_5_0)", + "ANGLE (AMD Radeon RX 580 Direct3D11 vs_5_0 ps_5_0)", + "ANGLE (Intel HD Graphics 630 Direct3D11 vs_5_0 ps_5_0)", + "ANGLE (NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0)", + "ANGLE (AMD Radeon RX 6700 XT Direct3D11 vs_5_0 ps_5_0)", + "Mesa Intel(R) UHD Graphics 620", + "Mesa AMD Radeon RX 580", + "ANGLE (Intel, Mesa Intel(R) UHD Graphics 620, OpenGL 4.6)" + ]; + + const resolutions = [ + { width: 2560, height: 1440 }, + { width: 1366, height: 768 }, + { width: 1536, height: 864 }, + { width: 1440, height: 900 }, + { width: 1680, height: 1050 }, + { width: 2560, height: 1080 }, + { width: 3440, height: 1440 }, + { width: 1600, height: 900 } + ]; + + const languageSets = [ + ["en-US", "en"], + ["en-GB", "en"], + ["en-US"], + ["de-DE", "de", "en-US", "en"], + ["fr-FR", "fr", "en-US", "en"] + ]; + + // Exclude real hardwareConcurrency (4) + const hardwareConcurrencies = [2, 6, 8, 12, 16]; + const deviceMemories = [4, 8, 16, 32]; + const colorDepths = [24, 30, 32]; + + // Timezones for spoofing — common real-world timezones + const timezones = [ + { name: "America/New_York", offset: 300 }, + { name: "America/Chicago", offset: 360 }, + { name: "America/Denver", offset: 420 }, + { name: "America/Los_Angeles", offset: 480 }, + { name: "Europe/London", offset: 0 }, + { name: "Europe/Berlin", offset: -60 }, + { name: "Europe/Paris", offset: -60 }, + { name: "Asia/Tokyo", offset: -540 }, + { name: "Australia/Sydney", offset: -660 }, + { name: "America/Toronto", offset: 300 }, + { name: "America/Phoenix", offset: 420 } + ]; + + // Resolution: never match real 1920x1080 + const res = pickExcluding(resolutions, { width: REAL_HARDWARE.screenWidth, height: REAL_HARDWARE.screenHeight }); + + return { + seed: masterSeed, + canvasSeed: subSeed(), + audioSeed: subSeed(), + fontSeed: subSeed(), + rectSeed: subSeed(), + nav: { + hardwareConcurrency: pick(hardwareConcurrencies), + platform: pick(platforms), + languages: pick(languageSets), + deviceMemory: pick(deviceMemories), + maxTouchPoints: 0 + }, + screen: { + width: res.width, + height: res.height, + colorDepth: pick(colorDepths) + }, + webgl: { + vendor: pick(vendors), + renderer: pick(renderers) + }, + timezone: pick(timezones), + webrtc: { + blockLocal: true + } + }; +} diff --git a/lib/prng.js b/lib/prng.js new file mode 100644 index 0000000..faf85e0 --- /dev/null +++ b/lib/prng.js @@ -0,0 +1,11 @@ +// Mulberry32 — fast, deterministic 32-bit PRNG +// Same seed always produces the same sequence +function mulberry32(seed) { + return function() { + seed |= 0; + seed = seed + 0x6D2B79F5 | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = t + Math.imul(t ^ (t >>> 7), 61 | t) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..397b0c0 --- /dev/null +++ b/manifest.json @@ -0,0 +1,36 @@ +{ + "manifest_version": 2, + "name": "ContainSite", + "version": "0.1.0", + "description": "Per-container fingerprint isolation — each container gets its own device identity", + "permissions": [ + "contextualIdentities", + "cookies", + "storage", + "tabs", + "webRequest", + "webRequestBlocking", + "" + ], + "background": { + "scripts": ["lib/prng.js", "lib/fingerprint-gen.js", "background.js"] + }, + "browser_action": { + "default_popup": "popup/popup.html", + "default_icon": { + "48": "icons/icon-48.png", + "96": "icons/icon-96.png" + }, + "default_title": "ContainSite" + }, + "icons": { + "48": "icons/icon-48.png", + "96": "icons/icon-96.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "containsite@salmutt.dev", + "strict_min_version": "100.0" + } + } +} diff --git a/popup/popup.css b/popup/popup.css new file mode 100644 index 0000000..2fc266e --- /dev/null +++ b/popup/popup.css @@ -0,0 +1,35 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { width: 300px; font: 13px/1.4 system-ui, sans-serif; color: #e0e0e0; background: #1e1e2e; } +h1 { padding: 10px 12px 6px; font-size: 15px; font-weight: 600; border-bottom: 1px solid #333; } +#container-list { max-height: 320px; overflow-y: auto; } +.row { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-bottom: 1px solid #2a2a3a; } +.row:hover { background: #2a2a3a; } +.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.name { flex: 1; overflow: hidden; min-width: 0; } +.name-primary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.name-domain { font-size: 10px; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.toggle { appearance: none; width: 32px; height: 18px; background: #444; border-radius: 9px; position: relative; cursor: pointer; flex-shrink: 0; } +.toggle::after { content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; background: #888; border-radius: 50%; transition: .15s; } +.toggle:checked { background: #4a9eff; } +.toggle:checked::after { left: 16px; background: #fff; } +.regen { background: none; border: 1px solid #555; color: #aaa; border-radius: 4px; padding: 2px 6px; font-size: 11px; cursor: pointer; flex-shrink: 0; } +.regen:hover { border-color: #888; color: #ddd; } +.actions { padding: 8px 12px; border-top: 1px solid #333; } +#regen-all { width: 100%; padding: 6px; background: #333; border: 1px solid #555; color: #ccc; border-radius: 4px; cursor: pointer; font-size: 12px; } +#regen-all:hover { background: #444; color: #fff; } +.actions-row { display: flex; gap: 6px; margin-top: 6px; } +.actions-row button { flex: 1; padding: 6px; border-radius: 4px; cursor: pointer; font-size: 12px; } +.secondary { background: #2a2a3a; border: 1px solid #555; color: #aaa; } +.secondary:hover { background: #333; color: #ddd; } +.danger { background: #3a1a1a; border: 1px solid #663333; color: #ff613d; } +.danger:hover { background: #4a2020; color: #ff8866; } + +.dot-blue { background: #37adff; } +.dot-turquoise { background: #00c79a; } +.dot-green { background: #51cd00; } +.dot-yellow { background: #ffcb00; } +.dot-orange { background: #ff9f00; } +.dot-red { background: #ff613d; } +.dot-pink { background: #ff4bda; } +.dot-purple { background: #af51f5; } +.dot-toolbar { background: #888; } diff --git a/popup/popup.html b/popup/popup.html new file mode 100644 index 0000000..c0449f4 --- /dev/null +++ b/popup/popup.html @@ -0,0 +1,19 @@ + + + + + + + +

ContainSite

+
+
+ +
+ + +
+
+ + + diff --git a/popup/popup.js b/popup/popup.js new file mode 100644 index 0000000..5d901c2 --- /dev/null +++ b/popup/popup.js @@ -0,0 +1,88 @@ +async function loadContainers() { + const containers = await browser.runtime.sendMessage({ type: "getContainerList" }); + const list = document.getElementById("container-list"); + list.innerHTML = ""; + + for (const c of containers) { + const row = document.createElement("div"); + row.className = "row"; + + const dot = document.createElement("span"); + dot.className = `dot dot-${c.color}`; + row.appendChild(dot); + + const nameWrap = document.createElement("div"); + nameWrap.className = "name"; + const name = document.createElement("div"); + name.className = "name-primary"; + name.textContent = c.name; + nameWrap.appendChild(name); + if (c.domain) { + const domain = document.createElement("div"); + domain.className = "name-domain"; + domain.textContent = c.domain; + nameWrap.appendChild(domain); + } + row.appendChild(nameWrap); + + const toggle = document.createElement("input"); + toggle.type = "checkbox"; + toggle.className = "toggle"; + toggle.checked = c.enabled; + toggle.addEventListener("change", async () => { + await browser.runtime.sendMessage({ + type: "toggleContainer", + cookieStoreId: c.cookieStoreId, + enabled: toggle.checked + }); + }); + row.appendChild(toggle); + + const regen = document.createElement("button"); + regen.className = "regen"; + regen.textContent = "New"; + regen.title = "Generate new fingerprint"; + regen.addEventListener("click", async () => { + regen.textContent = "..."; + await browser.runtime.sendMessage({ + type: "regenerateFingerprint", + cookieStoreId: c.cookieStoreId + }); + regen.textContent = "OK"; + setTimeout(() => { regen.textContent = "New"; }, 800); + }); + row.appendChild(regen); + + list.appendChild(row); + } +} + +document.getElementById("regen-all").addEventListener("click", async (e) => { + e.target.textContent = "Regenerating..."; + await browser.runtime.sendMessage({ type: "regenerateAll" }); + e.target.textContent = "Done!"; + setTimeout(() => { e.target.textContent = "Regenerate All"; }, 800); +}); + +document.getElementById("prune").addEventListener("click", async (e) => { + e.target.textContent = "Pruning..."; + const result = await browser.runtime.sendMessage({ type: "pruneContainers" }); + e.target.textContent = `Removed ${result.pruned}`; + setTimeout(() => { + e.target.textContent = "Prune Unused"; + loadContainers(); + }, 1200); +}); + +document.getElementById("reset").addEventListener("click", async (e) => { + if (!confirm("Remove all ContainSite containers and data? You will need to log in to all sites again.")) return; + e.target.textContent = "Resetting..."; + await browser.runtime.sendMessage({ type: "resetAll" }); + e.target.textContent = "Done!"; + setTimeout(() => { + e.target.textContent = "Reset All"; + loadContainers(); + }, 1200); +}); + +loadContainers();