.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
107 lines
3.2 KiB
Go
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
|
|
}
|