Files
affiliate_dash/docs/密钥轮换说明.md
T
yml2213 d821fe13df 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
2026-10-06 19:42:42 +08:00

4.7 KiB
Raw Blame History

密钥轮换说明

适用版本: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. 回滚

  1. 停止服务;
  2. 恢复 .env 备份(旧的 DATA_ENCRYPTION_KEY);
  3. 恢复数据库快照;
  4. 启动服务。

注意:只回滚 .env 而不回滚数据库是无效的 —— 第 5 步已经用新密钥覆盖了密文。