.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
146 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|