From d821fe13dfa166e61d3dc814c7119ea2bdf9a195 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 6 Oct 2026 19:42:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E5=88=86=E7=A6=BB=20JWT=20?= =?UTF-8?q?=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=8A=A0=E5=AF=86=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=E5=90=AF=E5=8A=A8=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E4=BD=93=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .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 --- .env.example | 6 + .gitignore | 4 + README.md | 5 + backend/cmd/reencrypt_secrets/main.go | 91 +++++++++++ backend/cmd/server/main.go | 5 + backend/internal/config/config.go | 28 ++++ backend/internal/config/config_test.go | 78 ++++++++++ backend/internal/service/secret_reencrypt.go | 106 +++++++++++++ .../internal/service/secret_reencrypt_test.go | 145 ++++++++++++++++++ docs/密钥轮换说明.md | 126 +++++++++++++++ 10 files changed, 594 insertions(+) create mode 100644 backend/cmd/reencrypt_secrets/main.go create mode 100644 backend/internal/config/config_test.go create mode 100644 backend/internal/service/secret_reencrypt.go create mode 100644 backend/internal/service/secret_reencrypt_test.go create mode 100644 docs/密钥轮换说明.md diff --git a/.env.example b/.env.example index f905fe6..67ab7e9 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,13 @@ FRONTEND_PORT=15173 # Caddy 对外端口:生产部署需域名绑定 80/443;本机与其他站点共存时可覆盖 # HTTP_PORT=80 # HTTPS_PORT=443 +# JWT_SECRET 用于签发后台登录 Token;泄露 = 任何人都能伪造管理员身份。 +# 生成方式:openssl rand -hex 48 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 # 仅用于空数据库的首次启动;已有管理员账号时不会被覆盖。 POSTGRES_USER=affiliate diff --git a/.gitignore b/.gitignore index 779ef1d..6fba6ad 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ .env .env.local .env.*.local +# 密钥轮换时的本地备份,含真实密钥,绝不能入库 +.env.bak +.env.bak.* +.env.*.bak backend/.env frontend/.env !.env.example diff --git a/README.md b/README.md index f6d9ac6..379458f 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ make docker-down # 停止并移除 | `FRONTEND_PORT` | 前端开发端口 | `15173` | | `HTTP_PORT` / `HTTPS_PORT` | Caddy 对外 HTTP/HTTPS 端口 | `80` / `443` | | `JWT_SECRET` | JWT 签名密钥 | 开发默认值 | +| `DATA_ENCRYPTION_KEY` | 加密落库的 API/回调密钥;留空会回退到 `JWT_SECRET` 并在启动日志告警 | 回退到 `JWT_SECRET` | | `DATABASE_URL` | PostgreSQL 连接串 | 本地开发默认值 | | `DOCKER_DATABASE_URL` | Docker 后端容器内 PostgreSQL 连接串,通常无需设置 | Compose 内置默认值 | | `POSTGRES_PORT` | PostgreSQL 映射到宿主机的端口 | `15432` | @@ -118,6 +119,10 @@ 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)。 + ## 开放接口(皮肤源头对接) 项目有两套外部 API,请先区分调用方: diff --git a/backend/cmd/reencrypt_secrets/main.go b/backend/cmd/reencrypt_secrets/main.go new file mode 100644 index 0000000..e4092b7 --- /dev/null +++ b/backend/cmd/reencrypt_secrets/main.go @@ -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重加密完成。请确认业务正常后再移除旧密钥。") +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 8318e92..c175762 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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), }) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ffa027e..0f600e0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..4796f1e --- /dev/null +++ b/backend/internal/config/config_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/service/secret_reencrypt.go b/backend/internal/service/secret_reencrypt.go new file mode 100644 index 0000000..4541c0c --- /dev/null +++ b/backend/internal/service/secret_reencrypt.go @@ -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 +} diff --git a/backend/internal/service/secret_reencrypt_test.go b/backend/internal/service/secret_reencrypt_test.go new file mode 100644 index 0000000..3471e1a --- /dev/null +++ b/backend/internal/service/secret_reencrypt_test.go @@ -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) + } +} diff --git a/docs/密钥轮换说明.md b/docs/密钥轮换说明.md new file mode 100644 index 0000000..19731b9 --- /dev/null +++ b/docs/密钥轮换说明.md @@ -0,0 +1,126 @@ +# 密钥轮换说明 + +> 适用版本: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 步已经用新密钥覆盖了密文。