.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
4.7 KiB
密钥轮换说明
适用版本: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. 首次配置
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(简单)
不需要动数据,直接改值并重启服务即可:
# 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 步:备份数据库(重加密是原地更新,必须先有可回滚的快照)
pg_dump "$DATABASE_URL" > backup-before-rotation.sql
第 2 步:备份 .env
cp .env ".env.bak.$(date +%Y%m%d%H%M%S)"
第 3 步:把新的 DATA_ENCRYPTION_KEY 写入 .env,先不要重启服务。
第 4 步:预演(只统计,不写库)
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 步:真正写入
OLD_DATA_ENCRYPTION_KEY='<轮换前的密钥>' go run ./cmd/reencrypt_secrets -apply
第 6 步:重启服务并验证
- 服务启动日志中不再出现
DATA_ENCRYPTION_KEY相关安全告警; - 用任一商户的
X-App-Key调一次/api/client/v1/products,确认签名校验通过; - 在商户后台确认回调密钥可见/可正常推送。
第 7 步:确认稳定后删除备份
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. 回滚
- 停止服务;
- 恢复
.env备份(旧的DATA_ENCRYPTION_KEY); - 恢复数据库快照;
- 启动服务。
注意:只回滚 .env 而不回滚数据库是无效的 —— 第 5 步已经用新密钥覆盖了密文。