Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ concurrency:

jobs:
backend:
name: 后端编译
name: 后端编译与测试
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
12 changes: 7 additions & 5 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,不跨线程。必须从控制器线程传参。
Expand All @@ -122,8 +123,9 @@ docs/
验证码和定时任务都用内存存储,多实例会出错。
见[性能优化第 6 节](operations/performance.md#6-水平扩容的阻塞点)。

**⚠️ 仓库没有测试** ——
无 `src/test`,前端无 test 脚本。CI 只做编译校验。
**⚠️ 测试覆盖极低** ——
目前只有 `ConfigCryptoTest`(9 个用例,守加密相关的安全属性),
前端仍无 test 脚本。CI 会跑 `mvn test`。

---

Expand Down
82 changes: 24 additions & 58 deletions docs/architecture/infrastructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,26 +118,22 @@ public interface MessageTransport<T> {
| **`ProviderConfigConverter`** | **服务商配置——加密存储** |

`ProviderConfigConverter` 与众不同:它在写库前加密、读库后解密,
是唯一涉及加密的转换器。**但其加密实现有严重问题,见[第 9 节](#9-已知坑与注意事项)。**
是唯一涉及加密的转换器,实现见 `ConfigCrypto`。

---

## 6. 加密与工具类

```
utils/
├── ConfigEncryptor AES 加密(★ 死代码,全仓库零调用)
├── ConfigCrypto 敏感配置加解密(AES/GCM,密钥来自环境变量)
├── JwtUtils JWT 签发与校验
├── JsonUtils JSON 序列化
├── PasswordUtils 密码哈希
├── ValidationUtils 校验 + 内嵌 EncryptUtils(★ 实际在用)
├── ValidationUtils 参数校验
└── ModelResponseToJsonUtils 模型响应解析
```

> ⚠️ 仓库里存在**两套 AES 实现**:`ConfigEncryptor` 和 `ValidationUtils.EncryptUtils`。
> 前者无人调用,后者才是真正用于服务商密钥落库的那个。
> 两者都**硬编码了密钥**,详见[第 9 节](#9-已知坑与注意事项)。

---

## 7. 拦截器与异常
Expand Down Expand Up @@ -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 验证码仅内存实现,阻塞水平扩容

Expand Down
10 changes: 10 additions & 0 deletions docs/operations/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 31 additions & 23 deletions docs/operations/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/** 启动时校验配置加密密钥。
*
* <p>
* 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;

/** 把旧格式加密的服务商配置迁移为新格式。
*
* <p>
* 默认不执行。设置环境变量 {@code CONFIG_CRYPTO_MIGRATE=true} 后于启动时运行一次,完成后应把该变量去掉。
*
* <p>
* <b>为什么还需要它</b>:{@link ConfigCrypto} 读取时兼容旧格式,写入时一律用新格式,所以数据本来会随使用
* 逐步迁移。但「逐步」意味着不活跃的记录会长期以旧格式留存,而旧格式的密钥已随公开仓库泄露。这个任务
* 把存量数据一次性转完,之后才能安全地删除旧格式解密逻辑。
*
* <p>
* <b>注意</b>:迁移只改变加密方式,<b>不会</b>让已泄露的 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<ProviderEntity> 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);
}
}
Loading
Loading