后端: - 新增 payment_configs 单行配置表;APIv3 密钥、应用私钥/公钥以 AES-256-GCM 密文入库(enc:v1 前缀,主密钥取 PAYMENT_SECRET_KEY,缺省回退 JWT_SECRET 派生) - 证书/私钥落 PAYMENT_CERT_DIR(默认 /data/payment-certs),目录 0700 / 文件 0600,不经任何静态路由暴露 - 接口:GET/PUT /api/admin/payment/config、POST /upload(PEM 校验 + 微信证书序列号自动回填)、POST /test(真打官方接口:微信 /v3/certificates 签名校验、支付宝 alipay.trade.query 签名校验)、POST /wechat/platform-certs(APIv3 密钥解密平台证书并落盘) - 未配置凭据时测试返回 skipped 而不是 500;敏感字段在返回体里一律脱敏 前端: - /payments 改为「通用设置 + 微信支付 / 支付宝 / Waffo 海外支付」页签(原 Waffo 页整页迁到 views/payments/Waffo.vue) - 微信:mchid、AppID、APIv3 密钥(密码框 + 掩码回显 + 更换/清除)、证书序列号、支付方式多选、三项证书上传、自动获取平台证书 - 支付宝:app_id、应用私钥(粘贴/上传)、支付宝公钥、网关、RSA2、回调地址 - 菜单归入「审计与设置 → 支付配置」
218 lines
5.9 KiB
Go
218 lines
5.9 KiB
Go
package payment
|
||
|
||
import (
|
||
"crypto/aes"
|
||
"crypto/cipher"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"errors"
|
||
"io"
|
||
"log"
|
||
"os"
|
||
"strings"
|
||
"sync"
|
||
)
|
||
|
||
// 支付配置敏感字段的对称加密实现(AES-256-GCM)。
|
||
//
|
||
// 设计要点:
|
||
// 1. 主密钥来自环境变量 PAYMENT_SECRET_KEY(32 字节 hex / base64,或任意口令经 SHA-256 派生);
|
||
// 2. 未设置时退化为用 JWT_SECRET 做域分离派生,保证开箱可用,但轮换 JWT_SECRET 会失去解密能力;
|
||
// 3. 密文格式 enc:v1:<base64url(nonce||ciphertext)>,前缀便于未来平滑升级算法。 // Logan.Li
|
||
const (
|
||
// EnvPaymentMasterKey 加密主密钥环境变量名 // Logan.Li
|
||
EnvPaymentMasterKey = "PAYMENT_SECRET_KEY"
|
||
// cipherPrefix 密文前缀 // Logan.Li
|
||
cipherPrefix = "enc:v1:"
|
||
// cipherAAD 附加认证数据,避免密文被跨用途搬运 // Logan.Li
|
||
cipherAAD = "opsx-licence/payment-config/v1"
|
||
)
|
||
|
||
var (
|
||
masterKeyOnce sync.Once
|
||
masterKeyBytes []byte
|
||
masterKeyErr error
|
||
masterKeyOrigin string
|
||
)
|
||
|
||
// PaymentMasterKeyOrigin 返回主密钥来源(env / jwt_secret),供页面提示 // Logan.Li
|
||
func PaymentMasterKeyOrigin() string {
|
||
_, _ = masterKey()
|
||
return masterKeyOrigin
|
||
}
|
||
|
||
// masterKey 惰性解析主密钥,仅解析一次 // Logan.Li
|
||
func masterKey() ([]byte, error) {
|
||
masterKeyOnce.Do(func() {
|
||
if raw := strings.TrimSpace(os.Getenv(EnvPaymentMasterKey)); raw != "" {
|
||
key, err := parseKeyMaterial(raw)
|
||
if err != nil {
|
||
masterKeyErr = err
|
||
return
|
||
}
|
||
masterKeyBytes = key
|
||
masterKeyOrigin = "env"
|
||
return
|
||
}
|
||
if jwt := strings.TrimSpace(os.Getenv("JWT_SECRET")); jwt != "" {
|
||
sum := sha256.Sum256([]byte(cipherAAD + "|jwt:" + jwt))
|
||
masterKeyBytes = sum[:]
|
||
masterKeyOrigin = "jwt_secret"
|
||
log.Printf("[支付配置] 未设置 %s,已用 JWT_SECRET 派生加密主密钥;生产环境建议显式设置独立主密钥", EnvPaymentMasterKey)
|
||
return
|
||
}
|
||
masterKeyOrigin = "none"
|
||
masterKeyErr = errors.New("未配置加密主密钥:请设置环境变量 " + EnvPaymentMasterKey + "(32 字节 hex/base64,或任意口令)")
|
||
})
|
||
return masterKeyBytes, masterKeyErr
|
||
}
|
||
|
||
// parseKeyMaterial 把环境变量值解析成 32 字节密钥 // Logan.Li
|
||
func parseKeyMaterial(raw string) ([]byte, error) {
|
||
// 1) 64 位 hex
|
||
if len(raw) == 64 {
|
||
if b, err := hex.DecodeString(raw); err == nil && len(b) == 32 {
|
||
return b, nil
|
||
}
|
||
}
|
||
// 2) base64(标准 / URL / 无填充)
|
||
for _, enc := range []*base64.Encoding{
|
||
base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding,
|
||
} {
|
||
if b, err := enc.DecodeString(raw); err == nil && len(b) == 32 {
|
||
return b, nil
|
||
}
|
||
}
|
||
// 3) 任意口令:SHA-256 派生
|
||
if len(raw) >= 8 {
|
||
sum := sha256.Sum256([]byte(raw))
|
||
return sum[:], nil
|
||
}
|
||
return nil, errors.New("PAYMENT_SECRET_KEY 过短(至少 8 个字符,推荐 openssl rand -hex 32)")
|
||
}
|
||
|
||
// EncryptSecret 加密敏感字段;空串直接返回空串 // Logan.Li
|
||
func EncryptSecret(plain string) (string, error) {
|
||
if strings.TrimSpace(plain) == "" {
|
||
return "", nil
|
||
}
|
||
key, err := masterKey()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
block, err := aes.NewCipher(key)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
gcm, err := cipher.NewGCM(block)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
nonce := make([]byte, gcm.NonceSize())
|
||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||
return "", err
|
||
}
|
||
sealed := gcm.Seal(nonce, nonce, []byte(plain), []byte(cipherAAD))
|
||
return cipherPrefix + base64.RawURLEncoding.EncodeToString(sealed), nil
|
||
}
|
||
|
||
// DecryptSecret 解密敏感字段;无前缀视为历史明文直接返回 // Logan.Li
|
||
func DecryptSecret(stored string) (string, error) {
|
||
raw := strings.TrimSpace(stored)
|
||
if raw == "" {
|
||
return "", nil
|
||
}
|
||
if !strings.HasPrefix(raw, cipherPrefix) {
|
||
return raw, nil
|
||
}
|
||
key, err := masterKey()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
payload, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(raw, cipherPrefix))
|
||
if err != nil {
|
||
return "", errors.New("密文格式错误")
|
||
}
|
||
block, err := aes.NewCipher(key)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
gcm, err := cipher.NewGCM(block)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if len(payload) < gcm.NonceSize() {
|
||
return "", errors.New("密文长度异常")
|
||
}
|
||
nonce, cipherText := payload[:gcm.NonceSize()], payload[gcm.NonceSize():]
|
||
plain, err := gcm.Open(nil, nonce, cipherText, []byte(cipherAAD))
|
||
if err != nil {
|
||
return "", errors.New("解密失败:加密主密钥(PAYMENT_SECRET_KEY)与保存时不一致")
|
||
}
|
||
return string(plain), nil
|
||
}
|
||
|
||
// MaskTail 脱敏展示:仅保留末尾 keep 位,其余用掩码填充到 total 位 // Logan.Li
|
||
func MaskTail(secret string, keep, total int) string {
|
||
s := strings.TrimSpace(secret)
|
||
if s == "" {
|
||
return ""
|
||
}
|
||
if total <= 0 {
|
||
total = len(s)
|
||
}
|
||
if total < keep {
|
||
total = keep
|
||
}
|
||
if len(s) <= keep {
|
||
return strings.Repeat("•", total-len(s)) + s
|
||
}
|
||
return strings.Repeat("•", total-keep) + s[len(s)-keep:]
|
||
}
|
||
|
||
// FingerprintSHA256 计算内容指纹,用于「已配置但不回显」字段 // Logan.Li
|
||
func FingerprintSHA256(data []byte) string {
|
||
if len(data) == 0 {
|
||
return ""
|
||
}
|
||
sum := sha256.Sum256(data)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
// ShortFingerprint 指纹取前 16 位做展示 // Logan.Li
|
||
func ShortFingerprint(fp string) string {
|
||
if len(fp) <= 16 {
|
||
return fp
|
||
}
|
||
return fp[:16]
|
||
}
|
||
|
||
// NormalizePEM 把可能是裸 base64 的密钥补全成 PEM 块 // Logan.Li
|
||
func NormalizePEM(raw, begin, end string) string {
|
||
s := strings.TrimSpace(raw)
|
||
if s == "" {
|
||
return ""
|
||
}
|
||
// 已含 PEM 头则原样(统一换行)
|
||
if strings.Contains(s, "-----BEGIN") {
|
||
return strings.ReplaceAll(s, "\r\n", "\n")
|
||
}
|
||
body := strings.Join(strings.Fields(s), "")
|
||
var b strings.Builder
|
||
b.WriteString(begin)
|
||
b.WriteString("\n")
|
||
for i := 0; i < len(body); i += 64 {
|
||
j := i + 64
|
||
if j > len(body) {
|
||
j = len(body)
|
||
}
|
||
b.WriteString(body[i:j])
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString(end)
|
||
b.WriteString("\n")
|
||
return b.String()
|
||
}
|