diff --git a/server/internal/logic/sys/app_member.go b/server/internal/logic/sys/app_member.go index 72927ef..953e9fe 100644 --- a/server/internal/logic/sys/app_member.go +++ b/server/internal/logic/sys/app_member.go @@ -458,6 +458,7 @@ func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaym if row.HolderCipher != "" { plain, decryptErr := encrypt.DecryptField(ctx, row.HolderCipher) if decryptErr != nil { + row.SensitiveUnavailable = true g.Log().Warningf(ctx, "收款人姓名解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr) row.Holder = row.HolderMask } else { @@ -469,6 +470,7 @@ func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaym if row.IdentityNoCipher != "" { plain, decryptErr := encrypt.DecryptField(ctx, row.IdentityNoCipher) if decryptErr != nil { + row.SensitiveUnavailable = true g.Log().Warningf(ctx, "收款人证件号解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr) row.IdentityNo = row.IdentityNoMask } else { @@ -480,6 +482,7 @@ func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaym if row.MobileCipher != "" { plain, decryptErr := encrypt.DecryptField(ctx, row.MobileCipher) if decryptErr != nil { + row.SensitiveUnavailable = true g.Log().Warningf(ctx, "收款手机号解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr) row.Mobile = row.MobileMask } else { @@ -491,6 +494,7 @@ func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaym if row.AccountNoCipher != "" { plain, decryptErr := encrypt.DecryptField(ctx, row.AccountNoCipher) if decryptErr != nil { + row.SensitiveUnavailable = true g.Log().Warningf(ctx, "收款账号解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr) row.AccountNo = row.AccountNoMask } else { @@ -502,6 +506,7 @@ func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaym if row.DigitalRmbCodeCipher != "" { plain, decryptErr := encrypt.DecryptField(ctx, row.DigitalRmbCodeCipher) if decryptErr != nil { + row.SensitiveUnavailable = true g.Log().Warningf(ctx, "数字钱包编码解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr) row.DigitalRMBCode = row.DigitalRMBCodeMask } else { @@ -534,7 +539,7 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym data[c.AlipayQrAttachmentId] = in.AlipayQrAttachmentId } if in.Holder != "" { - v, e := encrypt.EncryptField(ctx, in.Holder) + v, e := encrypt.EncryptPaymentField(ctx, in.Holder) if e != nil { return e } @@ -542,7 +547,7 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym data[c.HolderMask] = encrypt.MaskName(in.Holder) } if in.Mobile != "" { - v, e := encrypt.EncryptField(ctx, in.Mobile) + v, e := encrypt.EncryptPaymentField(ctx, in.Mobile) if e != nil { return e } @@ -550,11 +555,11 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym data[c.MobileMask] = encrypt.MaskMobile(in.Mobile) } if in.IdentityNo != "" { - v, e := encrypt.EncryptField(ctx, in.IdentityNo) + v, e := encrypt.EncryptPaymentField(ctx, in.IdentityNo) if e != nil { return e } - h, e := encrypt.HashField(ctx, in.IdentityNo) + h, e := encrypt.HashPaymentField(ctx, in.IdentityNo) if e != nil { return e } @@ -563,11 +568,11 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym data[c.IdentityNoMask] = encrypt.MaskAccount(in.IdentityNo) } if in.AccountNo != "" { - v, e := encrypt.EncryptField(ctx, in.AccountNo) + v, e := encrypt.EncryptPaymentField(ctx, in.AccountNo) if e != nil { return e } - h, e := encrypt.HashField(ctx, in.AccountNo) + h, e := encrypt.HashPaymentField(ctx, in.AccountNo) if e != nil { return e } @@ -576,11 +581,11 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym data[c.AccountNoMask] = encrypt.MaskAccount(in.AccountNo) } if in.DigitalRMBCode != "" { - v, e := encrypt.EncryptField(ctx, in.DigitalRMBCode) + v, e := encrypt.EncryptPaymentField(ctx, in.DigitalRMBCode) if e != nil { return e } - h, e := encrypt.HashField(ctx, in.DigitalRMBCode) + h, e := encrypt.HashPaymentField(ctx, in.DigitalRMBCode) if e != nil { return e } diff --git a/server/internal/model/input/sysin/app_member.go b/server/internal/model/input/sysin/app_member.go index 206fa61..8ec8a73 100644 --- a/server/internal/model/input/sysin/app_member.go +++ b/server/internal/model/input/sysin/app_member.go @@ -95,6 +95,9 @@ type AppMemberEditInp struct { } func (in *AppMemberEditInp) Filter(ctx context.Context) error { + if err := rejectMaskedSensitiveFields(in.RealName, in.IdNo); err != nil { + return err + } if in.Id == 0 { return gerror.New("后台不支持创建会员,请由会员在C端完成注册") } @@ -156,6 +159,7 @@ type AppMemberPaymentListInp struct { func (in *AppMemberPaymentListInp) Filter(ctx context.Context) error { return nil } type AppMemberPaymentModel struct { + SensitiveUnavailable bool `json:"sensitiveUnavailable"` Id uint64 `json:"id"` MemberId uint64 `json:"memberId"` AccountType int `json:"accountType"` @@ -207,6 +211,9 @@ type AppMemberPaymentEditInp struct { } func (in *AppMemberPaymentEditInp) Filter(ctx context.Context) error { + if err := rejectMaskedSensitiveFields(in.Holder, in.IdentityNo, in.Mobile, in.AccountNo, in.DigitalRMBCode); err != nil { + return err + } if !dict.HasOptionKey(consts.PaymentAccountTypeOptions, in.AccountType) { return gerror.New("收款账户类型不合法") } @@ -260,12 +267,24 @@ type AppMemberAddressEditInp struct { } func (in *AppMemberAddressEditInp) Filter(ctx context.Context) error { + if err := rejectMaskedSensitiveFields(in.Receiver, in.Mobile, in.Address); err != nil { + return err + } if in.Id == 0 && (strings.TrimSpace(in.Receiver) == "" || strings.TrimSpace(in.Mobile) == "" || strings.TrimSpace(in.Address) == "") { return gerror.New("新增地址时收货人、手机号和详细地址不能为空") } return nil } +func rejectMaskedSensitiveFields(values ...string) error { + for _, value := range values { + if strings.Contains(value, "*") { + return gerror.New("不能保存带星号的脱敏资料,请填写完整信息或留空保留原值") + } + } + return nil +} + type AppMemberAddressDeleteInp struct { Id uint64 `json:"id" v:"required|min:1#地址ID不能为空|地址ID不能为空"` } diff --git a/server/internal/model/input/sysin/app_member_cipher_test.go b/server/internal/model/input/sysin/app_member_cipher_test.go new file mode 100644 index 0000000..756e0e5 --- /dev/null +++ b/server/internal/model/input/sysin/app_member_cipher_test.go @@ -0,0 +1,58 @@ +package sysin + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/gogf/gf/v2/container/gvar" + "github.com/gogf/gf/v2/database/gdb" +) + +func TestPaymentCipherFieldsScanWithoutJSONExposure(t *testing.T) { + const ciphertext = "test-ciphertext-not-a-real-bank-account" + record := gdb.Record{} + for _, field := range []string{"holder_cipher", "identity_no_cipher", "mobile_cipher", "account_no_cipher", "digital_rmb_code_cipher"} { + record[field] = gvar.New(ciphertext) + } + var row AppMemberPaymentModel + if err := record.Struct(&row); err != nil { + t.Fatal(err) + } + for name, actual := range map[string]string{"holder": row.HolderCipher, "identity": row.IdentityNoCipher, "mobile": row.MobileCipher, "account": row.AccountNoCipher, "digital": row.DigitalRmbCodeCipher} { + if actual != ciphertext { + t.Errorf("%s ciphertext was not loaded by database scan", name) + } + } + data, err := json.Marshal(row) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), ciphertext) { + t.Fatal("internal ciphertext must not be exposed in API JSON") + } +} + +func TestPaymentEditRejectsMaskedValues(t *testing.T) { + for _, field := range []string{"holder", "account", "identity", "mobile", "digital"} { + t.Run(field, func(t *testing.T) { + in := &AppMemberPaymentEditInp{Id: 1, MemberId: 1, AccountType: 1} + switch field { + case "holder": + in.Holder = "王*" + case "account": + in.AccountNo = "6222********5937" + case "identity": + in.IdentityNo = "1234********5678" + case "mobile": + in.Mobile = "138****1234" + case "digital": + in.DigitalRMBCode = "0021********8708" + } + if err := in.Filter(context.Background()); err == nil { + t.Fatal("masked values must never overwrite originals") + } + }) + } +} diff --git a/server/manifest/config/config.example.yaml b/server/manifest/config/config.example.yaml index 126b1d7..baefc67 100644 --- a/server/manifest/config/config.example.yaml +++ b/server/manifest/config/config.example.yaml @@ -195,6 +195,11 @@ token: # 业务敏感字段保护。生产环境必须替换为独立的高强度密钥,并通过密钥管理系统注入。 security: dataKey: "change-me-before-production" + # 与 C 端收款资料使用相同配置;历史主密钥只用于读取,不修改原密文。 + financeMasterKey: "" + financeLegacyMasterKeys: [] + financeDataEncryptionKey: "" + financeLookupHmacKey: "" # 消息队列 diff --git a/server/utility/encrypt/field.go b/server/utility/encrypt/field.go index 7aa6ada..794ba7c 100644 --- a/server/utility/encrypt/field.go +++ b/server/utility/encrypt/field.go @@ -54,30 +54,32 @@ func DecryptField(ctx context.Context, encoded string) (string, error) { if encoded == "" { return "", nil } - key, err := fieldKey(ctx) + raw, err := decodeFieldPayload(encoded) if err != nil { return "", err } - raw, err := base64.RawURLEncoding.DecodeString(encoded) - if err != nil { - return "", gerror.Wrap(err, "敏感字段密文格式错误") - } - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - if len(raw) < gcm.NonceSize() { + if len(raw) < 12+16 { return "", gerror.New("敏感字段密文不完整") } - plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil) - if err != nil { - return "", gerror.Wrap(err, "敏感字段解密失败") + keys := financeFieldReadKeys(ctx) + if key, keyErr := fieldKey(ctx); keyErr == nil { + keys = append(keys, key) } - return string(plain), nil + for _, key := range keys { + block, keyErr := aes.NewCipher(key) + if keyErr != nil { + continue + } + gcm, keyErr := cipher.NewGCM(block) + if keyErr != nil { + continue + } + plain, decryptErr := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil) + if decryptErr == nil { + return string(plain), nil + } + } + return "", gerror.New("敏感字段解密失败,请核对当前及历史密钥配置") } // HashField creates a deterministic, non-reversible lookup key with a secret pepper. diff --git a/server/utility/encrypt/finance_field.go b/server/utility/encrypt/finance_field.go new file mode 100644 index 0000000..31070c8 --- /dev/null +++ b/server/utility/encrypt/finance_field.go @@ -0,0 +1,111 @@ +package encrypt + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "io" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" +) + +// financeFieldKey uses the same key derivation as the customer-facing finance service. +// An empty configuration retains the existing HotGo format for older installations. +func financeFieldKey(ctx context.Context, purpose string) ([]byte, error) { + master := strings.TrimSpace(g.Cfg().MustGet(ctx, "security.financeMasterKey", "").String()) + if master != "" { + if len([]byte(master)) < 32 { + return nil, gerror.New("收款资料密钥配置不正确") + } + return deriveFinanceFieldKey(master, purpose), nil + } + name := "security.financeDataEncryptionKey" + if purpose == "lookup-hmac" { + name = "security.financeLookupHmacKey" + } + encoded := strings.TrimSpace(g.Cfg().MustGet(ctx, name, "").String()) + if encoded == "" { + return nil, nil + } + key, err := decodeFieldPayload(encoded) + if err != nil || (purpose == "data-encryption" && len(key) != 32) || (purpose == "lookup-hmac" && len(key) < 32) { + return nil, gerror.New("收款资料密钥配置不正确") + } + return key, nil +} + +func deriveFinanceFieldKey(master, purpose string) []byte { + mac := hmac.New(sha256.New, []byte(master)) + _, _ = mac.Write([]byte("olons-finance:" + purpose + ":v1")) + return mac.Sum(nil) +} + +func financeFieldReadKeys(ctx context.Context) [][]byte { + keys := make([][]byte, 0) + if key, err := financeFieldKey(ctx, "data-encryption"); err == nil && len(key) > 0 { + keys = append(keys, key) + } + for _, master := range g.Cfg().MustGet(ctx, "security.financeLegacyMasterKeys", []string{}).Strings() { + if master = strings.TrimSpace(master); len([]byte(master)) >= 32 { + keys = append(keys, deriveFinanceFieldKey(master, "data-encryption")) + } + } + return keys +} + +func decodeFieldPayload(value string) ([]byte, error) { + value = strings.TrimSpace(value) + for _, encoding := range []*base64.Encoding{base64.RawURLEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.StdEncoding} { + if raw, err := encoding.DecodeString(value); err == nil { + return raw, nil + } + } + return nil, gerror.New("敏感字段密文格式错误") +} + +// EncryptPaymentField keeps new admin edits readable by the customer-facing service. +func EncryptPaymentField(ctx context.Context, plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + key, err := financeFieldKey(ctx, "data-encryption") + if err != nil { + return "", err + } + if len(key) == 0 { + return EncryptField(ctx, plaintext) + } + block, err := aes.NewCipher(key) + if err != nil { + return "", gerror.Wrap(err, "初始化收款资料加密失败") + } + aead, err := cipher.NewGCM(block) + if err != nil { + return "", gerror.Wrap(err, "初始化收款资料加密失败") + } + nonce := make([]byte, aead.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", gerror.Wrap(err, "生成收款资料随机数失败") + } + return base64.RawStdEncoding.EncodeToString(aead.Seal(nonce, nonce, []byte(plaintext), nil)), nil +} + +func HashPaymentField(ctx context.Context, value string) (string, error) { + key, err := financeFieldKey(ctx, "lookup-hmac") + if err != nil { + return "", err + } + if len(key) == 0 { + return HashField(ctx, value) + } + mac := hmac.New(sha256.New, key) + _, _ = mac.Write([]byte(strings.TrimSpace(value))) + return hex.EncodeToString(mac.Sum(nil)), nil +} diff --git a/server/utility/encrypt/finance_field_test.go b/server/utility/encrypt/finance_field_test.go new file mode 100644 index 0000000..774ee22 --- /dev/null +++ b/server/utility/encrypt/finance_field_test.go @@ -0,0 +1,113 @@ +package encrypt + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "testing" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" +) + +func TestDecryptFieldAcceptsCustomerPaymentKeys(t *testing.T) { + ctx := context.Background() + current := "current-finance-master-key-with-at-least-32-characters" + previous := "previous-finance-master-key-with-at-least-32-characters" + setFieldTestConfig(t, map[string]interface{}{"dataKey": "admin-legacy-key", "financeMasterKey": current, "financeLegacyMasterKeys": []string{previous}}) + for _, master := range []string{current, previous} { + encoded := customerPaymentFixture(t, master, "6222000000001234") + plain, err := DecryptField(ctx, encoded) + if err != nil || plain != "6222000000001234" { + t.Fatalf("customer payment must decrypt with current or previous key: %v", err) + } + } +} + +func TestPaymentWritesRemainReadableByCustomerService(t *testing.T) { + ctx := context.Background() + master := "current-finance-master-key-with-at-least-32-characters" + setFieldTestConfig(t, map[string]interface{}{"dataKey": "admin-legacy-key", "financeMasterKey": master}) + const value = "6222000000001234" + encoded, err := EncryptPaymentField(ctx, value) + if err != nil { + t.Fatal(err) + } + payload, err := base64.RawStdEncoding.DecodeString(encoded) + if err != nil { + t.Fatal("payment edit must use the customer's standard Base64 format") + } + mac := hmac.New(sha256.New, []byte(master)) + _, _ = mac.Write([]byte("olons-finance:data-encryption:v1")) + block, _ := aes.NewCipher(mac.Sum(nil)) + aead, _ := cipher.NewGCM(block) + plain, err := aead.Open(nil, payload[:aead.NonceSize()], payload[aead.NonceSize():], nil) + if err != nil || string(plain) != value { + t.Fatalf("customer service cannot read admin edit: %v", err) + } + actualHash, err := HashPaymentField(ctx, value) + if err != nil { + t.Fatal(err) + } + mac = hmac.New(sha256.New, []byte(master)) + _, _ = mac.Write([]byte("olons-finance:lookup-hmac:v1")) + lookup := hmac.New(sha256.New, mac.Sum(nil)) + _, _ = lookup.Write([]byte(value)) + if actualHash != hex.EncodeToString(lookup.Sum(nil)) { + t.Fatal("admin lookup hash must match customer service") + } +} + +func TestDecryptFieldKeepsLegacyHotGoDataReadable(t *testing.T) { + ctx := context.Background() + setFieldTestConfig(t, map[string]interface{}{"dataKey": "admin-legacy-key", "financeMasterKey": "current-finance-master-key-with-at-least-32-characters"}) + encoded, err := EncryptField(ctx, "旧资料") + if err != nil { + t.Fatal(err) + } + plain, err := DecryptField(ctx, encoded) + if err != nil || plain != "旧资料" { + t.Fatalf("legacy HotGo record unreadable: %v", err) + } + for _, invalid := range []string{"DEMO-NOT-CIPHERTEXT", "AAAA", customerPaymentFixture(t, "unknown-master-key-with-at-least-32-characters", "姓名")} { + if _, err := DecryptField(ctx, invalid); err == nil { + t.Fatal("invalid or unknown-key records must not be reported as plaintext") + } + } +} + +func setFieldTestConfig(t *testing.T, security map[string]interface{}) { + t.Helper() + data, err := json.Marshal(map[string]interface{}{"security": security}) + if err != nil { + t.Fatal(err) + } + adapter, err := gcfg.NewAdapterContent(string(data)) + if err != nil { + t.Fatal(err) + } + previous := g.Cfg().GetAdapter() + g.Cfg().SetAdapter(adapter) + t.Cleanup(func() { g.Cfg().SetAdapter(previous) }) +} + +func customerPaymentFixture(t *testing.T, master, value string) string { + t.Helper() + mac := hmac.New(sha256.New, []byte(master)) + _, _ = mac.Write([]byte("olons-finance:data-encryption:v1")) + block, err := aes.NewCipher(mac.Sum(nil)) + if err != nil { + t.Fatal(err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + nonce := []byte{251, 255, 239, 255, 251, 255, 239, 255, 251, 255, 239, 255} + return base64.RawStdEncoding.EncodeToString(aead.Seal(nonce, nonce, []byte(value), nil)) +} diff --git a/web/src/views/app-member/list/index.vue b/web/src/views/app-member/list/index.vue index e481d46..2a5cab5 100644 --- a/web/src/views/app-member/list/index.vue +++ b/web/src/views/app-member/list/index.vue @@ -178,8 +178,10 @@