mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-22 11:20:42 +08:00
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
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<string, BrowserAuthContextHandle>();
|
|
let handlesLoaded = false;
|
|
let hmacKeyPromise: Promise<CryptoKey> | 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<CryptoKey> {
|
|
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<string> {
|
|
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<BrowserAuthContextHandle>;
|
|
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<void> {
|
|
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<void> {
|
|
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<CapturedAuthContextSnapshot> {
|
|
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<void> {
|
|
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<BrowserAuthContextHandle> {
|
|
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<BrowserAuthContextHandle> {
|
|
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}`);
|
|
}
|
|
}
|