feat: 后端接口请求/响应体加解密(RSA + AES-GCM)

新增可一键开关的接口数据加密能力:前端经 RSA 公钥协商 AES-256 会话密钥,普通 JSON 接口的请求体与响应体统一以 AES-GCM 信封收发,避免明文 JSON 被抓包;SSE 流式接口、文件上传下载与 GET 查询参数按设计保持明文。

变更内容:
- 后端新增常量开关 CryptoConstants.API_ENCRYPTION_ENABLED 与 util/crypto(RSA 密钥管理、AES 会话存储、加解密服务)
- 后端新增 /api/crypto 公钥与握手接口,及会话拦截器、请求体解密与响应体加密 Advice,并在 WebConfig 注册、JWT 放行 /api/crypto
- 前端新增 utils/apiCrypto.ts(node-forge)并改造 api/request.ts,实现自动握手、请求/响应加解密与会话失效自动重试
- 前端 package.json 增加 node-forge 与 @types/node-forge 依赖

说明:
- RSA 密钥对持久化在 config/crypto-keys.json(已 gitignore),AES 会话密钥仅存内存并按 TTL 与上限清理
- 关闭常量开关后后端整体旁路,前端探测到 enabled=false 自动改走明文
- 不加密 SSE、multipart 上传、二进制下载/图片与 GET 查询参数
This commit is contained in:
2026-10-01 23:42:27 +08:00
parent 0aaceef235
commit 8c4f343c34
13 changed files with 1258 additions and 9 deletions
+2
View File
@@ -15,6 +15,7 @@
"echarts": "^6.1.0",
"highlight.js": "^11.12.0",
"naive-ui": "^2.45.2",
"node-forge": "^1.3.1",
"pinia": "^4.0.3",
"sql-formatter": "^15.9.0",
"vue": "^3.5.41",
@@ -25,6 +26,7 @@
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/node-forge": "^1.3.11",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
+64 -3
View File
@@ -2,6 +2,18 @@
import axios from 'axios'
import { useUserStore } from '../store/user'
import router from '../router'
import {
ENVELOPE_MARKER,
SESSION_EXPIRED_CODE,
decryptResponseBody,
encryptRequestBody,
ensureSession,
getSessionId,
isCryptoEnabled,
resetSession,
shouldSkipCrypto,
type CryptoEnvelope,
} from '../utils/apiCrypto'
// 创建 axios 实例,设置基础路径和超时时间
const request = axios.create({
@@ -9,12 +21,37 @@ const request = axios.create({
timeout: 10000, // 请求超时时间(毫秒)
})
// 请求拦截器:自动携带 Authorization 请求头
request.interceptors.request.use((config) => {
// 请求拦截器:自动携带 Authorization 请求头,并在启用加密时加密请求体
request.interceptors.request.use(async (config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}` // 附加 JWT token
}
// FormData、二进制下载、握手等请求不加密
if (!shouldSkipCrypto(config.url, config.data, config.responseType)) {
try {
await ensureSession()
} catch {
// 握手失败时退回明文,避免完全阻断业务
}
if (isCryptoEnabled()) {
const cfg = config as any
cfg.__rawData = config.data
// 标记本次请求携带加密会话:GET/DELETE 无请求体,仅凭该头让响应被加密
config.headers['X-Crypto-Session'] = getSessionId() as string
if (config.data !== undefined && config.data !== null) {
try {
const plain = typeof config.data === 'string' ? config.data : JSON.stringify(config.data)
config.data = encryptRequestBody(plain)
} catch {
// 加密失败时退回明文
cfg.__rawData = undefined
delete config.headers['X-Crypto-Session']
}
}
}
}
return config
})
@@ -41,9 +78,33 @@ request.interceptors.response.use(
}
return response
}
const data = response.data
let data = response.data
// 加密信封:先解密回原始业务响应体
if (data && typeof data === 'object' && (data as CryptoEnvelope).enc === ENVELOPE_MARKER) {
try {
data = JSON.parse(decryptResponseBody(data as CryptoEnvelope))
} catch (decryptError) {
return Promise.reject(decryptError)
}
}
if (!data || typeof data !== 'object') {
return data
}
// 如果业务状态码不是 200,视为请求失败
if (data.code !== 200) {
// 加密会话失效:清除本地会话并重试一次
const cryptoConfig = response.config as any
if (data.code === SESSION_EXPIRED_CODE && !cryptoConfig.__cryptoRetried) {
resetSession()
cryptoConfig.__cryptoRetried = true
// 恢复原始请求体,交给请求拦截器重新加密
if (cryptoConfig.__rawData !== undefined) {
cryptoConfig.data = cryptoConfig.__rawData
} else {
delete cryptoConfig.data
}
return request(cryptoConfig)
}
// 5031:数据库未配置,更新本地状态并跳转到配置引导页
if (data.code === 5031) {
import('../store/database').then(({ useDatabaseStore }) => {
+186
View File
@@ -0,0 +1,186 @@
// 接口数据加解密工具:基于 node-forge 实现「RSA 传输 AES 会话密钥 + AES-GCM 加密报文」
// 之所以不用原生 Web Crypto(crypto.subtle),是因为本应用在局域网以纯 HTTP 访问,
// crypto.subtle 仅在 HTTPS / localhost 等安全上下文可用。
import forge from 'node-forge'
/** 加密信封标记(与后端 CryptoConstants.ENVELOPE_MARKER 一致) */
export const ENVELOPE_MARKER = 'A256GCM'
/** 会话失效业务错误码(与后端 CryptoConstants.SESSION_EXPIRED_CODE 一致) */
export const SESSION_EXPIRED_CODE = 4201
/** 不参与加密的请求路径(SSE、文件上传下载、图片等) */
const SKIP_PATHS = [
'/crypto/',
'/ai/image/',
'/ai/record/image',
'/bill-import/parse',
'/transaction/export',
]
/** 加密信封结构(与后端一致) */
export interface CryptoEnvelope {
enc: string
iv: string
data: string
}
/** 是否启用加密:null 表示尚未探测,false 表示后端关闭,true 表示启用 */
let enabled: boolean | null = null
/** 当前会话标识 */
let sessionId: string | null = null
/** 当前 AES 会话密钥(forge 二进制字符串) */
let aesKey: string | null = null
/** 握手单飞:避免并发请求重复握手 */
let handshakePromise: Promise<void> | null = null
/**
* 判断当前请求是否跳过加密。
*
* @param url 请求地址(相对 baseURL)
* @param data 请求体
* @param responseType 响应类型
* @returns 跳过返回 true
*/
export function shouldSkipCrypto(
url: string | undefined,
data: unknown,
responseType: string | undefined
): boolean {
if (data instanceof FormData) return true
if (responseType === 'blob') return true
if (!url) return false
return SKIP_PATHS.some((path) => url.includes(path))
}
/**
* 后端是否已确认启用加密。
*
* @returns 已启用返回 true
*/
export function isCryptoEnabled(): boolean {
return enabled === true && sessionId !== null && aesKey !== null
}
/**
* 获取当前会话标识。
*
* @returns 会话标识,未建立时为 null
*/
export function getSessionId(): string | null {
return sessionId
}
/**
* 重置会话(会话失效时调用),下次请求会重新握手。
*/
export function resetSession(): void {
sessionId = null
aesKey = null
}
/**
* 确保已建立加密会话;单飞以避免并发重复握手。
* 后端关闭加密时标记为禁用并直接返回。
*/
export async function ensureSession(): Promise<void> {
if (enabled === false) return
if (sessionId !== null && aesKey !== null) return
if (handshakePromise) return handshakePromise
handshakePromise = doHandshake()
.catch((error) => {
// 握手失败不缓存,抛出交由调用方决定(调用方可退回明文)
sessionId = null
aesKey = null
throw error
})
.finally(() => {
handshakePromise = null
})
return handshakePromise
}
/**
* 执行握手:取公钥 -> 生成 AES 密钥 -> RSA 加密上送 -> 记录会话。
*/
async function doHandshake(): Promise<void> {
const publicKeyRes = await fetch('/api/crypto/public-key', {
headers: { Accept: 'application/json' },
})
const info = await publicKeyRes.json()
if (!info?.enabled) {
enabled = false
return
}
const publicKey = forge.pki.publicKeyFromPem(info.publicKey)
const key = forge.random.getBytesSync(32)
const encrypted = publicKey.encrypt(key, 'RSA-OAEP', {
md: forge.md.sha256.create(),
mgf1: { md: forge.md.sha256.create() },
} as any)
const handshakeRes = await fetch('/api/crypto/handshake', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyId: info.keyId, key: forge.util.encode64(encrypted) }),
})
const handshake = await handshakeRes.json()
if (!handshake?.sessionId) {
throw new Error('加密握手失败')
}
enabled = true
sessionId = handshake.sessionId
aesKey = key
}
/**
* 将明文请求体加密为信封对象。
*
* @param plain 明文(JSON 字符串)
* @returns 加密信封
*/
export function encryptRequestBody(plain: string): CryptoEnvelope {
if (!aesKey || !sessionId) {
throw new Error('加密会话未建立')
}
const iv = forge.random.getBytesSync(12)
const cipher = forge.cipher.createCipher('AES-GCM', aesKey)
cipher.start({ iv, additionalData: sessionId, tagLength: 128 } as any)
cipher.update(forge.util.createBuffer(forge.util.encodeUtf8(plain)))
if (!cipher.finish()) {
throw new Error('请求加密失败')
}
const ciphertext = cipher.output.getBytes()
const tag = (cipher.mode as any).tag.getBytes()
return {
enc: ENVELOPE_MARKER,
iv: forge.util.encode64(iv),
data: forge.util.encode64(ciphertext + tag),
}
}
/**
* 将响应信封解密为明文字符串。
*
* @param envelope 响应信封
* @returns 明文(JSON 字符串)
*/
export function decryptResponseBody(envelope: CryptoEnvelope): string {
if (!aesKey || !sessionId) {
throw new Error('加密会话未建立')
}
const iv = forge.util.decode64(envelope.iv)
const raw = forge.util.decode64(envelope.data)
// 末 16 字节为 GCM 认证标签,其余为密文
const tagLength = 16
const ciphertext = raw.slice(0, raw.length - tagLength)
const tag = raw.slice(raw.length - tagLength)
const decipher = forge.cipher.createDecipher('AES-GCM', aesKey)
decipher.start({ iv, additionalData: sessionId, tag, tagLength: 128 } as any)
decipher.update(forge.util.createBuffer(ciphertext))
if (!decipher.finish()) {
throw new Error('响应解密失败')
}
return forge.util.decodeUtf8(decipher.output.getBytes())
}