From 8fef037be3529c6cf2e200a220f61c1ed1a810b5 Mon Sep 17 00:00:00 2001 From: yml Date: Sun, 14 Jun 2026 17:52:59 +0800 Subject: [PATCH] =?UTF-8?q?AES=20=E5=AD=97=E6=AE=B5=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=E8=BF=81=E9=85=8D=E7=BD=AE=E5=B9=B6=E5=85=BC=E5=AE=B9=E6=97=A7?= =?UTF-8?q?=E5=AF=86=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除字段加密硬编码主密钥,改为 FIELD_ENCRYPTION_KEY 注入。 保留 FIELD_ENCRYPTION_LEGACY_KEY 透明回退旧密文,新写入统一使用主密钥;生产环境校验主密钥和 legacy 密钥长度、占位符及相等关系,并统一生产环境判断口径。 补充配置与旧密文兼容回归测试。 --- backend/.env.example | 10 ++ backend/.env.prod.example | 8 + backend/cmd/api/main.go | 2 +- backend/internal/config/config.go | 53 ++++++- backend/internal/config/config_test.go | 104 +++++++++++++ backend/internal/e2e/rental_flow_test.go | 6 +- .../modules/paymentaccount/repository.go | 20 +-- .../modules/realname/cloudmarket_provider.go | 27 ++-- .../realname/cloudmarket_provider_test.go | 11 +- backend/internal/modules/realname/provider.go | 15 +- .../internal/modules/withdrawal/presenter.go | 3 +- .../internal/modules/withdrawal/repository.go | 8 +- backend/internal/router/router.go | 62 ++++++-- backend/pkg/crypto/aes.go | 127 +++++++++++---- backend/pkg/crypto/aes_test.go | 145 ++++++++++++++++++ 15 files changed, 519 insertions(+), 82 deletions(-) create mode 100644 backend/internal/config/config_test.go create mode 100644 backend/pkg/crypto/aes_test.go diff --git a/backend/.env.example b/backend/.env.example index 58792c0..d5b1f03 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -59,6 +59,16 @@ REALNAME_CLOUDMARKET_APPCODE= # 生成方式:openssl rand -hex 16 PAYMENT_CONFIG_ENCRYPTION_KEY= +# 业务字段加密密钥(必须为 16、24 或 32 字节;生产环境必填且与下方 legacy 不同) +# 用于加密实名信息(姓名、身份证)和收款账号(银行卡/支付宝号) +# 生成方式:openssl rand -hex 16 +FIELD_ENCRYPTION_KEY= +# 业务字段旧密钥(可选;密钥轮换期间用于透明解出旧密文) +# 留空时使用内置默认值,兼容历史硬编码密钥加密的开发/测试数据。 +# 生产环境轮换流程:设新 FIELD_ENCRYPTION_KEY,把旧密钥填到这里, +# 待所有存量密文被读出后(或跑重加密脚本),删除此变量。 +FIELD_ENCRYPTION_LEGACY_KEY= + # 开放导入接口可选签名密钥;留空或请求未带签名时按旧方式导入。 # 若调用方携带 X-HFB-Timestamp 和 X-HFB-Signature,则会校验 HMAC 签名。 EXTERNAL_UPLOAD_SECRET= diff --git a/backend/.env.prod.example b/backend/.env.prod.example index b6b3459..6a0823a 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -65,6 +65,14 @@ REALNAME_CLOUDMARKET_APPCODE= # 警告:此密钥一旦设置不要更改,否则已有配置无法解密 PAYMENT_CONFIG_ENCRYPTION_KEY=change-to-32-byte-encryption-key +# 业务字段加密密钥(必须为 16、24 或 32 字节,生产环境必填且与 legacy 不同) +# 用于加密实名信息(姓名、身份证)和收款账号(银行卡/支付宝号) +# 生成方式:openssl rand -hex 16 +FIELD_ENCRYPTION_KEY=change-to-32-byte-field-encryption-key +# 业务字段旧密钥(密钥轮换期间用于透明解出旧密文,验证全部密文轮换完后删除) +# 首次部署若数据库已有用旧硬编码密钥加密的存量数据,填 hfb-sys-2024-secret-key-32bytes! +FIELD_ENCRYPTION_LEGACY_KEY= + # 开放导入接口可选签名密钥;内部调用方暂不签名时可留空。 # 若启用签名,生成方式:openssl rand -hex 32 EXTERNAL_UPLOAD_SECRET= diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index ef7c0ed..779824b 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -128,7 +128,7 @@ func newPaymentConfigRepositoryForJobs(cfg config.Config, db *gorm.DB, logger *z } } if encryptor == nil { - if cfg.AppEnv == "production" { + if config.IsProductionEnv(cfg.AppEnv) { logger.Fatal("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid") } encryptor = &paymentconfig.MockEncryptor{} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index c18c588..857792e 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -16,6 +16,8 @@ type Config struct { RedisDB int JWTSecret string PaymentConfigEncryptionKey string + FieldEncryptionKey string + FieldEncryptionLegacyKey string ExternalUploadSecret string ExternalUploadAllowedIPs []string BootstrapAdminUsername string @@ -72,11 +74,16 @@ func Load() Config { RedisDB: getEnvInt("REDIS_DB", 0), JWTSecret: getEnv("JWT_SECRET", "change-me"), PaymentConfigEncryptionKey: getEnv("PAYMENT_CONFIG_ENCRYPTION_KEY", ""), - ExternalUploadSecret: getEnv("EXTERNAL_UPLOAD_SECRET", ""), - ExternalUploadAllowedIPs: getEnvList("EXTERNAL_UPLOAD_ALLOWED_IPS"), - BootstrapAdminUsername: getEnv("ADMIN_BOOTSTRAP_USERNAME", ""), - BootstrapAdminPassword: getEnv("ADMIN_BOOTSTRAP_PASSWORD", ""), - BootstrapAdminNickname: getEnv("ADMIN_BOOTSTRAP_NICKNAME", "超级管理员"), + FieldEncryptionKey: getEnv("FIELD_ENCRYPTION_KEY", ""), + // 旧密钥回退:生产环境必须显式设置(空 = 禁用回退,用于密钥轮换收敛); + // 非生产默认填历史硬编码密钥,兼容开发/测试库的存量密文。不能用 getEnv 的 fallback—— + // 那样生产删掉 env 会重新注入已泄露的硬编码密钥,破坏轮换闭环。 + FieldEncryptionLegacyKey: fieldEncryptionLegacyKey(getEnv("APP_ENV", "development")), + ExternalUploadSecret: getEnv("EXTERNAL_UPLOAD_SECRET", ""), + ExternalUploadAllowedIPs: getEnvList("EXTERNAL_UPLOAD_ALLOWED_IPS"), + BootstrapAdminUsername: getEnv("ADMIN_BOOTSTRAP_USERNAME", ""), + BootstrapAdminPassword: getEnv("ADMIN_BOOTSTRAP_PASSWORD", ""), + BootstrapAdminNickname: getEnv("ADMIN_BOOTSTRAP_NICKNAME", "超级管理员"), Storage: StorageConfig{ Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:9000"), Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"), @@ -110,7 +117,7 @@ func Load() Config { } func (c Config) ValidateProductionSecurity() error { - if strings.ToLower(strings.TrimSpace(c.AppEnv)) != "production" { + if !IsProductionEnv(c.AppEnv) { return nil } if strings.TrimSpace(c.JWTSecret) == "" || isPlaceholder(c.JWTSecret) || len([]byte(c.JWTSecret)) < 32 { @@ -120,6 +127,17 @@ func (c Config) ValidateProductionSecurity() error { if isPlaceholder(c.PaymentConfigEncryptionKey) || (keyLen != 16 && keyLen != 24 && keyLen != 32) { return errors.New("PAYMENT_CONFIG_ENCRYPTION_KEY must be 16, 24, or 32 bytes in production") } + fieldKeyLen := len([]byte(c.FieldEncryptionKey)) + if c.FieldEncryptionKey == "" || isPlaceholder(c.FieldEncryptionKey) || (fieldKeyLen != 16 && fieldKeyLen != 24 && fieldKeyLen != 32) { + return errors.New("FIELD_ENCRYPTION_KEY must be 16, 24, or 32 bytes in production") + } + legacyKeyLen := len([]byte(c.FieldEncryptionLegacyKey)) + if c.FieldEncryptionLegacyKey != "" && (isPlaceholder(c.FieldEncryptionLegacyKey) || (legacyKeyLen != 16 && legacyKeyLen != 24 && legacyKeyLen != 32)) { + return errors.New("FIELD_ENCRYPTION_LEGACY_KEY must be empty or 16, 24, or 32 bytes in production") + } + if c.FieldEncryptionKey == c.FieldEncryptionLegacyKey { + return errors.New("FIELD_ENCRYPTION_KEY must differ from FIELD_ENCRYPTION_LEGACY_KEY in production (set a new primary key to rotate)") + } if c.BootstrapAdminPassword != "" && isPlaceholder(c.BootstrapAdminPassword) { return errors.New("ADMIN_BOOTSTRAP_PASSWORD must not use the example placeholder in production") } @@ -134,6 +152,24 @@ func getEnv(key, fallback string) string { return value } +// historicFieldEncryptionKey 是迁移前 pkg/crypto 的硬编码密钥,仅用于解密历史存量密文。 +const historicFieldEncryptionKey = "hfb-sys-2024-secret-key-32bytes!" + +// fieldEncryptionLegacyKey 解析 FIELD_ENCRYPTION_LEGACY_KEY: +// - 生产环境:必须显式设置,空表示禁用回退(密钥轮换收敛后删除 env 即关闭旧密钥)。 +// 不能用硬编码默认值,否则轮换闭环不成立。 +// - 非生产环境:未设置时回退到历史硬编码密钥,兼容开发/测试库的存量密文(零配置)。 +func fieldEncryptionLegacyKey(appEnv string) string { + if IsProductionEnv(appEnv) { + // 生产显式空 = 禁用;未设置也视为禁用。 + return os.Getenv("FIELD_ENCRYPTION_LEGACY_KEY") + } + if v := os.Getenv("FIELD_ENCRYPTION_LEGACY_KEY"); v != "" { + return v + } + return historicFieldEncryptionKey +} + func getEnvInt(key string, fallback int) int { value := os.Getenv(key) if value == "" { @@ -178,3 +214,8 @@ func isPlaceholder(value string) bool { normalized := strings.ToLower(strings.TrimSpace(value)) return normalized == "change-me" || strings.HasPrefix(normalized, "change-") || strings.Contains(normalized, "change-to-") } + +// IsProductionEnv 判断 AppEnv 是否为生产环境(与 ValidateProductionSecurity 同口径)。 +func IsProductionEnv(appEnv string) bool { + return strings.ToLower(strings.TrimSpace(appEnv)) == "production" +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..53511be --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,104 @@ +package config + +import ( + "os" + "testing" +) + +const historicKey = "hfb-sys-2024-secret-key-32bytes!" + +func TestIsProductionEnv(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"production", true}, + {"PRODUCTION", true}, + {" production ", true}, + {"development", false}, + {"", false}, + {"staging", false}, + } + for _, c := range cases { + if got := IsProductionEnv(c.in); got != c.want { + t.Fatalf("IsProductionEnv(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +// TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric 验证非生产环境未设置 legacy 时 +// 回退到历史硬编码密钥(开发态零配置兼容旧密文)。 +func TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric(t *testing.T) { + unsetEnv(t, "FIELD_ENCRYPTION_LEGACY_KEY") + if got := fieldEncryptionLegacyKey("development"); got != historicKey { + t.Fatalf("non-production default legacy = %q, want %q", got, historicKey) + } +} + +// TestFieldEncryptionLegacyKeyNonProductionExplicitOverride 验证非生产环境显式设置 legacy 时用该值。 +func TestFieldEncryptionLegacyKeyNonProductionExplicitOverride(t *testing.T) { + t.Setenv("FIELD_ENCRYPTION_LEGACY_KEY", "explicit-legacy-16bytes!") + if got := fieldEncryptionLegacyKey("development"); got != "explicit-legacy-16bytes!" { + t.Fatalf("non-production explicit legacy = %q, want explicit-legacy-16bytes!", got) + } +} + +// TestFieldEncryptionLegacyKeyProductionEmptyDisablesFallback 验证生产环境未设置/留空 legacy +// 返回空(禁用回退),而不是注入历史硬编码密钥。 +// 这是密钥轮换闭环的关键:删除 env 必须真正关闭旧密钥。 +func TestFieldEncryptionLegacyKeyProductionEmptyDisablesFallback(t *testing.T) { + t.Setenv("FIELD_ENCRYPTION_LEGACY_KEY", "") + if got := fieldEncryptionLegacyKey("production"); got != "" { + t.Fatalf("production empty legacy = %q, want empty (fallback disabled); injecting historic key breaks rotation closure", got) + } +} + +// TestFieldEncryptionLegacyKeyProductionExplicitValue 验证生产环境显式设置 legacy 时用该值 +// (轮换期间保留旧密钥解密存量)。 +func TestFieldEncryptionLegacyKeyProductionExplicitValue(t *testing.T) { + t.Setenv("FIELD_ENCRYPTION_LEGACY_KEY", "prod-old-key-32bytes-0123456789") + if got := fieldEncryptionLegacyKey("production"); got != "prod-old-key-32bytes-0123456789" { + t.Fatalf("production explicit legacy = %q, want prod-old-key-32bytes-0123456789", got) + } +} + +// TestValidateProductionSecurityRejectsPrimaryEqualsLegacy 验证生产环境 primary 等于 legacy 时报错。 +func TestValidateProductionSecurityRejectsPrimaryEqualsLegacy(t *testing.T) { + t.Setenv("APP_ENV", "production") + t.Setenv("JWT_SECRET", "a-very-long-random-jwt-secret-at-least-32-bytes!!") + t.Setenv("PAYMENT_CONFIG_ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef") + t.Setenv("FIELD_ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef") + t.Setenv("FIELD_ENCRYPTION_LEGACY_KEY", "0123456789abcdef0123456789abcdef") + cfg := Load() + if err := cfg.ValidateProductionSecurity(); err == nil { + t.Fatal("ValidateProductionSecurity should reject FIELD_ENCRYPTION_KEY == LEGACY_KEY") + } +} + +// TestValidateProductionSecurityRejectsInvalidLegacyKey 验证生产环境显式设置 legacy 时也校验长度。 +func TestValidateProductionSecurityRejectsInvalidLegacyKey(t *testing.T) { + t.Setenv("APP_ENV", "PRODUCTION") + t.Setenv("JWT_SECRET", "a-very-long-random-jwt-secret-at-least-32-bytes!!") + t.Setenv("PAYMENT_CONFIG_ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef") + t.Setenv("FIELD_ENCRYPTION_KEY", "abcdef0123456789abcdef0123456789") + t.Setenv("FIELD_ENCRYPTION_LEGACY_KEY", "too-short") + cfg := Load() + if err := cfg.ValidateProductionSecurity(); err == nil { + t.Fatal("ValidateProductionSecurity should reject invalid FIELD_ENCRYPTION_LEGACY_KEY") + } +} + +func unsetEnv(t *testing.T, key string) { + t.Helper() + old, ok := os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + t.Cleanup(func() { + if ok { + _ = os.Setenv(key, old) + return + } + _ = os.Unsetenv(key) + }) +} diff --git a/backend/internal/e2e/rental_flow_test.go b/backend/internal/e2e/rental_flow_test.go index ff0c791..2ed8e39 100644 --- a/backend/internal/e2e/rental_flow_test.go +++ b/backend/internal/e2e/rental_flow_test.go @@ -232,10 +232,10 @@ func newFlowServices(db *gorm.DB) flowServices { order: order.NewService(orderRepo), payment: payment.NewService(paymentRepo), paymentConfig: paymentconfig.NewService(configRepo), - paymentAccount: paymentaccount.NewService(paymentaccount.NewRepository(db)), + paymentAccount: paymentaccount.NewService(paymentaccount.NewRepository(db, &crypto.MockEncryptor{})), dispute: dispute.NewService(disputeRepo), wallet: wallet.NewService(walletRepo), - withdrawal: withdrawal.NewService(withdrawal.NewRepository(db, walletRepo)), + withdrawal: withdrawal.NewService(withdrawal.NewRepository(db, walletRepo, &crypto.MockEncryptor{})), finance: adminfinance.NewService(adminfinance.NewRepository(db)), } } @@ -362,7 +362,7 @@ func seedUsers(t *testing.T, db *gorm.DB) (model.User, model.User, uint64) { func seedRealname(t *testing.T, db *gorm.DB, userID uint64, name string, verifiedAt time.Time) { t.Helper() - encryptedName, err := crypto.Encrypt(name) + encryptedName, err := (&crypto.MockEncryptor{}).Encrypt(name) if err != nil { t.Fatalf("加密实名姓名失败: %v", err) } diff --git a/backend/internal/modules/paymentaccount/repository.go b/backend/internal/modules/paymentaccount/repository.go index 3e011df..83f6ae6 100644 --- a/backend/internal/modules/paymentaccount/repository.go +++ b/backend/internal/modules/paymentaccount/repository.go @@ -13,14 +13,16 @@ import ( "gorm.io/gorm" ) -// 删除加密密钥常量 - type Repository struct { - db *gorm.DB + db *gorm.DB + encryptor crypto.Encryptor } -func NewRepository(db *gorm.DB) *Repository { - return &Repository{db: db} +func NewRepository(db *gorm.DB, encryptor crypto.Encryptor) *Repository { + if encryptor == nil { + encryptor = &crypto.MockEncryptor{} + } + return &Repository{db: db, encryptor: encryptor} } func (r *Repository) List(ctx context.Context, userID uint64, page, pageSize int) (*PaginatedResult, error) { @@ -73,7 +75,7 @@ func (r *Repository) FindByID(ctx context.Context, userID, id uint64) (*PaymentA func (r *Repository) Create(ctx context.Context, userID uint64, req CreatePaymentAccountRequest) (*PaymentAccountDTO, error) { db := r.db.WithContext(ctx) // 加密账号 - encryptedNo, err := crypto.Encrypt(req.AccountNo) + encryptedNo, err := r.encryptor.Encrypt(req.AccountNo) if err != nil { return nil, err } @@ -217,7 +219,7 @@ func (r *Repository) ValidateRealname(ctx context.Context, userID uint64, accoun // 验证姓名匹配 - 使用加密字段进行精确匹配 if realname.EncryptedName != "" { // 有加密字段,解密后精确匹配 - decryptedName, err := crypto.Decrypt(realname.EncryptedName) + decryptedName, err := r.encryptor.Decrypt(realname.EncryptedName) if err != nil { // 解密失败,降级到前缀匹配 return r.validateByMaskedName(realname.MaskedName, accountName) @@ -256,7 +258,7 @@ func (r *Repository) validateByMaskedName(maskedName, accountName string) error func (r *Repository) toDTO(account model.UserPaymentAccount) (*PaymentAccountDTO, error) { // 解密账号并脱敏 - decrypted, err := crypto.Decrypt(account.AccountNo) + decrypted, err := r.encryptor.Decrypt(account.AccountNo) if err != nil { decrypted = account.AccountNo // 降级处理 } @@ -314,5 +316,5 @@ func (r *Repository) GetDecryptedAccountNo(ctx context.Context, userID, id uint6 if err := r.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).First(&account).Error; err != nil { return "", err } - return crypto.Decrypt(account.AccountNo) + return r.encryptor.Decrypt(account.AccountNo) } diff --git a/backend/internal/modules/realname/cloudmarket_provider.go b/backend/internal/modules/realname/cloudmarket_provider.go index 055aeaf..dd0856c 100644 --- a/backend/internal/modules/realname/cloudmarket_provider.go +++ b/backend/internal/modules/realname/cloudmarket_provider.go @@ -16,14 +16,16 @@ import ( const cloudMarketProviderName = "aliyun_cloudmarket" type CloudMarketConfig struct { - URL string - AppCode string + URL string + AppCode string + Encryptor crypto.Encryptor } type CloudMarketProvider struct { - url string - appCode string - client *http.Client + url string + appCode string + client *http.Client + encryptor crypto.Encryptor } type cloudMarketResponse struct { @@ -42,10 +44,15 @@ func NewCloudMarketProvider(cfg CloudMarketConfig) (*CloudMarketProvider, error) if _, err := url.ParseRequestURI(endpoint); err != nil { return nil, fmt.Errorf("realname cloud market url invalid: %w", err) } + encryptor := cfg.Encryptor + if encryptor == nil { + encryptor = &crypto.MockEncryptor{} + } return &CloudMarketProvider{ - url: endpoint, - appCode: appCode, - client: &http.Client{Timeout: 10 * time.Second}, + url: endpoint, + appCode: appCode, + client: &http.Client{Timeout: 10 * time.Second}, + encryptor: encryptor, }, nil } @@ -108,11 +115,11 @@ func (p *CloudMarketProvider) Start(ctx context.Context, req StartRequest) (Prov } // 加密完整信息 - encryptedName, err := crypto.Encrypt(req.Name) + encryptedName, err := p.encryptor.Encrypt(req.Name) if err != nil { return ProviderResult{}, err } - encryptedIDNo, err := crypto.Encrypt(req.IDNo) + encryptedIDNo, err := p.encryptor.Encrypt(req.IDNo) if err != nil { return ProviderResult{}, err } diff --git a/backend/internal/modules/realname/cloudmarket_provider_test.go b/backend/internal/modules/realname/cloudmarket_provider_test.go index 1f862c2..380fd06 100644 --- a/backend/internal/modules/realname/cloudmarket_provider_test.go +++ b/backend/internal/modules/realname/cloudmarket_provider_test.go @@ -5,6 +5,8 @@ import ( "net/http" "net/http/httptest" "testing" + + "hfb_sys/backend/pkg/crypto" ) func TestCloudMarketProviderStartVerified(t *testing.T) { @@ -27,7 +29,8 @@ func TestCloudMarketProviderStartVerified(t *testing.T) { provider, err := NewCloudMarketProvider(CloudMarketConfig{ URL: server.URL, - AppCode: "test-code", + AppCode: "test-code", + Encryptor: &crypto.MockEncryptor{}, }) if err != nil { t.Fatalf("创建 Provider 失败:%v", err) @@ -60,7 +63,8 @@ func TestCloudMarketProviderStartRejected(t *testing.T) { provider, err := NewCloudMarketProvider(CloudMarketConfig{ URL: server.URL, - AppCode: "test-code", + AppCode: "test-code", + Encryptor: &crypto.MockEncryptor{}, }) if err != nil { t.Fatalf("创建 Provider 失败:%v", err) @@ -90,7 +94,8 @@ func TestCloudMarketProviderStartRateLimited(t *testing.T) { provider, err := NewCloudMarketProvider(CloudMarketConfig{ URL: server.URL, - AppCode: "test-code", + AppCode: "test-code", + Encryptor: &crypto.MockEncryptor{}, }) if err != nil { t.Fatalf("创建 Provider 失败:%v", err) diff --git a/backend/internal/modules/realname/provider.go b/backend/internal/modules/realname/provider.go index 25af766..fd4879d 100644 --- a/backend/internal/modules/realname/provider.go +++ b/backend/internal/modules/realname/provider.go @@ -33,10 +33,15 @@ type ProviderResult struct { FailReason string } -type MockProvider struct{} +type MockProvider struct { + encryptor crypto.Encryptor +} -func NewMockProvider() *MockProvider { - return &MockProvider{} +func NewMockProvider(encryptor crypto.Encryptor) *MockProvider { + if encryptor == nil { + encryptor = &crypto.MockEncryptor{} + } + return &MockProvider{encryptor: encryptor} } func (p *MockProvider) Name() string { @@ -53,11 +58,11 @@ func (p *MockProvider) Start(_ context.Context, req StartRequest) (ProviderResul } // 加密完整信息 - encryptedName, err := crypto.Encrypt(req.Name) + encryptedName, err := p.encryptor.Encrypt(req.Name) if err != nil { return ProviderResult{}, err } - encryptedIDNo, err := crypto.Encrypt(req.IDNo) + encryptedIDNo, err := p.encryptor.Encrypt(req.IDNo) if err != nil { return ProviderResult{}, err } diff --git a/backend/internal/modules/withdrawal/presenter.go b/backend/internal/modules/withdrawal/presenter.go index 92cb21a..bac8907 100644 --- a/backend/internal/modules/withdrawal/presenter.go +++ b/backend/internal/modules/withdrawal/presenter.go @@ -5,7 +5,6 @@ import ( "encoding/json" "hfb_sys/backend/internal/model" - "hfb_sys/backend/pkg/crypto" ) // 转换为用户DTO @@ -64,7 +63,7 @@ func (r *Repository) toDetailDTO(ctx context.Context, w model.WithdrawalRequest) var paymentAccount model.UserPaymentAccount if err := db.First(&paymentAccount, *w.PaymentAccountID).Error; err == nil { // 解密账号 - decrypted, err := crypto.Decrypt(paymentAccount.AccountNo) + decrypted, err := r.encryptor.Decrypt(paymentAccount.AccountNo) if err == nil { fullAccountNo = decrypted } diff --git a/backend/internal/modules/withdrawal/repository.go b/backend/internal/modules/withdrawal/repository.go index b74f54f..0d04d95 100644 --- a/backend/internal/modules/withdrawal/repository.go +++ b/backend/internal/modules/withdrawal/repository.go @@ -2,6 +2,7 @@ package withdrawal import ( "hfb_sys/backend/internal/modules/wallet" + "hfb_sys/backend/pkg/crypto" "gorm.io/gorm" ) @@ -9,12 +10,17 @@ import ( type Repository struct { db *gorm.DB walletRepo *wallet.Repository + encryptor crypto.Encryptor } -func NewRepository(db *gorm.DB, walletRepo *wallet.Repository) *Repository { +func NewRepository(db *gorm.DB, walletRepo *wallet.Repository, encryptor crypto.Encryptor) *Repository { + if encryptor == nil { + encryptor = &crypto.MockEncryptor{} + } return &Repository{ db: db, walletRepo: walletRepo, + encryptor: encryptor, } } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 92c3a06..bda04a2 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -34,6 +34,7 @@ import ( "hfb_sys/backend/internal/modules/withdrawal" _ "hfb_sys/backend/docs" // Swagger 文档 + "hfb_sys/backend/pkg/crypto" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" @@ -42,7 +43,7 @@ import ( ) func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { - if cfg.AppEnv == "production" { + if config.IsProductionEnv(cfg.AppEnv) { gin.SetMode(gin.ReleaseMode) } @@ -58,13 +59,53 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { engine.GET("/health", health.Check) // Swagger 文档路由(仅在非生产环境) - if cfg.AppEnv != "production" { + if !config.IsProductionEnv(cfg.AppEnv) { engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) } if cfg.RateLimit.Enabled { engine.Use(middleware.RateLimitPerMinute(cfg.RateLimit.RequestsPerMinute, deps.Redis)) } + // 业务字段加密器(实名/收款账号):主密钥 + legacy 回退,用于密钥轮换期间透明解出旧密文。 + // 生产环境必须配置 FIELD_ENCRYPTION_KEY(ValidateProductionSecurity 已校验)。 + // 开发/测试缺 FIELD_ENCRYPTION_KEY 时用 legacy 密钥构造单密钥加密器, + // 保证零配置兼容历史硬编码密钥加密的存量密文(非生产 legacy 默认填历史硬编码值)。 + var fieldEncryptor crypto.Encryptor + primary := cfg.FieldEncryptionKey + legacy := cfg.FieldEncryptionLegacyKey + switch { + case primary != "": + encryptor, err := crypto.NewFieldEncryptor(primary, legacy) + if err != nil { + if config.IsProductionEnv(cfg.AppEnv) { + logger.Fatal("FIELD_ENCRYPTION_KEY invalid", zap.Error(err)) + } + logger.Warn("FIELD_ENCRYPTION_KEY invalid, fallback to legacy-only", zap.Error(err)) + // primary 非法时退回 legacy 单密钥(若有),否则 MockEncryptor + if legacy != "" { + if e, eErr := crypto.NewFieldEncryptor(legacy); eErr == nil { + fieldEncryptor = e + } + } + } else { + fieldEncryptor = encryptor + } + case legacy != "": + // 开发态零配置:用 legacy 单密钥,能解历史存量密文。 + encryptor, err := crypto.NewFieldEncryptor(legacy) + if err == nil { + fieldEncryptor = encryptor + logger.Warn("FIELD_ENCRYPTION_KEY not set, using legacy-only encryptor for dev compatibility") + } + } + if fieldEncryptor == nil { + if config.IsProductionEnv(cfg.AppEnv) { + logger.Fatal("FIELD_ENCRYPTION_KEY not set") + } + fieldEncryptor = &crypto.MockEncryptor{} + logger.Warn("FIELD_ENCRYPTION_KEY and legacy both unset, using MockEncryptor") + } + jwtManager := auth.NewJWTManager(cfg.JWTSecret) var userRepo *auth.UserRepository if deps.DB != nil { @@ -108,7 +149,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { if deps.DB != nil { realnameRepo = realname.NewRepository(deps.DB) } - realnameService := realname.NewService(realnameRepo, newRealnameProvider(cfg, logger), logger) + realnameService := realname.NewService(realnameRepo, newRealnameProvider(cfg, fieldEncryptor, logger), logger) realnameHandler := realname.NewHandler(realnameService) var listingRepo *listing.Repository if deps.DB != nil { @@ -149,13 +190,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { walletHandler := wallet.NewHandler(walletService) var paymentAccountRepo *paymentaccount.Repository if deps.DB != nil { - paymentAccountRepo = paymentaccount.NewRepository(deps.DB) + paymentAccountRepo = paymentaccount.NewRepository(deps.DB, fieldEncryptor) } paymentAccountService := paymentaccount.NewService(paymentAccountRepo) paymentAccountHandler := paymentaccount.NewHandler(paymentAccountService) var withdrawalRepo *withdrawal.Repository if deps.DB != nil { - withdrawalRepo = withdrawal.NewRepository(deps.DB, walletRepo) + withdrawalRepo = withdrawal.NewRepository(deps.DB, walletRepo, fieldEncryptor) } withdrawalService := withdrawal.NewService(withdrawalRepo) withdrawalHandler := withdrawal.NewHandler(withdrawalService) @@ -174,7 +215,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } } if encryptor == nil { - if cfg.AppEnv == "production" { + if config.IsProductionEnv(cfg.AppEnv) { logger.Fatal("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid") } encryptor = &paymentconfig.MockEncryptor{} @@ -556,12 +597,13 @@ func newSMSProvider(cfg config.Config, logger *zap.Logger) smsintegration.Provid } } -func newRealnameProvider(cfg config.Config, logger *zap.Logger) realname.Provider { +func newRealnameProvider(cfg config.Config, encryptor crypto.Encryptor, logger *zap.Logger) realname.Provider { switch strings.ToLower(strings.TrimSpace(cfg.Realname.Provider)) { case "cloudmarket", "aliyun_cloudmarket": provider, err := realname.NewCloudMarketProvider(realname.CloudMarketConfig{ - URL: cfg.Realname.CloudMarketURL, - AppCode: cfg.Realname.CloudMarketAppCode, + URL: cfg.Realname.CloudMarketURL, + AppCode: cfg.Realname.CloudMarketAppCode, + Encryptor: encryptor, }) if err != nil { logger.Warn("cloud market realname provider unavailable; realname verify will fail", zap.Error(err)) @@ -569,6 +611,6 @@ func newRealnameProvider(cfg config.Config, logger *zap.Logger) realname.Provide } return provider default: - return realname.NewMockProvider() + return realname.NewMockProvider(encryptor) } } diff --git a/backend/pkg/crypto/aes.go b/backend/pkg/crypto/aes.go index 80d24ab..8492562 100644 --- a/backend/pkg/crypto/aes.go +++ b/backend/pkg/crypto/aes.go @@ -9,61 +9,124 @@ import ( "io" ) -// 加密密钥(生产环境应从配置文件读取) -// 必须是 16、24 或 32 字节 -const encryptionKey = "hfb-sys-2024-secret-key-32bytes!" // 正好32字节 +// Encryptor 字段加密器接口。Encrypt 永远用主密钥,Decrypt 先试主密钥、失败试 legacy 密钥。 +type Encryptor interface { + Encrypt(plaintext string) (string, error) + Decrypt(ciphertext string) (string, error) +} -// Encrypt 使用 AES-GCM 加密文本 -func Encrypt(plainText string) (string, error) { - block, err := aes.NewCipher([]byte(encryptionKey)) +// aesKey 持有一个 AES-GCM 密钥及其 cipher。 +type aesKey struct { + key []byte +} + +func newAESKey(key string) (*aesKey, error) { + keyBytes := []byte(key) + if len(keyBytes) != 16 && len(keyBytes) != 24 && len(keyBytes) != 32 { + return nil, errors.New("invalid key length: must be 16, 24, or 32 bytes") + } + return &aesKey{key: keyBytes}, nil +} + +// FieldEncryptor 持有主密钥 + 可选 legacy 密钥列表。 +// Encrypt 永远用 primary;Decrypt 先试 primary,gcm.Open 认证失败依次试 legacy, +// 用于密钥轮换期间透明解出旧密文。 +type FieldEncryptor struct { + primary *aesKey + legacy []*aesKey +} + +// NewFieldEncryptor 创建字段加密器。primary 为主密钥,legacy 为可选的旧密钥(用于回退解密)。 +func NewFieldEncryptor(primary string, legacy ...string) (*FieldEncryptor, error) { + primaryKey, err := newAESKey(primary) + if err != nil { + return nil, err + } + legacyKeys := make([]*aesKey, 0, len(legacy)) + for _, l := range legacy { + if l == "" || l == primary { + continue + } + k, err := newAESKey(l) + if err != nil { + return nil, err + } + legacyKeys = append(legacyKeys, k) + } + return &FieldEncryptor{primary: primaryKey, legacy: legacyKeys}, nil +} + +// Encrypt 使用 AES-GCM 加密明文,输出 base64(nonce || ciphertext || gcmTag)。 +func (e *FieldEncryptor) Encrypt(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + gcm, err := e.gcm(e.primary) if err != nil { return "", err } - - aesGCM, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - - // 创建随机 nonce - nonce := make([]byte, aesGCM.NonceSize()) + nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", err } - - // 加密并附加 nonce - cipherBytes := aesGCM.Seal(nonce, nonce, []byte(plainText), nil) - return base64.StdEncoding.EncodeToString(cipherBytes), nil + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil } -// Decrypt 使用 AES-GCM 解密文本 -func Decrypt(cipherText string) (string, error) { - block, err := aes.NewCipher([]byte(encryptionKey)) +// Decrypt 解密密文。先试主密钥,gcm.Open 认证失败依次试 legacy 密钥。 +func (e *FieldEncryptor) Decrypt(ciphertext string) (string, error) { + if ciphertext == "" { + return "", nil + } + decoded, err := base64.StdEncoding.DecodeString(ciphertext) if err != nil { return "", err } + for _, k := range append([]*aesKey{e.primary}, e.legacy...) { + plaintext, err := decryptWithKey(k, decoded) + if err == nil { + return plaintext, nil + } + } + return "", errors.New("decrypt failed: no matching key") +} - aesGCM, err := cipher.NewGCM(block) +func (e *FieldEncryptor) gcm(k *aesKey) (cipher.AEAD, error) { + block, err := aes.NewCipher(k.key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +func decryptWithKey(k *aesKey, decoded []byte) (string, error) { + block, err := aes.NewCipher(k.key) if err != nil { return "", err } - - cipherBytes, err := base64.StdEncoding.DecodeString(cipherText) + gcm, err := cipher.NewGCM(block) if err != nil { return "", err } - - nonceSize := aesGCM.NonceSize() - if len(cipherBytes) < nonceSize { + nonceSize := gcm.NonceSize() + if len(decoded) < nonceSize { return "", errors.New("ciphertext too short") } - - // 提取 nonce 和实际密文 - nonce, cipherBytes := cipherBytes[:nonceSize], cipherBytes[nonceSize:] - plainBytes, err := aesGCM.Open(nil, nonce, cipherBytes, nil) + nonce, body := decoded[:nonceSize], decoded[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, body, nil) if err != nil { return "", err } + return string(plaintext), nil +} - return string(plainBytes), nil +// MockEncryptor 测试用直通加密器,明文不加密。 +type MockEncryptor struct{} + +func (e *MockEncryptor) Encrypt(plaintext string) (string, error) { + return plaintext, nil +} + +func (e *MockEncryptor) Decrypt(ciphertext string) (string, error) { + return ciphertext, nil } diff --git a/backend/pkg/crypto/aes_test.go b/backend/pkg/crypto/aes_test.go new file mode 100644 index 0000000..57c0372 --- /dev/null +++ b/backend/pkg/crypto/aes_test.go @@ -0,0 +1,145 @@ +package crypto + +import ( + "encoding/base64" + "testing" +) + +const ( + testPrimaryKey = "0123456789abcdef0123456789abcdef" // 32 字节 + testLegacyKey = "hfb-sys-2024-secret-key-32bytes!" +) + +func TestFieldEncryptorRoundTrip(t *testing.T) { + enc, err := NewFieldEncryptor(testPrimaryKey) + if err != nil { + t.Fatalf("NewFieldEncryptor() error = %v", err) + } + cases := []string{"", "张三", "110101199003077734", "6222021234567890", "with spaces and 中文"} + for _, plain := range cases { + cipher, err := enc.Encrypt(plain) + if err != nil { + t.Fatalf("Encrypt(%q) error = %v", plain, err) + } + got, err := enc.Decrypt(cipher) + if err != nil { + t.Fatalf("Decrypt() error = %v", err) + } + if got != plain { + t.Fatalf("round-trip mismatch: got %q, want %q", got, plain) + } + } +} + +func TestFieldEncryptorEmptyStringPassthrough(t *testing.T) { + enc, _ := NewFieldEncryptor(testPrimaryKey) + cipher, err := enc.Encrypt("") + if err != nil { + t.Fatalf("Encrypt(\"\") error = %v", err) + } + if cipher != "" { + t.Fatalf("Encrypt(\"\") = %q, want empty", cipher) + } + got, err := enc.Decrypt("") + if err != nil { + t.Fatalf("Decrypt(\"\") error = %v", err) + } + if got != "" { + t.Fatalf("Decrypt(\"\") = %q, want empty", got) + } +} + +// TestFieldEncryptorLegacyFallback 验证旧密钥加密的密文,新 FieldEncryptor(含 legacy)能解出。 +func TestFieldEncryptorLegacyFallback(t *testing.T) { + legacyEnc, _ := NewFieldEncryptor(testLegacyKey) + plain := "110101199003077734" + cipher, err := legacyEnc.Encrypt(plain) + if err != nil { + t.Fatalf("legacy Encrypt() error = %v", err) + } + + // 新加密器:primary 不同,legacy 含旧密钥 + primaryEnc, err := NewFieldEncryptor(testPrimaryKey, testLegacyKey) + if err != nil { + t.Fatalf("NewFieldEncryptor(primary, legacy) error = %v", err) + } + got, err := primaryEnc.Decrypt(cipher) + if err != nil { + t.Fatalf("Decrypt() with legacy fallback error = %v", err) + } + if got != plain { + t.Fatalf("legacy fallback mismatch: got %q, want %q", got, plain) + } +} + +// TestFieldEncryptorNoLegacyFailsOnOldCiphertext 验证无 legacy 时解旧密文失败。 +func TestFieldEncryptorNoLegacyFailsOnOldCiphertext(t *testing.T) { + legacyEnc, _ := NewFieldEncryptor(testLegacyKey) + cipher, _ := legacyEnc.Encrypt("secret") + + primaryOnly, _ := NewFieldEncryptor(testPrimaryKey) + if _, err := primaryOnly.Decrypt(cipher); err == nil { + t.Fatal("Decrypt() with no legacy should fail on old ciphertext") + } +} + +// TestFieldEncryptorDecryptGarbageFails 验证非密文输入解密失败。 +func TestFieldEncryptorDecryptGarbageFails(t *testing.T) { + enc, _ := NewFieldEncryptor(testPrimaryKey) + cases := []string{"not-base64!!!", "dG9vIHNob3J0"} // 非 base64 / 解码后太短 + for _, c := range cases { + if _, err := enc.Decrypt(c); err == nil { + t.Fatalf("Decrypt(%q) should fail", c) + } + } +} + +func TestNewFieldEncryptorInvalidKeyLength(t *testing.T) { + // AES 只接受 16/24/32 字节,其余长度必须失败 + cases := []int{0, 1, 15, 17, 23, 25, 31, 33, 40} + for _, n := range cases { + key := string(make([]byte, n)) // n 个零字节 + if _, err := NewFieldEncryptor(key); err == nil { + t.Fatalf("NewFieldEncryptor(len=%d) should fail on invalid length", n) + } + } +} + +// TestNewFieldEncryptorDedupsLegacyKey 验证 legacy 等于 primary 时被忽略(避免无效回退)。 +func TestNewFieldEncryptorDedupsLegacyKey(t *testing.T) { + enc, err := NewFieldEncryptor(testPrimaryKey, testPrimaryKey, "") + if err != nil { + t.Fatalf("NewFieldEncryptor() error = %v", err) + } + if len(enc.legacy) != 0 { + t.Fatalf("legacy should be empty after dedup, got %d", len(enc.legacy)) + } +} + +func TestMockEncryptorPassthrough(t *testing.T) { + m := &MockEncryptor{} + plain := "明文直通" + cipher, err := m.Encrypt(plain) + if err != nil || cipher != plain { + t.Fatalf("MockEncryptor.Encrypt(%q) = (%q,%v), want (%q,nil)", plain, cipher, err, plain) + } + got, err := m.Decrypt(cipher) + if err != nil || got != plain { + t.Fatalf("MockEncryptor.Decrypt(%q) = (%q,%v), want (%q,nil)", cipher, got, err, plain) + } +} + +// TestFieldEncryptorCipherFormatStable 验证密文是 base64(nonce||ct||tag),格式与历史硬编码实现一致。 +// 确保迁移后存量密文(旧实现加密)能被新实现的 Decrypt 正确解析。 +func TestFieldEncryptorCipherFormatStable(t *testing.T) { + enc, _ := NewFieldEncryptor(testLegacyKey) + cipher, _ := enc.Encrypt("test") + // base64 解码后长度 > nonce(12) + tag(16) = 28,且明文长度 4 → 总长 32 + decoded, err := base64.StdEncoding.DecodeString(cipher) + if err != nil { + t.Fatalf("cipher not valid base64: %v", err) + } + if len(decoded) <= 28 { + t.Fatalf("decoded length = %d, want > 28 (nonce+tag)", len(decoded)) + } +}