277 lines
8.2 KiB
TypeScript
277 lines
8.2 KiB
TypeScript
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<string, string>
|
||
|
|
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<CryptoKey> {
|
||
|
|
return crypto.subtle.importKey(
|
||
|
|
'spki',
|
||
|
|
fromPem(publicKeyPem),
|
||
|
|
{
|
||
|
|
name: 'RSA-OAEP',
|
||
|
|
hash: 'SHA-256'
|
||
|
|
},
|
||
|
|
true,
|
||
|
|
['encrypt']
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
async function rsaEncrypt(publicKeyPem: string, data: Uint8Array): Promise<string> {
|
||
|
|
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<string> {
|
||
|
|
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<string> {
|
||
|
|
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<PublicKeyResponse | null> {
|
||
|
|
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<PublicKeyResponse | null> {
|
||
|
|
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<EncryptRequestResult> {
|
||
|
|
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<any> {
|
||
|
|
if (!data || data.encrypted !== true || !session) {
|
||
|
|
return data
|
||
|
|
}
|
||
|
|
|
||
|
|
const plainText = await aesDecrypt(String(data.data || ''), session.aesKey, session.iv)
|
||
|
|
return JSON.parse(plainText)
|
||
|
|
}
|