AES 字段加密迁配置并兼容旧密文
移除字段加密硬编码主密钥,改为 FIELD_ENCRYPTION_KEY 注入。 保留 FIELD_ENCRYPTION_LEGACY_KEY 透明回退旧密文,新写入统一使用主密钥;生产环境校验主密钥和 legacy 密钥长度、占位符及相等关系,并统一生产环境判断口径。 补充配置与旧密文兼容回归测试。
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user