From 8c4f343c3438e6996afeb04f487a5d826473227d Mon Sep 17 00:00:00 2001 From: rosercode Date: Thu, 1 Oct 2026 23:42:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8E=E7=AB=AF=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E8=AF=B7=E6=B1=82/=E5=93=8D=E5=BA=94=E4=BD=93=E5=8A=A0?= =?UTF-8?q?=E8=A7=A3=E5=AF=86=EF=BC=88RSA=20+=20AES-GCM=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增可一键开关的接口数据加密能力:前端经 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 查询参数 --- frontend/package.json | 2 + frontend/src/api/request.ts | 67 +++++- frontend/src/utils/apiCrypto.ts | 186 ++++++++++++++++ .../ledgerly/common/CryptoConstants.java | 66 ++++++ .../rosercode/ledgerly/config/WebConfig.java | 23 +- .../crypto/ApiCryptoRequestBodyAdvice.java | 130 +++++++++++ .../crypto/ApiCryptoResponseBodyAdvice.java | 107 +++++++++ .../crypto/CryptoSessionInterceptor.java | 62 ++++++ .../ledgerly/controller/CryptoController.java | 74 +++++++ .../ledgerly/dto/CryptoHandshakeRequest.java | 20 ++ .../util/crypto/ApiCryptoService.java | 208 ++++++++++++++++++ .../util/crypto/CryptoSessionStore.java | 124 +++++++++++ .../ledgerly/util/crypto/RsaKeyManager.java | 198 +++++++++++++++++ 13 files changed, 1258 insertions(+), 9 deletions(-) create mode 100644 frontend/src/utils/apiCrypto.ts create mode 100644 src/main/java/cn/rosercode/ledgerly/common/CryptoConstants.java create mode 100644 src/main/java/cn/rosercode/ledgerly/config/crypto/ApiCryptoRequestBodyAdvice.java create mode 100644 src/main/java/cn/rosercode/ledgerly/config/crypto/ApiCryptoResponseBodyAdvice.java create mode 100644 src/main/java/cn/rosercode/ledgerly/config/crypto/CryptoSessionInterceptor.java create mode 100644 src/main/java/cn/rosercode/ledgerly/controller/CryptoController.java create mode 100644 src/main/java/cn/rosercode/ledgerly/dto/CryptoHandshakeRequest.java create mode 100644 src/main/java/cn/rosercode/ledgerly/util/crypto/ApiCryptoService.java create mode 100644 src/main/java/cn/rosercode/ledgerly/util/crypto/CryptoSessionStore.java create mode 100644 src/main/java/cn/rosercode/ledgerly/util/crypto/RsaKeyManager.java diff --git a/frontend/package.json b/frontend/package.json index d45477c..2fbbda3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 30bcfad..d5e9623 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -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 }) => { diff --git a/frontend/src/utils/apiCrypto.ts b/frontend/src/utils/apiCrypto.ts new file mode 100644 index 0000000..8b6c7a9 --- /dev/null +++ b/frontend/src/utils/apiCrypto.ts @@ -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 | 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 { + 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 { + 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()) +} diff --git a/src/main/java/cn/rosercode/ledgerly/common/CryptoConstants.java b/src/main/java/cn/rosercode/ledgerly/common/CryptoConstants.java new file mode 100644 index 0000000..de62d00 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/common/CryptoConstants.java @@ -0,0 +1,66 @@ +package cn.rosercode.ledgerly.common; + +/** + * 接口数据加解密常量。 + * + *

集中维护接口请求/响应体加解密相关的开关、请求头、算法参数与会话配置, + * 避免密钥、算法等散落在各处。是否启用加密由 {@link #API_ENCRYPTION_ENABLED} 常量统一控制: + * 关闭时后端所有加解密逻辑旁路,前端探测到后自动改走明文。

+ * + * @date 2026-10-01 23:35:10 + */ +public final class CryptoConstants { + + /** + * 接口请求/响应体加密总开关:true 启用,false 关闭。 + * + *

刻意使用 {@link Boolean} 包装类型而非 {@code boolean} 基本类型, + * 避免被编译器当作编译期常量内联到使用处,确保修改本常量后重新编译即生效。

+ */ + 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() { + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/config/WebConfig.java b/src/main/java/cn/rosercode/ledgerly/config/WebConfig.java index 1c0770a..9b6e2a2 100644 --- a/src/main/java/cn/rosercode/ledgerly/config/WebConfig.java +++ b/src/main/java/cn/rosercode/ledgerly/config/WebConfig.java @@ -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/**"); } /** diff --git a/src/main/java/cn/rosercode/ledgerly/config/crypto/ApiCryptoRequestBodyAdvice.java b/src/main/java/cn/rosercode/ledgerly/config/crypto/ApiCryptoRequestBodyAdvice.java new file mode 100644 index 0000000..29e48a1 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/config/crypto/ApiCryptoRequestBodyAdvice.java @@ -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; + +/** + * 加密请求体解密通知器。 + * + *

在 {@code @RequestBody} 反序列化前,若请求携带有效加密会话则将信封解密为明文 JSON, + * 再交由消息转换器绑定 DTO;未开启加密、未携带会话或报文非信封时保持原样。 + * 当报文是加密信封但会话已失效时抛出专用错误码,前端据此重新握手并重试。

+ * + * @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> 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> 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; + } + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/config/crypto/ApiCryptoResponseBodyAdvice.java b/src/main/java/cn/rosercode/ledgerly/config/crypto/ApiCryptoResponseBodyAdvice.java new file mode 100644 index 0000000..cedb4a9 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/config/crypto/ApiCryptoResponseBodyAdvice.java @@ -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; + +/** + * 加密响应体通知器。 + * + *

仅对统一响应体 {@link Result} 且当前请求携带有效加密会话时,将其序列化后加密为信封返回; + * 无会话(如会话失效的明文错误、握手等)时保持明文。SSE、二进制下载等非 {@code Result} + * 返回体天然不被处理。

+ * + * @date 2026-10-01 23:43:40 + */ +@ControllerAdvice +public class ApiCryptoResponseBodyAdvice implements ResponseBodyAdvice { + + 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> 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> 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; + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/config/crypto/CryptoSessionInterceptor.java b/src/main/java/cn/rosercode/ledgerly/config/crypto/CryptoSessionInterceptor.java new file mode 100644 index 0000000..4c53cfc --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/config/crypto/CryptoSessionInterceptor.java @@ -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; + +/** + * 接口加密会话拦截器。 + * + *

在请求进入控制器前,从请求头 {@code X-Crypto-Session} 解析会话标识并取出 AES 会话密钥, + * 写入请求属性供请求体解密与响应体加密使用。加密关闭或未携带会话时不做处理(保持明文), + * 因此对不加密的接口(SSE、文件上传下载等)完全透明。

+ * + * @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; + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/controller/CryptoController.java b/src/main/java/cn/rosercode/ledgerly/controller/CryptoController.java new file mode 100644 index 0000000..4610c7d --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/controller/CryptoController.java @@ -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; + +/** + * 接口加密握手控制器。 + * + *

提供公钥获取与握手接口,供前端协商 AES 会话密钥。这两个接口本身不参与加解密, + * 也不要求 JWT 认证(在 {@code WebConfig} 中排除),否则前端在登录前无法建立加密会话。

+ * + * @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 公钥。 + * + *

加密关闭时仅返回 {@code enabled=false},前端据此改走明文。

+ * + * @return 加密状态与公钥信息 + */ + @GetMapping("/public-key") + public Map publicKey() { + Map 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 handshake(@RequestBody CryptoHandshakeRequest request) { + String sessionId = apiCryptoService.handshake(request.getKeyId(), request.getKey()); + Map result = new LinkedHashMap<>(); + result.put("sessionId", sessionId); + return result; + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/dto/CryptoHandshakeRequest.java b/src/main/java/cn/rosercode/ledgerly/dto/CryptoHandshakeRequest.java new file mode 100644 index 0000000..1d6601d --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/dto/CryptoHandshakeRequest.java @@ -0,0 +1,20 @@ +package cn.rosercode.ledgerly.dto; + +import lombok.Data; + +/** + * 接口加密握手请求。 + * + *

前端携带所用公钥标识与 RSA-OAEP 加密后的 AES 会话密钥,向后端发起握手。

+ * + * @date 2026-10-01 23:39:40 + */ +@Data +public class CryptoHandshakeRequest { + + /** 前端所用 RSA 公钥标识 */ + private String keyId; + + /** RSA-OAEP(SHA-256) 加密后的 AES 会话密钥(Base64) */ + private String key; +} diff --git a/src/main/java/cn/rosercode/ledgerly/util/crypto/ApiCryptoService.java b/src/main/java/cn/rosercode/ledgerly/util/crypto/ApiCryptoService.java new file mode 100644 index 0000000..a0c5a51 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/util/crypto/ApiCryptoService.java @@ -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; + +/** + * 接口数据加解密服务。 + * + *

基于「RSA 传输 AES 会话密钥 + AES-GCM 加密报文」的混合加密方案: + * 握手阶段用 RSA-OAEP(SHA-256) 解出前端随机生成的 AES-256 会话密钥并存入会话表; + * 之后请求体、响应体统一以 {@code {"enc","iv","data"}} 信封形式收发, + * 其中 GCM 的附加认证数据(AAD)绑定会话标识,避免跨会话重放。

+ * + * @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 encryptResponseBody(String sessionId, byte[] plain) { + byte[] key = requireSessionKey(sessionId); + Map 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); + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/util/crypto/CryptoSessionStore.java b/src/main/java/cn/rosercode/ledgerly/util/crypto/CryptoSessionStore.java new file mode 100644 index 0000000..a37a04f --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/util/crypto/CryptoSessionStore.java @@ -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; + +/** + * 接口加密会话存储。 + * + *

保存前端握手时协商出的 AES 会话密钥(仅存内存,不落盘),以会话标识为键, + * 带有效期与数量上限:读取时校验过期,并定时清理;数量超限时优先清理已过期会话, + * 仍超限则删除最早创建的会话,避免内存无限增长。

+ * + * @date 2026-10-01 23:37:20 + */ +@Component +@Slf4j +public class CryptoSessionStore { + + /** 会话表:会话标识 -> 会话密钥与过期时间 */ + private final Map 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) { + } +} diff --git a/src/main/java/cn/rosercode/ledgerly/util/crypto/RsaKeyManager.java b/src/main/java/cn/rosercode/ledgerly/util/crypto/RsaKeyManager.java new file mode 100644 index 0000000..22d19b1 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/util/crypto/RsaKeyManager.java @@ -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 密钥管理器。 + * + *

负责 RSA 密钥对的生成、持久化与加载,并提供 RSA-OAEP(SHA-256) 解密能力, + * 用于解密前端上送的 AES 会话密钥。密钥对保存在应用基准目录下的 + * {@code config/crypto-keys.json}(该目录已 gitignore),首次启动时生成,之后复用。

+ * + * @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; + } +}