fix: align customer payment encryption and protect original values
Deploy olons-admin production / deploy (push) Successful in 9m19s
Deploy olons-admin production / deploy (push) Successful in 9m19s
This commit is contained in:
@@ -458,6 +458,7 @@ func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaym
|
|||||||
if row.HolderCipher != "" {
|
if row.HolderCipher != "" {
|
||||||
plain, decryptErr := encrypt.DecryptField(ctx, row.HolderCipher)
|
plain, decryptErr := encrypt.DecryptField(ctx, row.HolderCipher)
|
||||||
if decryptErr != nil {
|
if decryptErr != nil {
|
||||||
|
row.SensitiveUnavailable = true
|
||||||
g.Log().Warningf(ctx, "收款人姓名解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr)
|
g.Log().Warningf(ctx, "收款人姓名解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr)
|
||||||
row.Holder = row.HolderMask
|
row.Holder = row.HolderMask
|
||||||
} else {
|
} else {
|
||||||
@@ -469,6 +470,7 @@ func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaym
|
|||||||
if row.IdentityNoCipher != "" {
|
if row.IdentityNoCipher != "" {
|
||||||
plain, decryptErr := encrypt.DecryptField(ctx, row.IdentityNoCipher)
|
plain, decryptErr := encrypt.DecryptField(ctx, row.IdentityNoCipher)
|
||||||
if decryptErr != nil {
|
if decryptErr != nil {
|
||||||
|
row.SensitiveUnavailable = true
|
||||||
g.Log().Warningf(ctx, "收款人证件号解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr)
|
g.Log().Warningf(ctx, "收款人证件号解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr)
|
||||||
row.IdentityNo = row.IdentityNoMask
|
row.IdentityNo = row.IdentityNoMask
|
||||||
} else {
|
} else {
|
||||||
@@ -480,6 +482,7 @@ func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaym
|
|||||||
if row.MobileCipher != "" {
|
if row.MobileCipher != "" {
|
||||||
plain, decryptErr := encrypt.DecryptField(ctx, row.MobileCipher)
|
plain, decryptErr := encrypt.DecryptField(ctx, row.MobileCipher)
|
||||||
if decryptErr != nil {
|
if decryptErr != nil {
|
||||||
|
row.SensitiveUnavailable = true
|
||||||
g.Log().Warningf(ctx, "收款手机号解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr)
|
g.Log().Warningf(ctx, "收款手机号解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr)
|
||||||
row.Mobile = row.MobileMask
|
row.Mobile = row.MobileMask
|
||||||
} else {
|
} else {
|
||||||
@@ -491,6 +494,7 @@ func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaym
|
|||||||
if row.AccountNoCipher != "" {
|
if row.AccountNoCipher != "" {
|
||||||
plain, decryptErr := encrypt.DecryptField(ctx, row.AccountNoCipher)
|
plain, decryptErr := encrypt.DecryptField(ctx, row.AccountNoCipher)
|
||||||
if decryptErr != nil {
|
if decryptErr != nil {
|
||||||
|
row.SensitiveUnavailable = true
|
||||||
g.Log().Warningf(ctx, "收款账号解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr)
|
g.Log().Warningf(ctx, "收款账号解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr)
|
||||||
row.AccountNo = row.AccountNoMask
|
row.AccountNo = row.AccountNoMask
|
||||||
} else {
|
} else {
|
||||||
@@ -502,6 +506,7 @@ func (s *sSysAppMember) PaymentList(ctx context.Context, in *sysin.AppMemberPaym
|
|||||||
if row.DigitalRmbCodeCipher != "" {
|
if row.DigitalRmbCodeCipher != "" {
|
||||||
plain, decryptErr := encrypt.DecryptField(ctx, row.DigitalRmbCodeCipher)
|
plain, decryptErr := encrypt.DecryptField(ctx, row.DigitalRmbCodeCipher)
|
||||||
if decryptErr != nil {
|
if decryptErr != nil {
|
||||||
|
row.SensitiveUnavailable = true
|
||||||
g.Log().Warningf(ctx, "数字钱包编码解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr)
|
g.Log().Warningf(ctx, "数字钱包编码解密失败,回退为脱敏值 memberId:%d accountId:%d err:%+v", row.MemberId, row.Id, decryptErr)
|
||||||
row.DigitalRMBCode = row.DigitalRMBCodeMask
|
row.DigitalRMBCode = row.DigitalRMBCodeMask
|
||||||
} else {
|
} else {
|
||||||
@@ -534,7 +539,7 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym
|
|||||||
data[c.AlipayQrAttachmentId] = in.AlipayQrAttachmentId
|
data[c.AlipayQrAttachmentId] = in.AlipayQrAttachmentId
|
||||||
}
|
}
|
||||||
if in.Holder != "" {
|
if in.Holder != "" {
|
||||||
v, e := encrypt.EncryptField(ctx, in.Holder)
|
v, e := encrypt.EncryptPaymentField(ctx, in.Holder)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
@@ -542,7 +547,7 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym
|
|||||||
data[c.HolderMask] = encrypt.MaskName(in.Holder)
|
data[c.HolderMask] = encrypt.MaskName(in.Holder)
|
||||||
}
|
}
|
||||||
if in.Mobile != "" {
|
if in.Mobile != "" {
|
||||||
v, e := encrypt.EncryptField(ctx, in.Mobile)
|
v, e := encrypt.EncryptPaymentField(ctx, in.Mobile)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
@@ -550,11 +555,11 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym
|
|||||||
data[c.MobileMask] = encrypt.MaskMobile(in.Mobile)
|
data[c.MobileMask] = encrypt.MaskMobile(in.Mobile)
|
||||||
}
|
}
|
||||||
if in.IdentityNo != "" {
|
if in.IdentityNo != "" {
|
||||||
v, e := encrypt.EncryptField(ctx, in.IdentityNo)
|
v, e := encrypt.EncryptPaymentField(ctx, in.IdentityNo)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
h, e := encrypt.HashField(ctx, in.IdentityNo)
|
h, e := encrypt.HashPaymentField(ctx, in.IdentityNo)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
@@ -563,11 +568,11 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym
|
|||||||
data[c.IdentityNoMask] = encrypt.MaskAccount(in.IdentityNo)
|
data[c.IdentityNoMask] = encrypt.MaskAccount(in.IdentityNo)
|
||||||
}
|
}
|
||||||
if in.AccountNo != "" {
|
if in.AccountNo != "" {
|
||||||
v, e := encrypt.EncryptField(ctx, in.AccountNo)
|
v, e := encrypt.EncryptPaymentField(ctx, in.AccountNo)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
h, e := encrypt.HashField(ctx, in.AccountNo)
|
h, e := encrypt.HashPaymentField(ctx, in.AccountNo)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
@@ -576,11 +581,11 @@ func (s *sSysAppMember) PaymentEdit(ctx context.Context, in *sysin.AppMemberPaym
|
|||||||
data[c.AccountNoMask] = encrypt.MaskAccount(in.AccountNo)
|
data[c.AccountNoMask] = encrypt.MaskAccount(in.AccountNo)
|
||||||
}
|
}
|
||||||
if in.DigitalRMBCode != "" {
|
if in.DigitalRMBCode != "" {
|
||||||
v, e := encrypt.EncryptField(ctx, in.DigitalRMBCode)
|
v, e := encrypt.EncryptPaymentField(ctx, in.DigitalRMBCode)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
h, e := encrypt.HashField(ctx, in.DigitalRMBCode)
|
h, e := encrypt.HashPaymentField(ctx, in.DigitalRMBCode)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ type AppMemberEditInp struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (in *AppMemberEditInp) Filter(ctx context.Context) error {
|
func (in *AppMemberEditInp) Filter(ctx context.Context) error {
|
||||||
|
if err := rejectMaskedSensitiveFields(in.RealName, in.IdNo); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if in.Id == 0 {
|
if in.Id == 0 {
|
||||||
return gerror.New("后台不支持创建会员,请由会员在C端完成注册")
|
return gerror.New("后台不支持创建会员,请由会员在C端完成注册")
|
||||||
}
|
}
|
||||||
@@ -156,6 +159,7 @@ type AppMemberPaymentListInp struct {
|
|||||||
func (in *AppMemberPaymentListInp) Filter(ctx context.Context) error { return nil }
|
func (in *AppMemberPaymentListInp) Filter(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
type AppMemberPaymentModel struct {
|
type AppMemberPaymentModel struct {
|
||||||
|
SensitiveUnavailable bool `json:"sensitiveUnavailable"`
|
||||||
Id uint64 `json:"id"`
|
Id uint64 `json:"id"`
|
||||||
MemberId uint64 `json:"memberId"`
|
MemberId uint64 `json:"memberId"`
|
||||||
AccountType int `json:"accountType"`
|
AccountType int `json:"accountType"`
|
||||||
@@ -207,6 +211,9 @@ type AppMemberPaymentEditInp struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (in *AppMemberPaymentEditInp) Filter(ctx context.Context) error {
|
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) {
|
if !dict.HasOptionKey(consts.PaymentAccountTypeOptions, in.AccountType) {
|
||||||
return gerror.New("收款账户类型不合法")
|
return gerror.New("收款账户类型不合法")
|
||||||
}
|
}
|
||||||
@@ -260,12 +267,24 @@ type AppMemberAddressEditInp struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (in *AppMemberAddressEditInp) Filter(ctx context.Context) error {
|
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) == "") {
|
if in.Id == 0 && (strings.TrimSpace(in.Receiver) == "" || strings.TrimSpace(in.Mobile) == "" || strings.TrimSpace(in.Address) == "") {
|
||||||
return gerror.New("新增地址时收货人、手机号和详细地址不能为空")
|
return gerror.New("新增地址时收货人、手机号和详细地址不能为空")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rejectMaskedSensitiveFields(values ...string) error {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.Contains(value, "*") {
|
||||||
|
return gerror.New("不能保存带星号的脱敏资料,请填写完整信息或留空保留原值")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type AppMemberAddressDeleteInp struct {
|
type AppMemberAddressDeleteInp struct {
|
||||||
Id uint64 `json:"id" v:"required|min:1#地址ID不能为空|地址ID不能为空"`
|
Id uint64 `json:"id" v:"required|min:1#地址ID不能为空|地址ID不能为空"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,6 +195,11 @@ token:
|
|||||||
# 业务敏感字段保护。生产环境必须替换为独立的高强度密钥,并通过密钥管理系统注入。
|
# 业务敏感字段保护。生产环境必须替换为独立的高强度密钥,并通过密钥管理系统注入。
|
||||||
security:
|
security:
|
||||||
dataKey: "change-me-before-production"
|
dataKey: "change-me-before-production"
|
||||||
|
# 与 C 端收款资料使用相同配置;历史主密钥只用于读取,不修改原密文。
|
||||||
|
financeMasterKey: ""
|
||||||
|
financeLegacyMasterKeys: []
|
||||||
|
financeDataEncryptionKey: ""
|
||||||
|
financeLookupHmacKey: ""
|
||||||
|
|
||||||
|
|
||||||
# 消息队列
|
# 消息队列
|
||||||
|
|||||||
@@ -54,30 +54,32 @@ func DecryptField(ctx context.Context, encoded string) (string, error) {
|
|||||||
if encoded == "" {
|
if encoded == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
key, err := fieldKey(ctx)
|
raw, err := decodeFieldPayload(encoded)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
if len(raw) < 12+16 {
|
||||||
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() {
|
|
||||||
return "", gerror.New("敏感字段密文不完整")
|
return "", gerror.New("敏感字段密文不完整")
|
||||||
}
|
}
|
||||||
plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
|
keys := financeFieldReadKeys(ctx)
|
||||||
if err != nil {
|
if key, keyErr := fieldKey(ctx); keyErr == nil {
|
||||||
return "", gerror.Wrap(err, "敏感字段解密失败")
|
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.
|
// HashField creates a deterministic, non-reversible lookup key with a secret pepper.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -178,8 +178,10 @@
|
|||||||
<n-spin :show="relatedLoading">
|
<n-spin :show="relatedLoading">
|
||||||
<n-form label-placement="left" label-width="110">
|
<n-form label-placement="left" label-width="110">
|
||||||
<template v-if="relatedType === 'payment'">
|
<template v-if="relatedType === 'payment'">
|
||||||
<n-alert v-if="paymentForm.id" type="warning" :show-icon="false">
|
<n-alert v-if="paymentForm.id" :type="paymentForm.sensitiveUnavailable ? 'error' : 'warning'" :show-icon="false">
|
||||||
敏感资料已明文展示;请仅用于当前业务处理。留空保存不会覆盖原敏感字段或收款码。
|
{{ paymentForm.sensitiveUnavailable
|
||||||
|
? '部分历史资料暂无法解密。脱敏值只作占位,不会写回原数据;请留空保留原值。'
|
||||||
|
: '资料已明文展示。留空保存不会覆盖原字段或收款码。' }}
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<n-form-item label="开户人"
|
<n-form-item label="开户人"
|
||||||
><n-input v-model:value="paymentForm.holder" :placeholder="paymentForm.holderMask || '留空不修改'"
|
><n-input v-model:value="paymentForm.holder" :placeholder="paymentForm.holderMask || '留空不修改'"
|
||||||
@@ -734,16 +736,17 @@
|
|||||||
reset(paymentForm, {
|
reset(paymentForm, {
|
||||||
memberId,
|
memberId,
|
||||||
id: bank?.id || 0,
|
id: bank?.id || 0,
|
||||||
holder: bank?.holder || bank?.holderMask || '',
|
holder: editableSensitive(bank?.holder),
|
||||||
|
sensitiveUnavailable: bank?.sensitiveUnavailable || false,
|
||||||
holderMask: bank?.holderMask || '',
|
holderMask: bank?.holderMask || '',
|
||||||
identityNo: bank?.identityNo || bank?.identityNoMask || '',
|
identityNo: editableSensitive(bank?.identityNo),
|
||||||
identityNoMask: bank?.identityNoMask || '',
|
identityNoMask: bank?.identityNoMask || '',
|
||||||
bankName: bank?.bankName || '',
|
bankName: bank?.bankName || '',
|
||||||
mobile: bank?.mobile || bank?.mobileMask || '',
|
mobile: editableSensitive(bank?.mobile),
|
||||||
mobileMask: bank?.mobileMask || '',
|
mobileMask: bank?.mobileMask || '',
|
||||||
bankAccount: bank?.accountNo || bank?.accountNoMask || '',
|
bankAccount: editableSensitive(bank?.accountNo),
|
||||||
accountNoMask: bank?.accountNoMask || '',
|
accountNoMask: bank?.accountNoMask || '',
|
||||||
digitalWallet: bank?.digitalRmbCode || bank?.digitalRmbCodeMask || '',
|
digitalWallet: editableSensitive(bank?.digitalRmbCode),
|
||||||
digitalRmbCodeMask: bank?.digitalRmbCodeMask || '',
|
digitalRmbCodeMask: bank?.digitalRmbCodeMask || '',
|
||||||
alipayQrCode: bank?.alipayQrUrl || '',
|
alipayQrCode: bank?.alipayQrUrl || '',
|
||||||
wechatQrCode: bank?.wechatQrUrl || '',
|
wechatQrCode: bank?.wechatQrUrl || '',
|
||||||
@@ -756,14 +759,14 @@
|
|||||||
reset(addressForm, {
|
reset(addressForm, {
|
||||||
id: row?.id || 0,
|
id: row?.id || 0,
|
||||||
memberId,
|
memberId,
|
||||||
receiver: row?.receiver || row?.receiverMask || '',
|
receiver: editableSensitive(row?.receiver),
|
||||||
mobile: row?.mobile || row?.mobileMask || '',
|
mobile: editableSensitive(row?.mobile),
|
||||||
region,
|
region,
|
||||||
originalRegion: region,
|
originalRegion: region,
|
||||||
province: row?.province || '',
|
province: row?.province || '',
|
||||||
city: row?.city || '',
|
city: row?.city || '',
|
||||||
district: row?.district || '',
|
district: row?.district || '',
|
||||||
address: '',
|
address: editableSensitive(row?.address),
|
||||||
isDefault: 1,
|
isDefault: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -778,6 +781,9 @@
|
|||||||
: await AddressList({ memberId: detail.value.id });
|
: await AddressList({ memberId: detail.value.id });
|
||||||
relatedRows.value = r.list || [];
|
relatedRows.value = r.list || [];
|
||||||
}
|
}
|
||||||
|
function editableSensitive(value: unknown) {
|
||||||
|
return typeof value === 'string' && !value.includes('*') ? value : '';
|
||||||
|
}
|
||||||
function startRelatedAdd() {
|
function startRelatedAdd() {
|
||||||
showRelatedForm.value = true;
|
showRelatedForm.value = true;
|
||||||
if (relatedType.value === 'payment')
|
if (relatedType.value === 'payment')
|
||||||
@@ -788,7 +794,8 @@
|
|||||||
bankName: '',
|
bankName: '',
|
||||||
holder: '',
|
holder: '',
|
||||||
mobile: '',
|
mobile: '',
|
||||||
accountNo: '',
|
bankAccount: '',
|
||||||
|
digitalWallet: '',
|
||||||
verifyStatus: 1,
|
verifyStatus: 1,
|
||||||
isDefault: 2,
|
isDefault: 2,
|
||||||
});
|
});
|
||||||
@@ -801,7 +808,7 @@
|
|||||||
province: '',
|
province: '',
|
||||||
city: '',
|
city: '',
|
||||||
district: '',
|
district: '',
|
||||||
address: row?.address || row?.addressMask || '',
|
address: '',
|
||||||
isDefault: 2,
|
isDefault: 2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -855,11 +862,16 @@
|
|||||||
memberId: row.memberId,
|
memberId: row.memberId,
|
||||||
accountType: row.accountType,
|
accountType: row.accountType,
|
||||||
bankName: row.bankName,
|
bankName: row.bankName,
|
||||||
holder: row.holder || row.holderMask || '',
|
holder: editableSensitive(row.holder),
|
||||||
identityNo: row.identityNo || row.identityNoMask || '',
|
sensitiveUnavailable: row.sensitiveUnavailable || false,
|
||||||
mobile: row.mobile || row.mobileMask || '',
|
identityNo: editableSensitive(row.identityNo),
|
||||||
accountNo: row.accountNo || row.accountNoMask || '',
|
mobile: editableSensitive(row.mobile),
|
||||||
digitalRmbCode: row.digitalRmbCode || row.digitalRmbCodeMask || '',
|
bankAccount: editableSensitive(row.accountNo),
|
||||||
|
digitalWallet: editableSensitive(row.digitalRmbCode),
|
||||||
|
wechatQrCode: row.wechatQrUrl || '',
|
||||||
|
alipayQrCode: row.alipayQrUrl || '',
|
||||||
|
wechatQrAttachmentId: row.wechatQrAttachmentId || 0,
|
||||||
|
alipayQrAttachmentId: row.alipayQrAttachmentId || 0,
|
||||||
verifyStatus: row.verifyStatus,
|
verifyStatus: row.verifyStatus,
|
||||||
isDefault: row.isDefault,
|
isDefault: row.isDefault,
|
||||||
});
|
});
|
||||||
@@ -867,12 +879,12 @@
|
|||||||
reset(addressForm, {
|
reset(addressForm, {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
memberId: row.memberId,
|
memberId: row.memberId,
|
||||||
receiver: row.receiver || row.receiverMask || '',
|
receiver: editableSensitive(row.receiver),
|
||||||
mobile: row.mobile || row.mobileMask || '',
|
mobile: editableSensitive(row.mobile),
|
||||||
province: row.province,
|
province: row.province,
|
||||||
city: row.city,
|
city: row.city,
|
||||||
district: row.district,
|
district: row.district,
|
||||||
address: row.address || row.addressMask || '',
|
address: editableSensitive(row.address),
|
||||||
isDefault: row.isDefault,
|
isDefault: row.isDefault,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user