Files
opsx-licence/backend/internal/service/payment/config_crypto.go
T
Logan.Li 7d53c6ed3c feat(payment): 后台「支付配置」支持微信支付与支付宝凭据管理
后端:
- 新增 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、回调地址
- 菜单归入「审计与设置 → 支付配置」
2026-11-01 05:59:51 +08:00

218 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}