mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-23 19:51:53 +08:00
496 lines
19 KiB
TypeScript
496 lines
19 KiB
TypeScript
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<string, BrowserIsolationProof>();
|
|
let proofsLoaded = false;
|
|
|
|
type AuthorizationPageContextCapture = (
|
|
options: PageContextOptions,
|
|
target?: BrowserTarget | number,
|
|
) => Promise<PageContext>;
|
|
|
|
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<string> {
|
|
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<TabLocalAuthenticationEvidence> {
|
|
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<BrowserIsolationProof> {
|
|
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<BrowserIsolationInspection['capabilities']['incognitoAccess']> {
|
|
if (import.meta.env.FIREFOX) return 'unsupported';
|
|
return await browser.extension.isAllowedIncognitoAccess() ? 'allowed' : 'denied';
|
|
}
|
|
|
|
export async function inspectBrowserIsolation(tabIds?: readonly number[]): Promise<BrowserIsolationInspection> {
|
|
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<string, BrowserIsolationContext>();
|
|
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<string> {
|
|
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<BrowserIsolationProof>;
|
|
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<void> {
|
|
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<void> {
|
|
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<BrowserIsolationProof> {
|
|
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<BrowserIsolationProof> {
|
|
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<BrowserIncognitoIdentityResult> {
|
|
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<BrowserFirefoxContainerIdentityResult> {
|
|
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<BrowserFirefoxManagedContainer[]> {
|
|
return listManagedFirefoxContainerIdentities();
|
|
}
|