AES 字段加密迁配置并兼容旧密文
移除字段加密硬编码主密钥,改为 FIELD_ENCRYPTION_KEY 注入。 保留 FIELD_ENCRYPTION_LEGACY_KEY 透明回退旧密文,新写入统一使用主密钥;生产环境校验主密钥和 legacy 密钥长度、占位符及相等关系,并统一生产环境判断口径。 补充配置与旧密文兼容回归测试。
This commit is contained in:
@@ -59,6 +59,16 @@ REALNAME_CLOUDMARKET_APPCODE=
|
|||||||
# 生成方式:openssl rand -hex 16
|
# 生成方式:openssl rand -hex 16
|
||||||
PAYMENT_CONFIG_ENCRYPTION_KEY=
|
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 签名。
|
# 若调用方携带 X-HFB-Timestamp 和 X-HFB-Signature,则会校验 HMAC 签名。
|
||||||
EXTERNAL_UPLOAD_SECRET=
|
EXTERNAL_UPLOAD_SECRET=
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ REALNAME_CLOUDMARKET_APPCODE=
|
|||||||
# 警告:此密钥一旦设置不要更改,否则已有配置无法解密
|
# 警告:此密钥一旦设置不要更改,否则已有配置无法解密
|
||||||
PAYMENT_CONFIG_ENCRYPTION_KEY=change-to-32-byte-encryption-key
|
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
|
# 若启用签名,生成方式:openssl rand -hex 32
|
||||||
EXTERNAL_UPLOAD_SECRET=
|
EXTERNAL_UPLOAD_SECRET=
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func newPaymentConfigRepositoryForJobs(cfg config.Config, db *gorm.DB, logger *z
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if encryptor == nil {
|
if encryptor == nil {
|
||||||
if cfg.AppEnv == "production" {
|
if config.IsProductionEnv(cfg.AppEnv) {
|
||||||
logger.Fatal("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid")
|
logger.Fatal("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid")
|
||||||
}
|
}
|
||||||
encryptor = &paymentconfig.MockEncryptor{}
|
encryptor = &paymentconfig.MockEncryptor{}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ type Config struct {
|
|||||||
RedisDB int
|
RedisDB int
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
PaymentConfigEncryptionKey string
|
PaymentConfigEncryptionKey string
|
||||||
|
FieldEncryptionKey string
|
||||||
|
FieldEncryptionLegacyKey string
|
||||||
ExternalUploadSecret string
|
ExternalUploadSecret string
|
||||||
ExternalUploadAllowedIPs []string
|
ExternalUploadAllowedIPs []string
|
||||||
BootstrapAdminUsername string
|
BootstrapAdminUsername string
|
||||||
@@ -72,11 +74,16 @@ func Load() Config {
|
|||||||
RedisDB: getEnvInt("REDIS_DB", 0),
|
RedisDB: getEnvInt("REDIS_DB", 0),
|
||||||
JWTSecret: getEnv("JWT_SECRET", "change-me"),
|
JWTSecret: getEnv("JWT_SECRET", "change-me"),
|
||||||
PaymentConfigEncryptionKey: getEnv("PAYMENT_CONFIG_ENCRYPTION_KEY", ""),
|
PaymentConfigEncryptionKey: getEnv("PAYMENT_CONFIG_ENCRYPTION_KEY", ""),
|
||||||
ExternalUploadSecret: getEnv("EXTERNAL_UPLOAD_SECRET", ""),
|
FieldEncryptionKey: getEnv("FIELD_ENCRYPTION_KEY", ""),
|
||||||
ExternalUploadAllowedIPs: getEnvList("EXTERNAL_UPLOAD_ALLOWED_IPS"),
|
// 旧密钥回退:生产环境必须显式设置(空 = 禁用回退,用于密钥轮换收敛);
|
||||||
BootstrapAdminUsername: getEnv("ADMIN_BOOTSTRAP_USERNAME", ""),
|
// 非生产默认填历史硬编码密钥,兼容开发/测试库的存量密文。不能用 getEnv 的 fallback——
|
||||||
BootstrapAdminPassword: getEnv("ADMIN_BOOTSTRAP_PASSWORD", ""),
|
// 那样生产删掉 env 会重新注入已泄露的硬编码密钥,破坏轮换闭环。
|
||||||
BootstrapAdminNickname: getEnv("ADMIN_BOOTSTRAP_NICKNAME", "超级管理员"),
|
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{
|
Storage: StorageConfig{
|
||||||
Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:9000"),
|
Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:9000"),
|
||||||
Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"),
|
Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"),
|
||||||
@@ -110,7 +117,7 @@ func Load() Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) ValidateProductionSecurity() error {
|
func (c Config) ValidateProductionSecurity() error {
|
||||||
if strings.ToLower(strings.TrimSpace(c.AppEnv)) != "production" {
|
if !IsProductionEnv(c.AppEnv) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(c.JWTSecret) == "" || isPlaceholder(c.JWTSecret) || len([]byte(c.JWTSecret)) < 32 {
|
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) {
|
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")
|
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) {
|
if c.BootstrapAdminPassword != "" && isPlaceholder(c.BootstrapAdminPassword) {
|
||||||
return errors.New("ADMIN_BOOTSTRAP_PASSWORD must not use the example placeholder in production")
|
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
|
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 {
|
func getEnvInt(key string, fallback int) int {
|
||||||
value := os.Getenv(key)
|
value := os.Getenv(key)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
@@ -178,3 +214,8 @@ func isPlaceholder(value string) bool {
|
|||||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
return normalized == "change-me" || strings.HasPrefix(normalized, "change-") || strings.Contains(normalized, "change-to-")
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -232,10 +232,10 @@ func newFlowServices(db *gorm.DB) flowServices {
|
|||||||
order: order.NewService(orderRepo),
|
order: order.NewService(orderRepo),
|
||||||
payment: payment.NewService(paymentRepo),
|
payment: payment.NewService(paymentRepo),
|
||||||
paymentConfig: paymentconfig.NewService(configRepo),
|
paymentConfig: paymentconfig.NewService(configRepo),
|
||||||
paymentAccount: paymentaccount.NewService(paymentaccount.NewRepository(db)),
|
paymentAccount: paymentaccount.NewService(paymentaccount.NewRepository(db, &crypto.MockEncryptor{})),
|
||||||
dispute: dispute.NewService(disputeRepo),
|
dispute: dispute.NewService(disputeRepo),
|
||||||
wallet: wallet.NewService(walletRepo),
|
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)),
|
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) {
|
func seedRealname(t *testing.T, db *gorm.DB, userID uint64, name string, verifiedAt time.Time) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
encryptedName, err := crypto.Encrypt(name)
|
encryptedName, err := (&crypto.MockEncryptor{}).Encrypt(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("加密实名姓名失败: %v", err)
|
t.Fatalf("加密实名姓名失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,16 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 删除加密密钥常量
|
|
||||||
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
encryptor crypto.Encryptor
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRepository(db *gorm.DB) *Repository {
|
func NewRepository(db *gorm.DB, encryptor crypto.Encryptor) *Repository {
|
||||||
return &Repository{db: db}
|
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) {
|
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) {
|
func (r *Repository) Create(ctx context.Context, userID uint64, req CreatePaymentAccountRequest) (*PaymentAccountDTO, error) {
|
||||||
db := r.db.WithContext(ctx)
|
db := r.db.WithContext(ctx)
|
||||||
// 加密账号
|
// 加密账号
|
||||||
encryptedNo, err := crypto.Encrypt(req.AccountNo)
|
encryptedNo, err := r.encryptor.Encrypt(req.AccountNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -217,7 +219,7 @@ func (r *Repository) ValidateRealname(ctx context.Context, userID uint64, accoun
|
|||||||
// 验证姓名匹配 - 使用加密字段进行精确匹配
|
// 验证姓名匹配 - 使用加密字段进行精确匹配
|
||||||
if realname.EncryptedName != "" {
|
if realname.EncryptedName != "" {
|
||||||
// 有加密字段,解密后精确匹配
|
// 有加密字段,解密后精确匹配
|
||||||
decryptedName, err := crypto.Decrypt(realname.EncryptedName)
|
decryptedName, err := r.encryptor.Decrypt(realname.EncryptedName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 解密失败,降级到前缀匹配
|
// 解密失败,降级到前缀匹配
|
||||||
return r.validateByMaskedName(realname.MaskedName, accountName)
|
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) {
|
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 {
|
if err != nil {
|
||||||
decrypted = account.AccountNo // 降级处理
|
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 {
|
if err := r.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).First(&account).Error; err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return crypto.Decrypt(account.AccountNo)
|
return r.encryptor.Decrypt(account.AccountNo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,16 @@ import (
|
|||||||
const cloudMarketProviderName = "aliyun_cloudmarket"
|
const cloudMarketProviderName = "aliyun_cloudmarket"
|
||||||
|
|
||||||
type CloudMarketConfig struct {
|
type CloudMarketConfig struct {
|
||||||
URL string
|
URL string
|
||||||
AppCode string
|
AppCode string
|
||||||
|
Encryptor crypto.Encryptor
|
||||||
}
|
}
|
||||||
|
|
||||||
type CloudMarketProvider struct {
|
type CloudMarketProvider struct {
|
||||||
url string
|
url string
|
||||||
appCode string
|
appCode string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
encryptor crypto.Encryptor
|
||||||
}
|
}
|
||||||
|
|
||||||
type cloudMarketResponse struct {
|
type cloudMarketResponse struct {
|
||||||
@@ -42,10 +44,15 @@ func NewCloudMarketProvider(cfg CloudMarketConfig) (*CloudMarketProvider, error)
|
|||||||
if _, err := url.ParseRequestURI(endpoint); err != nil {
|
if _, err := url.ParseRequestURI(endpoint); err != nil {
|
||||||
return nil, fmt.Errorf("realname cloud market url invalid: %w", err)
|
return nil, fmt.Errorf("realname cloud market url invalid: %w", err)
|
||||||
}
|
}
|
||||||
|
encryptor := cfg.Encryptor
|
||||||
|
if encryptor == nil {
|
||||||
|
encryptor = &crypto.MockEncryptor{}
|
||||||
|
}
|
||||||
return &CloudMarketProvider{
|
return &CloudMarketProvider{
|
||||||
url: endpoint,
|
url: endpoint,
|
||||||
appCode: appCode,
|
appCode: appCode,
|
||||||
client: &http.Client{Timeout: 10 * time.Second},
|
client: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
encryptor: encryptor,
|
||||||
}, nil
|
}, 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 {
|
if err != nil {
|
||||||
return ProviderResult{}, err
|
return ProviderResult{}, err
|
||||||
}
|
}
|
||||||
encryptedIDNo, err := crypto.Encrypt(req.IDNo)
|
encryptedIDNo, err := p.encryptor.Encrypt(req.IDNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ProviderResult{}, err
|
return ProviderResult{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"hfb_sys/backend/pkg/crypto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCloudMarketProviderStartVerified(t *testing.T) {
|
func TestCloudMarketProviderStartVerified(t *testing.T) {
|
||||||
@@ -27,7 +29,8 @@ func TestCloudMarketProviderStartVerified(t *testing.T) {
|
|||||||
|
|
||||||
provider, err := NewCloudMarketProvider(CloudMarketConfig{
|
provider, err := NewCloudMarketProvider(CloudMarketConfig{
|
||||||
URL: server.URL,
|
URL: server.URL,
|
||||||
AppCode: "test-code",
|
AppCode: "test-code",
|
||||||
|
Encryptor: &crypto.MockEncryptor{},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("创建 Provider 失败:%v", err)
|
t.Fatalf("创建 Provider 失败:%v", err)
|
||||||
@@ -60,7 +63,8 @@ func TestCloudMarketProviderStartRejected(t *testing.T) {
|
|||||||
|
|
||||||
provider, err := NewCloudMarketProvider(CloudMarketConfig{
|
provider, err := NewCloudMarketProvider(CloudMarketConfig{
|
||||||
URL: server.URL,
|
URL: server.URL,
|
||||||
AppCode: "test-code",
|
AppCode: "test-code",
|
||||||
|
Encryptor: &crypto.MockEncryptor{},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("创建 Provider 失败:%v", err)
|
t.Fatalf("创建 Provider 失败:%v", err)
|
||||||
@@ -90,7 +94,8 @@ func TestCloudMarketProviderStartRateLimited(t *testing.T) {
|
|||||||
|
|
||||||
provider, err := NewCloudMarketProvider(CloudMarketConfig{
|
provider, err := NewCloudMarketProvider(CloudMarketConfig{
|
||||||
URL: server.URL,
|
URL: server.URL,
|
||||||
AppCode: "test-code",
|
AppCode: "test-code",
|
||||||
|
Encryptor: &crypto.MockEncryptor{},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("创建 Provider 失败:%v", err)
|
t.Fatalf("创建 Provider 失败:%v", err)
|
||||||
|
|||||||
@@ -33,10 +33,15 @@ type ProviderResult struct {
|
|||||||
FailReason string
|
FailReason string
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockProvider struct{}
|
type MockProvider struct {
|
||||||
|
encryptor crypto.Encryptor
|
||||||
|
}
|
||||||
|
|
||||||
func NewMockProvider() *MockProvider {
|
func NewMockProvider(encryptor crypto.Encryptor) *MockProvider {
|
||||||
return &MockProvider{}
|
if encryptor == nil {
|
||||||
|
encryptor = &crypto.MockEncryptor{}
|
||||||
|
}
|
||||||
|
return &MockProvider{encryptor: encryptor}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *MockProvider) Name() string {
|
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 {
|
if err != nil {
|
||||||
return ProviderResult{}, err
|
return ProviderResult{}, err
|
||||||
}
|
}
|
||||||
encryptedIDNo, err := crypto.Encrypt(req.IDNo)
|
encryptedIDNo, err := p.encryptor.Encrypt(req.IDNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ProviderResult{}, err
|
return ProviderResult{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/pkg/crypto"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 转换为用户DTO
|
// 转换为用户DTO
|
||||||
@@ -64,7 +63,7 @@ func (r *Repository) toDetailDTO(ctx context.Context, w model.WithdrawalRequest)
|
|||||||
var paymentAccount model.UserPaymentAccount
|
var paymentAccount model.UserPaymentAccount
|
||||||
if err := db.First(&paymentAccount, *w.PaymentAccountID).Error; err == nil {
|
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 {
|
if err == nil {
|
||||||
fullAccountNo = decrypted
|
fullAccountNo = decrypted
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package withdrawal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"hfb_sys/backend/internal/modules/wallet"
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
|
"hfb_sys/backend/pkg/crypto"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -9,12 +10,17 @@ import (
|
|||||||
type Repository struct {
|
type Repository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
walletRepo *wallet.Repository
|
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{
|
return &Repository{
|
||||||
db: db,
|
db: db,
|
||||||
walletRepo: walletRepo,
|
walletRepo: walletRepo,
|
||||||
|
encryptor: encryptor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/modules/withdrawal"
|
"hfb_sys/backend/internal/modules/withdrawal"
|
||||||
|
|
||||||
_ "hfb_sys/backend/docs" // Swagger 文档
|
_ "hfb_sys/backend/docs" // Swagger 文档
|
||||||
|
"hfb_sys/backend/pkg/crypto"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
swaggerFiles "github.com/swaggo/files"
|
swaggerFiles "github.com/swaggo/files"
|
||||||
@@ -42,7 +43,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
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)
|
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)
|
engine.GET("/health", health.Check)
|
||||||
|
|
||||||
// Swagger 文档路由(仅在非生产环境)
|
// Swagger 文档路由(仅在非生产环境)
|
||||||
if cfg.AppEnv != "production" {
|
if !config.IsProductionEnv(cfg.AppEnv) {
|
||||||
engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||||
}
|
}
|
||||||
if cfg.RateLimit.Enabled {
|
if cfg.RateLimit.Enabled {
|
||||||
engine.Use(middleware.RateLimitPerMinute(cfg.RateLimit.RequestsPerMinute, deps.Redis))
|
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)
|
jwtManager := auth.NewJWTManager(cfg.JWTSecret)
|
||||||
var userRepo *auth.UserRepository
|
var userRepo *auth.UserRepository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
@@ -108,7 +149,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
realnameRepo = realname.NewRepository(deps.DB)
|
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)
|
realnameHandler := realname.NewHandler(realnameService)
|
||||||
var listingRepo *listing.Repository
|
var listingRepo *listing.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
@@ -149,13 +190,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
walletHandler := wallet.NewHandler(walletService)
|
walletHandler := wallet.NewHandler(walletService)
|
||||||
var paymentAccountRepo *paymentaccount.Repository
|
var paymentAccountRepo *paymentaccount.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
paymentAccountRepo = paymentaccount.NewRepository(deps.DB)
|
paymentAccountRepo = paymentaccount.NewRepository(deps.DB, fieldEncryptor)
|
||||||
}
|
}
|
||||||
paymentAccountService := paymentaccount.NewService(paymentAccountRepo)
|
paymentAccountService := paymentaccount.NewService(paymentAccountRepo)
|
||||||
paymentAccountHandler := paymentaccount.NewHandler(paymentAccountService)
|
paymentAccountHandler := paymentaccount.NewHandler(paymentAccountService)
|
||||||
var withdrawalRepo *withdrawal.Repository
|
var withdrawalRepo *withdrawal.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
withdrawalRepo = withdrawal.NewRepository(deps.DB, walletRepo)
|
withdrawalRepo = withdrawal.NewRepository(deps.DB, walletRepo, fieldEncryptor)
|
||||||
}
|
}
|
||||||
withdrawalService := withdrawal.NewService(withdrawalRepo)
|
withdrawalService := withdrawal.NewService(withdrawalRepo)
|
||||||
withdrawalHandler := withdrawal.NewHandler(withdrawalService)
|
withdrawalHandler := withdrawal.NewHandler(withdrawalService)
|
||||||
@@ -174,7 +215,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if encryptor == nil {
|
if encryptor == nil {
|
||||||
if cfg.AppEnv == "production" {
|
if config.IsProductionEnv(cfg.AppEnv) {
|
||||||
logger.Fatal("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid")
|
logger.Fatal("PAYMENT_CONFIG_ENCRYPTION_KEY not set or invalid")
|
||||||
}
|
}
|
||||||
encryptor = &paymentconfig.MockEncryptor{}
|
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)) {
|
switch strings.ToLower(strings.TrimSpace(cfg.Realname.Provider)) {
|
||||||
case "cloudmarket", "aliyun_cloudmarket":
|
case "cloudmarket", "aliyun_cloudmarket":
|
||||||
provider, err := realname.NewCloudMarketProvider(realname.CloudMarketConfig{
|
provider, err := realname.NewCloudMarketProvider(realname.CloudMarketConfig{
|
||||||
URL: cfg.Realname.CloudMarketURL,
|
URL: cfg.Realname.CloudMarketURL,
|
||||||
AppCode: cfg.Realname.CloudMarketAppCode,
|
AppCode: cfg.Realname.CloudMarketAppCode,
|
||||||
|
Encryptor: encryptor,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("cloud market realname provider unavailable; realname verify will fail", zap.Error(err))
|
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
|
return provider
|
||||||
default:
|
default:
|
||||||
return realname.NewMockProvider()
|
return realname.NewMockProvider(encryptor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-32
@@ -9,61 +9,124 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 加密密钥(生产环境应从配置文件读取)
|
// Encryptor 字段加密器接口。Encrypt 永远用主密钥,Decrypt 先试主密钥、失败试 legacy 密钥。
|
||||||
// 必须是 16、24 或 32 字节
|
type Encryptor interface {
|
||||||
const encryptionKey = "hfb-sys-2024-secret-key-32bytes!" // 正好32字节
|
Encrypt(plaintext string) (string, error)
|
||||||
|
Decrypt(ciphertext string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Encrypt 使用 AES-GCM 加密文本
|
// aesKey 持有一个 AES-GCM 密钥及其 cipher。
|
||||||
func Encrypt(plainText string) (string, error) {
|
type aesKey struct {
|
||||||
block, err := aes.NewCipher([]byte(encryptionKey))
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
aesGCM, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建随机 nonce
|
|
||||||
nonce := make([]byte, aesGCM.NonceSize())
|
|
||||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
// 加密并附加 nonce
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||||
cipherBytes := aesGCM.Seal(nonce, nonce, []byte(plainText), nil)
|
|
||||||
return base64.StdEncoding.EncodeToString(cipherBytes), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt 使用 AES-GCM 解密文本
|
// Decrypt 解密密文。先试主密钥,gcm.Open 认证失败依次试 legacy 密钥。
|
||||||
func Decrypt(cipherText string) (string, error) {
|
func (e *FieldEncryptor) Decrypt(ciphertext string) (string, error) {
|
||||||
block, err := aes.NewCipher([]byte(encryptionKey))
|
if ciphertext == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
cipherBytes, err := base64.StdEncoding.DecodeString(cipherText)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
nonceSize := aesGCM.NonceSize()
|
if len(decoded) < nonceSize {
|
||||||
if len(cipherBytes) < nonceSize {
|
|
||||||
return "", errors.New("ciphertext too short")
|
return "", errors.New("ciphertext too short")
|
||||||
}
|
}
|
||||||
|
nonce, body := decoded[:nonceSize], decoded[nonceSize:]
|
||||||
// 提取 nonce 和实际密文
|
plaintext, err := gcm.Open(nil, nonce, body, nil)
|
||||||
nonce, cipherBytes := cipherBytes[:nonceSize], cipherBytes[nonceSize:]
|
|
||||||
plainBytes, err := aesGCM.Open(nil, nonce, cipherBytes, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user