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

146 lines
5.4 KiB
Go

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)
}
}