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())
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package cn.rosercode.ledgerly.common;
|
||||
|
||||
/**
|
||||
* 接口数据加解密常量。
|
||||
*
|
||||
* <p>集中维护接口请求/响应体加解密相关的开关、请求头、算法参数与会话配置,
|
||||
* 避免密钥、算法等散落在各处。<b>是否启用加密由 {@link #API_ENCRYPTION_ENABLED} 常量统一控制</b>:
|
||||
* 关闭时后端所有加解密逻辑旁路,前端探测到后自动改走明文。</p>
|
||||
*
|
||||
* @date 2026-10-01 23:35:10
|
||||
*/
|
||||
public final class CryptoConstants {
|
||||
|
||||
/**
|
||||
* 接口请求/响应体加密总开关:true 启用,false 关闭。
|
||||
*
|
||||
* <p>刻意使用 {@link Boolean} 包装类型而非 {@code boolean} 基本类型,
|
||||
* 避免被编译器当作编译期常量内联到使用处,确保修改本常量后重新编译即生效。</p>
|
||||
*/
|
||||
public static final Boolean API_ENCRYPTION_ENABLED = Boolean.TRUE;
|
||||
|
||||
/** 会话标识请求头名称 */
|
||||
public static final String SESSION_HEADER = "X-Crypto-Session";
|
||||
|
||||
/** 请求属性名:当前请求解析出的 AES 会话密钥(byte[]) */
|
||||
public static final String SESSION_KEY_ATTR = "cryptoSessionKey";
|
||||
|
||||
/** 请求属性名:当前请求携带的会话标识(String) */
|
||||
public static final String SESSION_ID_ATTR = "cryptoSessionId";
|
||||
|
||||
/** 会话失效业务错误码,前端据此自动重新握手并重试 */
|
||||
public static final int SESSION_EXPIRED_CODE = 4201;
|
||||
|
||||
/** 加密相关接口路径前缀(握手、取公钥等,不参与加解密与 JWT 认证) */
|
||||
public static final String CRYPTO_PATH_PREFIX = "/api/crypto";
|
||||
|
||||
/** AES-GCM 转换名 */
|
||||
public static final String AES_TRANSFORMATION = "AES/GCM/NoPadding";
|
||||
|
||||
/** AES 会话密钥长度(字节),对应 AES-256 */
|
||||
public static final int AES_KEY_BYTES = 32;
|
||||
|
||||
/** GCM 初始向量长度(字节) */
|
||||
public static final int GCM_IV_BYTES = 12;
|
||||
|
||||
/** GCM 认证标签长度(位) */
|
||||
public static final int GCM_TAG_BITS = 128;
|
||||
|
||||
/** RSA 密钥长度(位) */
|
||||
public static final int RSA_KEY_SIZE = 2048;
|
||||
|
||||
/** RSA-OAEP 转换名(摘要与 MGF1 均为 SHA-256,需显式指定 MGF1 以匹配前端 node-forge) */
|
||||
public static final String RSA_OAEP_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
|
||||
|
||||
/** 会话有效期(毫秒),默认 2 小时 */
|
||||
public static final long SESSION_TTL_MILLIS = 2 * 60 * 60 * 1000L;
|
||||
|
||||
/** 会话数量上限,超出时清理最旧的会话 */
|
||||
public static final int MAX_SESSIONS = 2000;
|
||||
|
||||
/** 加密信封标记值(enc 字段) */
|
||||
public static final String ENVELOPE_MARKER = "A256GCM";
|
||||
|
||||
private CryptoConstants() {
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import cn.rosercode.ledgerly.config.crypto.CryptoSessionInterceptor;
|
||||
import cn.rosercode.ledgerly.interceptor.AccessLogInterceptor;
|
||||
import cn.rosercode.ledgerly.interceptor.JwtInterceptor;
|
||||
|
||||
@@ -17,16 +18,20 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final JwtInterceptor jwtInterceptor;
|
||||
private final AccessLogInterceptor accessLogInterceptor;
|
||||
private final CryptoSessionInterceptor cryptoSessionInterceptor;
|
||||
|
||||
/**
|
||||
* 构造方法,注入 JWT 拦截器与访问日志拦截器。
|
||||
* 构造方法,注入 JWT 拦截器、访问日志拦截器与加密会话拦截器。
|
||||
*
|
||||
* @param jwtInterceptor JWT 拦截器实例
|
||||
* @param accessLogInterceptor 访问日志拦截器实例
|
||||
* @param jwtInterceptor JWT 拦截器实例
|
||||
* @param accessLogInterceptor 访问日志拦截器实例
|
||||
* @param cryptoSessionInterceptor 加密会话拦截器实例
|
||||
*/
|
||||
public WebConfig(JwtInterceptor jwtInterceptor, AccessLogInterceptor accessLogInterceptor) {
|
||||
public WebConfig(JwtInterceptor jwtInterceptor, AccessLogInterceptor accessLogInterceptor,
|
||||
CryptoSessionInterceptor cryptoSessionInterceptor) {
|
||||
this.jwtInterceptor = jwtInterceptor;
|
||||
this.accessLogInterceptor = accessLogInterceptor;
|
||||
this.cryptoSessionInterceptor = cryptoSessionInterceptor;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,11 +47,17 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
registry.addInterceptor(accessLogInterceptor)
|
||||
.addPathPatterns("/api/**");
|
||||
|
||||
// 加密会话拦截器需在 JWT 之前解析会话,供请求体解密与响应体加密使用
|
||||
registry.addInterceptor(cryptoSessionInterceptor)
|
||||
.addPathPatterns("/api/**");
|
||||
|
||||
registry.addInterceptor(jwtInterceptor)
|
||||
.addPathPatterns("/api/**")
|
||||
// 登录、注册与状态查询接口不需要认证;数据库状态查询用于前端判断是否已配置
|
||||
// 登录、注册与状态查询接口不需要认证;数据库状态查询用于前端判断是否已配置;
|
||||
// 加密握手接口需在登录前可达,同样免认证
|
||||
.excludePathPatterns("/api/user/login", "/api/user/register",
|
||||
"/api/user/registration-status", "/api/database/status");
|
||||
"/api/user/registration-status", "/api/database/status",
|
||||
"/api/crypto/**");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package cn.rosercode.ledgerly.config.crypto;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Type;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpInputMessage;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.util.StreamUtils;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.context.request.RequestAttributes;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdviceAdapter;
|
||||
|
||||
import cn.rosercode.ledgerly.common.BusinessException;
|
||||
import cn.rosercode.ledgerly.common.CryptoConstants;
|
||||
import cn.rosercode.ledgerly.util.crypto.ApiCryptoService;
|
||||
|
||||
/**
|
||||
* 加密请求体解密通知器。
|
||||
*
|
||||
* <p>在 {@code @RequestBody} 反序列化前,若请求携带有效加密会话则将信封解密为明文 JSON,
|
||||
* 再交由消息转换器绑定 DTO;未开启加密、未携带会话或报文非信封时保持原样。
|
||||
* 当报文是加密信封但会话已失效时抛出专用错误码,前端据此重新握手并重试。</p>
|
||||
*
|
||||
* @date 2026-10-01 23:42:30
|
||||
*/
|
||||
@ControllerAdvice
|
||||
public class ApiCryptoRequestBodyAdvice extends RequestBodyAdviceAdapter {
|
||||
|
||||
private final ApiCryptoService apiCryptoService;
|
||||
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param apiCryptoService 加解密服务
|
||||
*/
|
||||
public ApiCryptoRequestBodyAdvice(ApiCryptoService apiCryptoService) {
|
||||
this.apiCryptoService = apiCryptoService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否对该参数应用解密:仅在加密总开关开启时启用。
|
||||
*
|
||||
* @param methodParameter 方法参数
|
||||
* @param targetType 目标类型
|
||||
* @param converterType 消息转换器类型
|
||||
* @return 启用返回 true
|
||||
*/
|
||||
@Override
|
||||
public boolean supports(MethodParameter methodParameter, Type targetType,
|
||||
Class<? extends HttpMessageConverter<?>> converterType) {
|
||||
return apiCryptoService.enabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取请求体并按需解密。
|
||||
*
|
||||
* @param inputMessage 原始请求体
|
||||
* @param parameter 方法参数
|
||||
* @param targetType 目标类型
|
||||
* @param converterType 消息转换器类型
|
||||
* @return 处理后的请求体
|
||||
* @throws IOException 读取请求体失败
|
||||
*/
|
||||
@Override
|
||||
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter,
|
||||
Type targetType,
|
||||
Class<? extends HttpMessageConverter<?>> converterType)
|
||||
throws IOException {
|
||||
byte[] raw = StreamUtils.copyToByteArray(inputMessage.getBody());
|
||||
HttpServletRequest request = currentRequest();
|
||||
if (request == null || raw.length == 0
|
||||
|| request.getRequestURI().startsWith(CryptoConstants.CRYPTO_PATH_PREFIX)) {
|
||||
return new WrappedInputMessage(raw, inputMessage.getHeaders());
|
||||
}
|
||||
byte[] key = (byte[]) request.getAttribute(CryptoConstants.SESSION_KEY_ATTR);
|
||||
if (key == null) {
|
||||
// 无有效会话却收到加密信封,说明会话已过期,返回明文错误信息供前端恢复
|
||||
if (apiCryptoService.isEnvelope(raw)) {
|
||||
throw new BusinessException(CryptoConstants.SESSION_EXPIRED_CODE, "加密会话已失效,请重试");
|
||||
}
|
||||
return new WrappedInputMessage(raw, inputMessage.getHeaders());
|
||||
}
|
||||
// 有会话但报文非信封(如个别未加密请求),按原样处理
|
||||
if (!apiCryptoService.isEnvelope(raw)) {
|
||||
return new WrappedInputMessage(raw, inputMessage.getHeaders());
|
||||
}
|
||||
String sessionId = (String) request.getAttribute(CryptoConstants.SESSION_ID_ATTR);
|
||||
byte[] plain = apiCryptoService.decryptRequestBody(sessionId, raw);
|
||||
return new WrappedInputMessage(plain, inputMessage.getHeaders());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前请求对象。
|
||||
*
|
||||
* @return HTTP 请求,非 Servlet 环境时为 null
|
||||
*/
|
||||
private HttpServletRequest currentRequest() {
|
||||
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
|
||||
if (attributes instanceof ServletRequestAttributes servletAttributes) {
|
||||
return servletAttributes.getRequest();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可重复读取的请求体包装。
|
||||
*
|
||||
* @param body 请求体字节
|
||||
* @param headers 请求头
|
||||
*/
|
||||
private record WrappedInputMessage(byte[] body, HttpHeaders headers) implements HttpInputMessage {
|
||||
|
||||
@Override
|
||||
public InputStream getBody() {
|
||||
return new ByteArrayInputStream(body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders getHeaders() {
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package cn.rosercode.ledgerly.config.crypto;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.server.ServerHttpRequest;
|
||||
import org.springframework.http.server.ServerHttpResponse;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.context.request.RequestAttributes;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
||||
|
||||
import cn.rosercode.ledgerly.common.BusinessException;
|
||||
import cn.rosercode.ledgerly.common.CryptoConstants;
|
||||
import cn.rosercode.ledgerly.common.Result;
|
||||
import cn.rosercode.ledgerly.util.crypto.ApiCryptoService;
|
||||
|
||||
/**
|
||||
* 加密响应体通知器。
|
||||
*
|
||||
* <p>仅对统一响应体 {@link Result} 且当前请求携带有效加密会话时,将其序列化后加密为信封返回;
|
||||
* 无会话(如会话失效的明文错误、握手等)时保持明文。SSE、二进制下载等非 {@code Result}
|
||||
* 返回体天然不被处理。</p>
|
||||
*
|
||||
* @date 2026-10-01 23:43:40
|
||||
*/
|
||||
@ControllerAdvice
|
||||
public class ApiCryptoResponseBodyAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
private final ApiCryptoService apiCryptoService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param apiCryptoService 加解密服务
|
||||
* @param objectMapper Jackson 对象映射器
|
||||
*/
|
||||
public ApiCryptoResponseBodyAdvice(ApiCryptoService apiCryptoService, ObjectMapper objectMapper) {
|
||||
this.apiCryptoService = apiCryptoService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅当开启加密且返回类型为 {@link Result} 时生效。
|
||||
*
|
||||
* @param returnType 返回类型
|
||||
* @param converterType 消息转换器类型
|
||||
* @return 生效返回 true
|
||||
*/
|
||||
@Override
|
||||
public boolean supports(MethodParameter returnType,
|
||||
Class<? extends HttpMessageConverter<?>> converterType) {
|
||||
return apiCryptoService.enabled()
|
||||
&& Result.class.isAssignableFrom(returnType.getParameterType());
|
||||
}
|
||||
|
||||
/**
|
||||
* 写出前按需加密响应体。
|
||||
*
|
||||
* @param body 响应体
|
||||
* @param returnType 返回类型
|
||||
* @param selectedContentType 选定内容类型
|
||||
* @param selectedConverterType 选定消息转换器类型
|
||||
* @param request 请求
|
||||
* @param response 响应
|
||||
* @return 加密信封或原响应体
|
||||
*/
|
||||
@Override
|
||||
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request, ServerHttpResponse response) {
|
||||
if (!(body instanceof Result)) {
|
||||
return body;
|
||||
}
|
||||
HttpServletRequest httpRequest = currentRequest();
|
||||
if (httpRequest == null) {
|
||||
return body;
|
||||
}
|
||||
Object sessionId = httpRequest.getAttribute(CryptoConstants.SESSION_ID_ATTR);
|
||||
if (sessionId == null) {
|
||||
return body;
|
||||
}
|
||||
try {
|
||||
byte[] json = objectMapper.writeValueAsBytes(body);
|
||||
return apiCryptoService.encryptResponseBody((String) sessionId, json);
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("响应数据加密失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前请求对象。
|
||||
*
|
||||
* @return HTTP 请求,非 Servlet 环境时为 null
|
||||
*/
|
||||
private HttpServletRequest currentRequest() {
|
||||
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
|
||||
if (attributes instanceof ServletRequestAttributes servletAttributes) {
|
||||
return servletAttributes.getRequest();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cn.rosercode.ledgerly.config.crypto;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import cn.rosercode.ledgerly.common.CryptoConstants;
|
||||
import cn.rosercode.ledgerly.util.crypto.ApiCryptoService;
|
||||
|
||||
/**
|
||||
* 接口加密会话拦截器。
|
||||
*
|
||||
* <p>在请求进入控制器前,从请求头 {@code X-Crypto-Session} 解析会话标识并取出 AES 会话密钥,
|
||||
* 写入请求属性供请求体解密与响应体加密使用。加密关闭或未携带会话时不做处理(保持明文),
|
||||
* 因此对不加密的接口(SSE、文件上传下载等)完全透明。</p>
|
||||
*
|
||||
* @date 2026-10-01 23:41:05
|
||||
*/
|
||||
@Component
|
||||
public class CryptoSessionInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final ApiCryptoService apiCryptoService;
|
||||
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param apiCryptoService 加解密服务
|
||||
*/
|
||||
public CryptoSessionInterceptor(ApiCryptoService apiCryptoService) {
|
||||
this.apiCryptoService = apiCryptoService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析加密会话并写入请求属性。
|
||||
*
|
||||
* @param request HTTP 请求
|
||||
* @param response HTTP 响应
|
||||
* @param handler 处理器
|
||||
* @return 始终返回 true
|
||||
*/
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
if (!apiCryptoService.enabled()) {
|
||||
return true;
|
||||
}
|
||||
// 握手等加密接口自身不参与加解密
|
||||
if (request.getRequestURI().startsWith(CryptoConstants.CRYPTO_PATH_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
String sessionId = request.getHeader(CryptoConstants.SESSION_HEADER);
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
byte[] key = apiCryptoService.sessionKey(sessionId);
|
||||
if (key != null) {
|
||||
request.setAttribute(CryptoConstants.SESSION_ID_ATTR, sessionId);
|
||||
request.setAttribute(CryptoConstants.SESSION_KEY_ATTR, key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package cn.rosercode.ledgerly.controller;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import cn.rosercode.ledgerly.dto.CryptoHandshakeRequest;
|
||||
import cn.rosercode.ledgerly.util.crypto.ApiCryptoService;
|
||||
import cn.rosercode.ledgerly.util.crypto.RsaKeyManager;
|
||||
|
||||
/**
|
||||
* 接口加密握手控制器。
|
||||
*
|
||||
* <p>提供公钥获取与握手接口,供前端协商 AES 会话密钥。这两个接口本身不参与加解密,
|
||||
* 也不要求 JWT 认证(在 {@code WebConfig} 中排除),否则前端在登录前无法建立加密会话。</p>
|
||||
*
|
||||
* @date 2026-10-01 23:40:12
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/crypto")
|
||||
public class CryptoController {
|
||||
|
||||
private final ApiCryptoService apiCryptoService;
|
||||
private final RsaKeyManager rsaKeyManager;
|
||||
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param apiCryptoService 加解密服务
|
||||
* @param rsaKeyManager RSA 密钥管理器
|
||||
*/
|
||||
public CryptoController(ApiCryptoService apiCryptoService, RsaKeyManager rsaKeyManager) {
|
||||
this.apiCryptoService = apiCryptoService;
|
||||
this.rsaKeyManager = rsaKeyManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取接口加密状态与 RSA 公钥。
|
||||
*
|
||||
* <p>加密关闭时仅返回 {@code enabled=false},前端据此改走明文。</p>
|
||||
*
|
||||
* @return 加密状态与公钥信息
|
||||
*/
|
||||
@GetMapping("/public-key")
|
||||
public Map<String, Object> publicKey() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("enabled", apiCryptoService.enabled());
|
||||
if (apiCryptoService.enabled()) {
|
||||
result.put("keyId", rsaKeyManager.getKeyId());
|
||||
result.put("algorithm", "RSA-OAEP-SHA256");
|
||||
result.put("publicKey", rsaKeyManager.getPublicKeyPem());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立加密会话。
|
||||
*
|
||||
* @param request 握手请求(公钥标识 + RSA 加密的 AES 密钥)
|
||||
* @return 会话标识
|
||||
*/
|
||||
@PostMapping("/handshake")
|
||||
public Map<String, Object> handshake(@RequestBody CryptoHandshakeRequest request) {
|
||||
String sessionId = apiCryptoService.handshake(request.getKeyId(), request.getKey());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("sessionId", sessionId);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.rosercode.ledgerly.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 接口加密握手请求。
|
||||
*
|
||||
* <p>前端携带所用公钥标识与 RSA-OAEP 加密后的 AES 会话密钥,向后端发起握手。</p>
|
||||
*
|
||||
* @date 2026-10-01 23:39:40
|
||||
*/
|
||||
@Data
|
||||
public class CryptoHandshakeRequest {
|
||||
|
||||
/** 前端所用 RSA 公钥标识 */
|
||||
private String keyId;
|
||||
|
||||
/** RSA-OAEP(SHA-256) 加密后的 AES 会话密钥(Base64) */
|
||||
private String key;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package cn.rosercode.ledgerly.util.crypto;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import cn.rosercode.ledgerly.common.BusinessException;
|
||||
import cn.rosercode.ledgerly.common.CryptoConstants;
|
||||
|
||||
/**
|
||||
* 接口数据加解密服务。
|
||||
*
|
||||
* <p>基于「RSA 传输 AES 会话密钥 + AES-GCM 加密报文」的混合加密方案:
|
||||
* 握手阶段用 RSA-OAEP(SHA-256) 解出前端随机生成的 AES-256 会话密钥并存入会话表;
|
||||
* 之后请求体、响应体统一以 {@code {"enc","iv","data"}} 信封形式收发,
|
||||
* 其中 GCM 的附加认证数据(AAD)绑定会话标识,避免跨会话重放。</p>
|
||||
*
|
||||
* @date 2026-10-01 23:38:15
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ApiCryptoService {
|
||||
|
||||
private final RsaKeyManager rsaKeyManager;
|
||||
private final CryptoSessionStore sessionStore;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** 初始向量随机源 */
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param rsaKeyManager RSA 密钥管理器
|
||||
* @param sessionStore 会话存储
|
||||
* @param objectMapper Jackson 对象映射器
|
||||
*/
|
||||
public ApiCryptoService(RsaKeyManager rsaKeyManager, CryptoSessionStore sessionStore, ObjectMapper objectMapper) {
|
||||
this.rsaKeyManager = rsaKeyManager;
|
||||
this.sessionStore = sessionStore;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否启用接口加密(由常量总开关控制)。
|
||||
*
|
||||
* @return 启用返回 true
|
||||
*/
|
||||
public boolean enabled() {
|
||||
return Boolean.TRUE.equals(CryptoConstants.API_ENCRYPTION_ENABLED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理握手:用 RSA 私钥解出 AES 会话密钥并创建会话。
|
||||
*
|
||||
* @param keyId 前端所用公钥标识
|
||||
* @param encryptedKeyBase64 RSA-OAEP 加密后的 AES 密钥(Base64)
|
||||
* @return 新会话标识
|
||||
*/
|
||||
public String handshake(String keyId, String encryptedKeyBase64) {
|
||||
if (!rsaKeyManager.getKeyId().equals(keyId)) {
|
||||
throw new BusinessException("加密密钥标识不匹配,请刷新页面后重试");
|
||||
}
|
||||
if (encryptedKeyBase64 == null || encryptedKeyBase64.isBlank()) {
|
||||
throw new BusinessException("缺少加密会话密钥");
|
||||
}
|
||||
byte[] aesKey = rsaKeyManager.decrypt(Base64.getDecoder().decode(encryptedKeyBase64));
|
||||
if (aesKey.length != CryptoConstants.AES_KEY_BYTES) {
|
||||
throw new BusinessException("会话密钥长度非法");
|
||||
}
|
||||
String sessionId = sessionStore.create(aesKey);
|
||||
log.debug("已建立接口加密会话,keyId={}", keyId);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话密钥。
|
||||
*
|
||||
* @param sessionId 会话标识
|
||||
* @return AES 会话密钥,无效时为 null
|
||||
*/
|
||||
public byte[] sessionKey(String sessionId) {
|
||||
return sessionStore.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密请求体信封。
|
||||
*
|
||||
* @param sessionId 会话标识
|
||||
* @param body 请求体字节(加密信封 JSON)
|
||||
* @return 解密后的明文字节
|
||||
*/
|
||||
public byte[] decryptRequestBody(String sessionId, byte[] body) {
|
||||
byte[] key = requireSessionKey(sessionId);
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
byte[] iv = Base64.getDecoder().decode(node.path("iv").asText());
|
||||
byte[] data = Base64.getDecoder().decode(node.path("data").asText());
|
||||
return aesGcmDecrypt(key, iv, data, sessionId);
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.warn("解密请求体失败:{}", e.getMessage());
|
||||
throw new BusinessException("请求数据解密失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将明文响应体加密为信封结构。
|
||||
*
|
||||
* @param sessionId 会话标识
|
||||
* @param plain 明文响应体字节(Result 序列化 JSON)
|
||||
* @return 加密信封(可直接由 Jackson 序列化返回)
|
||||
*/
|
||||
public Map<String, Object> encryptResponseBody(String sessionId, byte[] plain) {
|
||||
byte[] key = requireSessionKey(sessionId);
|
||||
Map<String, Object> envelope = new LinkedHashMap<>();
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(CryptoConstants.AES_TRANSFORMATION);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"),
|
||||
new GCMParameterSpec(CryptoConstants.GCM_TAG_BITS, newIv()));
|
||||
// 绑定会话标识,避免密文被挪用于其他会话
|
||||
cipher.updateAAD(sessionId.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] encrypted = cipher.doFinal(plain);
|
||||
byte[] iv = cipher.getIV();
|
||||
|
||||
envelope.put("enc", CryptoConstants.ENVELOPE_MARKER);
|
||||
envelope.put("iv", Base64.getEncoder().encodeToString(iv));
|
||||
envelope.put("data", Base64.getEncoder().encodeToString(encrypted));
|
||||
return envelope;
|
||||
} catch (Exception e) {
|
||||
log.error("加密响应体失败:{}", e.getMessage());
|
||||
throw new BusinessException("响应数据加密失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断请求体是否为加密信封。
|
||||
*
|
||||
* @param body 请求体字节
|
||||
* @return 是信封返回 true
|
||||
*/
|
||||
public boolean isEnvelope(byte[] body) {
|
||||
if (body == null || body.length == 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
return node != null && node.hasNonNull("enc")
|
||||
&& CryptoConstants.ENVELOPE_MARKER.equals(node.path("enc").asText());
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话密钥,无效时抛出会话失效异常(前端据此重新握手)。
|
||||
*
|
||||
* @param sessionId 会话标识
|
||||
* @return AES 会话密钥
|
||||
*/
|
||||
private byte[] requireSessionKey(String sessionId) {
|
||||
byte[] key = sessionStore.get(sessionId);
|
||||
if (key == null) {
|
||||
throw new BusinessException(CryptoConstants.SESSION_EXPIRED_CODE, "加密会话已失效,请重试");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 12 字节随机初始向量。
|
||||
*
|
||||
* @return 初始向量
|
||||
*/
|
||||
private byte[] newIv() {
|
||||
byte[] iv = new byte[CryptoConstants.GCM_IV_BYTES];
|
||||
secureRandom.nextBytes(iv);
|
||||
return iv;
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-GCM 解密。
|
||||
*
|
||||
* @param key AES 密钥
|
||||
* @param iv 初始向量
|
||||
* @param data 密文(含认证标签)
|
||||
* @param sessionId 会话标识(作为 AAD)
|
||||
* @return 明文字节
|
||||
* @throws Exception 解密失败
|
||||
*/
|
||||
private byte[] aesGcmDecrypt(byte[] key, byte[] iv, byte[] data, String sessionId) throws Exception {
|
||||
Cipher cipher = Cipher.getInstance(CryptoConstants.AES_TRANSFORMATION);
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"),
|
||||
new GCMParameterSpec(CryptoConstants.GCM_TAG_BITS, iv));
|
||||
cipher.updateAAD(sessionId.getBytes(StandardCharsets.UTF_8));
|
||||
return cipher.doFinal(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package cn.rosercode.ledgerly.util.crypto;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import cn.rosercode.ledgerly.common.CryptoConstants;
|
||||
|
||||
/**
|
||||
* 接口加密会话存储。
|
||||
*
|
||||
* <p>保存前端握手时协商出的 AES 会话密钥(仅存内存,不落盘),以会话标识为键,
|
||||
* 带有效期与数量上限:读取时校验过期,并定时清理;数量超限时优先清理已过期会话,
|
||||
* 仍超限则删除最早创建的会话,避免内存无限增长。</p>
|
||||
*
|
||||
* @date 2026-10-01 23:37:20
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class CryptoSessionStore {
|
||||
|
||||
/** 会话表:会话标识 -> 会话密钥与过期时间 */
|
||||
private final Map<String, Session> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
/** 会话标识随机源 */
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
/**
|
||||
* 创建会话并保存 AES 密钥。
|
||||
*
|
||||
* @param key AES 会话密钥
|
||||
* @return 新会话标识
|
||||
*/
|
||||
public String create(byte[] key) {
|
||||
evictIfNeeded();
|
||||
String sessionId = newSessionId();
|
||||
sessions.put(sessionId, new Session(key, System.currentTimeMillis() + CryptoConstants.SESSION_TTL_MILLIS));
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话密钥,会话不存在或已过期时返回 null 并顺带移除过期项。
|
||||
*
|
||||
* @param sessionId 会话标识
|
||||
* @return AES 会话密钥,无效时为 null
|
||||
*/
|
||||
public byte[] get(String sessionId) {
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Session session = sessions.get(sessionId);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
if (session.expireAt() < System.currentTimeMillis()) {
|
||||
sessions.remove(sessionId);
|
||||
return null;
|
||||
}
|
||||
return session.key();
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除指定会话。
|
||||
*
|
||||
* @param sessionId 会话标识
|
||||
*/
|
||||
public void remove(String sessionId) {
|
||||
if (sessionId != null) {
|
||||
sessions.remove(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时清理过期会话。
|
||||
*/
|
||||
@Scheduled(fixedDelay = 10 * 60 * 1000L, initialDelay = 10 * 60 * 1000L)
|
||||
public void cleanup() {
|
||||
long now = System.currentTimeMillis();
|
||||
sessions.entrySet().removeIf(entry -> entry.getValue().expireAt() < now);
|
||||
}
|
||||
|
||||
/**
|
||||
* 数量超限时清理会话:先清过期项,仍超限则删除最早创建的会话。
|
||||
*/
|
||||
private void evictIfNeeded() {
|
||||
if (sessions.size() < CryptoConstants.MAX_SESSIONS) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
sessions.entrySet().removeIf(entry -> entry.getValue().expireAt() < now);
|
||||
while (sessions.size() >= CryptoConstants.MAX_SESSIONS) {
|
||||
sessions.entrySet().stream()
|
||||
.min(Comparator.comparingLong(entry -> entry.getValue().expireAt()))
|
||||
.map(Map.Entry::getKey)
|
||||
.ifPresent(this::remove);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机会话标识(URL 安全的 Base64,24 字节随机)。
|
||||
*
|
||||
* @return 会话标识
|
||||
*/
|
||||
private String newSessionId() {
|
||||
byte[] bytes = new byte[24];
|
||||
random.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话记录。
|
||||
*
|
||||
* @param key AES 会话密钥
|
||||
* @param expireAt 过期时间戳(毫秒)
|
||||
*/
|
||||
private record Session(byte[] key, long expireAt) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package cn.rosercode.ledgerly.util.crypto;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.OAEPParameterSpec;
|
||||
import javax.crypto.spec.PSource;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import cn.rosercode.ledgerly.common.CryptoConstants;
|
||||
import cn.rosercode.ledgerly.util.AppHome;
|
||||
|
||||
/**
|
||||
* 接口加密 RSA 密钥管理器。
|
||||
*
|
||||
* <p>负责 RSA 密钥对的生成、持久化与加载,并提供 RSA-OAEP(SHA-256) 解密能力,
|
||||
* 用于解密前端上送的 AES 会话密钥。密钥对保存在应用基准目录下的
|
||||
* {@code config/crypto-keys.json}(该目录已 gitignore),首次启动时生成,之后复用。</p>
|
||||
*
|
||||
* @date 2026-10-01 23:36:02
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class RsaKeyManager {
|
||||
|
||||
/** 密钥文件名 */
|
||||
private static final String KEY_FILE = "crypto-keys.json";
|
||||
|
||||
/** keyId 取公钥 SHA-256 摘要的前 16 字节(32 位十六进制) */
|
||||
private static final int KEY_ID_BYTES = 16;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** RSA 私钥 */
|
||||
private PrivateKey privateKey;
|
||||
/** RSA 公钥 */
|
||||
private PublicKey publicKey;
|
||||
/** 密钥标识 */
|
||||
private String keyId;
|
||||
|
||||
/**
|
||||
* 构造方法:加载已持久化的密钥对,不存在或损坏时重新生成并保存。
|
||||
*
|
||||
* @param objectMapper Jackson 对象映射器
|
||||
*/
|
||||
public RsaKeyManager(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化密钥对。
|
||||
*/
|
||||
private void init() {
|
||||
Path file = AppHome.configDir().resolve(KEY_FILE);
|
||||
if (Files.exists(file)) {
|
||||
try {
|
||||
KeyFile keyFile = objectMapper.readValue(file.toFile(), KeyFile.class);
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
this.privateKey = keyFactory.generatePrivate(
|
||||
new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyFile.getPrivateKey())));
|
||||
this.publicKey = keyFactory.generatePublic(
|
||||
new X509EncodedKeySpec(Base64.getDecoder().decode(keyFile.getPublicKey())));
|
||||
this.keyId = keyFile.getKeyId();
|
||||
log.info("已加载接口加密 RSA 密钥,keyId={}", keyId);
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
log.warn("加载接口加密 RSA 密钥失败,将重新生成:{}", e.getMessage());
|
||||
}
|
||||
}
|
||||
generateAndSave(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 RSA 密钥对并持久化。
|
||||
*
|
||||
* @param file 密钥文件路径
|
||||
*/
|
||||
private void generateAndSave(Path file) {
|
||||
try {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(CryptoConstants.RSA_KEY_SIZE);
|
||||
KeyPair keyPair = generator.generateKeyPair();
|
||||
this.privateKey = keyPair.getPrivate();
|
||||
this.publicKey = keyPair.getPublic();
|
||||
this.keyId = computeKeyId(this.publicKey.getEncoded());
|
||||
|
||||
KeyFile keyFile = new KeyFile();
|
||||
keyFile.setKeyId(this.keyId);
|
||||
keyFile.setPublicKey(Base64.getEncoder().encodeToString(this.publicKey.getEncoded()));
|
||||
keyFile.setPrivateKey(Base64.getEncoder().encodeToString(this.privateKey.getEncoded()));
|
||||
keyFile.setCreatedAt(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||
|
||||
Files.createDirectories(file.getParent());
|
||||
objectMapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), keyFile);
|
||||
log.info("已生成接口加密 RSA 密钥并保存到 {},keyId={}", file.toAbsolutePath(), keyId);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("生成接口加密 RSA 密钥失败:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算密钥标识(公钥 SHA-256 摘要前 16 字节的十六进制)。
|
||||
*
|
||||
* @param encodedPublicKey 公钥编码字节
|
||||
* @return 密钥标识
|
||||
*/
|
||||
private String computeKeyId(byte[] encodedPublicKey) {
|
||||
try {
|
||||
byte[] hash = MessageDigest.getInstance("SHA-256").digest(encodedPublicKey);
|
||||
StringBuilder sb = new StringBuilder(KEY_ID_BYTES * 2);
|
||||
for (int i = 0; i < KEY_ID_BYTES; i++) {
|
||||
sb.append(String.format("%02x", hash[i]));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("计算 RSA 密钥标识失败:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取密钥标识。
|
||||
*
|
||||
* @return 密钥标识
|
||||
*/
|
||||
public String getKeyId() {
|
||||
return keyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公钥的 PEM 文本(SPKI,供前端 node-forge 解析)。
|
||||
*
|
||||
* @return PEM 格式公钥
|
||||
*/
|
||||
public String getPublicKeyPem() {
|
||||
String base64 = Base64.getEncoder().encodeToString(publicKey.getEncoded());
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("-----BEGIN PUBLIC KEY-----\n");
|
||||
for (int i = 0; i < base64.length(); i += 64) {
|
||||
sb.append(base64, i, Math.min(i + 64, base64.length())).append('\n');
|
||||
}
|
||||
sb.append("-----END PUBLIC KEY-----");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用私钥解密前端上送的 RSA-OAEP(SHA-256) 密文。
|
||||
*
|
||||
* @param cipherText 密文字节
|
||||
* @return 明文字节
|
||||
*/
|
||||
public byte[] decrypt(byte[] cipherText) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(CryptoConstants.RSA_OAEP_TRANSFORMATION);
|
||||
// JDK 的 OAEP MGF1 默认使用 SHA-1,必须显式指定 SHA-256,否则与前端 node-forge 不匹配
|
||||
OAEPParameterSpec spec = new OAEPParameterSpec("SHA-256", "MGF1",
|
||||
MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT);
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey, spec);
|
||||
return cipher.doFinal(cipherText);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("RSA 解密失败:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA 密钥文件结构。
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class KeyFile {
|
||||
|
||||
/** 密钥标识 */
|
||||
private String keyId;
|
||||
/** 公钥(SPKI Base64) */
|
||||
private String publicKey;
|
||||
/** 私钥(PKCS#8 Base64) */
|
||||
private String privateKey;
|
||||
/** 生成时间 */
|
||||
private String createdAt;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user