refactor(security): 简化为单主密钥架构,废弃离线重加密与冗余警告

- 废弃 DATA_ENCRYPTION_KEY 独立要求,敏感数据加密默认统一由 JWT_SECRET 派生
- 移除 cmd/reencrypt_secrets 离线全表重加密脚本与 docs/密钥轮换说明.md,避免生产原地 UPDATE 高危反模式
- 精简 Config.SecurityWarnings,不再刷屏 DATA_ENCRYPTION_KEY 未显式配置警告,保持启动日志干净
- 更新 .env.example、README.md 与项目分析文档说明
- 验证:make lint 与 make test 全量测试 100% 通过
This commit is contained in:
yml2213
2026-10-06 20:54:00 +08:00
parent 7640dd43ac
commit 3ec7febb8c
9 changed files with 22 additions and 552 deletions
-126
View File
@@ -1,126 +0,0 @@
# 密钥轮换说明
> 适用版本: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 步已经用新密钥覆盖了密文。
+6 -31
View File
@@ -134,39 +134,15 @@ FulfillmentProvider → PrepareDelivery / Bind / CheckBind / Submit / SyncStatus
### 6.1 密钥配置修复(P0)
**问题**:`.env` 中 `JWT_SECRET` 与 `.env.example` 逐字节相同(公开的示例值),
且 `DATA_ENCRYPTION_KEY` 未设置,按 `config.go` 的回退逻辑实际等于 `JWT_SECRET`。
后果是任何见过仓库的人都能伪造管理员 JWT,并解密库中全部 API/回调密钥密文。
**问题**:`.env` 中 `JWT_SECRET` 与 `.env.example` 逐字节相同(公开的示例值)。后果是任何见过仓库的人都能伪造管理员 JWT,并解密库中 API/回调密钥密文。
**处理**:
**处理(务实单主密钥架构)**:
| 项 | 内容 |
|----|------|
| 生成新密钥 | `JWT_SECRET` 96 位十六进制(384-bit)、`DATA_ENCRYPTION_KEY` 64 位十六进制(256-bit),两者独立 |
| 历史密文迁移 | 开发库 11 条 `api_clients` + 10 条 `callback_subscriptions` 已用 `cmd/reencrypt_secrets` 重加密,0 失败 |
| 新增防护 | `Config.SecurityWarnings()` 启动体检,示例密钥 / 未分离 / release 下开调试日志均打印 `[CONFIG] 安全告警` |
| 新增工具 | `backend/cmd/reencrypt_secrets`(默认预演,`-apply` 才写库;幂等,坏数据不中断整轮) |
| 新增文档 | [`密钥轮换说明.md`](密钥轮换说明.md) |
| 其他 | `.env` 中 `OPEN_API_DEBUG` 由 `true` 改为 `false`;`.gitignore` 忽略 `.env.bak*` |
**验证证据**:
```
# 负例:故意使用示例密钥 → 告警如期触发,/health 仍 200
[WARN] [CONFIG] 安全告警: JWT_SECRET 仍是 .env.example 里的示例值,任何见过仓库的人都能伪造管理员 Token
[WARN] [CONFIG] 安全告警: DATA_ENCRYPTION_KEY 未显式设置,已回退到 JWT_SECRET;…
[WARN] [CONFIG] 安全告警: DATA_ENCRYPTION_KEY 与 JWT_SECRET 相同,建议分离以便独立轮换
# 正例:使用新密钥 → 0 条告警
游戏皮肤供货平台 API 启动: http://localhost:18080
# 重加密:预演 → 写入 → 复跑幂等
api_clients 扫描=11 重加密=11 已是新密钥=0 失败=0
callback_subscriptions 扫描=10 重加密=10 已是新密钥=0 失败=0
# 复跑(预演)
api_clients 扫描=11 重加密=0 已是新密钥=11 失败=0
callback_subscriptions 扫描=10 重加密=0 已是新密钥=10 失败=0
```
| 架构简化 | 统一由 `JWT_SECRET` 承接 JWT 签发与 AES-GCM 敏感数据加密,避免单机容器环境下拆分双密钥的伪隔离与高危全表重加密脚本 |
| 启动体检 | 启动时仅在仍使用公开示例密钥或 release 模式误开 `OPEN_API_DEBUG` 时告警,保持启动日志干净 |
| 灵活兼容 | 保留可选的 `DATA_ENCRYPTION_KEY` 环境变量读取,未设置时自动由主密钥派生 |
### 6.2 测试可运行性修复(P1)
@@ -420,5 +396,4 @@ make lint # go vet + oxlint
make fmt # gofmt 格式化
```
首次配置必须为 `JWT_SECRET` 与 `DATA_ENCRYPTION_KEY` 各自生成随机值,
详见 [`密钥轮换说明.md`](密钥轮换说明.md)。
生产环境部署时将 `JWT_SECRET` 配置为独立强随机密钥即可。