import { browser } from 'wxt/browser'; import type { BrowserAuthContextHandle, BrowserIsolationContext, BrowserTarget, } from '@/types/models'; import { capturePageContext } from '@/features/page-context/service'; import { getState } from '@/platform/storage/state'; import { ExtensionError } from '@/shared/errors'; import { authenticationFingerprint, authenticationStorageEntries, } from './auth-fingerprint'; import { getBrowserIsolationProof, inspectBrowserIsolation, } from './isolation'; import { AUTHORIZATION_WORKSPACE_TTL_MS } from './lifetime'; export const AUTH_CONTEXT_TTL_MS = AUTHORIZATION_WORKSPACE_TTL_MS; const MAX_AUTH_CONTEXTS = 32; const MAX_AUTH_CONTEXT_STORAGE_BYTES = 64 * 1_024; const STORAGE_KEY = 'browser.authorization.auth-contexts.v1'; const HMAC_KEY_STORAGE_KEY = 'browser.authorization.hmac-key.v1'; const handles = new Map(); let handlesLoaded = false; let hmacKeyPromise: Promise | undefined; function bytesToBase64(bytes: Uint8Array): string { let binary = ''; for (let offset = 0; offset < bytes.length; offset += 8_192) { binary += String.fromCharCode(...bytes.subarray(offset, offset + 8_192)); } return btoa(binary); } function base64ToBytes(value: string): Uint8Array { const binary = atob(value); return Uint8Array.from(binary, (character) => character.charCodeAt(0)); } function bytesToHex(bytes: Uint8Array): string { return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join(''); } async function sessionHmacKey(): Promise { if (hmacKeyPromise) return hmacKeyPromise; hmacKeyPromise = (async () => { let raw: Uint8Array | undefined; try { const stored = await browser.storage.session.get(HMAC_KEY_STORAGE_KEY); const encoded = stored[HMAC_KEY_STORAGE_KEY]; if (typeof encoded === 'string') { const candidate = base64ToBytes(encoded); if (candidate.byteLength === 32) raw = candidate; } } catch { // A fresh in-memory session key is sufficient when storage.session is unavailable. } if (!raw) { raw = crypto.getRandomValues(new Uint8Array(32)); try { await browser.storage.session.set({ [HMAC_KEY_STORAGE_KEY]: bytesToBase64(raw) }); } catch { // Keep the key in this service worker lifetime as the fallback. } } return crypto.subtle.importKey( 'raw', Uint8Array.from(raw).buffer, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); })(); return hmacKeyPromise; } async function hmac(value: string): Promise { const signature = await crypto.subtle.sign( 'HMAC', await sessionHmacKey(), new TextEncoder().encode(value), ); return bytesToHex(new Uint8Array(signature)); } function authRelated(name: string): boolean { return /(auth|token|jwt|session|login|csrf|xsrf|sid|credential|bearer)/i.test(name); } function validStoredHandle(value: unknown): value is BrowserAuthContextHandle { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const handle = value as Partial; return handle.version === 1 && typeof handle.id === 'string' && handle.id.length > 0 && handle.id.length <= 160 && ['left', 'right'].includes(String(handle.slotId)) && typeof handle.deviceId === 'string' && handle.deviceId.length > 0 && handle.deviceId.length <= 320 && typeof handle.installationId === 'string' && handle.installationId.length > 0 && handle.installationId.length <= 320 && typeof handle.isolationContextId === 'string' && handle.isolationContextId.length > 0 && handle.isolationContextId.length <= 320 && typeof handle.isolationProofId === 'string' && handle.isolationProofId.length > 0 && handle.isolationProofId.length <= 160 && typeof handle.cookieStoreId === 'string' && handle.cookieStoreId.length > 0 && handle.cookieStoreId.length <= 320 && typeof handle.origin === 'string' && handle.origin.length > 0 && handle.origin.length <= 8_192 && typeof handle.grantId === 'string' && handle.grantId.length > 0 && handle.grantId.length <= 160 && typeof handle.fingerprint === 'string' && /^hmac-sha256:[a-f0-9]{64}$/.test(handle.fingerprint) && (handle.accountLabel === undefined || (typeof handle.accountLabel === 'string' && handle.accountLabel.length <= 80)) && Boolean(handle.target) && Number.isSafeInteger(handle.target?.tabId) && Number(handle.target?.tabId) > 0 && Number.isSafeInteger(handle.target?.frameId) && Number(handle.target?.frameId) >= 0 && typeof handle.target?.documentId === 'string' && handle.target.documentId.length > 0 && handle.target.documentId.length <= 160 && Boolean(handle.authentication) && ['authenticated', 'unauthenticated', 'unknown'].includes(String(handle.authentication?.status)) && Number.isSafeInteger(handle.authentication?.cookieCount) && Number(handle.authentication?.cookieCount) >= 0 && Number.isSafeInteger(handle.authentication?.storageEntryCount) && Number(handle.authentication?.storageEntryCount) >= 0 && Array.isArray(handle.authentication?.authCookieNames) && handle.authentication.authCookieNames.length <= 100 && handle.authentication.authCookieNames.every((name) => typeof name === 'string' && name.length <= 500) && Array.isArray(handle.authentication?.authStorageKeys) && handle.authentication.authStorageKeys.length <= 100 && handle.authentication.authStorageKeys.every((key) => typeof key === 'string' && key.length <= 520) && typeof handle.createdAt === 'number' && typeof handle.expiresAt === 'number' && handle.expiresAt > handle.createdAt && handle.expiresAt - handle.createdAt <= AUTH_CONTEXT_TTL_MS; } function purgeHandles(now = Date.now(), reserve = 0): boolean { let changed = false; for (const [id, handle] of handles) { if (handle.expiresAt <= now) { handles.delete(id); changed = true; } } while (handles.size > MAX_AUTH_CONTEXTS - reserve) { const oldest = handles.keys().next().value as string | undefined; if (!oldest) break; handles.delete(oldest); changed = true; } return changed; } async function loadHandles(): Promise { if (handlesLoaded) return; handlesLoaded = true; try { const stored = await browser.storage.session.get(STORAGE_KEY); const values = stored[STORAGE_KEY]; if (!Array.isArray(values)) return; for (const value of values.slice(-MAX_AUTH_CONTEXTS)) { if (validStoredHandle(value)) handles.set(value.id, value); } purgeHandles(); } catch { // Keep the bounded memory registry on adapters without storage.session. } } async function saveHandles(): Promise { try { const retained: BrowserAuthContextHandle[] = []; for (const handle of [...handles.values()].reverse()) { const candidate = [handle, ...retained]; if (new TextEncoder().encode(JSON.stringify(candidate)).byteLength > MAX_AUTH_CONTEXT_STORAGE_BYTES) break; retained.unshift(handle); } handles.clear(); for (const handle of retained) handles.set(handle.id, handle); await browser.storage.session.set({ [STORAGE_KEY]: retained, }); } catch { // Keep the bounded memory registry on adapters without storage.session. } } function isolationContext( contexts: BrowserIsolationContext[], isolationContextId: string | undefined, ): BrowserIsolationContext | undefined { return contexts.find((context) => context.contextId === isolationContextId); } export interface CapturedAuthContextSnapshot { deviceId: string; installationId: string; isolationContextId: string; cookieStoreId: string; origin: string; target: BrowserTarget & { documentId: string }; fingerprint: string; authentication: BrowserAuthContextHandle['authentication']; } type AuthContextBinding = Pick< BrowserAuthContextHandle, | 'deviceId' | 'installationId' | 'isolationContextId' | 'cookieStoreId' | 'origin' | 'target' | 'fingerprint' >; export async function captureAuthContextSnapshot( target: BrowserTarget, ): Promise { const inspection = await inspectBrowserIsolation([target.tabId]); const tab = inspection.tabs[0]; const context = isolationContext(inspection.contexts, tab?.isolationContextId); if (!tab || !context?.cookieStoreId || context.level === 'none') { throw new ExtensionError('isolation_unresolved', '目标页面没有可用的隔离上下文,不能创建认证快照'); } const pageContext = await capturePageContext( { includeDom: false, includeStorage: true, includeCookies: true }, target, ); if (!pageContext.target.documentId) { throw new ExtensionError('stale_document', '目标页面缺少稳定 document 标识'); } const state = await getState(); const deviceId = state.bridge.pairedEngine?.deviceId; if (!deviceId) throw new ExtensionError('bridge_disconnected', '插件尚未与 Yak 引擎配对'); const cookies = pageContext.cookies || []; const storage = authenticationStorageEntries(pageContext); return { deviceId, installationId: state.bridge.installationId, isolationContextId: context.contextId, cookieStoreId: context.cookieStoreId, origin: new URL(pageContext.document.url).origin, target: { tabId: pageContext.target.tabId, frameId: pageContext.target.frameId, documentId: pageContext.target.documentId, }, fingerprint: await authenticationFingerprint(pageContext, hmac), authentication: { status: pageContext.authentication.status, cookieCount: cookies.length, storageEntryCount: storage.length, authCookieNames: cookies .filter((cookie) => authRelated(cookie.name)) .map((cookie) => cookie.name) .slice(0, 100), authStorageKeys: storage .filter((entry) => authRelated(entry.key)) .map((entry) => `${entry.area}:${entry.key}`) .slice(0, 100), }, }; } export async function validateAuthContextBinding(binding: AuthContextBinding): Promise { const state = await getState(); if (state.bridge.pairedEngine?.deviceId !== binding.deviceId || state.bridge.installationId !== binding.installationId) { throw new ExtensionError('auth_context_stale', '插件安装身份或配对引擎已经变化'); } const current = await captureAuthContextSnapshot(binding.target); if (current.isolationContextId !== binding.isolationContextId || current.cookieStoreId !== binding.cookieStoreId) { throw new ExtensionError('auth_context_stale', '目标页面的 Cookie Store 或隔离上下文已经变化'); } if (current.target.documentId !== binding.target.documentId || current.origin !== binding.origin || current.fingerprint !== binding.fingerprint) { throw new ExtensionError('auth_context_stale', '目标文档、来源或认证材料已经变化'); } } export async function captureAuthContextHandle(input: { slotId: 'left' | 'right'; accountLabel?: string; isolationProofId: string; target: BrowserTarget; grantId: string; grantExpiresAt: number; }): Promise { await loadHandles(); const proof = await getBrowserIsolationProof(input.isolationProofId); if (proof.level === 'none') { throw new ExtensionError('isolation_unresolved', '当前证明没有建立两个身份的隔离关系,不能创建认证句柄'); } const expectedTabId = input.slotId === 'left' ? proof.leftTabId : proof.rightTabId; if (input.target.tabId !== expectedTabId) { throw new ExtensionError('target_denied', '认证上下文目标与隔离证明中的身份槽位不一致'); } const expectedContextId = input.slotId === 'left' ? proof.leftContextId : proof.rightContextId; const snapshot = await captureAuthContextSnapshot(input.target); if (snapshot.isolationContextId !== expectedContextId) { throw new ExtensionError('isolation_stale', '目标页面的隔离上下文已经变化,请重新执行预检'); } const now = Date.now(); const handle: BrowserAuthContextHandle = { version: 1, id: crypto.randomUUID(), slotId: input.slotId, accountLabel: input.accountLabel?.trim().slice(0, 80) || undefined, ...snapshot, isolationProofId: proof.id, grantId: input.grantId, createdAt: now, expiresAt: Math.min(now + AUTH_CONTEXT_TTL_MS, proof.expiresAt, input.grantExpiresAt), }; if (handle.expiresAt <= now) throw new ExtensionError('grant_expired', '共享会话或隔离证明已经过期'); purgeHandles(now, 1); handles.set(handle.id, handle); await saveHandles(); return handle; } export async function getAuthContextHandle(id: string, grantId: string): Promise { await loadHandles(); if (purgeHandles()) await saveHandles(); const handle = handles.get(id); if (!handle || handle.grantId !== grantId) { throw new ExtensionError('auth_context_stale', '认证上下文句柄不存在、已过期或不属于当前共享会话'); } try { const proof = await getBrowserIsolationProof(handle.isolationProofId); if (proof.level === 'none') throw new ExtensionError('auth_context_stale', '身份隔离证明已经失效'); const expectedTabId = handle.slotId === 'left' ? proof.leftTabId : proof.rightTabId; const expectedContextId = handle.slotId === 'left' ? proof.leftContextId : proof.rightContextId; if (handle.target.tabId !== expectedTabId || handle.isolationContextId !== expectedContextId) { throw new ExtensionError('auth_context_stale', '认证句柄与当前隔离证明不一致'); } await validateAuthContextBinding(handle); return handle; } catch (error) { handles.delete(id); await saveHandles(); if (error instanceof ExtensionError && error.code === 'auth_context_stale') throw error; const message = error instanceof Error ? error.message : String(error); throw new ExtensionError('auth_context_stale', `认证上下文实时复核失败:${message}`); } }