Files
affiliate_dash/backend/internal/service/secret_reencrypt.go
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

107 lines
3.2 KiB
Go

package service
import (
"fmt"
"gorm.io/gorm"
)
// secretBearingColumns 列出所有以加密形式落库的密文字段。
// 新增落库密文时务必同步登记,否则密钥轮换会漏表。
var secretBearingColumns = []struct {
Table string
Column string
}{
{"api_clients", "secret_ciphertext"},
{"callback_subscriptions", "secret_ciphertext"},
}
// SecretReencryptReport 汇总一次密钥轮换的重加密结果。
type SecretReencryptReport struct {
Scanned int // 扫描到的非空密文行数
Reencrypted int // 用新密钥重新写入的行数
AlreadyNewKey int // 已能用新密钥解密的行数(重复执行时出现)
Failed int // 新旧密钥都无法解密的行数
}
// SecretReencryptTable 单个表的明细,便于输出可读日志。
type SecretReencryptTable struct {
Table string
Report SecretReencryptReport
}
// ReencryptSecrets 使用 newCodec 重新加密所有落库密文,用于轮换 DATA_ENCRYPTION_KEY。
//
// apply=false 时只统计不写库(预演)。单行解密失败不会中断整轮操作,
// 而是计入 Failed 并由调用方决定退出码,避免一行坏数据卡死整个轮换。
// 这里使用原生 SQL 而非 GORM 模型,软删除的行同样会被处理——
// 否则将来恢复一条软删除记录时会因为密钥没换而解密失败。
func ReencryptSecrets(db *gorm.DB, oldCodec, newCodec *SecretCodec, apply bool) ([]SecretReencryptTable, error) {
if oldCodec == nil || newCodec == nil {
return nil, fmt.Errorf("新旧密钥编解码器都不能为空")
}
results := make([]SecretReencryptTable, 0, len(secretBearingColumns))
for _, target := range secretBearingColumns {
report := SecretReencryptReport{}
type row struct {
ID uint
Ciphertext string
}
var rows []row
query := fmt.Sprintf(
"SELECT id, %s AS ciphertext FROM %s WHERE %s IS NOT NULL AND %s <> '' ORDER BY id",
target.Column, target.Table, target.Column, target.Column,
)
if err := db.Raw(query).Scan(&rows).Error; err != nil {
return results, fmt.Errorf("读取 %s.%s 失败: %w", target.Table, target.Column, err)
}
for _, r := range rows {
report.Scanned++
plain, err := oldCodec.Decrypt(r.Ciphertext)
if err != nil {
// 旧密钥解不开:可能上一轮已经换过,也可能是数据损坏,需区分对待。
if _, newErr := newCodec.Decrypt(r.Ciphertext); newErr == nil {
report.AlreadyNewKey++
continue
}
report.Failed++
continue
}
ciphertext, err := newCodec.Encrypt(plain)
if err != nil {
report.Failed++
continue
}
if !apply {
report.Reencrypted++
continue
}
update := fmt.Sprintf("UPDATE %s SET %s = ? WHERE id = ?", target.Table, target.Column)
if err := db.Exec(update, ciphertext, r.ID).Error; err != nil {
report.Failed++
continue
}
report.Reencrypted++
}
results = append(results, SecretReencryptTable{Table: target.Table, Report: report})
}
return results, nil
}
// TotalFailed 返回所有表中解密失败的总行数,供调用方决定退出码。
func TotalFailed(results []SecretReencryptTable) int {
total := 0
for _, r := range results {
total += r.Report.Failed
}
return total
}