interface CredentialKeyMaterial { algorithm: string; expiresAt: string; fingerprint: string; keyId: string; nonce: string; publicKey: string; transitions?: CredentialKeyTransition[]; } interface CredentialKeyPin { fingerprint: string; keyId: string; publicKey: string; } interface CredentialKeyTransition { fromKeyId: string; payload: string; signature: string; toKeyId: string; } interface TransitionPayload { newFingerprint: string; newKeyId: string; newPublicKey: string; 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 = '登录安全校验失败,请刷新或联系管理员'; 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), ); }, }; /** * 加密密码类请求载荷。 * @param loadMaterial 获取后端公钥材料的方法 * @param payload 待加密载荷 * @returns 加密信封 */ export async function encryptCredentialPayload( loadMaterial: () => Promise, payload: Record, ) { const material = await loadMaterial(); 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); } } 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); return; } if (current.fingerprint === material.fingerprint) { writePin(material); return; } const nextPin = await verifyTransitionChain(current, material, engine); if (!nextPin || nextPin.fingerprint !== material.fingerprint) { throw new Error(SECURITY_ERROR); } writePin(material); } async function verifyTransitionChain( current: CredentialKeyPin, material: CredentialKeyMaterial, engine: CredentialCryptoEngine, ) { let cursor = { ...current }; const transitions = material.transitions || []; const visited = new Set(); while (cursor.fingerprint !== material.fingerprint) { if (visited.has(cursor.keyId)) { return null; } visited.add(cursor.keyId); const transition = transitions.find((item) => item.fromKeyId === cursor.keyId); if (!transition) { return null; } const payload = decodeTransitionPayload(transition.payload); if ( payload.oldKeyId !== cursor.keyId || payload.newKeyId !== transition.toKeyId ) { return null; } const verified = await safeVerifyTransition(engine, cursor.publicKey, transition); if (!verified) { return null; } cursor = { fingerprint: payload.newFingerprint, keyId: payload.newKeyId, publicKey: payload.newPublicKey, }; } return cursor; } async function safeVerifyTransition( engine: CredentialCryptoEngine, publicKey: string, transition: CredentialKeyTransition, ) { try { return await engine.verifyTransition(publicKey, transition); } catch { return false; } } function decodeTransitionPayload(payload: string): TransitionPayload { const json = utf8BytesToString(base64UrlToBytes(payload)); return JSON.parse(json) as TransitionPayload; } async function importRsaPublicKey(publicKeyPem: string, usages: KeyUsage[]) { return window.crypto.subtle.importKey( 'spki', pemToBytes(publicKeyPem), { hash: 'SHA-256', name: 'RSA-OAEP' }, false, usages, ); } async function importSignPublicKey(publicKeyPem: string) { return window.crypto.subtle.importKey( 'spki', pemToBytes(publicKeyPem), { hash: 'SHA-256', name: 'RSASSA-PKCS1-v1_5' }, false, ['verify'], ); } function readPin(): CredentialKeyPin | null { const raw = window.localStorage.getItem(PIN_STORAGE_KEY); if (!raw) { return null; } try { return JSON.parse(raw) as CredentialKeyPin; } catch { window.localStorage.removeItem(PIN_STORAGE_KEY); return null; } } function writePin(material: CredentialKeyMaterial) { window.localStorage.setItem( PIN_STORAGE_KEY, JSON.stringify({ fingerprint: material.fingerprint, keyId: material.keyId, publicKey: material.publicKey, }), ); } function pemToBytes(pem: string) { const base64 = pem .replace('-----BEGIN PUBLIC KEY-----', '') .replace('-----END PUBLIC KEY-----', '') .replace(/\s/g, ''); return base64ToBytes(base64); } function base64ToBytes(value: string) { 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); } return bytes; } function base64UrlToBytes(value: string) { const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); 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 = ''; 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 };