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())
}
@@ -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 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;
}
}