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
This commit is contained in:
yml2213
2026-10-06 19:42:42 +08:00
parent a2c4eb59b6
commit d821fe13df
10 changed files with 594 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
// Command reencrypt_secrets 在轮换 DATA_ENCRYPTION_KEY 时,
// 把存量以密文落库的 API Client / 回调密钥从旧密钥重新加密为新密钥。
//
// 典型用法:
//
// OLD_DATA_ENCRYPTION_KEY=<旧密钥> go run ./cmd/reencrypt_secrets # 预演
// OLD_DATA_ENCRYPTION_KEY=<旧密钥> go run ./cmd/reencrypt_secrets -apply # 真正写库
//
// 详见 docs/密钥轮换说明.md
package main
import (
"flag"
"fmt"
"log"
"os"
"affiliate_dash/internal/config"
"affiliate_dash/internal/service"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
apply := flag.Bool("apply", false, "真正写库;不加该参数时只做预演(dry-run),不修改任何数据")
flag.Parse()
cfg := config.Load()
newKey := cfg.DataEncryptionKey
// 兼容两种历史配置:显式配置过 DATA_ENCRYPTION_KEY,或曾经回退到 JWT_SECRET。
oldKey := os.Getenv("OLD_DATA_ENCRYPTION_KEY")
if oldKey == "" {
oldKey = os.Getenv("OLD_JWT_SECRET")
}
if oldKey == "" {
log.Fatal("缺少旧密钥:请用 OLD_DATA_ENCRYPTION_KEY(或旧 JWT_SECRET)环境变量提供轮换前的密钥")
}
if oldKey == newKey {
log.Fatal("新旧密钥相同,无需轮换")
}
oldCodec, err := service.NewSecretCodec(oldKey)
if err != nil {
log.Fatalf("初始化旧密钥编解码器失败: %v", err)
}
newCodec, err := service.NewSecretCodec(newKey)
if err != nil {
log.Fatalf("初始化新密钥编解码器失败: %v", err)
}
db, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
log.Fatalf("获取数据库连接失败: %v", err)
}
defer sqlDB.Close()
mode := "预演(dry-run)"
if *apply {
mode = "写入(apply)"
}
fmt.Printf("密钥轮换模式: %s\n\n", mode)
results, err := service.ReencryptSecrets(db, oldCodec, newCodec, *apply)
if err != nil {
log.Fatalf("重加密失败: %v", err)
}
for _, r := range results {
fmt.Printf("%-24s 扫描=%d 重加密=%d 已是新密钥=%d 失败=%d\n",
r.Table, r.Report.Scanned, r.Report.Reencrypted, r.Report.AlreadyNewKey, r.Report.Failed)
}
if failed := service.TotalFailed(results); failed > 0 {
log.Fatalf("有 %d 行新旧密钥都无法解密,请先排查数据来源,未做任何破坏性操作", failed)
}
if !*apply {
fmt.Println("\n预演完成,未修改任何数据。确认无误后加 -apply 执行写入。")
return
}
fmt.Println("\n重加密完成。请确认业务正常后再移除旧密钥。")
}
+5
View File
@@ -50,6 +50,11 @@ func main() {
gin.SetMode(cfg.Mode)
// 密钥配置体检:让「示例密钥被带到真实部署」这类问题在启动日志里立刻可见。
for _, warning := range cfg.SecurityWarnings() {
applog.Warnf("[CONFIG] 安全告警: %s", warning)
}
db, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{
Logger: applog.NewGormLogger(cfg.Mode),
})
+28
View File
@@ -104,6 +104,34 @@ func Load() *Config {
}
}
// 仓库 .env.example 中的占位密钥。这些值对所有人公开,一旦被带到真实部署,
// JWT 可被伪造、落库密文可被解密,因此启动时必须显式告警。
const (
exampleJWTSecret = "your_very_secure_jwt_secret_here_change_me_in_production"
exampleDataKey = "your_32_plus_char_data_encryption_key_change_me"
)
// SecurityWarnings 返回启动时的密钥配置风险清单;返回空切片表示配置正常。
// 刻意只告警不阻断:升级过程直接 fatal 会让存量部署起不来,风险由运维可见性兜住。
func (c *Config) SecurityWarnings() []string {
var warnings []string
if c.JWTSecret == exampleJWTSecret {
warnings = append(warnings, "JWT_SECRET 仍是 .env.example 里的示例值,任何见过仓库的人都能伪造管理员 Token")
}
if os.Getenv("DATA_ENCRYPTION_KEY") == "" {
warnings = append(warnings, "DATA_ENCRYPTION_KEY 未显式设置,已回退到 JWT_SECRET;请设置独立密钥,更换后需用 cmd/reencrypt_secrets 重加密历史密文")
} else if c.DataEncryptionKey == exampleDataKey {
warnings = append(warnings, "DATA_ENCRYPTION_KEY 仍是 .env.example 里的示例值,落库的 API/回调密钥可被解密")
}
if c.DataEncryptionKey == c.JWTSecret {
warnings = append(warnings, "DATA_ENCRYPTION_KEY 与 JWT_SECRET 相同,建议分离以便独立轮换")
}
if c.Mode == "release" && c.OpenAPIDebug {
warnings = append(warnings, "GIN_MODE=release 下开启了 OPEN_API_DEBUG,开放接口报文详情会持续写盘")
}
return warnings
}
// getEnvList 解析逗号分隔的字符串列表(如 CORS 来源);空串返回默认值。
func getEnvList(key string, def []string) []string {
v := os.Getenv(key)
+78
View File
@@ -0,0 +1,78 @@
package config
import (
"strings"
"testing"
)
// containsWarning 判断告警列表里是否存在包含指定片段的告警。
func containsWarning(warnings []string, fragment string) bool {
for _, w := range warnings {
if strings.Contains(w, fragment) {
return true
}
}
return false
}
func TestSecurityWarnings(t *testing.T) {
cases := []struct {
name string
dataKeyEnv string
cfg Config
wantWarning string // 期望命中的告警片段;为空表示期望无任何告警
}{
{
name: "示例 JWT_SECRET 必须告警",
dataKeyEnv: "a-real-data-key",
cfg: Config{JWTSecret: exampleJWTSecret, DataEncryptionKey: "a-real-data-key", Mode: "debug"},
wantWarning: "JWT_SECRET",
},
{
name: "未显式设置 DATA_ENCRYPTION_KEY 时提示回退 JWT_SECRET",
dataKeyEnv: "",
cfg: Config{JWTSecret: "a-real-jwt-secret", DataEncryptionKey: "a-real-jwt-secret", Mode: "debug"},
wantWarning: "DATA_ENCRYPTION_KEY 未显式设置",
},
{
name: "示例 DATA_ENCRYPTION_KEY 必须告警",
dataKeyEnv: exampleDataKey,
cfg: Config{JWTSecret: "a-real-jwt-secret", DataEncryptionKey: exampleDataKey, Mode: "debug"},
wantWarning: "DATA_ENCRYPTION_KEY 仍是",
},
{
name: "release 模式开启 OPEN_API_DEBUG 必须告警",
dataKeyEnv: "a-real-data-key",
cfg: Config{JWTSecret: "a-real-jwt-secret", DataEncryptionKey: "a-real-data-key", Mode: "release", OpenAPIDebug: true},
wantWarning: "OPEN_API_DEBUG",
},
{
name: "健康配置不应产生任何告警",
dataKeyEnv: "a-real-data-key",
cfg: Config{
JWTSecret: "a-real-jwt-secret",
DataEncryptionKey: "a-real-data-key",
Mode: "release",
OpenAPIDebug: false,
},
wantWarning: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DATA_ENCRYPTION_KEY", tc.dataKeyEnv)
warnings := tc.cfg.SecurityWarnings()
if tc.wantWarning == "" {
if len(warnings) != 0 {
t.Fatalf("期望无告警,实际得到 %v", warnings)
}
return
}
if !containsWarning(warnings, tc.wantWarning) {
t.Fatalf("期望告警包含 %q,实际得到 %v", tc.wantWarning, warnings)
}
})
}
}
@@ -0,0 +1,106 @@
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
}
@@ -0,0 +1,145 @@
package service
import (
"testing"
"affiliate_dash/internal/model"
"affiliate_dash/internal/testdb"
"gorm.io/gorm"
)
// TestReencryptSecrets 覆盖密钥轮换的三种行:旧密钥密文、已是新密钥的密文、以及无法解密的坏数据。
// 同时验证预演(dry-run)不写库、写入模式会真正换密钥、第二张表也被覆盖、重复执行幂等。
func TestReencryptSecrets(t *testing.T) {
db := testdb.New(t)
oldCodec, err := NewSecretCodec("old-master-key-for-rotation-test")
if err != nil {
t.Fatalf("初始化旧密钥失败: %v", err)
}
newCodec, err := NewSecretCodec("new-master-key-for-rotation-test")
if err != nil {
t.Fatalf("初始化新密钥失败: %v", err)
}
encryptWith := func(codec *SecretCodec, plain string) string {
ciphertext, err := codec.Encrypt(plain)
if err != nil {
t.Fatalf("加密 %q 失败: %v", plain, err)
}
return ciphertext
}
const corrupt = "not-a-valid-ciphertext"
oldClientCipher := encryptWith(oldCodec, "client-secret-a1")
newClientCipher := encryptWith(newCodec, "client-secret-a2")
oldCallbackCipher := encryptWith(oldCodec, "callback-secret-c1")
clients := []model.APIClient{
{MerchantID: 1, Name: "旧密钥客户端", AppKey: "key-a1", SecretCiphertext: oldClientCipher, Scopes: "orders:read"},
{MerchantID: 1, Name: "已是新密钥", AppKey: "key-a2", SecretCiphertext: newClientCipher, Scopes: "orders:read"},
{MerchantID: 1, Name: "坏数据", AppKey: "key-a3", SecretCiphertext: corrupt, Scopes: "orders:read"},
}
for i := range clients {
if err := db.Create(&clients[i]).Error; err != nil {
t.Fatalf("写入 api_clients 失败: %v", err)
}
}
subscription := model.CallbackSubscription{
MerchantID: 1,
Name: "回调",
URL: "https://example.com/callback",
Events: "order.delivered",
SecretCiphertext: oldCallbackCipher,
}
if err := db.Create(&subscription).Error; err != nil {
t.Fatalf("写入 callback_subscriptions 失败: %v", err)
}
// ---- 第一轮:预演,必须只统计不写库 ----
dryRun, err := ReencryptSecrets(db, oldCodec, newCodec, false)
if err != nil {
t.Fatalf("预演失败: %v", err)
}
assertTableReport(t, dryRun, "api_clients", SecretReencryptReport{Scanned: 3, Reencrypted: 1, AlreadyNewKey: 1, Failed: 1})
assertTableReport(t, dryRun, "callback_subscriptions", SecretReencryptReport{Scanned: 1, Reencrypted: 1})
if got := TotalFailed(dryRun); got != 1 {
t.Fatalf("预演期望 1 行失败,实际 %d", got)
}
var afterDryRun model.APIClient
if err := db.First(&afterDryRun, clients[0].ID).Error; err != nil {
t.Fatalf("读取客户端失败: %v", err)
}
if afterDryRun.SecretCiphertext != oldClientCipher {
t.Fatal("预演不应修改数据库,但密文已被改写")
}
// ---- 第二轮:真正写入 ----
applied, err := ReencryptSecrets(db, oldCodec, newCodec, true)
if err != nil {
t.Fatalf("写入失败: %v", err)
}
assertTableReport(t, applied, "api_clients", SecretReencryptReport{Scanned: 3, Reencrypted: 1, AlreadyNewKey: 1, Failed: 1})
assertTableReport(t, applied, "callback_subscriptions", SecretReencryptReport{Scanned: 1, Reencrypted: 1})
// 换过密钥的行必须能被新密钥解开,且旧密钥已解不开。
assertStoredCipherDecryptsTo(t, db, newCodec, "api_clients", clients[0].ID, "client-secret-a1")
assertStoredCipherDecryptsTo(t, db, newCodec, "callback_subscriptions", subscription.ID, "callback-secret-c1")
var migrated model.APIClient
if err := db.First(&migrated, clients[0].ID).Error; err != nil {
t.Fatalf("读取客户端失败: %v", err)
}
if _, err := oldCodec.Decrypt(migrated.SecretCiphertext); err == nil {
t.Fatal("换密钥后旧密钥不应还能解密")
}
// 坏数据必须原样保留,等待人工排查。
var broken model.APIClient
if err := db.First(&broken, clients[2].ID).Error; err != nil {
t.Fatalf("读取坏数据行失败: %v", err)
}
if broken.SecretCiphertext != corrupt {
t.Fatalf("坏数据行不应被改写,实际变为 %q", broken.SecretCiphertext)
}
// ---- 第三轮:重复执行必须幂等,全部识别为已是新密钥 ----
again, err := ReencryptSecrets(db, oldCodec, newCodec, true)
if err != nil {
t.Fatalf("重复执行失败: %v", err)
}
assertTableReport(t, again, "api_clients", SecretReencryptReport{Scanned: 3, AlreadyNewKey: 2, Failed: 1})
assertTableReport(t, again, "callback_subscriptions", SecretReencryptReport{Scanned: 1, AlreadyNewKey: 1})
}
func assertTableReport(t *testing.T, results []SecretReencryptTable, table string, want SecretReencryptReport) {
t.Helper()
for _, r := range results {
if r.Table == table {
if r.Report != want {
t.Fatalf("%s 统计不符:期望 %+v,实际 %+v", table, want, r.Report)
}
return
}
}
t.Fatalf("结果中缺少表 %s", table)
}
// assertStoredCipherDecryptsTo 从库里读回密文,确认其能被指定密钥解出期望明文。
func assertStoredCipherDecryptsTo(t *testing.T, db *gorm.DB, codec *SecretCodec, table string, id uint, wantPlain string) {
t.Helper()
var stored string
query := "SELECT secret_ciphertext FROM " + table + " WHERE id = ?"
if err := db.Raw(query, id).Scan(&stored).Error; err != nil {
t.Fatalf("读取 %s#%d 密文失败: %v", table, id, err)
}
plain, err := codec.Decrypt(stored)
if err != nil {
t.Fatalf("%s#%d 无法用新密钥解密: %v", table, id, err)
}
if plain != wantPlain {
t.Fatalf("%s#%d 明文不符:期望 %q,实际 %q", table, id, wantPlain, plain)
}
}