diff --git a/easyflow-ui-admin/app/package.json b/easyflow-ui-admin/app/package.json index 200d647c..4ace20c9 100644 --- a/easyflow-ui-admin/app/package.json +++ b/easyflow-ui-admin/app/package.json @@ -39,6 +39,7 @@ "fetch-event-stream": "^0.1.6", "highlight.js": "^11.11.1", "markdown-it": "^14.1.0", + "node-forge": "1.3.1", "pinia": "catalog:", "radash": "^12.1.1", "vue": "catalog:", @@ -48,6 +49,7 @@ "vue3-json-viewer": "^2.4.1" }, "devDependencies": { + "@types/node-forge": "^1.3.14", "cssnano": "catalog:", "unplugin-element-plus": "catalog:" } diff --git a/easyflow-ui-admin/app/src/utils/credential-encryption.ts b/easyflow-ui-admin/app/src/utils/credential-encryption.ts index ac97dad7..39a259eb 100644 --- a/easyflow-ui-admin/app/src/utils/credential-encryption.ts +++ b/easyflow-ui-admin/app/src/utils/credential-encryption.ts @@ -28,9 +28,106 @@ interface TransitionPayload { oldKeyId: string; } +interface EncryptedCredentialEnvelope { + ciphertext: string; + encryptedKey: string; + iv: string; + keyId: string; + nonce: string; +} + +interface CredentialCryptoEngine { + encryptPayload( + material: CredentialKeyMaterial, + plaintext: string, + ): Promise; + verifyTransition( + publicKeyPem: string, + transition: CredentialKeyTransition, + ): Promise; +} + const PIN_STORAGE_KEY = 'easyflow:credential-key-pin:v1'; const SECURITY_ERROR = '登录安全校验失败,请刷新或联系管理员'; -const UNSUPPORTED_ERROR = '当前浏览器不支持登录安全加密,请升级浏览器'; +let forgeModulePromise: Promise | null = null; + +const webCryptoEngine: CredentialCryptoEngine = { + async encryptPayload(material, plaintext) { + const aesKey = await window.crypto.subtle.generateKey( + { length: 256, name: 'AES-GCM' }, + true, + ['encrypt'], + ); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await window.crypto.subtle.encrypt( + { iv, name: 'AES-GCM' }, + aesKey, + stringToUtf8Bytes(plaintext), + ); + const rawAesKey = await window.crypto.subtle.exportKey('raw', aesKey); + const publicKey = await importRsaPublicKey(material.publicKey, ['encrypt']); + const encryptedKey = await window.crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + publicKey, + rawAesKey, + ); + return { + ciphertext: bytesToBase64(new Uint8Array(ciphertext)), + encryptedKey: bytesToBase64(new Uint8Array(encryptedKey)), + iv: bytesToBase64(iv), + keyId: material.keyId, + nonce: material.nonce, + }; + }, + + async verifyTransition(publicKeyPem, transition) { + const publicKey = await importSignPublicKey(publicKeyPem); + return window.crypto.subtle.verify( + { name: 'RSASSA-PKCS1-v1_5' }, + publicKey, + base64ToBytes(transition.signature), + stringToUtf8Bytes(transition.payload), + ); + }, +}; + +const forgeCryptoEngine: CredentialCryptoEngine = { + async encryptPayload(material, plaintext) { + const forge = await loadForge(); + const aesKey = forge.random.getBytesSync(32); + const iv = forge.random.getBytesSync(12); + const cipher = forge.cipher.createCipher('AES-GCM', aesKey); + cipher.start({ iv, tagLength: 128 }); + cipher.update(forge.util.createBuffer(plaintext, 'utf8')); + cipher.finish(); + const ciphertextWithTag = cipher.output.getBytes() + cipher.mode.tag.getBytes(); + const publicKey = forge.pki.publicKeyFromPem(material.publicKey); + const encryptedKey = publicKey.encrypt(aesKey, 'RSA-OAEP', { + md: forge.md.sha256.create(), + mgf1: { + md: forge.md.sha256.create(), + }, + }); + return { + ciphertext: binaryToBase64(ciphertextWithTag), + encryptedKey: binaryToBase64(encryptedKey), + iv: binaryToBase64(iv), + keyId: material.keyId, + nonce: material.nonce, + }; + }, + + async verifyTransition(publicKeyPem, transition) { + const forge = await loadForge(); + const publicKey = forge.pki.publicKeyFromPem(publicKeyPem); + const md = forge.md.sha256.create(); + md.update(transition.payload, 'utf8'); + return publicKey.verify( + md.digest().bytes(), + forge.util.decode64(transition.signature), + ); + }, +}; /** * 加密密码类请求载荷。 @@ -42,44 +139,44 @@ export async function encryptCredentialPayload( loadMaterial: () => Promise, payload: Record, ) { - ensureWebCrypto(); const material = await loadMaterial(); - await verifyAndPersistPin(material); - const aesKey = await window.crypto.subtle.generateKey( - { length: 256, name: 'AES-GCM' }, - true, - ['encrypt'], - ); - const iv = window.crypto.getRandomValues(new Uint8Array(12)); - const plaintext = new TextEncoder().encode( - JSON.stringify({ - ...payload, - nonce: material.nonce, - timestamp: Date.now(), - }), - ); - const ciphertext = await window.crypto.subtle.encrypt( - { iv, name: 'AES-GCM' }, - aesKey, - plaintext, - ); - const rawAesKey = await window.crypto.subtle.exportKey('raw', aesKey); - const publicKey = await importRsaPublicKey(material.publicKey, ['encrypt']); - const encryptedKey = await window.crypto.subtle.encrypt( - { name: 'RSA-OAEP' }, - publicKey, - rawAesKey, - ); - return { - ciphertext: bytesToBase64(new Uint8Array(ciphertext)), - encryptedKey: bytesToBase64(new Uint8Array(encryptedKey)), - iv: bytesToBase64(iv), - keyId: material.keyId, - nonce: material.nonce, - }; + const engine = getCredentialCryptoEngine(); + try { + await verifyAndPersistPin(material, engine); + return await engine.encryptPayload( + material, + JSON.stringify({ + ...payload, + nonce: material.nonce, + timestamp: Date.now(), + }), + ); + } catch (error) { + if (error instanceof Error && error.message === SECURITY_ERROR) { + throw error; + } + throw new Error(SECURITY_ERROR); + } } -async function verifyAndPersistPin(material: CredentialKeyMaterial) { +function getCredentialCryptoEngine() { + return isWebCryptoAvailable() ? webCryptoEngine : forgeCryptoEngine; +} + +function isWebCryptoAvailable() { + return Boolean(window.crypto?.subtle && window.crypto.getRandomValues); +} + +async function loadForge() { + forgeModulePromise ||= import('node-forge'); + const forgeModule = await forgeModulePromise; + return forgeModule.default || forgeModule; +} + +async function verifyAndPersistPin( + material: CredentialKeyMaterial, + engine: CredentialCryptoEngine, +) { const current = readPin(); if (!current) { writePin(material); @@ -89,7 +186,7 @@ async function verifyAndPersistPin(material: CredentialKeyMaterial) { writePin(material); return; } - const nextPin = await verifyTransitionChain(current, material); + const nextPin = await verifyTransitionChain(current, material, engine); if (!nextPin || nextPin.fingerprint !== material.fingerprint) { throw new Error(SECURITY_ERROR); } @@ -99,6 +196,7 @@ async function verifyAndPersistPin(material: CredentialKeyMaterial) { async function verifyTransitionChain( current: CredentialKeyPin, material: CredentialKeyMaterial, + engine: CredentialCryptoEngine, ) { let cursor = { ...current }; const transitions = material.transitions || []; @@ -119,7 +217,7 @@ async function verifyTransitionChain( ) { return null; } - const verified = await verifyTransition(cursor.publicKey, transition); + const verified = await safeVerifyTransition(engine, cursor.publicKey, transition); if (!verified) { return null; } @@ -132,21 +230,20 @@ async function verifyTransitionChain( return cursor; } -async function verifyTransition( - publicKeyPem: string, +async function safeVerifyTransition( + engine: CredentialCryptoEngine, + publicKey: string, transition: CredentialKeyTransition, ) { - const publicKey = await importSignPublicKey(publicKeyPem); - return window.crypto.subtle.verify( - { name: 'RSASSA-PKCS1-v1_5' }, - publicKey, - base64ToBytes(transition.signature), - new TextEncoder().encode(transition.payload), - ); + try { + return await engine.verifyTransition(publicKey, transition); + } catch { + return false; + } } function decodeTransitionPayload(payload: string): TransitionPayload { - const json = new TextDecoder().decode(base64UrlToBytes(payload)); + const json = utf8BytesToString(base64UrlToBytes(payload)); return JSON.parse(json) as TransitionPayload; } @@ -170,12 +267,6 @@ async function importSignPublicKey(publicKeyPem: string) { ); } -function ensureWebCrypto() { - if (!window.crypto?.subtle || !window.crypto.getRandomValues) { - throw new Error(UNSUPPORTED_ERROR); - } -} - function readPin(): CredentialKeyPin | null { const raw = window.localStorage.getItem(PIN_STORAGE_KEY); if (!raw) { @@ -209,7 +300,14 @@ function pemToBytes(pem: string) { } function base64ToBytes(value: string) { - const binary = window.atob(value); + return binaryToBytes(base64ToBinary(value)); +} + +function base64ToBinary(value: string) { + return window.atob(value); +} + +function binaryToBytes(binary: string) { const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); @@ -219,16 +317,35 @@ function base64ToBytes(value: string) { function base64UrlToBytes(value: string) { const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); - const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + let padded = normalized; + while (padded.length % 4 !== 0) { + padded += '='; + } return base64ToBytes(padded); } function bytesToBase64(bytes: Uint8Array) { + return binaryToBase64(bytesToBinary(bytes)); +} + +function bytesToBinary(bytes: Uint8Array) { let binary = ''; - bytes.forEach((item) => { - binary += String.fromCharCode(item); - }); + for (let index = 0; index < bytes.length; index += 1) { + binary += String.fromCharCode(bytes[index] ?? 0); + } + return binary; +} + +function binaryToBase64(binary: string) { return window.btoa(binary); } +function stringToUtf8Bytes(value: string) { + return new TextEncoder().encode(value); +} + +function utf8BytesToString(bytes: Uint8Array) { + return new TextDecoder().decode(bytes); +} + export type { CredentialKeyMaterial }; diff --git a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.ts b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.ts index 91e527c6..9065364b 100644 --- a/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.ts +++ b/easyflow-ui-admin/app/src/views/ai/agents/composables/useAgentTryoutRawRounds.ts @@ -258,7 +258,7 @@ function selectedVariant(round: AgentTryoutRawRound) { return ( round.variants.find( (variant) => variant.variantIndex === round.selectedVariantIndex, - ) || round.variants.at(-1) + ) || round.variants[round.variants.length - 1] ); } diff --git a/easyflow-ui-admin/app/src/views/ai/resource/ResourceModal.vue b/easyflow-ui-admin/app/src/views/ai/resource/ResourceModal.vue index 448f5d5a..b69fe391 100644 --- a/easyflow-ui-admin/app/src/views/ai/resource/ResourceModal.vue +++ b/easyflow-ui-admin/app/src/views/ai/resource/ResourceModal.vue @@ -1,18 +1,17 @@