import { browser } from 'wxt/browser'; import type { ActiveTabInfo, BrowserFirefoxContainerIdentityResult, BrowserFirefoxManagedContainer, BrowserIncognitoIdentityResult, BrowserIsolationContext, BrowserIsolationInspection, BrowserIsolationProof, BrowserTarget, PageContext, PageContextOptions, } from '@/types/models'; import { ExtensionError } from '@/shared/errors'; import { authenticationFingerprint, authenticationStorageEntries, } from './auth-fingerprint'; import { activeTabInfo, browserTabDescriptor, isolationContextForTab, listIsolationCookieStores, resolveTabCookieStoreId as resolveCookieStoreId, uniqueTabIds, type IsolationCookieStore, type IsolationTabDescriptor, } from '@/platform/browser/isolation'; import { createFirefoxContainerIdentity, firefoxContainerManagementAvailable, listFirefoxContainerDescriptors, listManagedFirefoxContainerIdentities, removeFirefoxContainerIdentity, } from './firefox-container'; import { AUTHORIZATION_WORKSPACE_TTL_MS } from './lifetime'; export { activeTabInfo, isolationContextForTab, type IsolationCookieStore, type IsolationTabDescriptor, } from '@/platform/browser/isolation'; const PROOF_TTL_MS = AUTHORIZATION_WORKSPACE_TTL_MS; const MAX_PROOFS = 32; const MAX_PROOF_STORAGE_BYTES = 64 * 1_024; const PROOF_STORAGE_KEY = 'browser.authorization.isolation-proofs.v1'; const proofs = new Map(); let proofsLoaded = false; type AuthorizationPageContextCapture = ( options: PageContextOptions, target?: BrowserTarget | number, ) => Promise; let authorizationPageContextCapture: AuthorizationPageContextCapture | undefined; export function configureAuthorizationPageContextCapture( capture: AuthorizationPageContextCapture, ): void { authorizationPageContextCapture = capture; } export interface TabLocalAuthenticationEvidence { origin: string; status: 'authenticated' | 'unauthenticated' | 'unknown'; authCookieNames: string[]; authLocalStorageKeys: string[]; authSessionStorageKeys: string[]; fingerprint: string; } function appendProofReason( proof: BrowserIsolationProof, reason: string, ): BrowserIsolationProof { const reasons = [...proof.reasons]; if (!reasons.includes(reason)) reasons.push(reason); return { ...proof, reasons: reasons.slice(-16), }; } export function applyTabLocalAuthenticationEvidence( proof: BrowserIsolationProof, left: TabLocalAuthenticationEvidence, right: TabLocalAuthenticationEvidence, ): BrowserIsolationProof { if (!proof.sameOrigin || proof.cookieStoreRelation !== 'same' || left.origin !== right.origin) { return proof; } if (left.status === 'unauthenticated' || right.status === 'unauthenticated') { return appendProofReason(proof, '至少一个普通 Tab 明确未登录,不能建立 Tab-local 条件隔离'); } if (left.authCookieNames.length || right.authCookieNames.length) { return appendProofReason(proof, '检测到认证 Cookie;普通 Tab 共享 Cookie Store,已拒绝伪造 Tab-local 隔离'); } if (left.authLocalStorageKeys.length || right.authLocalStorageKeys.length) { return appendProofReason(proof, '检测到 localStorage 认证材料;普通 Tab 共享站点存储,已拒绝 Tab-local 隔离'); } if (!left.authSessionStorageKeys.length || !right.authSessionStorageKeys.length) { return appendProofReason(proof, '没有在两个 Tab 中同时发现独立 sessionStorage 认证材料'); } if (!left.fingerprint || !right.fingerprint || left.fingerprint === right.fingerprint) { return appendProofReason(proof, '两个 Tab 的认证快照不能证明不同登录态'); } return { ...proof, accountEvidenceRelation: 'different', requestCredentialRelation: 'unknown', refreshCheck: 'passed', level: 'conditional', reasons: [ ...proof.reasons.filter((reason) => !reason.includes('不同 tabId 不代表不同登录态')), '两个普通 Tab 共享 Cookie Store,但认证材料仅存在于各自 sessionStorage', '两个 Tab 的认证快照不同;仍需 A/B 正常请求证明实际发送的认证字段不同', ].slice(-16), }; } function authRelated(name: string): boolean { return /(auth|token|jwt|session|login|csrf|xsrf|sid|credential|bearer)/i.test(name); } async function sha256(value: string): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); return [...new Uint8Array(digest)] .map((byte) => byte.toString(16).padStart(2, '0')) .join(''); } async function tabLocalAuthenticationEvidence( context: PageContext, ): Promise { const storage = authenticationStorageEntries(context); return { origin: new URL(context.document.url).origin, status: context.authentication.status, authCookieNames: (context.cookies || []) .filter((cookie) => authRelated(cookie.name)) .map((cookie) => cookie.name) .slice(0, 100), authLocalStorageKeys: storage .filter((entry) => entry.area === 'local') .map((entry) => entry.key) .slice(0, 100), authSessionStorageKeys: storage .filter((entry) => entry.area === 'session') .map((entry) => entry.key) .slice(0, 100), fingerprint: await authenticationFingerprint(context, sha256), }; } async function inspectTabLocalIsolation( proof: BrowserIsolationProof, ): Promise { if (proof.level !== 'none' || proof.cookieStoreRelation !== 'same' || !proof.sameOrigin) { return proof; } if (!authorizationPageContextCapture) { return appendProofReason(proof, 'Tab-local 认证预检能力尚未初始化'); } try { const [leftContext, rightContext] = await Promise.all([ authorizationPageContextCapture( { includeDom: false, includeStorage: true, includeCookies: true }, proof.leftTabId, ), authorizationPageContextCapture( { includeDom: false, includeStorage: true, includeCookies: true }, proof.rightTabId, ), ]); const [left, right] = await Promise.all([ tabLocalAuthenticationEvidence(leftContext), tabLocalAuthenticationEvidence(rightContext), ]); return applyTabLocalAuthenticationEvidence(proof, left, right); } catch (error) { const message = error instanceof Error ? error.message : String(error); return appendProofReason( proof, `Tab-local 认证预检未通过:${message}`.slice(0, 500), ); } } function originOf(url: string): string | undefined { try { const parsed = new URL(url); return ['http:', 'https:'].includes(parsed.protocol) ? parsed.origin : undefined; } catch { return undefined; } } export function buildIsolationProof( left: ActiveTabInfo, right: ActiveTabInfo, contexts: readonly BrowserIsolationContext[], now = Date.now(), id: string = crypto.randomUUID(), ): BrowserIsolationProof { if (left.id === right.id) throw new ExtensionError('isolation_invalid', '双身份槽位不能选择同一个标签页'); const leftContext = contexts.find((context) => context.contextId === left.isolationContextId); const rightContext = contexts.find((context) => context.contextId === right.isolationContextId); const leftStore = leftContext?.cookieStoreId; const rightStore = rightContext?.cookieStoreId; const cookieStoreRelation = leftStore && rightStore ? leftStore === rightStore ? 'same' : 'different' : 'unknown'; const sameOrigin = Boolean(originOf(left.url) && originOf(left.url) === originOf(right.url)); const reasons: string[] = []; let level: BrowserIsolationProof['level'] = 'none'; if (!leftContext || !rightContext || cookieStoreRelation === 'unknown') { reasons.push('至少一个身份无法解析 Cookie Store,不能证明隔离'); } else if (leftContext.contextId === rightContext.contextId || cookieStoreRelation === 'same') { reasons.push('两个标签页共享同一个 Cookie Store;不同 tabId 不代表不同登录态'); } else { level = 'strong'; reasons.push('两个身份使用不同的浏览器 Cookie Store'); if (left.incognito !== right.incognito) reasons.push('普通与无痕浏览上下文已分离'); if (leftContext.kind === 'firefox-container' || rightContext.kind === 'firefox-container') { reasons.push('Firefox Container 上下文已分离'); } } if (!sameOrigin) reasons.push('两个页面来源不同,后续授权差异计划必须显式确认跨来源语义'); return { version: 1, id, leftContextId: leftContext?.contextId || left.isolationContextId || `unresolved:${left.id}`, rightContextId: rightContext?.contextId || right.isolationContextId || `unresolved:${right.id}`, leftTabId: left.id, rightTabId: right.id, sameOrigin, cookieStoreRelation, accountEvidenceRelation: 'unknown', requestCredentialRelation: 'unknown', refreshCheck: level === 'strong' ? 'not-required' : 'failed', level, reasons, createdAt: now, expiresAt: now + PROOF_TTL_MS, }; } async function incognitoAccess(): Promise { if (import.meta.env.FIREFOX) return 'unsupported'; return await browser.extension.isAllowedIncognitoAccess() ? 'allowed' : 'denied'; } export async function inspectBrowserIsolation(tabIds?: readonly number[]): Promise { const requested = tabIds?.length ? new Set(uniqueTabIds(tabIds)) : undefined; const [rawTabs, stores, access, containers] = await Promise.all([ requested ? Promise.all([...requested].map((tabId) => browser.tabs.get(tabId))) : browser.tabs.query({}), listIsolationCookieStores(), incognitoAccess(), listFirefoxContainerDescriptors(), ]); const descriptors = rawTabs.map(browserTabDescriptor).filter((tab): tab is IsolationTabDescriptor => Boolean(tab)); if (requested && descriptors.length !== requested.size) { throw new ExtensionError('target_unavailable', '至少一个身份标签页已经关闭或不是 HTTP(S) 页面'); } const browserKind: BrowserIsolationInspection['browser'] = import.meta.env.FIREFOX ? 'firefox' : 'chromium'; const contextById = new Map(); const tabs = descriptors.map((tab) => { const context = isolationContextForTab(tab, stores, browserKind, containers); contextById.set(context.contextId, context); return activeTabInfo(tab, context); }); return { version: 1, inspectedAt: Date.now(), browser: browserKind, capabilities: { incognitoAccess: access, containerTabs: browserKind === 'firefox' && firefoxContainerManagementAvailable(), managedProfiles: false, }, contexts: [...contextById.values()], tabs, }; } export async function resolveTabCookieStoreId(tabId: number): Promise { return resolveCookieStoreId(tabId); } function purgeProofs(now = Date.now(), reserve = 0): boolean { let changed = false; for (const [id, proof] of proofs) { if (proof.expiresAt <= now) { proofs.delete(id); changed = true; } } while (proofs.size > MAX_PROOFS - reserve) { const oldest = proofs.keys().next().value as string | undefined; if (!oldest) break; proofs.delete(oldest); changed = true; } return changed; } function validStoredProof(value: unknown): value is BrowserIsolationProof { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const proof = value as Partial; return proof.version === 1 && typeof proof.id === 'string' && proof.id.length > 0 && proof.id.length <= 160 && typeof proof.leftContextId === 'string' && proof.leftContextId.length > 0 && proof.leftContextId.length <= 320 && typeof proof.rightContextId === 'string' && proof.rightContextId.length > 0 && proof.rightContextId.length <= 320 && Number.isSafeInteger(proof.leftTabId) && Number(proof.leftTabId) > 0 && Number.isSafeInteger(proof.rightTabId) && Number(proof.rightTabId) > 0 && proof.leftTabId !== proof.rightTabId && typeof proof.sameOrigin === 'boolean' && ['different', 'same', 'unknown'].includes(String(proof.cookieStoreRelation)) && ['different', 'same', 'unknown'].includes(String(proof.accountEvidenceRelation)) && ['different', 'same', 'unknown'].includes(String(proof.requestCredentialRelation)) && ['passed', 'failed', 'not-required'].includes(String(proof.refreshCheck)) && ['strong', 'conditional', 'none'].includes(String(proof.level)) && Array.isArray(proof.reasons) && proof.reasons.length <= 16 && proof.reasons.every((reason) => typeof reason === 'string' && reason.length <= 500) && typeof proof.createdAt === 'number' && typeof proof.expiresAt === 'number' && proof.expiresAt > proof.createdAt && proof.expiresAt - proof.createdAt <= PROOF_TTL_MS; } async function loadProofs(): Promise { if (proofsLoaded) return; proofsLoaded = true; try { const stored = await browser.storage.session.get(PROOF_STORAGE_KEY); const values = stored[PROOF_STORAGE_KEY]; if (!Array.isArray(values)) return; for (const value of values.slice(-MAX_PROOFS)) { if (validStoredProof(value)) proofs.set(value.id, value); } purgeProofs(); } catch { // Firefox MV2 and tests may not expose storage.session; the bounded in-memory registry remains available. } } async function saveProofs(): Promise { try { const retained: BrowserIsolationProof[] = []; for (const proof of [...proofs.values()].reverse()) { const candidate = [proof, ...retained]; if (new TextEncoder().encode(JSON.stringify(candidate)).byteLength > MAX_PROOF_STORAGE_BYTES) break; retained.unshift(proof); } proofs.clear(); for (const proof of retained) proofs.set(proof.id, proof); await browser.storage.session.set({ [PROOF_STORAGE_KEY]: retained, }); } catch { // The in-memory copy remains the fallback when storage.session is unavailable. } } export async function createBrowserIsolationProof(leftTabId: number, rightTabId: number): Promise { await loadProofs(); const inspection = await inspectBrowserIsolation([leftTabId, rightTabId]); const left = inspection.tabs.find((tab) => tab.id === leftTabId); const right = inspection.tabs.find((tab) => tab.id === rightTabId); if (!left || !right) throw new ExtensionError('target_unavailable', '双身份标签页已经失效'); const proof = await inspectTabLocalIsolation( buildIsolationProof(left, right, inspection.contexts), ); purgeProofs(proof.createdAt, 1); proofs.set(proof.id, proof); await saveProofs(); return proof; } export async function getBrowserIsolationProof(id: string): Promise { await loadProofs(); if (purgeProofs()) await saveProofs(); const proof = proofs.get(id); if (!proof) throw new ExtensionError('isolation_stale', '身份隔离证明不存在或已经过期,请重新执行预检'); const inspection = await inspectBrowserIsolation([proof.leftTabId, proof.rightTabId]); const left = inspection.tabs.find((tab) => tab.id === proof.leftTabId); const right = inspection.tabs.find((tab) => tab.id === proof.rightTabId); if (!left || !right) throw new ExtensionError('isolation_stale', '身份页面已经关闭,请重新执行隔离预检'); const current = await inspectTabLocalIsolation( buildIsolationProof(left, right, inspection.contexts, proof.createdAt, proof.id), ); if (current.leftContextId !== proof.leftContextId || current.rightContextId !== proof.rightContextId || current.cookieStoreRelation !== proof.cookieStoreRelation || current.level !== proof.level) { proofs.delete(id); await saveProofs(); throw new ExtensionError('isolation_stale', '身份页面的 Cookie Store 或隔离关系已经变化,请重新执行预检'); } return proof; } export async function openIncognitoIdentity(url: string): Promise { let parsed: URL; try { parsed = new URL(url); } catch { throw new ExtensionError('isolation_invalid', '身份页面 URL 无效'); } if (!['http:', 'https:'].includes(parsed.protocol)) { throw new ExtensionError('isolation_invalid', '身份页面只能使用 HTTP(S) URL'); } if (import.meta.env.FIREFOX) { throw new ExtensionError('channel_unavailable', 'Firefox 双身份应使用 Container Tab,而不是 Chrome 无痕路径'); } if (!await browser.extension.isAllowedIncognitoAccess()) { throw new ExtensionError('incognito_access_denied', '请先在扩展详情中开启“允许在无痕模式下运行”'); } const created = await browser.windows.create({ url: parsed.href, incognito: true, focused: true }); if (!created) throw new ExtensionError('target_unavailable', '浏览器拒绝创建无痕身份窗口'); const createdTabs = created.tabs || (created.id ? await browser.tabs.query({ windowId: created.id }) : []); const tab = createdTabs.find((candidate) => candidate.id && candidate.incognito); if (!tab?.id) throw new ExtensionError('target_unavailable', '无痕窗口已创建,但无法定位身份页面'); for (let attempt = 0; attempt < 20; attempt += 1) { const inspection = await inspectBrowserIsolation([tab.id]); const activeTab = inspection.tabs[0]; const context = inspection.contexts.find((candidate) => candidate.contextId === activeTab?.isolationContextId); if (activeTab && context?.cookieStoreId) return { tab: activeTab, context }; await new Promise((resolve) => globalThis.setTimeout(resolve, 50)); } throw new ExtensionError('target_unavailable', '无痕页面尚未获得独立 Cookie Store,请稍后重试'); } export async function openFirefoxContainerIdentity(input: { url: string; name?: string; }): Promise { const created = await createFirefoxContainerIdentity(input); if (!created.tab.id) { await removeFirefoxContainerIdentity(created.container.cookieStoreId).catch(() => undefined); throw new ExtensionError('target_unavailable', 'Container 已创建,但无法定位身份页面'); } for (let attempt = 0; attempt < 20; attempt += 1) { const inspection = await inspectBrowserIsolation([created.tab.id]); const tab = inspection.tabs[0]; const context = inspection.contexts.find( (candidate) => candidate.contextId === tab?.isolationContextId, ); if (tab && context?.cookieStoreId === created.container.cookieStoreId) { return { tab, context, container: { cookieStoreId: created.container.cookieStoreId, name: created.container.name, color: created.container.color, managed: true, }, }; } await new Promise((resolve) => globalThis.setTimeout(resolve, 50)); } await removeFirefoxContainerIdentity(created.container.cookieStoreId).catch(() => undefined); throw new ExtensionError( 'target_unavailable', 'Container 页面尚未获得独立 Cookie Store,请稍后重试', ); } export async function deleteFirefoxContainerIdentity( cookieStoreId: string, ): Promise<{ cookieStoreId: string; removedTabs: number }> { return removeFirefoxContainerIdentity(cookieStoreId); } export async function listFirefoxContainerIdentities(): Promise { return listManagedFirefoxContainerIdentities(); }