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:
@@ -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",
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user