From 3ec7febb8c81b25b2047acfdae52be48401e38b2 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 6 Oct 2026 20:54:00 +0800 Subject: [PATCH] =?UTF-8?q?refactor(security):=20=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E4=B8=BA=E5=8D=95=E4=B8=BB=E5=AF=86=E9=92=A5=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=EF=BC=8C=E5=BA=9F=E5=BC=83=E7=A6=BB=E7=BA=BF=E9=87=8D=E5=8A=A0?= =?UTF-8?q?=E5=AF=86=E4=B8=8E=E5=86=97=E4=BD=99=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 废弃 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% 通过 --- .env.example | 11 +- README.md | 9 +- backend/cmd/reencrypt_secrets/main.go | 91 ----------- backend/internal/config/config.go | 19 +-- backend/internal/config/config_test.go | 30 +--- backend/internal/service/secret_reencrypt.go | 106 ------------- .../internal/service/secret_reencrypt_test.go | 145 ------------------ docs/密钥轮换说明.md | 126 --------------- docs/项目分析.md | 37 +---- 9 files changed, 22 insertions(+), 552 deletions(-) delete mode 100644 backend/cmd/reencrypt_secrets/main.go delete mode 100644 backend/internal/service/secret_reencrypt.go delete mode 100644 backend/internal/service/secret_reencrypt_test.go delete mode 100644 docs/密钥轮换说明.md diff --git a/.env.example b/.env.example index 67ab7e9..d0462e8 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 5ca8e6e..da92c56 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/backend/cmd/reencrypt_secrets/main.go b/backend/cmd/reencrypt_secrets/main.go deleted file mode 100644 index e4092b7..0000000 --- a/backend/cmd/reencrypt_secrets/main.go +++ /dev/null @@ -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重加密完成。请确认业务正常后再移除旧密钥。") -} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 0f600e0..d6e8f02 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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,开放接口报文详情会持续写盘") diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 4796f1e..af0e669 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -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 == "" { diff --git a/backend/internal/service/secret_reencrypt.go b/backend/internal/service/secret_reencrypt.go deleted file mode 100644 index 4541c0c..0000000 --- a/backend/internal/service/secret_reencrypt.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/service/secret_reencrypt_test.go b/backend/internal/service/secret_reencrypt_test.go deleted file mode 100644 index 3471e1a..0000000 --- a/backend/internal/service/secret_reencrypt_test.go +++ /dev/null @@ -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) - } -} diff --git a/docs/密钥轮换说明.md b/docs/密钥轮换说明.md deleted file mode 100644 index 19731b9..0000000 --- a/docs/密钥轮换说明.md +++ /dev/null @@ -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 步已经用新密钥覆盖了密文。 diff --git a/docs/项目分析.md b/docs/项目分析.md index e775af3..90d5dcd 100644 --- a/docs/项目分析.md +++ b/docs/项目分析.md @@ -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` 配置为独立强随机密钥即可。