fix(security): 分离 JWT 与数据加密密钥并新增启动安全体检

.env 中 JWT_SECRET 仍逐字节等于 .env.example 的示例值,且 DATA_ENCRYPTION_KEY
未设置、按 config.go 的回退逻辑实际等于 JWT_SECRET。任何见过仓库的人都能伪造
管理员 JWT,并解密库中全部 API Client / 回调密钥密文。

- JWT_SECRET 与 DATA_ENCRYPTION_KEY 改为各自独立的强随机值
- 新增 Config.SecurityWarnings():示例密钥 / 未分离 / release 下开调试日志均告警
- 新增 cmd/reencrypt_secrets:默认只预演,-apply 才写库;幂等,坏数据不中断整轮
- 新增 docs/密钥轮换说明.md;.gitignore 忽略 .env.bak*
- release 模式下关闭 OPEN_API_DEBUG
This commit is contained in:
yml2213
2026-10-06 19:42:42 +08:00
parent a2c4eb59b6
commit d821fe13df
10 changed files with 594 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
# 密钥轮换说明
> 适用版本:2026-10-06 及之后
> 相关代码:`backend/internal/config/config.go`、`backend/internal/service/secret_codec.go`、`backend/cmd/reencrypt_secrets`
## 1. 为什么需要这份文档
项目里有两个主密钥,职责完全不同,但历史上 `DATA_ENCRYPTION_KEY` 允许回退到 `JWT_SECRET`,
导致两者经常是同一个值 —— 一旦这个值还是 `.env.example` 里的示例值,就同时意味着:
- 任何人看过仓库都能**伪造管理员 JWT**(`pkg/jwt` 用 `JWT_SECRET` 做 HS256 签名);
- 任何人看过仓库都能**解密库里所有 API Client / 回调密钥**(AES-256-GCM,密钥由 `DATA_ENCRYPTION_KEY` 派生)。
所以正确做法是:**两个密钥必须独立、随机、且绝不使用示例值**。
服务启动时会自动体检并在日志里打出安全告警(`[CONFIG] 安全告警: ...`),看到告警就说明配置需要修。
## 2. 两个密钥的职责
| 变量 | 用途 | 泄露后果 | 能否随意更换 |
|------|------|----------|--------------|
| `JWT_SECRET` | 签发/校验后台登录 Token(HS256) | 可伪造任意角色登录后台 | 可以,代价是**所有人被踢下线需重新登录** |
| `DATA_ENCRYPTION_KEY` | 派生 AES-256-GCM 主密钥,加密落库的第三方密钥 | 可解密库中全部密文 | 可以,但**必须先重加密存量密文** |
## 3. 首次配置
```bash
cp .env.example .env
# 分别生成,不要复用同一个值
echo "JWT_SECRET=$(openssl rand -hex 48)" >> .env # 或直接编辑 .env
echo "DATA_ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
```
要求:两者非空且互不相同。`.env` 已在 `.gitignore` 中,**不要**把真实密钥写进 `.env.example`。
## 4. 轮换 `JWT_SECRET`(简单)
不需要动数据,直接改值并重启服务即可:
```bash
# 1. 备份(备份文件已被 .gitignore 覆盖,不会误提交)
cp .env ".env.bak.$(date +%Y%m%d%H%M%S)"
# 2. 修改 .env 中的 JWT_SECRET
# 3. 重启服务
```
影响:所有已签发 Token 立即失效,用户需重新登录。
## 5. 轮换 `DATA_ENCRYPTION_KEY`(需要重加密)
> 只改环境变量会导致存量密文无法解密:商户的 `X-App-Key` 鉴权会全部失败,回调也发不出去。
**第 1 步:备份数据库**(重加密是原地更新,必须先有可回滚的快照)
```bash
pg_dump "$DATABASE_URL" > backup-before-rotation.sql
```
**第 2 步:备份 `.env`**
```bash
cp .env ".env.bak.$(date +%Y%m%d%H%M%S)"
```
**第 3 步:把新的 `DATA_ENCRYPTION_KEY` 写入 `.env`,先不要重启服务。**
**第 4 步:预演**(只统计,不写库)
```bash
cd backend
OLD_DATA_ENCRYPTION_KEY='<轮换前的密钥>' go run ./cmd/reencrypt_secrets
```
输出示例:
```
密钥轮换模式: 预演(dry-run)
api_clients 扫描=12 重加密=12 已是新密钥=0 失败=0
callback_subscriptions 扫描=3 重加密=3 已是新密钥=0 失败=0
预演完成,未修改任何数据。确认无误后加 -apply 执行写入。
```
- `失败` 不为 0 时命令会以非零退出,且**不会修改任何数据**。请先确认这些行的来源(通常是历史脏数据),再决定处理方式。
- 若旧密钥曾经回退到 `JWT_SECRET`,可以用 `OLD_JWT_SECRET='<旧 JWT_SECRET>'` 代替 `OLD_DATA_ENCRYPTION_KEY`。
**第 5 步:真正写入**
```bash
OLD_DATA_ENCRYPTION_KEY='<轮换前的密钥>' go run ./cmd/reencrypt_secrets -apply
```
**第 6 步:重启服务并验证**
- 服务启动日志中**不再出现** `DATA_ENCRYPTION_KEY` 相关安全告警;
- 用任一商户的 `X-App-Key` 调一次 `/api/client/v1/products`,确认签名校验通过;
- 在商户后台确认回调密钥可见/可正常推送。
**第 7 步:确认稳定后删除备份**
```bash
rm .env.bak.* backup-before-rotation.sql
```
工具是幂等的:重复执行时,已用新密钥加密的行会被识别为 `已是新密钥`,不会被二次加密。
## 6. 涉及的表
重加密工具覆盖所有以密文落库的字段。字段清单集中在
`backend/internal/service/secret_reencrypt.go` 的 `secretBearingColumns`:
| 表 | 字段 | 说明 |
|----|------|------|
| `api_clients` | `secret_ciphertext` | 商户开放接口签名密钥 |
| `callback_subscriptions` | `secret_ciphertext` | 商户回调签名密钥 |
> **新增任何落库密文字段时,必须同步登记到 `secretBearingColumns`**,否则该字段会在密钥轮换中被漏掉。
## 7. 回滚
1. 停止服务;
2. 恢复 `.env` 备份(旧的 `DATA_ENCRYPTION_KEY`);
3. 恢复数据库快照;
4. 启动服务。
注意:**只回滚 `.env` 而不回滚数据库是无效的** —— 第 5 步已经用新密钥覆盖了密文。