interface PublicKeyResponse { enabled: boolean scope: string algorithm: string key_version: string public_key: string encrypted_paths?: string[] } interface EncryptSession { aesKey: Uint8Array iv: Uint8Array keyVersion: string encryptedKey: string encryptedIv: string } interface EncryptRequestResult { body: string headers: Record session: EncryptSession | null } const PUBLIC_KEY_PATH = '/system/security/api-encrypt/public-key' let publicKeyCache: PublicKeyResponse | null = null let publicKeyFailureAt = 0 const PUBLIC_KEY_RETRY_INTERVAL = 30000 const textEncoder = new TextEncoder() const textDecoder = new TextDecoder() function normalizeBaseUrl(baseURL: string): string { return baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL } function normalizePath(path: string): string { return path.startsWith('/') ? path : `/${path}` } function resolveRequestPath(baseURL: string, path: string): string { const normalizedBaseUrl = normalizeBaseUrl(baseURL) const normalizedPath = normalizePath(path) if (/^https?:\/\//i.test(normalizedPath)) { const url = new URL(normalizedPath) return url.pathname } if (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/adminapi/')) { return normalizedPath } try { const url = new URL(`${normalizedBaseUrl}${normalizedPath}`, window.location.origin) return url.pathname } catch { return normalizedPath } } function shouldSkipEncrypt(path: string): boolean { return path === '/api/auth/refresh' || path === '/adminapi/auth/refresh' || path === '/api/system/security/api-encrypt/public-key' || path === '/adminapi/system/security/api-encrypt/public-key' } function matchesEncryptedPath(path: string, encryptedPaths: string[] = []): boolean { const normalizedPath = normalizePath(path) return encryptedPaths.some((encryptedPath) => { const normalizedEncryptedPath = normalizePath(encryptedPath) return normalizedEncryptedPath === normalizedPath || normalizedPath.startsWith(`${normalizedEncryptedPath}/`) }) } function toBase64(buffer: ArrayBuffer | Uint8Array): string { const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer) let binary = '' bytes.forEach((byte) => { binary += String.fromCharCode(byte) }) return btoa(binary) } function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer } function fromPem(pem: string): ArrayBuffer { const clean = pem.replace(/-----BEGIN PUBLIC KEY-----/g, '') .replace(/-----END PUBLIC KEY-----/g, '') .replace(/\s+/g, '') const binary = atob(clean) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i) } return bytes.buffer } async function importPublicKey(publicKeyPem: string): Promise { return crypto.subtle.importKey( 'spki', fromPem(publicKeyPem), { name: 'RSA-OAEP', hash: 'SHA-256' }, true, ['encrypt'] ) } async function rsaEncrypt(publicKeyPem: string, data: Uint8Array): Promise { const publicKey = await importPublicKey(publicKeyPem) const encrypted = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, data) return toBase64(encrypted) } async function aesEncrypt(plainText: string, aesKey: Uint8Array, iv: Uint8Array): Promise { const cryptoKey = await crypto.subtle.importKey( 'raw', toArrayBuffer(aesKey), { name: 'AES-CBC' }, false, ['encrypt'] ) const encrypted = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toArrayBuffer(iv) }, cryptoKey, textEncoder.encode(plainText)) return toBase64(encrypted) } async function aesDecrypt(cipherText: string, aesKey: Uint8Array, iv: Uint8Array): Promise { const cryptoKey = await crypto.subtle.importKey( 'raw', toArrayBuffer(aesKey), { name: 'AES-CBC' }, false, ['decrypt'] ) const binary = atob(cipherText) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i) } const decrypted = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toArrayBuffer(iv) }, cryptoKey, toArrayBuffer(bytes)) return textDecoder.decode(decrypted) } async function fetchPublicKey(baseURL: string): Promise { const requestUrl = `${normalizeBaseUrl(baseURL)}${PUBLIC_KEY_PATH}` const response = await fetch(requestUrl, { method: 'GET', headers: { 'Content-Type': 'application/json' } }) const result = await response.json() if (!result || result.code !== 0 || !result.data) { return null } return result.data as PublicKeyResponse } async function ensurePublicKey(baseURL: string): Promise { if (publicKeyCache) { return publicKeyCache } if (publicKeyFailureAt > 0 && Date.now() - publicKeyFailureAt < PUBLIC_KEY_RETRY_INTERVAL) { return null } let data: PublicKeyResponse | null = null try { data = await fetchPublicKey(baseURL) } catch { publicKeyFailureAt = Date.now() return null } if (!data) { publicKeyFailureAt = Date.now() return null } publicKeyFailureAt = 0 publicKeyCache = data return publicKeyCache } export function clearApiEncryptPublicKeyCache() { publicKeyCache = null publicKeyFailureAt = 0 } export async function prepareEncryptedRequest(baseURL: string, path: string, method: string, body?: unknown): Promise { const resolvedPath = resolveRequestPath(baseURL, path) if (!resolvedPath.startsWith('/api/') && !resolvedPath.startsWith('/adminapi/')) { return { body: body ? JSON.stringify(body) : '', headers: {}, session: null } } if (shouldSkipEncrypt(resolvedPath)) { return { body: body ? JSON.stringify(body) : '', headers: {}, session: null } } const publicKeyData = await ensurePublicKey(baseURL) if (!publicKeyData || !publicKeyData.enabled || publicKeyData.scope === 'off') { return { body: body ? JSON.stringify(body) : '', headers: {}, session: null } } if (publicKeyData.scope === 'partial' && !matchesEncryptedPath(resolvedPath, publicKeyData.encrypted_paths || [])) { return { body: body ? JSON.stringify(body) : '', headers: {}, session: null } } const aesKey = crypto.getRandomValues(new Uint8Array(32)) const iv = crypto.getRandomValues(new Uint8Array(16)) const encryptedKey = await rsaEncrypt(publicKeyData.public_key, aesKey) const encryptedIv = await rsaEncrypt(publicKeyData.public_key, iv) let encryptedBody = '' if (body !== undefined && !['GET', 'HEAD'].includes(method.toUpperCase())) { const payload = JSON.stringify(body) encryptedBody = await aesEncrypt(payload, aesKey, iv) } return { body: encryptedBody === '' ? '' : JSON.stringify({ encrypted: true, algorithm: publicKeyData.algorithm, key_version: publicKeyData.key_version, data: encryptedBody, method }), headers: { 'X-Api-Encrypt': '1', 'X-Api-Encrypt-Version': publicKeyData.key_version, 'X-Api-Encrypt-Key': encryptedKey, 'X-Api-Encrypt-Iv': encryptedIv }, session: { aesKey, iv, keyVersion: publicKeyData.key_version, encryptedKey, encryptedIv } } } export async function decryptEncryptedResponse(data: any, session: EncryptSession | null): Promise { if (!data || data.encrypted !== true || !session) { return data } const plainText = await aesDecrypt(String(data.data || ''), session.aesKey, session.iv) return JSON.parse(plainText) }