diff --git a/.env.example b/.env.example index 136d4f2..ea33509 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,16 @@ SILICONFLOW_API_KEY=your_siliconflow_api_key SILICONFLOW_API_URL_RERANK=https://api.siliconflow.com/v1/rerank SILICONFLOW_MODEL_RERANK_MODEL=Qwen/Qwen3-Reranker-4B +# ================================ +# 敏感配置加密(必填) +# ================================ +# 用于加密服务商配置中的模型 API Key。缺失时后端启动即失败,不会回落到默认值。 +# 生成:openssl rand -base64 32 +CONFIG_ENCRYPTION_KEY= + +# 设为 true 时,启动执行一次存量数据的加密格式迁移,完成后请改回 false 或删除。 +CONFIG_CRYPTO_MIGRATE=false + # LLM 调用超时(秒)。非流式用于意图识别/语义改写/查询扩展等同步小请求,流式用于对话补全。 LLM_REQUEST_TIMEOUT_SECONDS=60 LLM_STREAM_TIMEOUT_SECONDS=300 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70fca92..24f7407 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ concurrency: jobs: backend: - name: 后端编译 + name: 后端编译与测试 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -27,6 +27,11 @@ jobs: - name: mvn compile run: mvn -B compile -DskipTests + # 单元测试。注意不要退回 -DskipTests——加密相关的行为约束靠这些用例守着, + # 跳过测试等于把安全属性的回归保护关掉。 + - name: mvn test + run: mvn -B test + frontend: name: 前端构建 runs-on: ubuntu-latest diff --git a/docker-compose.yml b/docker-compose.yml index 6dfd771..89a3161 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,6 +91,13 @@ services: SILICONFLOW_API_URL_RERANK: ${SILICONFLOW_API_URL_RERANK:-https://api.siliconflow.com/v1/rerank} SILICONFLOW_MODEL_RERANK_MODEL: ${SILICONFLOW_MODEL_RERANK_MODEL:-Qwen/Qwen3-Reranker-4B} + # 敏感配置加密密钥(必填,无默认值) + # 用于加密服务商配置中的模型 API Key。缺失时后端启动即失败——这是有意为之, + # 回落到默认密钥等于没有加密。生成方式:openssl rand -base64 32 + CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY:?必须在 .env 中设置 CONFIG_ENCRYPTION_KEY} + # 设为 true 时启动执行一次存量数据加密格式迁移,完成后应移除 + CONFIG_CRYPTO_MIGRATE: ${CONFIG_CRYPTO_MIGRATE:-false} + # LLM 调用超时(秒) # 非流式:意图识别/语义改写/查询扩展等同步小请求,超时必须短 LLM_REQUEST_TIMEOUT_SECONDS: ${LLM_REQUEST_TIMEOUT_SECONDS:-60} diff --git a/docs/README.md b/docs/README.md index b895657..f01798f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -102,9 +102,10 @@ docs/ 如果你只有三分钟,读这些: -**🔴 有一个待修复的高危安全问题** —— -服务商 API Key 的加密密钥硬编码在公开源码里,实际等同未加密。 -见[安全实践 P0](operations/security.md#-p0服务商-api-key-的加密密钥硬编码在公开仓库中)。 +**⚠️ 部署必须设置 `CONFIG_ENCRYPTION_KEY`** —— +用于加密服务商 API Key,缺失时后端**启动即失败**(有意为之,无默认值)。 +此前该密钥硬编码在公开源码中,**存量 API Key 应视为已泄露,需通知用户轮换**。 +见[安全实践](operations/security.md#-已修复p0服务商-api-key-的加密密钥硬编码在公开仓库中)。 **⚠️ 异步代码里取不到当前用户** —— `UserContext` 基于 ThreadLocal,不跨线程。必须从控制器线程传参。 @@ -122,8 +123,9 @@ docs/ 验证码和定时任务都用内存存储,多实例会出错。 见[性能优化第 6 节](operations/performance.md#6-水平扩容的阻塞点)。 -**⚠️ 仓库没有测试** —— -无 `src/test`,前端无 test 脚本。CI 只做编译校验。 +**⚠️ 测试覆盖极低** —— +目前只有 `ConfigCryptoTest`(9 个用例,守加密相关的安全属性), +前端仍无 test 脚本。CI 会跑 `mvn test`。 --- diff --git a/docs/architecture/infrastructure.md b/docs/architecture/infrastructure.md index 832e762..b780932 100644 --- a/docs/architecture/infrastructure.md +++ b/docs/architecture/infrastructure.md @@ -118,7 +118,7 @@ public interface MessageTransport { | **`ProviderConfigConverter`** | **服务商配置——加密存储** | `ProviderConfigConverter` 与众不同:它在写库前加密、读库后解密, -是唯一涉及加密的转换器。**但其加密实现有严重问题,见[第 9 节](#9-已知坑与注意事项)。** +是唯一涉及加密的转换器,实现见 `ConfigCrypto`。 --- @@ -126,18 +126,14 @@ public interface MessageTransport { ``` utils/ -├── ConfigEncryptor AES 加密(★ 死代码,全仓库零调用) +├── ConfigCrypto 敏感配置加解密(AES/GCM,密钥来自环境变量) ├── JwtUtils JWT 签发与校验 ├── JsonUtils JSON 序列化 ├── PasswordUtils 密码哈希 -├── ValidationUtils 校验 + 内嵌 EncryptUtils(★ 实际在用) +├── ValidationUtils 参数校验 └── ModelResponseToJsonUtils 模型响应解析 ``` -> ⚠️ 仓库里存在**两套 AES 实现**:`ConfigEncryptor` 和 `ValidationUtils.EncryptUtils`。 -> 前者无人调用,后者才是真正用于服务商密钥落库的那个。 -> 两者都**硬编码了密钥**,详见[第 9 节](#9-已知坑与注意事项)。 - --- ## 7. 拦截器与异常 @@ -178,69 +174,39 @@ BusinessException 通用业务异常 ## 9. 已知坑与注意事项 -### 9.1 🔴 服务商 API Key 的加密密钥硬编码在公开仓库中 - -**这是当前代码库最严重的安全问题。** +### 9.1 ✅ 服务商 API Key 的加密(已重写) -`ProviderConfigConverter` 负责把 `ProviderConfig`(**内含用户填写的 LLM 服务商 API Key**) -加密后落库: - -```java -public void setNonNullParameter(PreparedStatement ps, int i, ProviderConfig parameter, ...) { - String jsonStr = JsonUtils.toJsonString(parameter); - String encryptedStr = ValidationUtils.EncryptUtils.encrypt(jsonStr); // ← 加密 - ps.setString(i, encryptedStr); -} -``` +历史上这里有一个严重问题:`ValidationUtils.EncryptUtils` 用硬编码密钥 +`"1234567890123456"` 加 ECB 模式加密服务商配置,而仓库是公开的——加密形同虚设。 -而 `ValidationUtils.EncryptUtils` 的密钥是: +现已替换为 `ConfigCrypto`: ```java -private static final String ALGORITHM = "AES"; -private static final String SECRET_KEY = "1234567890123456"; // 16位密钥 -... -Cipher cipher = Cipher.getInstance(ALGORITHM); // 无模式 → 默认 AES/ECB/PKCS5Padding +public static final String KEY_ENV = "CONFIG_ENCRYPTION_KEY"; // 环境变量注入,无默认值 +private static final String TRANSFORMATION = "AES/GCM/NoPadding"; +private static final int IV_LENGTH = 12; // 每条记录随机 IV +private static final int TAG_LENGTH_BITS = 128; // 认证标签 ``` -三个问题叠加: - -| 问题 | 说明 | -| --- | --- | -| **密钥硬编码** | 写死在源码里,无法轮换 | -| **仓库公开** | 密钥等同于公开信息,加密形同虚设 | -| **ECB 模式** | `Cipher.getInstance("AES")` 默认 ECB,相同明文产生相同密文,可做模式分析 | +密文格式为 `v2:` + Base64(IV ‖ 密文+标签)。不带前缀的旧密文仍可解密, +写入时一律转为新格式,因此存量数据会随使用逐步迁移; +也可用 `CONFIG_CRYPTO_MIGRATE=true` 一次性迁移。 -**影响**:任何拿到数据库内容的人(备份泄露、SQL 注入、运维越权、云厂商快照) -都能直接解出**全部用户的模型服务商 API Key**。 +`ValidationUtils.EncryptUtils` 与 `ConfigEncryptor` 均已删除。 -**建议修复方向**: +> ⚠️ **加密方式修好了,不等于旧密钥安全了**。存量 API Key 应视为已泄露, +> 需通知用户轮换。详见[安全实践](../operations/security.md#-已修复p0服务商-api-key-的加密密钥硬编码在公开仓库中)。 -1. 密钥改从环境变量注入(如 `CONFIG_ENCRYPTION_KEY`),随部署配置提供 -2. 改用 `AES/GCM/NoPadding`,每条记录随机 IV,密文与 IV 一同存储 -3. 提供一次性迁移任务,用旧密钥解密、新密钥重新加密存量数据 -4. 修复后**所有已存的服务商密钥应视为已泄露**,通知用户轮换 +### 9.2 ✅ 两套 AES 实现已清理 -### 9.2 存在两套 AES 实现,其一为死代码 - -`ConfigEncryptor` 全仓库零调用,但同样硬编码密钥(`"AgentX-Config-Key"`)。 - -死代码本身无害,风险在于**将来有人误以为它是"正确的那个"而开始使用**。 -建议直接删除,只保留一套经过修复的加密工具。 - -### 9.3 `getNullableResult(ResultSet, int)` 直接返回 null - -```java -@Override -public ProviderConfig getNullableResult(ResultSet rs, int columnIndex) throws SQLException { - return null; // ← 未实现 -} -``` +`ConfigEncryptor`(零调用死代码,硬编码 `"AgentX-Config-Key"`)与 +`ValidationUtils.EncryptUtils` 都已删除,现在只保留 `ConfigCrypto` 一套实现。 -三个重载中,按**列索引**取值的那个没有实现,另外两个(按列名、按 -CallableStatement)都正常解密。 +### 9.3 ✅ `getNullableResult(ResultSet, int)` 已实现 -如果某条查询走到按索引取值的路径,会静默拿到 `null` 而非配置, -表现为「服务商配置突然为空」,且不报错。改动查询写法时需留意。 +此前三个重载中按**列索引**取值的那个直接 `return null`, +导致该路径静默拿到空配置而非报错,表现为「服务商配置突然为空」且无异常。 +现已与按列名取值行为保持一致。 ### 9.4 验证码仅内存实现,阻塞水平扩容 diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index 698dccb..951f481 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -97,6 +97,16 @@ SILICONFLOW_API_KEY= MAIL_SMTP_USERNAME= MAIL_SMTP_PASSWORD= ``` +### 加密密钥(必填) + +```bash +CONFIG_ENCRYPTION_KEY= # 生成:openssl rand -base64 32 +CONFIG_CRYPTO_MIGRATE=false # 仅迁移存量数据时临时设为 true +``` + +> ⚠️ **缺失会导致后端启动失败**,这是有意为之——回落到默认密钥等于没有加密。 +> 首次升级到含此改动的版本前,**必须先在服务器 `.env` 中配好**,否则部署后服务起不来。 + ### 超时配置 ```bash diff --git a/docs/operations/security.md b/docs/operations/security.md index c16112b..935ad8c 100644 --- a/docs/operations/security.md +++ b/docs/operations/security.md @@ -21,38 +21,46 @@ ## 1. 待修复问题 -### 🔴 P0:服务商 API Key 的加密密钥硬编码在公开仓库中 +### ✅ 已修复(P0):服务商 API Key 的加密密钥硬编码在公开仓库中 -**影响**:任何拿到数据库内容的人都能解出**全部用户的模型服务商 API Key**。 +**原问题**:`ValidationUtils.EncryptUtils` 用硬编码密钥 `"1234567890123456"` 与 +默认 ECB 模式加密服务商配置。仓库公开 ⇒ 密钥公开 ⇒ **加密形同虚设**, +任何拿到数据库内容的人都能解出全部用户的模型服务商 API Key。 -`ProviderConfigConverter` 把含 API Key 的 `ProviderConfig` 加密后落库, -调用的是 `ValidationUtils.EncryptUtils`: +**现在的实现**(`ConfigCrypto`): -```java -private static final String ALGORITHM = "AES"; -private static final String SECRET_KEY = "1234567890123456"; // ← 硬编码 -... -Cipher cipher = Cipher.getInstance(ALGORITHM); // ← 默认 AES/ECB/PKCS5Padding -``` +| 项 | 修复后 | +| --- | --- | +| 密钥来源 | 环境变量 `CONFIG_ENCRYPTION_KEY`,**缺失即启动失败**,无默认值 | +| 加密算法 | `AES/GCM/NoPadding`,每条记录随机 12 字节 IV | +| 完整性 | GCM 认证标签,密文被篡改则解密失败而非返回垃圾数据 | +| 密文格式 | `v2:` + Base64(IV ‖ 密文+标签) | +| 存量兼容 | 无前缀的旧密文仍可读取,写入时自动转新格式 | +| 防误用 | 若把 `CONFIG_ENCRYPTION_KEY` 设为旧的泄露密钥,启动直接报错 | -三重问题: +启动时由 `ConfigCryptoInitializer` 主动校验,让配置缺失在启动阶段暴露, +而不是等到用户第一次读写服务商配置时才报错。 -| 问题 | 后果 | -| --- | --- | -| 密钥硬编码 | 无法轮换 | -| **仓库公开** | 密钥等同公开信息,**加密形同虚设** | -| ECB 模式 | 相同明文产生相同密文,可做模式分析 | +**回归保护**:`ConfigCryptoTest` 9 个用例锁定上述安全属性,CI 每次 PR 都跑。 -**泄露途径**:数据库备份、SQL 注入、云厂商快照、运维越权——任一发生即全量泄露。 +#### ⚠️ 仍需人工处理的两件事 -**修复方向**: +**① 存量密钥必须视为已泄露。** +加密方式的修复**不会**让此前已存的 API Key 重新变安全——旧密钥早已随公开仓库外泄。 +**必须通知用户轮换其模型服务商 API Key。** -1. 密钥从环境变量注入(如 `CONFIG_ENCRYPTION_KEY`),缺失时**启动失败**而非回落默认值 -2. 改用 `AES/GCM/NoPadding`,每条记录随机 IV,IV 与密文一同存储 -3. 提供一次性迁移:旧密钥解密 → 新密钥重新加密 -4. 修复后**所有存量服务商密钥应视为已泄露,需通知用户轮换** +**② 存量数据需要迁移。** +读取时兼容旧格式,写入时才转新格式,所以不活跃的记录会长期以旧格式留存。 +一次性迁移: + +```bash +# 在服务器上临时开启,重启后自动执行一次 +CONFIG_CRYPTO_MIGRATE=true docker compose up -d backend +# 确认日志中「配置加密格式迁移完成」后,改回 false +``` -详见 [基础设施 9.1](../architecture/infrastructure.md#91--服务商-api-key-的加密密钥硬编码在公开仓库中)。 +迁移全部完成后,应删除 `ConfigCrypto.decryptLegacy()` 及相关常量, +彻底移除对旧密钥的依赖。 ### 🟠 P1:docker.sock 挂载等同于宿主机 root diff --git a/src/main/java/org/lucas/infrastructure/config/ConfigCryptoInitializer.java b/src/main/java/org/lucas/infrastructure/config/ConfigCryptoInitializer.java new file mode 100644 index 0000000..f6af26d --- /dev/null +++ b/src/main/java/org/lucas/infrastructure/config/ConfigCryptoInitializer.java @@ -0,0 +1,25 @@ +package org.lucas.infrastructure.config; + +import jakarta.annotation.PostConstruct; +import org.lucas.infrastructure.utils.ConfigCrypto; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** 启动时校验配置加密密钥。 + * + *

+ * ConfigCrypto 被 MyBatis 的 TypeHandler 使用,而 TypeHandler 由 MyBatis 而非 Spring 实例化,无法直接注入配置。 + * 因此这里用一个 Spring Bean 在启动阶段主动触发一次校验,让「密钥未配置」在启动时就暴露,而不是等到某个用户 + * 第一次读写服务商配置时才报错——那时故障已经发生在业务链路里,排查成本高得多。 */ +@Component +public class ConfigCryptoInitializer { + + private static final Logger log = LoggerFactory.getLogger(ConfigCryptoInitializer.class); + + @PostConstruct + public void verify() { + ConfigCrypto.validateConfiguration(); + log.info("配置加密密钥校验通过(来源:环境变量 {})", ConfigCrypto.KEY_ENV); + } +} diff --git a/src/main/java/org/lucas/infrastructure/config/ConfigCryptoMigration.java b/src/main/java/org/lucas/infrastructure/config/ConfigCryptoMigration.java new file mode 100644 index 0000000..0534af4 --- /dev/null +++ b/src/main/java/org/lucas/infrastructure/config/ConfigCryptoMigration.java @@ -0,0 +1,70 @@ +package org.lucas.infrastructure.config; + +import java.util.List; +import org.lucas.domain.llm.model.ProviderEntity; +import org.lucas.domain.llm.repository.ProviderRepository; +import org.lucas.infrastructure.utils.ConfigCrypto; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +/** 把旧格式加密的服务商配置迁移为新格式。 + * + *

+ * 默认不执行。设置环境变量 {@code CONFIG_CRYPTO_MIGRATE=true} 后于启动时运行一次,完成后应把该变量去掉。 + * + *

+ * 为什么还需要它:{@link ConfigCrypto} 读取时兼容旧格式,写入时一律用新格式,所以数据本来会随使用 + * 逐步迁移。但「逐步」意味着不活跃的记录会长期以旧格式留存,而旧格式的密钥已随公开仓库泄露。这个任务 + * 把存量数据一次性转完,之后才能安全地删除旧格式解密逻辑。 + * + *

+ * 注意:迁移只改变加密方式,不会让已泄露的 API Key 重新变得安全。存量密钥应视为已泄露, + * 需要通知用户轮换。 */ +@Component +public class ConfigCryptoMigration implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(ConfigCryptoMigration.class); + + private static final String MIGRATE_ENV = "CONFIG_CRYPTO_MIGRATE"; + + private final ProviderRepository providerRepository; + + public ConfigCryptoMigration(ProviderRepository providerRepository) { + this.providerRepository = providerRepository; + } + + @Override + public void run(ApplicationArguments args) { + if (!"true".equalsIgnoreCase(System.getenv(MIGRATE_ENV))) { + return; + } + + log.info("检测到 {}=true,开始迁移服务商配置的加密格式", MIGRATE_ENV); + + List providers = providerRepository.selectList(null); + int migrated = 0; + int failed = 0; + + for (ProviderEntity provider : providers) { + try { + // 读取时 TypeHandler 已按旧格式解密;这里原样写回, + // 写入时 TypeHandler 会用新格式重新加密。 + if (provider.getConfig() == null) { + continue; + } + providerRepository.updateById(provider); + migrated++; + } catch (Exception e) { + failed++; + // 不打印配置内容,避免密钥进日志 + log.error("迁移服务商配置失败: providerId={}", provider.getId(), e); + } + } + + log.info("配置加密格式迁移完成:成功 {} 条,失败 {} 条,共 {} 条。" + "请移除 {} 环境变量,并通知用户轮换其模型服务商 API Key" + + "(旧密钥已随公开仓库泄露)。", migrated, failed, providers.size(), MIGRATE_ENV); + } +} diff --git a/src/main/java/org/lucas/infrastructure/converter/ProviderConfigConverter.java b/src/main/java/org/lucas/infrastructure/converter/ProviderConfigConverter.java index 1370f58..2309080 100644 --- a/src/main/java/org/lucas/infrastructure/converter/ProviderConfigConverter.java +++ b/src/main/java/org/lucas/infrastructure/converter/ProviderConfigConverter.java @@ -9,10 +9,14 @@ import org.apache.ibatis.type.MappedJdbcTypes; import org.apache.ibatis.type.MappedTypes; import org.lucas.domain.llm.model.config.ProviderConfig; +import org.lucas.infrastructure.utils.ConfigCrypto; import org.lucas.infrastructure.utils.JsonUtils; -import org.lucas.infrastructure.utils.ValidationUtils; -/** 服务商配置转换器 处理加密存储的配置信息 */ +/** 服务商配置转换器,处理加密存储的配置信息。 + * + *

+ * ProviderConfig 中含用户填写的模型服务商 API Key,属于敏感数据,因此落库前加密、读取后解密。 + * 加解密实现见 {@link ConfigCrypto}。 */ @MappedTypes(ProviderConfig.class) @MappedJdbcTypes({JdbcType.VARCHAR, JdbcType.LONGVARCHAR, JdbcType.OTHER}) public class ProviderConfigConverter extends BaseTypeHandler { @@ -21,35 +25,30 @@ public class ProviderConfigConverter extends BaseTypeHandler { public void setNonNullParameter(PreparedStatement ps, int i, ProviderConfig parameter, JdbcType jdbcType) throws SQLException { String jsonStr = JsonUtils.toJsonString(parameter); - String encryptedStr = ValidationUtils.EncryptUtils.encrypt(jsonStr); - ps.setString(i, encryptedStr); + ps.setString(i, ConfigCrypto.encrypt(jsonStr)); } @Override public ProviderConfig getNullableResult(ResultSet rs, String columnName) throws SQLException { - String encryptedStr = rs.getString(columnName); - return parseEncryptedJson(encryptedStr); + return parseEncryptedJson(rs.getString(columnName)); } @Override public ProviderConfig getNullableResult(ResultSet rs, int columnIndex) throws SQLException { - return null; + // 此前该重载直接返回 null,导致按列索引取值时静默拿到空配置而非报错, + // 表现为「服务商配置突然为空」且无任何异常。现与按列名取值行为保持一致。 + return parseEncryptedJson(rs.getString(columnIndex)); } @Override public ProviderConfig getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { - String encryptedStr = cs.getString(columnIndex); - return parseEncryptedJson(encryptedStr); + return parseEncryptedJson(cs.getString(columnIndex)); } private ProviderConfig parseEncryptedJson(String encryptedStr) throws SQLException { if (encryptedStr == null || encryptedStr.isEmpty()) { return new ProviderConfig(); } - - String jsonStr = ValidationUtils.EncryptUtils.decrypt(encryptedStr);; - - return JsonUtils.parseObject(jsonStr, ProviderConfig.class); - + return JsonUtils.parseObject(ConfigCrypto.decrypt(encryptedStr), ProviderConfig.class); } -} \ No newline at end of file +} diff --git a/src/main/java/org/lucas/infrastructure/utils/ConfigCrypto.java b/src/main/java/org/lucas/infrastructure/utils/ConfigCrypto.java new file mode 100644 index 0000000..beb874c --- /dev/null +++ b/src/main/java/org/lucas/infrastructure/utils/ConfigCrypto.java @@ -0,0 +1,204 @@ +package org.lucas.infrastructure.utils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.lucas.infrastructure.exception.BusinessException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** 敏感配置的加解密工具。 + * + * 用于服务商配置(内含用户填写的模型 API Key)的落库加密,见 ProviderConfigConverter。 + * + *

+ * 为什么重写:旧实现把 AES 密钥硬编码在源码里({@code "1234567890123456"}),而本仓库是公开的 + * ——密钥等同于公开信息,加密形同虚设;且使用默认的 ECB 模式,相同明文会产生相同密文。任何拿到数据库 + * 内容的人都能解出全部用户的服务商密钥。 + * + *

+ * 现在的做法:密钥由环境变量 {@code CONFIG_ENCRYPTION_KEY} 注入,缺失时启动即失败(不回落到 + * 任何默认值);加密使用 AES/GCM,每条记录随机 IV,并带认证标签,密文被篡改会解密失败而非返回垃圾数据。 + * + *

+ * 密文格式:{@code v2:} + Base64(IV ‖ 密文+认证标签)。前缀用于与历史数据区分。 + * + *

+ * 兼容历史数据:不带 {@code v2:} 前缀的密文按旧格式(ECB + 硬编码密钥)解密,以便存量数据仍可读取。 + * 任何一次写入都会转成新格式,因此数据会随使用逐步完成迁移;也可用 + * {@code ConfigCryptoMigration} 一次性迁移。存量数据全部迁移完成后,应删除 + * {@link #decryptLegacy(String)} 及相关常量。 */ +public final class ConfigCrypto { + + private static final Logger log = LoggerFactory.getLogger(ConfigCrypto.class); + + /** 注入加密密钥的环境变量名。 */ + public static final String KEY_ENV = "CONFIG_ENCRYPTION_KEY"; + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final String KEY_ALGORITHM = "AES"; + private static final String VERSION_PREFIX = "v2:"; + /** GCM 推荐的 IV 长度(字节)。 */ + private static final int IV_LENGTH = 12; + /** GCM 认证标签长度(位)。 */ + private static final int TAG_LENGTH_BITS = 128; + + /** 旧实现的硬编码密钥。仅用于解密存量数据,绝不用于加密。 */ + private static final String LEGACY_KEY = "1234567890123456"; + private static final String LEGACY_TRANSFORMATION = "AES"; + + private static final SecureRandom RANDOM = new SecureRandom(); + + /** 由 {@link #KEY_ENV} 派生的密钥,首次使用时初始化。 */ + private static volatile SecretKeySpec cachedKey; + /** 只在首次读到历史格式数据时打一次告警,避免刷屏。 */ + private static volatile boolean legacyWarned = false; + /** 仅供同包测试注入密钥,生产运行时始终为 null。 */ + private static volatile String testKeyOverride; + + private ConfigCrypto() { + } + + /** 校验密钥配置是否可用。由启动检查调用,使配置缺失在启动阶段就暴露,而不是等到第一次读写配置时。 + * + * @throws IllegalStateException 环境变量缺失或为空 */ + public static void validateConfiguration() { + resolveKey(); + } + + /** 加密。始终产出新格式({@code v2:} 前缀)。 + * + * @param plaintext 明文,为 null 时返回 null + * @return 密文 */ + public static String encrypt(String plaintext) { + if (plaintext == null) { + return null; + } + try { + byte[] iv = new byte[IV_LENGTH]; + RANDOM.nextBytes(iv); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, resolveKey(), new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + + // IV 与密文拼在一起存储:IV 不是秘密,但解密时必须拿到同一个 + byte[] combined = new byte[iv.length + ciphertext.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length); + + return VERSION_PREFIX + Base64.getEncoder().encodeToString(combined); + } catch (Exception e) { + // 不带上原始明文,避免异常信息泄露密钥内容 + throw new BusinessException("配置加密失败: " + e.getMessage(), e); + } + } + + /** 解密。自动识别新旧格式。 + * + * @param encrypted 密文,为 null 或空串时原样返回 + * @return 明文 */ + public static String decrypt(String encrypted) { + if (encrypted == null || encrypted.isEmpty()) { + return encrypted; + } + if (!encrypted.startsWith(VERSION_PREFIX)) { + return decryptLegacy(encrypted); + } + try { + byte[] combined = Base64.getDecoder().decode(encrypted.substring(VERSION_PREFIX.length())); + if (combined.length <= IV_LENGTH) { + throw new IllegalArgumentException("密文长度不足,无法取出 IV"); + } + + byte[] iv = new byte[IV_LENGTH]; + System.arraycopy(combined, 0, iv, 0, IV_LENGTH); + byte[] ciphertext = new byte[combined.length - IV_LENGTH]; + System.arraycopy(combined, IV_LENGTH, ciphertext, 0, ciphertext.length); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, resolveKey(), new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new BusinessException("配置解密失败: " + e.getMessage(), e); + } + } + + /** 判断密文是否已是新格式。迁移工具据此跳过无需处理的数据。 + * + * @param encrypted 密文 + * @return 是否为新格式 */ + public static boolean isNewFormat(String encrypted) { + return encrypted != null && encrypted.startsWith(VERSION_PREFIX); + } + + /** 按旧格式(AES/ECB + 硬编码密钥)解密存量数据。 + * + *

+ * 存量数据全部迁移完成后应删除本方法。 */ + private static String decryptLegacy(String encrypted) { + if (!legacyWarned) { + legacyWarned = true; + log.warn("检测到使用旧格式加密的配置数据。旧格式的密钥硬编码于源码中且仓库公开," + + "应视为已泄露。请运行迁移将其转为新格式,并通知用户轮换相关 API Key。"); + } + try { + SecretKeySpec legacyKey = new SecretKeySpec(LEGACY_KEY.getBytes(StandardCharsets.UTF_8), KEY_ALGORITHM); + Cipher cipher = Cipher.getInstance(LEGACY_TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, legacyKey); + return new String(cipher.doFinal(Base64.getDecoder().decode(encrypted)), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new BusinessException("旧格式配置解密失败: " + e.getMessage(), e); + } + } + + /** 读取并派生密钥。 + * + *

+ * 用 SHA-256 派生出固定的 256 位密钥,这样环境变量可以是任意长度的字符串,不必恰好 16/24/32 字节。 + * 注意派生不等于增强——环境变量本身仍应是高熵随机值。 */ + private static SecretKeySpec resolveKey() { + SecretKeySpec key = cachedKey; + if (key != null) { + return key; + } + synchronized (ConfigCrypto.class) { + if (cachedKey != null) { + return cachedKey; + } + String raw = testKeyOverride != null ? testKeyOverride : System.getenv(KEY_ENV); + if (raw == null || raw.isBlank()) { + throw new IllegalStateException("缺少环境变量 " + KEY_ENV + "。该变量用于加密服务商配置中的 API Key," + + "必须显式配置且不提供默认值。生成方式:openssl rand -base64 32"); + } + if (LEGACY_KEY.equals(raw)) { + throw new IllegalStateException( + KEY_ENV + " 不能使用旧的硬编码密钥值——它已随公开仓库泄露。请生成新密钥:openssl rand -base64 32"); + } + try { + byte[] derived = MessageDigest.getInstance("SHA-256").digest(raw.getBytes(StandardCharsets.UTF_8)); + cachedKey = new SecretKeySpec(derived, KEY_ALGORITHM); + return cachedKey; + } catch (Exception e) { + throw new IllegalStateException("派生加密密钥失败: " + e.getMessage(), e); + } + } + } + + /** 仅供测试使用:注入密钥并清除缓存。传 null 表示恢复为读取环境变量。 + * + *

+ * 设计为包级私有,生产代码无法调用;测试因此不必去反射篡改 JVM 的环境变量表 + * (那种做法在 JDK 17 的模块系统下已不可行)。 */ + static void overrideKeyForTesting(String rawKey) { + synchronized (ConfigCrypto.class) { + testKeyOverride = rawKey; + cachedKey = null; + legacyWarned = false; + } + } +} diff --git a/src/main/java/org/lucas/infrastructure/utils/ConfigEncryptor.java b/src/main/java/org/lucas/infrastructure/utils/ConfigEncryptor.java deleted file mode 100644 index 18a4b25..0000000 --- a/src/main/java/org/lucas/infrastructure/utils/ConfigEncryptor.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.lucas.infrastructure.utils; - -import cn.hutool.crypto.SecureUtil; -import cn.hutool.crypto.symmetric.AES; -import com.alibaba.fastjson.JSON; -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import org.apache.commons.lang3.StringUtils; - -/** 配置加密工具 */ -public class ConfigEncryptor { - private static final String KEY = "AgentX-Config-Key"; - private static final AES aes = SecureUtil.aes(KEY.getBytes(StandardCharsets.UTF_8)); - - /** 加密对象 */ - public static String encrypt(Object obj) { - if (obj == null) { - return null; - } - String json = JSON.toJSONString(obj); - byte[] encrypted = aes.encrypt(json); - return Base64.getEncoder().encodeToString(encrypted); - } - - /** 解密对象 */ - public static T decrypt(String encryptedStr, Class clazz) { - if (StringUtils.isBlank(encryptedStr)) { - return null; - } - byte[] decoded = Base64.getDecoder().decode(encryptedStr); - byte[] decrypted = aes.decrypt(decoded); - String json = new String(decrypted, StandardCharsets.UTF_8); - return JSON.parseObject(json, clazz); - } -} \ No newline at end of file diff --git a/src/main/java/org/lucas/infrastructure/utils/ValidationUtils.java b/src/main/java/org/lucas/infrastructure/utils/ValidationUtils.java index abdaff0..d0023c1 100644 --- a/src/main/java/org/lucas/infrastructure/utils/ValidationUtils.java +++ b/src/main/java/org/lucas/infrastructure/utils/ValidationUtils.java @@ -63,52 +63,4 @@ public static void validVersionFormat(String version, String paramName) { } } - /** 加密工具类 */ - public static class EncryptUtils { - - private static final String ALGORITHM = "AES"; - private static final String SECRET_KEY = "1234567890123456"; // 16位密钥 - - private EncryptUtils() { - // 私有构造函数,防止实例化 - } - - /** 加密字符串 - * - * @param data 待加密的字符串 - * @return 加密后的字符串 */ - public static String encrypt(String data) { - try { - if (data == null) { - return null; - } - SecretKeySpec secretKey = new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM); - Cipher cipher = Cipher.getInstance(ALGORITHM); - cipher.init(Cipher.ENCRYPT_MODE, secretKey); - byte[] encryptedBytes = cipher.doFinal(data.getBytes()); - return Base64.getEncoder().encodeToString(encryptedBytes); - } catch (Exception e) { - throw new BusinessException("加密失败" + e.getMessage(), e); - } - } - - /** 解密字符串 - * - * @param encryptedData 已加密的字符串 - * @return 解密后的字符串 */ - public static String decrypt(String encryptedData) { - try { - if (encryptedData == null) { - return null; - } - SecretKeySpec secretKey = new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM); - Cipher cipher = Cipher.getInstance(ALGORITHM); - cipher.init(Cipher.DECRYPT_MODE, secretKey); - byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedData)); - return new String(decryptedBytes); - } catch (Exception e) { - throw new BusinessException("解密失败:" + e.getMessage(), e); - } - } - } -} \ No newline at end of file +} diff --git a/src/test/java/org/lucas/infrastructure/utils/ConfigCryptoTest.java b/src/test/java/org/lucas/infrastructure/utils/ConfigCryptoTest.java new file mode 100644 index 0000000..74c00e9 --- /dev/null +++ b/src/test/java/org/lucas/infrastructure/utils/ConfigCryptoTest.java @@ -0,0 +1,138 @@ +package org.lucas.infrastructure.utils; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.lucas.infrastructure.exception.BusinessException; + +/** ConfigCrypto 的行为约束。 + * + *

+ * 这些用例锁定的是安全属性,不是实现细节:密钥必须来自环境变量、相同明文不能产生相同密文、 + * 密文被篡改必须失败、以及存量旧格式数据仍可读取。 */ +class ConfigCryptoTest { + + private static final String TEST_KEY = "test-key-do-not-use-in-production"; + private static final String LEGACY_KEY = "1234567890123456"; + + @BeforeEach + void setUp() { + ConfigCrypto.overrideKeyForTesting(TEST_KEY); + } + + @AfterEach + void tearDown() { + ConfigCrypto.overrideKeyForTesting(null); + } + + @Test + @DisplayName("加密后能原样解回") + void roundTrip() { + String plaintext = "{\"apiKey\":\"sk-secret-value\",\"baseUrl\":\"https://api.example.com\"}"; + + String encrypted = ConfigCrypto.encrypt(plaintext); + + assertAll(() -> assertNotEquals(plaintext, encrypted, "密文不应等于明文"), + () -> assertFalse(encrypted.contains("sk-secret-value"), "密文中不应出现明文片段"), + () -> assertEquals(plaintext, ConfigCrypto.decrypt(encrypted))); + } + + @Test + @DisplayName("相同明文两次加密结果不同(随机 IV,避免 ECB 那种可做模式分析的问题)") + void sameInputProducesDifferentCiphertext() { + String plaintext = "same-content"; + + String first = ConfigCrypto.encrypt(plaintext); + String second = ConfigCrypto.encrypt(plaintext); + + assertAll(() -> assertNotEquals(first, second, "相同明文必须产生不同密文"), + () -> assertEquals(plaintext, ConfigCrypto.decrypt(first)), + () -> assertEquals(plaintext, ConfigCrypto.decrypt(second))); + } + + @Test + @DisplayName("密文被篡改时解密失败,而不是返回垃圾数据") + void tamperedCiphertextIsRejected() { + String encrypted = ConfigCrypto.encrypt("sensitive"); + + // 翻转 Base64 载荷中的一个字符 + String payload = encrypted.substring("v2:".length()); + char[] chars = payload.toCharArray(); + chars[chars.length - 2] = chars[chars.length - 2] == 'A' ? 'B' : 'A'; + String tampered = "v2:" + new String(chars); + + assertThrows(BusinessException.class, () -> ConfigCrypto.decrypt(tampered)); + } + + @Test + @DisplayName("换一把密钥解不出原密文") + void wrongKeyCannotDecrypt() { + String encrypted = ConfigCrypto.encrypt("secret"); + + ConfigCrypto.overrideKeyForTesting("a-completely-different-key"); + + assertThrows(BusinessException.class, () -> ConfigCrypto.decrypt(encrypted)); + } + + @Test + @DisplayName("环境变量缺失时必须报错,不能回落到默认密钥") + void missingKeyFailsFast() { + ConfigCrypto.overrideKeyForTesting(null); + + IllegalStateException e = assertThrows(IllegalStateException.class, ConfigCrypto::validateConfiguration); + assertTrue(e.getMessage().contains(ConfigCrypto.KEY_ENV)); + } + + @Test + @DisplayName("拒绝沿用已泄露的旧硬编码密钥") + void rejectsLeakedLegacyKey() { + ConfigCrypto.overrideKeyForTesting(LEGACY_KEY); + + assertThrows(IllegalStateException.class, ConfigCrypto::validateConfiguration); + } + + @Test + @DisplayName("旧格式数据仍能读取(保证升级后存量配置不失效)") + void legacyCiphertextRemainsReadable() throws Exception { + String plaintext = "{\"apiKey\":\"legacy-key\"}"; + String legacyCiphertext = encryptWithLegacyScheme(plaintext); + + assertAll(() -> assertFalse(ConfigCrypto.isNewFormat(legacyCiphertext), "旧格式不应带 v2 前缀"), + () -> assertEquals(plaintext, ConfigCrypto.decrypt(legacyCiphertext))); + } + + @Test + @DisplayName("新写入的一律是新格式") + void newWritesUseNewFormat() { + assertTrue(ConfigCrypto.isNewFormat(ConfigCrypto.encrypt("anything"))); + } + + @Test + @DisplayName("null 与空串按原样处理,不抛异常") + void handlesNullAndEmpty() { + assertAll(() -> assertNull(ConfigCrypto.encrypt(null)), () -> assertNull(ConfigCrypto.decrypt(null)), + () -> assertEquals("", ConfigCrypto.decrypt(""))); + } + + /** 用旧实现的方式加密,模拟数据库里的存量数据。 */ + private static String encryptWithLegacyScheme(String data) throws Exception { + SecretKeySpec key = new SecretKeySpec(LEGACY_KEY.getBytes(StandardCharsets.UTF_8), "AES"); + Cipher cipher = Cipher.getInstance("AES"); + cipher.init(Cipher.ENCRYPT_MODE, key); + return Base64.getEncoder().encodeToString(cipher.doFinal(data.getBytes(StandardCharsets.UTF_8))); + } + +}