diff --git a/internal/config/config.go b/internal/config/config.go index 797a54f..41f17c6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -64,14 +64,15 @@ type RedisConfig struct { } type AuthConfig struct { - APIKeys []string `json:"api_keys"` - SessionKeyPattern string `json:"session_key_pattern"` - SessionTTLSeconds int `json:"session_ttl_seconds"` - DataEncryptionKey string `json:"data_encryption_key"` - LookupHMACKey string `json:"lookup_hmac_key"` - FinanceMasterKey string `json:"finance_master_key"` - AllowDevHeaderAuth bool `json:"allow_dev_header_auth"` - RequiredAPIKey bool `json:"required_api_key"` + APIKeys []string `json:"api_keys"` + SessionKeyPattern string `json:"session_key_pattern"` + SessionTTLSeconds int `json:"session_ttl_seconds"` + DataEncryptionKey string `json:"data_encryption_key"` + LookupHMACKey string `json:"lookup_hmac_key"` + FinanceMasterKey string `json:"finance_master_key"` + FinanceLegacyMasterKeys []string `json:"finance_legacy_master_keys"` + AllowDevHeaderAuth bool `json:"allow_dev_header_auth"` + RequiredAPIKey bool `json:"required_api_key"` } func Load() (*Config, error) { @@ -85,7 +86,11 @@ func Load() (*Config, error) { return nil, err } } + configuredFinanceMasterKey := strings.TrimSpace(cfg.Auth.FinanceMasterKey) applyEnv(cfg) + if configuredFinanceMasterKey != "" && configuredFinanceMasterKey != strings.TrimSpace(cfg.Auth.FinanceMasterKey) { + cfg.Auth.FinanceLegacyMasterKeys = append(cfg.Auth.FinanceLegacyMasterKeys, configuredFinanceMasterKey) + } if err := cfg.validate(); err != nil { return nil, err } @@ -134,6 +139,9 @@ func applyEnv(cfg *Config) { setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY") setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY") setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY") + if keys := strings.TrimSpace(os.Getenv("OLONS_SERVER_FINANCE_LEGACY_MASTER_KEYS")); keys != "" { + cfg.Auth.FinanceLegacyMasterKeys = append(cfg.Auth.FinanceLegacyMasterKeys, splitCSV(keys)...) + } setBool(&cfg.Auth.AllowDevHeaderAuth, "OLONS_SERVER_ALLOW_DEV_HEADER_AUTH") setBool(&cfg.Auth.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY") ApplyUploadEnv(&cfg.Upload) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fe77552..269ab5d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,10 @@ package config -import "testing" +import ( + "os" + "path/filepath" + "testing" +) func TestDefaultConfigUsesOlonsAdmin(t *testing.T) { cfg := defaultConfig() @@ -17,6 +21,24 @@ func TestConfigRejectsOtherDatabase(t *testing.T) { } } +func TestLoadPreservesConfiguredFinanceKeyAsLegacyWhenEnvironmentOverridesIt(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + data := []byte(`{"auth":{"finance_master_key":"legacy-finance-master-key-with-at-least-32-characters"}}`) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("OLONS_SERVER_CONFIG", path) + t.Setenv("OLONS_SERVER_FINANCE_MASTER_KEY", "current-finance-master-key-with-at-least-32-characters") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if len(cfg.Auth.FinanceLegacyMasterKeys) != 1 || + cfg.Auth.FinanceLegacyMasterKeys[0] != "legacy-finance-master-key-with-at-least-32-characters" { + t.Fatalf("configured finance key was not retained for historical data: %#v", cfg.Auth.FinanceLegacyMasterKeys) + } +} + func TestApplyUploadSystemConfig(t *testing.T) { cfg := UploadConfig{Driver: "local", Region: "us-east-1"} ApplyUploadSystemConfig(&cfg, map[string]string{ diff --git a/internal/finance/service.go b/internal/finance/service.go index 68dd128..d7b02e7 100644 --- a/internal/finance/service.go +++ b/internal/finance/service.go @@ -35,7 +35,6 @@ const ( var ( tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`) idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`) - identityNoPattern = regexp.MustCompile(`^[1-9][0-9]{14}(?:[0-9]{2}[0-9X])?$`) ) type Repository interface { @@ -59,19 +58,19 @@ type Repository interface { } type Service struct { - repository Repository - redis *redis.Client - aead cipher.AEAD - lookupKey []byte - ready bool - now func() time.Time + repository Repository + redis *redis.Client + aead cipher.AEAD + legacyAEADs []cipher.AEAD + lookupKey []byte + ready bool + now func() time.Time } type PaymentAccountInput struct { ID int64 `json:"id"` BankName string `json:"bank_name"` HolderName string `json:"holder_name"` - IdentityNo string `json:"identity_no"` AccountNo string `json:"account_no"` DigitalRMBCode string `json:"digital_rmb_code"` WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"` @@ -84,7 +83,6 @@ type PaymentAccount struct { BankCode string `json:"bank_code"` BankName string `json:"bank_name"` HolderMask string `json:"holder_mask"` - IdentityNoMask string `json:"identity_no_mask"` AccountNoMask string `json:"account_no_mask"` DigitalRMBCodeMask string `json:"digital_rmb_code_mask"` WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"` @@ -99,7 +97,6 @@ type PaymentAccountDetail struct { ID int64 `json:"id"` BankName string `json:"bank_name"` HolderName string `json:"holder_name"` - IdentityNo string `json:"identity_no"` AccountNo string `json:"account_no"` DigitalRMBCode string `json:"digital_rmb_code"` WechatQRURL string `json:"wechat_qr_url"` @@ -266,20 +263,39 @@ func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig return service } } - block, err := aes.NewCipher(encryptionKey) - if err != nil { - return service - } - aead, err := cipher.NewGCM(block) + aead, err := newAEAD(encryptionKey) if err != nil { return service } service.aead = aead + seenLegacyKeys := make(map[string]struct{}, len(cfg.FinanceLegacyMasterKeys)) + for _, legacyMasterKey := range cfg.FinanceLegacyMasterKeys { + legacyMasterKey = strings.TrimSpace(legacyMasterKey) + if len([]byte(legacyMasterKey)) < 32 || legacyMasterKey == masterKey { + continue + } + if _, exists := seenLegacyKeys[legacyMasterKey]; exists { + continue + } + legacyAEAD, legacyErr := newAEAD(deriveKey(legacyMasterKey, "data-encryption")) + if legacyErr == nil { + service.legacyAEADs = append(service.legacyAEADs, legacyAEAD) + seenLegacyKeys[legacyMasterKey] = struct{}{} + } + } service.lookupKey = lookupKey service.ready = repository != nil && redisClient != nil return service } +func newAEAD(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + func deriveKey(masterKey, purpose string) []byte { mac := hmac.New(sha256.New, []byte(masterKey)) _, _ = mac.Write([]byte("olons-finance:" + purpose + ":v1")) @@ -312,20 +328,13 @@ func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID items := make([]PaymentAccountDetail, 0, len(rows)) for _, row := range rows { holderName, accountNo := row.HolderMask, row.AccountNoMask - identityNo, digitalRMBCode := row.IdentityNoMask, row.DigitalRMBCodeMask + digitalRMBCode := row.DigitalRMBCodeMask masked := true if s.ready { decryptedHolder, holderErr := s.decrypt(row.HolderCipher) decryptedAccount, accountErr := s.decrypt(row.AccountNoCipher) if holderErr == nil && accountErr == nil { holderName, accountNo, masked = decryptedHolder, decryptedAccount, false - if row.IdentityNoCipher != "" { - if value, decryptErr := s.decrypt(row.IdentityNoCipher); decryptErr == nil { - identityNo = value - } else { - masked = true - } - } if row.DigitalRMBCodeCipher != "" { if value, decryptErr := s.decrypt(row.DigitalRMBCodeCipher); decryptErr == nil { digitalRMBCode = value @@ -337,7 +346,7 @@ func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID } items = append(items, PaymentAccountDetail{ ID: row.ID, BankName: row.BankName, HolderName: holderName, - IdentityNo: identityNo, AccountNo: accountNo, DigitalRMBCode: digitalRMBCode, + AccountNo: accountNo, DigitalRMBCode: digitalRMBCode, WechatQRURL: row.WechatQRURL, AlipayQRURL: row.AlipayQRURL, WechatQRAttachmentID: row.WechatQRAttachmentID, AlipayQRAttachmentID: row.AlipayQRAttachmentID, VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1, Masked: masked, @@ -386,10 +395,6 @@ func (s *Service) paymentAccountCreation(tenantID, memberID int64, input Payment if err != nil { return store.PaymentAccountCreation{}, err } - identityNoCipher, err := s.encrypt(input.IdentityNo) - if err != nil { - return store.PaymentAccountCreation{}, err - } accountCipher, err := s.encrypt(input.AccountNo) if err != nil { return store.PaymentAccountCreation{}, err @@ -406,9 +411,8 @@ func (s *Service) paymentAccountCreation(tenantID, memberID int64, input Payment return store.PaymentAccountCreation{ TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName, HolderCipher: holderCipher, HolderMask: maskName(input.HolderName), - IdentityNoCipher: identityNoCipher, IdentityNoHash: s.lookupHash(input.IdentityNo), - IdentityNoMask: maskIdentityNo(input.IdentityNo), AccountNoCipher: accountCipher, - AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo), + AccountNoCipher: accountCipher, + AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo), DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: digitalRMBCodeHash, DigitalRMBCodeMask: digitalRMBCodeMask, WechatQRAttachmentID: input.WechatQRAttachmentID, @@ -454,13 +458,6 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64 if err != nil { return nil, store.ErrPaymentAccountInvalid } - identityNo := "" - if account.IdentityNoCipher != "" { - identityNo, err = s.decrypt(account.IdentityNoCipher) - if err != nil { - return nil, store.ErrPaymentAccountInvalid - } - } digitalRMBCode := "" if account.DigitalRMBCodeCipher != "" { digitalRMBCode, err = s.decrypt(account.DigitalRMBCodeCipher) @@ -471,7 +468,7 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64 snapshot, err := json.Marshal(map[string]any{ "account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode, "bank_name": account.BankName, "holder_name": holderName, - "identity_no": identityNo, "account_no": accountNo, + "account_no": accountNo, "digital_rmb_code": digitalRMBCode, "wechat_qr_attachment_id": account.WechatQRAttachmentID, "alipay_qr_attachment_id": account.AlipayQRAttachmentID, @@ -849,10 +846,9 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in func normalizePaymentAccount(input *PaymentAccountInput) error { input.BankName = strings.TrimSpace(input.BankName) input.HolderName = strings.TrimSpace(input.HolderName) - input.IdentityNo = strings.ToUpper(strings.NewReplacer(" ", "", "-", "").Replace(input.IdentityNo)) input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo)) input.DigitalRMBCode = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.DigitalRMBCode)) - if !validHumanName(input.HolderName) || !identityNoPattern.MatchString(input.IdentityNo) || + if !validHumanName(input.HolderName) || utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 || len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) || (input.DigitalRMBCode != "" && (len(input.DigitalRMBCode) < 6 || len(input.DigitalRMBCode) > 32 || !allDigits(input.DigitalRMBCode))) || @@ -865,7 +861,7 @@ func normalizePaymentAccount(input *PaymentAccountInput) error { func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount { return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode, - BankName: row.BankName, HolderMask: row.HolderMask, IdentityNoMask: row.IdentityNoMask, + BankName: row.BankName, HolderMask: row.HolderMask, AccountNoMask: row.AccountNoMask, DigitalRMBCodeMask: row.DigitalRMBCodeMask, WechatQRAttachmentID: row.WechatQRAttachmentID, AlipayQRAttachmentID: row.AlipayQRAttachmentID, @@ -1042,11 +1038,17 @@ func (s *Service) decrypt(value string) (string, error) { if err != nil || len(payload) <= s.aead.NonceSize() { return "", errors.New("encrypted value is invalid") } - plain, err := s.aead.Open(nil, payload[:s.aead.NonceSize()], payload[s.aead.NonceSize():], nil) - if err != nil { - return "", err + aeads := append([]cipher.AEAD{s.aead}, s.legacyAEADs...) + for _, aead := range aeads { + if len(payload) <= aead.NonceSize() { + continue + } + plain, decryptErr := aead.Open(nil, payload[:aead.NonceSize()], payload[aead.NonceSize():], nil) + if decryptErr == nil { + return string(plain), nil + } } - return string(plain), nil + return "", errors.New("encrypted value cannot be decrypted") } func (s *Service) lookupHash(value string) string { @@ -1116,13 +1118,6 @@ func maskAccount(value string) string { return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:]) } -func maskIdentityNo(value string) string { - if len(value) <= 8 { - return strings.Repeat("*", len(value)) - } - return value[:4] + strings.Repeat("*", len(value)-8) + value[len(value)-4:] -} - func validHumanName(value string) bool { count := utf8.RuneCountInString(value) if count < 2 || count > 64 { diff --git a/internal/finance/service_test.go b/internal/finance/service_test.go index 236b4a1..b4bcbad 100644 --- a/internal/finance/service_test.go +++ b/internal/finance/service_test.go @@ -135,22 +135,21 @@ func TestQuoteHonorsExplicitDisabledWithdrawalSwitch(t *testing.T) { func TestNormalizePaymentAccount(t *testing.T) { input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三", - IdentityNo: "11010119900101123X", AccountNo: "6222 0000-0000 1234", + AccountNo: "6222 0000-0000 1234", DigitalRMBCode: "1234 5678", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9} if err := normalizePaymentAccount(&input); err != nil { t.Fatal(err) } - if input.IdentityNo != "11010119900101123X" || input.AccountNo != "6222000000001234" || - input.DigitalRMBCode != "12345678" || - maskAccount(input.AccountNo) != "6222********1234" || maskIdentityNo(input.IdentityNo) != "1101**********123X" { + if input.AccountNo != "6222000000001234" || input.DigitalRMBCode != "12345678" || + maskAccount(input.AccountNo) != "6222********1234" { t.Fatalf("unexpected normalized input: %#v", input) } } func TestNormalizePaymentAccountAllowsEmptyDigitalRMBCode(t *testing.T) { input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三", - IdentityNo: "11010119900101123X", AccountNo: "6222000000001234", + AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9} if err := normalizePaymentAccount(&input); err != nil { t.Fatalf("empty digital RMB code should be optional: %v", err) @@ -167,11 +166,10 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) { FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters", }) holder, _ := service.encrypt("张三") - identity, _ := service.encrypt("11010119900101123X") account, _ := service.encrypt("6222000000001234") digital, _ := service.encrypt("123456") repository.details = []store.PaymentAccount{{ - ID: 7, BankName: "中国工商银行", HolderCipher: holder, IdentityNoCipher: identity, + ID: 7, BankName: "中国工商银行", HolderCipher: holder, AccountNoCipher: account, DigitalRMBCodeCipher: digital, WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, VerifyStatus: 2, @@ -180,7 +178,7 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) { if err != nil { t.Fatal(err) } - if len(items) != 1 || items[0].HolderName != "张三" || items[0].IdentityNo != "11010119900101123X" || + if len(items) != 1 || items[0].HolderName != "张三" || items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" || items[0].WechatQRURL != "/wechat.png" || items[0].AlipayQRURL != "/alipay.png" || items[0].WechatQRAttachmentID != 8 || items[0].AlipayQRAttachmentID != 9 { @@ -188,13 +186,38 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) { } } +func TestPaymentAccountDetailsDecryptsWithLegacyMasterKey(t *testing.T) { + repository := &fakeRepository{} + legacyMasterKey := "legacy-finance-master-key-with-at-least-32-characters" + legacyService := New(repository, &redis.Client{}, config.AuthConfig{FinanceMasterKey: legacyMasterKey}) + holder, _ := legacyService.encrypt("张三") + account, _ := legacyService.encrypt("6222000000001234") + digital, _ := legacyService.encrypt("123456") + repository.details = []store.PaymentAccount{{ + ID: 7, BankName: "中国工商银行", HolderCipher: holder, AccountNoCipher: account, + DigitalRMBCodeCipher: digital, WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, + }} + service := New(repository, &redis.Client{}, config.AuthConfig{ + FinanceMasterKey: "current-finance-master-key-with-at-least-32-characters", + FinanceLegacyMasterKeys: []string{legacyMasterKey}, + }) + items, err := service.PaymentAccountDetails(context.Background(), 1, 2) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].Masked || items[0].HolderName != "张三" || + items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" { + t.Fatalf("unexpected legacy account details: %#v", items) + } +} + func TestUpdatePaymentAccountEncryptsEditedFields(t *testing.T) { repository := &paymentAccountRepository{} service := New(repository, &redis.Client{}, config.AuthConfig{ FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters", }) result, err := service.UpdatePaymentAccount(context.Background(), 1, 2, PaymentAccountInput{ - ID: 7, BankName: "中国工商银行", HolderName: "李四", IdentityNo: "11010119900101123X", + ID: 7, BankName: "中国工商银行", HolderName: "李四", AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, }) if err != nil { @@ -226,7 +249,7 @@ func TestPaymentAccountDetailsFallsBackToMasksWhenHistoricalKeyIsUnavailable(t * t.Fatal(err) } if len(items) != 1 || !items[0].Masked || items[0].HolderName != "张*" || - items[0].IdentityNo != "1101**********123X" || items[0].AccountNo != "6222********1234" || + items[0].AccountNo != "6222********1234" || items[0].DigitalRMBCode != "12****78" { t.Fatalf("unexpected masked fallback: %#v", items) } @@ -238,7 +261,7 @@ func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) { FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters", }) _, err := service.CreatePaymentAccount(context.Background(), 0, 1, PaymentAccountInput{ - BankName: "中国工商银行", HolderName: "张三", IdentityNo: "11010119900101123X", + BankName: "中国工商银行", HolderName: "张三", AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, }) if err != nil { @@ -247,6 +270,9 @@ func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) { if repository.created.DigitalRMBCodeCipher != "" || repository.created.DigitalRMBCodeHash != "" || repository.created.DigitalRMBCodeMask != "" { t.Fatalf("empty digital RMB code must not be encrypted, hashed or masked: %#v", repository.created) } + if repository.created.IdentityNoCipher != "" || repository.created.IdentityNoHash != "" || repository.created.IdentityNoMask != "" { + t.Fatalf("identity number must no longer be collected: %#v", repository.created) + } } func TestMaskAccountShortValueDoesNotLeak(t *testing.T) { diff --git a/web/src/mobile/views/FormView.vue b/web/src/mobile/views/FormView.vue index 29e8f2c..5454453 100644 --- a/web/src/mobile/views/FormView.vue +++ b/web/src/mobile/views/FormView.vue @@ -52,15 +52,10 @@
- + - -