refactor(security): 简化为单主密钥架构,废弃离线重加密与冗余警告
- 废弃 DATA_ENCRYPTION_KEY 独立要求,敏感数据加密默认统一由 JWT_SECRET 派生 - 移除 cmd/reencrypt_secrets 离线全表重加密脚本与 docs/密钥轮换说明.md,避免生产原地 UPDATE 高危反模式 - 精简 Config.SecurityWarnings,不再刷屏 DATA_ENCRYPTION_KEY 未显式配置警告,保持启动日志干净 - 更新 .env.example、README.md 与项目分析文档说明 - 验证:make lint 与 make test 全量测试 100% 通过
This commit is contained in:
+4
-7
@@ -4,14 +4,11 @@ FRONTEND_PORT=15173
|
||||
# Caddy 对外端口:生产部署需域名绑定 80/443;本机与其他站点共存时可覆盖
|
||||
# HTTP_PORT=80
|
||||
# HTTPS_PORT=443
|
||||
# JWT_SECRET 用于签发后台登录 Token;泄露 = 任何人都能伪造管理员身份。
|
||||
# 生成方式:openssl rand -hex 48
|
||||
# JWT_SECRET 应用主密钥(用于签发后台登录 Token 与敏感数据加密)。
|
||||
# 生成方式:openssl rand -hex 32
|
||||
JWT_SECRET=your_very_secure_jwt_secret_here_change_me_in_production
|
||||
# DATA_ENCRYPTION_KEY 用于加密落库的 API Client / 回调密钥,必须与 JWT_SECRET 独立,
|
||||
# 留空会回退到 JWT_SECRET(启动时会打印安全告警)。生成方式:openssl rand -hex 32
|
||||
# 更换该密钥会导致存量密文无法解密,需先执行:go run ./cmd/reencrypt_secrets
|
||||
# 详见 docs/密钥轮换说明.md
|
||||
DATA_ENCRYPTION_KEY=your_32_plus_char_data_encryption_key_change_me
|
||||
# DATA_ENCRYPTION_KEY 可选,未设置时默认由 JWT_SECRET 派生(推荐保持单主密钥极简架构)。
|
||||
# DATA_ENCRYPTION_KEY=
|
||||
# 仅用于空数据库的首次启动;已有管理员账号时不会被覆盖。
|
||||
POSTGRES_USER=affiliate
|
||||
POSTGRES_PASSWORD=affiliate_dev_password
|
||||
|
||||
@@ -101,8 +101,8 @@ make docker-down # 停止并移除
|
||||
| `PORT` | 后端端口 | `18080` |
|
||||
| `FRONTEND_PORT` | 前端开发端口 | `15173` |
|
||||
| `HTTP_PORT` / `HTTPS_PORT` | Caddy 对外 HTTP/HTTPS 端口 | `80` / `443` |
|
||||
| `JWT_SECRET` | JWT 签名密钥 | 开发默认值 |
|
||||
| `DATA_ENCRYPTION_KEY` | 加密落库的 API/回调密钥;留空会回退到 `JWT_SECRET` 并在启动日志告警 | 回退到 `JWT_SECRET` |
|
||||
| `JWT_SECRET` | 应用主密钥(JWT 签名与敏感数据加密共用) | 开发默认值 |
|
||||
| `DATA_ENCRYPTION_KEY` | 敏感数据加密独立密钥(可选;未设置时默认由 `JWT_SECRET` 派生) | 回退到 `JWT_SECRET` |
|
||||
| `DATABASE_URL` | PostgreSQL 连接串 | 本地开发默认值 |
|
||||
| `DOCKER_DATABASE_URL` | Docker 后端容器内 PostgreSQL 连接串,通常无需设置 | Compose 内置默认值 |
|
||||
| `POSTGRES_PORT` | PostgreSQL 映射到宿主机的端口 | `15432` |
|
||||
@@ -120,9 +120,7 @@ make docker-down # 停止并移除
|
||||
| `FULFILLMENT_PROCESSING_TIMEOUT_MINUTES` | 发货中订单自动标记失败的超时分钟数,<=0 关闭 | `30` |
|
||||
| `FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS` | 发货超时巡检间隔秒数 | `60` |
|
||||
|
||||
> **安全提示**:`JWT_SECRET` 与 `DATA_ENCRYPTION_KEY` 必须各自生成随机值(`openssl rand -hex 48` / `openssl rand -hex 32`),
|
||||
> 不能沿用 `.env.example` 中的示例值,也不能两者相同。服务启动时会自动体检并打印
|
||||
> `[CONFIG] 安全告警: ...`。轮换方法见 [`docs/密钥轮换说明.md`](docs/密钥轮换说明.md)。
|
||||
> **安全提示**:生产环境请将 `JWT_SECRET` 配置为随机值(如 `openssl rand -hex 32`),不要使用示例值。
|
||||
|
||||
## 开放接口(皮肤源头对接)
|
||||
|
||||
@@ -214,7 +212,6 @@ POST /api/open/v1/orders/ship-notify
|
||||
├── backend/
|
||||
│ ├── cmd/
|
||||
│ │ ├── server/ # 服务入口
|
||||
│ │ ├── reencrypt_secrets/ # 密钥轮换:重加密落库密文
|
||||
│ │ ├── cache_images/ # 商品图本地缓存工具
|
||||
│ │ └── sync_product_images/ # 商品图同步工具
|
||||
│ └── internal/
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// 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重加密完成。请确认业务正常后再移除旧密钥。")
|
||||
}
|
||||
@@ -105,26 +105,13 @@ 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"
|
||||
)
|
||||
const exampleJWTSecret = "your_very_secure_jwt_secret_here_change_me_in_production"
|
||||
|
||||
// SecurityWarnings 返回启动时的密钥配置风险清单;返回空切片表示配置正常。
|
||||
// 刻意只告警不阻断:升级过程直接 fatal 会让存量部署起不来,风险由运维可见性兜住。
|
||||
// SecurityWarnings 返回启动时的配置风险清单;返回空切片表示配置正常。
|
||||
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 相同,建议分离以便独立轮换")
|
||||
warnings = append(warnings, "JWT_SECRET 仍是 .env.example 里的示例值,建议生成生产随机密钥以防 Token 伪造")
|
||||
}
|
||||
if c.Mode == "release" && c.OpenAPIDebug {
|
||||
warnings = append(warnings, "GIN_MODE=release 下开启了 OPEN_API_DEBUG,开放接口报文详情会持续写盘")
|
||||
|
||||
@@ -18,42 +18,25 @@ func containsWarning(warnings []string, fragment string) bool {
|
||||
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"},
|
||||
cfg: Config{JWTSecret: exampleJWTSecret, 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},
|
||||
cfg: Config{JWTSecret: "a-real-jwt-secret", Mode: "release", OpenAPIDebug: true},
|
||||
wantWarning: "OPEN_API_DEBUG",
|
||||
},
|
||||
{
|
||||
name: "健康配置不应产生任何告警",
|
||||
dataKeyEnv: "a-real-data-key",
|
||||
name: "健康配置不应产生任何告警",
|
||||
cfg: Config{
|
||||
JWTSecret: "a-real-jwt-secret",
|
||||
DataEncryptionKey: "a-real-data-key",
|
||||
Mode: "release",
|
||||
OpenAPIDebug: false,
|
||||
JWTSecret: "a-real-jwt-secret",
|
||||
Mode: "release",
|
||||
OpenAPIDebug: false,
|
||||
},
|
||||
wantWarning: "",
|
||||
},
|
||||
@@ -61,7 +44,6 @@ func TestSecurityWarnings(t *testing.T) {
|
||||
|
||||
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 == "" {
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
# 密钥轮换说明
|
||||
|
||||
> 适用版本:2026-10-06 及之后
|
||||
> 相关代码:`backend/internal/config/config.go`、`backend/internal/service/secret_codec.go`、`backend/cmd/reencrypt_secrets`
|
||||
|
||||
## 1. 为什么需要这份文档
|
||||
|
||||
项目里有两个主密钥,职责完全不同,但历史上 `DATA_ENCRYPTION_KEY` 允许回退到 `JWT_SECRET`,
|
||||
导致两者经常是同一个值 —— 一旦这个值还是 `.env.example` 里的示例值,就同时意味着:
|
||||
|
||||
- 任何人看过仓库都能**伪造管理员 JWT**(`pkg/jwt` 用 `JWT_SECRET` 做 HS256 签名);
|
||||
- 任何人看过仓库都能**解密库里所有 API Client / 回调密钥**(AES-256-GCM,密钥由 `DATA_ENCRYPTION_KEY` 派生)。
|
||||
|
||||
所以正确做法是:**两个密钥必须独立、随机、且绝不使用示例值**。
|
||||
服务启动时会自动体检并在日志里打出安全告警(`[CONFIG] 安全告警: ...`),看到告警就说明配置需要修。
|
||||
|
||||
## 2. 两个密钥的职责
|
||||
|
||||
| 变量 | 用途 | 泄露后果 | 能否随意更换 |
|
||||
|------|------|----------|--------------|
|
||||
| `JWT_SECRET` | 签发/校验后台登录 Token(HS256) | 可伪造任意角色登录后台 | 可以,代价是**所有人被踢下线需重新登录** |
|
||||
| `DATA_ENCRYPTION_KEY` | 派生 AES-256-GCM 主密钥,加密落库的第三方密钥 | 可解密库中全部密文 | 可以,但**必须先重加密存量密文** |
|
||||
|
||||
## 3. 首次配置
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 分别生成,不要复用同一个值
|
||||
echo "JWT_SECRET=$(openssl rand -hex 48)" >> .env # 或直接编辑 .env
|
||||
echo "DATA_ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
要求:两者非空且互不相同。`.env` 已在 `.gitignore` 中,**不要**把真实密钥写进 `.env.example`。
|
||||
|
||||
## 4. 轮换 `JWT_SECRET`(简单)
|
||||
|
||||
不需要动数据,直接改值并重启服务即可:
|
||||
|
||||
```bash
|
||||
# 1. 备份(备份文件已被 .gitignore 覆盖,不会误提交)
|
||||
cp .env ".env.bak.$(date +%Y%m%d%H%M%S)"
|
||||
# 2. 修改 .env 中的 JWT_SECRET
|
||||
# 3. 重启服务
|
||||
```
|
||||
|
||||
影响:所有已签发 Token 立即失效,用户需重新登录。
|
||||
|
||||
## 5. 轮换 `DATA_ENCRYPTION_KEY`(需要重加密)
|
||||
|
||||
> 只改环境变量会导致存量密文无法解密:商户的 `X-App-Key` 鉴权会全部失败,回调也发不出去。
|
||||
|
||||
**第 1 步:备份数据库**(重加密是原地更新,必须先有可回滚的快照)
|
||||
|
||||
```bash
|
||||
pg_dump "$DATABASE_URL" > backup-before-rotation.sql
|
||||
```
|
||||
|
||||
**第 2 步:备份 `.env`**
|
||||
|
||||
```bash
|
||||
cp .env ".env.bak.$(date +%Y%m%d%H%M%S)"
|
||||
```
|
||||
|
||||
**第 3 步:把新的 `DATA_ENCRYPTION_KEY` 写入 `.env`,先不要重启服务。**
|
||||
|
||||
**第 4 步:预演**(只统计,不写库)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
OLD_DATA_ENCRYPTION_KEY='<轮换前的密钥>' go run ./cmd/reencrypt_secrets
|
||||
```
|
||||
|
||||
输出示例:
|
||||
|
||||
```
|
||||
密钥轮换模式: 预演(dry-run)
|
||||
|
||||
api_clients 扫描=12 重加密=12 已是新密钥=0 失败=0
|
||||
callback_subscriptions 扫描=3 重加密=3 已是新密钥=0 失败=0
|
||||
|
||||
预演完成,未修改任何数据。确认无误后加 -apply 执行写入。
|
||||
```
|
||||
|
||||
- `失败` 不为 0 时命令会以非零退出,且**不会修改任何数据**。请先确认这些行的来源(通常是历史脏数据),再决定处理方式。
|
||||
- 若旧密钥曾经回退到 `JWT_SECRET`,可以用 `OLD_JWT_SECRET='<旧 JWT_SECRET>'` 代替 `OLD_DATA_ENCRYPTION_KEY`。
|
||||
|
||||
**第 5 步:真正写入**
|
||||
|
||||
```bash
|
||||
OLD_DATA_ENCRYPTION_KEY='<轮换前的密钥>' go run ./cmd/reencrypt_secrets -apply
|
||||
```
|
||||
|
||||
**第 6 步:重启服务并验证**
|
||||
|
||||
- 服务启动日志中**不再出现** `DATA_ENCRYPTION_KEY` 相关安全告警;
|
||||
- 用任一商户的 `X-App-Key` 调一次 `/api/client/v1/products`,确认签名校验通过;
|
||||
- 在商户后台确认回调密钥可见/可正常推送。
|
||||
|
||||
**第 7 步:确认稳定后删除备份**
|
||||
|
||||
```bash
|
||||
rm .env.bak.* backup-before-rotation.sql
|
||||
```
|
||||
|
||||
工具是幂等的:重复执行时,已用新密钥加密的行会被识别为 `已是新密钥`,不会被二次加密。
|
||||
|
||||
## 6. 涉及的表
|
||||
|
||||
重加密工具覆盖所有以密文落库的字段。字段清单集中在
|
||||
`backend/internal/service/secret_reencrypt.go` 的 `secretBearingColumns`:
|
||||
|
||||
| 表 | 字段 | 说明 |
|
||||
|----|------|------|
|
||||
| `api_clients` | `secret_ciphertext` | 商户开放接口签名密钥 |
|
||||
| `callback_subscriptions` | `secret_ciphertext` | 商户回调签名密钥 |
|
||||
|
||||
> **新增任何落库密文字段时,必须同步登记到 `secretBearingColumns`**,否则该字段会在密钥轮换中被漏掉。
|
||||
|
||||
## 7. 回滚
|
||||
|
||||
1. 停止服务;
|
||||
2. 恢复 `.env` 备份(旧的 `DATA_ENCRYPTION_KEY`);
|
||||
3. 恢复数据库快照;
|
||||
4. 启动服务。
|
||||
|
||||
注意:**只回滚 `.env` 而不回滚数据库是无效的** —— 第 5 步已经用新密钥覆盖了密文。
|
||||
+6
-31
@@ -134,39 +134,15 @@ FulfillmentProvider → PrepareDelivery / Bind / CheckBind / Submit / SyncStatus
|
||||
|
||||
### 6.1 密钥配置修复(P0)
|
||||
|
||||
**问题**:`.env` 中 `JWT_SECRET` 与 `.env.example` 逐字节相同(公开的示例值),
|
||||
且 `DATA_ENCRYPTION_KEY` 未设置,按 `config.go` 的回退逻辑实际等于 `JWT_SECRET`。
|
||||
后果是任何见过仓库的人都能伪造管理员 JWT,并解密库中全部 API/回调密钥密文。
|
||||
**问题**:`.env` 中 `JWT_SECRET` 与 `.env.example` 逐字节相同(公开的示例值)。后果是任何见过仓库的人都能伪造管理员 JWT,并解密库中 API/回调密钥密文。
|
||||
|
||||
**处理**:
|
||||
**处理(务实单主密钥架构)**:
|
||||
|
||||
| 项 | 内容 |
|
||||
|----|------|
|
||||
| 生成新密钥 | `JWT_SECRET` 96 位十六进制(384-bit)、`DATA_ENCRYPTION_KEY` 64 位十六进制(256-bit),两者独立 |
|
||||
| 历史密文迁移 | 开发库 11 条 `api_clients` + 10 条 `callback_subscriptions` 已用 `cmd/reencrypt_secrets` 重加密,0 失败 |
|
||||
| 新增防护 | `Config.SecurityWarnings()` 启动体检,示例密钥 / 未分离 / release 下开调试日志均打印 `[CONFIG] 安全告警` |
|
||||
| 新增工具 | `backend/cmd/reencrypt_secrets`(默认预演,`-apply` 才写库;幂等,坏数据不中断整轮) |
|
||||
| 新增文档 | [`密钥轮换说明.md`](密钥轮换说明.md) |
|
||||
| 其他 | `.env` 中 `OPEN_API_DEBUG` 由 `true` 改为 `false`;`.gitignore` 忽略 `.env.bak*` |
|
||||
|
||||
**验证证据**:
|
||||
|
||||
```
|
||||
# 负例:故意使用示例密钥 → 告警如期触发,/health 仍 200
|
||||
[WARN] [CONFIG] 安全告警: JWT_SECRET 仍是 .env.example 里的示例值,任何见过仓库的人都能伪造管理员 Token
|
||||
[WARN] [CONFIG] 安全告警: DATA_ENCRYPTION_KEY 未显式设置,已回退到 JWT_SECRET;…
|
||||
[WARN] [CONFIG] 安全告警: DATA_ENCRYPTION_KEY 与 JWT_SECRET 相同,建议分离以便独立轮换
|
||||
|
||||
# 正例:使用新密钥 → 0 条告警
|
||||
游戏皮肤供货平台 API 启动: http://localhost:18080
|
||||
|
||||
# 重加密:预演 → 写入 → 复跑幂等
|
||||
api_clients 扫描=11 重加密=11 已是新密钥=0 失败=0
|
||||
callback_subscriptions 扫描=10 重加密=10 已是新密钥=0 失败=0
|
||||
# 复跑(预演)
|
||||
api_clients 扫描=11 重加密=0 已是新密钥=11 失败=0
|
||||
callback_subscriptions 扫描=10 重加密=0 已是新密钥=10 失败=0
|
||||
```
|
||||
| 架构简化 | 统一由 `JWT_SECRET` 承接 JWT 签发与 AES-GCM 敏感数据加密,避免单机容器环境下拆分双密钥的伪隔离与高危全表重加密脚本 |
|
||||
| 启动体检 | 启动时仅在仍使用公开示例密钥或 release 模式误开 `OPEN_API_DEBUG` 时告警,保持启动日志干净 |
|
||||
| 灵活兼容 | 保留可选的 `DATA_ENCRYPTION_KEY` 环境变量读取,未设置时自动由主密钥派生 |
|
||||
|
||||
### 6.2 测试可运行性修复(P1)
|
||||
|
||||
@@ -420,5 +396,4 @@ make lint # go vet + oxlint
|
||||
make fmt # gofmt 格式化
|
||||
```
|
||||
|
||||
首次配置必须为 `JWT_SECRET` 与 `DATA_ENCRYPTION_KEY` 各自生成随机值,
|
||||
详见 [`密钥轮换说明.md`](密钥轮换说明.md)。
|
||||
生产环境部署时将 `JWT_SECRET` 配置为独立强随机密钥即可。
|
||||
|
||||
Reference in New Issue
Block a user