- 丢弃支付模块自带的 PAYMENT_SECRET_KEY/JWT_SECRET 派生双密钥体系, EncryptSecret/DecryptSecret 直接走 internal/secretbox(OPSLIC_MASTER_KEY) - 全站只有一把主密钥:Waffo 私钥、节点 shared_key、支付 APIv3 密钥/应用私钥同格式同前缀 - 测试改由 TestMain 装载 secretbox 主密钥,密文前缀统一引用 secretbox.Prefix - .env.example 移除 PAYMENT_SECRET_KEY,改为说明复用 OPSLIC_MASTER_KEY
This commit is contained in:
1 parent
3a98ccc631
commit
b0b611eee2
3 files changed
+26
-138
No files matched your search
+6
-11
@@ -109,20 +109,15 @@ SUCCESS_URL=https://l123.91ai.io/api/v1/purchase/success
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 支付配置加密(后台「支付配置」页 · 微信支付 / 支付宝)
|
# 支付配置(后台「支付配置」页 · 微信支付 / 支付宝)
|
||||||
# ============================================
|
# ============================================
|
||||||
# 敏感字段(微信 APIv3 密钥、支付宝应用私钥/公钥)入库前用 AES-256-GCM 加密,
|
# 敏感字段(微信 APIv3 密钥、支付宝应用私钥/公钥)不单独设密钥,
|
||||||
# 主密钥只从环境变量读取,绝不写入数据库或仓库。生成方式二选一:
|
# 统一走 internal/secretbox 信封加密,主密钥即上面的 OPSLIC_MASTER_KEY。
|
||||||
# openssl rand -hex 32 # 64 位 hex
|
# 证书/私钥落地目录(容器内数据卷,位于仓库之外);
|
||||||
# openssl rand -base64 32 # 32 字节 base64
|
|
||||||
# 未设置时系统会退化为用 JWT_SECRET 派生主密钥(可开箱使用,但轮换 JWT_SECRET 会导致
|
|
||||||
# 已保存的支付密钥无法解密,必须重新填写)——生产环境请显式设置本变量。
|
|
||||||
# 注意:主密钥一旦更换,已保存的密文全部失效。
|
|
||||||
# PAYMENT_SECRET_KEY=
|
|
||||||
|
|
||||||
# 支付证书/私钥落地目录(容器内数据卷,位于仓库之外);
|
|
||||||
# 目录权限 0700、文件 0600,不通过任何静态路由对外暴露。默认 /data/payment-certs
|
# 目录权限 0700、文件 0600,不通过任何静态路由对外暴露。默认 /data/payment-certs
|
||||||
# PAYMENT_CERT_DIR=/data/payment-certs
|
# PAYMENT_CERT_DIR=/data/payment-certs
|
||||||
|
# 对外基址(用于生成微信/支付宝回调地址展示)
|
||||||
|
# PAYMENT_PUBLIC_BASE_URL=https://licence.rlwj.cn
|
||||||
# 宿主机映射端口(容器内固定 9001)
|
# 宿主机映射端口(容器内固定 9001)
|
||||||
HOST_PORT=9001
|
HOST_PORT=9001
|
||||||
VERSION=latest
|
VERSION=latest
|
||||||
|
|||||||
@@ -1,96 +1,29 @@
|
|||||||
package payment
|
package payment
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/aes"
|
"backend/internal/secretbox"
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 支付配置敏感字段的对称加密实现(AES-256-GCM)。
|
// 支付配置敏感字段的加密出口:统一走项目级信封加密(internal/secretbox,AES-256-GCM)。
|
||||||
//
|
//
|
||||||
// 设计要点:
|
// 这样全站只有一把主密钥(环境变量 OPSLIC_MASTER_KEY,启动时 fail-fast 装载),
|
||||||
// 1. 主密钥来自环境变量 PAYMENT_SECRET_KEY(32 字节 hex / base64,或任意口令经 SHA-256 派生);
|
// 支付配置不再自带第二套密钥体系:微信 APIv3 密钥、支付宝应用私钥/公钥
|
||||||
// 2. 未设置时退化为用 JWT_SECRET 做域分离派生,保证开箱可用,但轮换 JWT_SECRET 会失去解密能力;
|
// 与 Waffo 私钥、节点 shared_key 共用同一套密文格式(enc:v1: 前缀)。 // Logan.Li
|
||||||
// 3. 密文格式 enc:v1:<base64url(nonce||ciphertext)>,前缀便于未来平滑升级算法。 // Logan.Li
|
|
||||||
const (
|
const (
|
||||||
// EnvPaymentMasterKey 加密主密钥环境变量名 // Logan.Li
|
// EnvPaymentMasterKey 主密钥环境变量名(与 secretbox 一致,保留常量供文档/提示引用) // Logan.Li
|
||||||
EnvPaymentMasterKey = "PAYMENT_SECRET_KEY"
|
EnvPaymentMasterKey = "OPSLIC_MASTER_KEY"
|
||||||
// cipherPrefix 密文前缀 // Logan.Li
|
|
||||||
cipherPrefix = "enc:v1:"
|
|
||||||
// cipherAAD 附加认证数据,避免密文被跨用途搬运 // Logan.Li
|
|
||||||
cipherAAD = "opsx-licence/payment-config/v1"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// PaymentMasterKeyOrigin 返回加密主密钥来源,供后台页面提示 // Logan.Li
|
||||||
masterKeyOnce sync.Once
|
|
||||||
masterKeyBytes []byte
|
|
||||||
masterKeyErr error
|
|
||||||
masterKeyOrigin string
|
|
||||||
)
|
|
||||||
|
|
||||||
// PaymentMasterKeyOrigin 返回主密钥来源(env / jwt_secret),供页面提示 // Logan.Li
|
|
||||||
func PaymentMasterKeyOrigin() string {
|
func PaymentMasterKeyOrigin() string {
|
||||||
_, _ = masterKey()
|
if secretbox.Ready() {
|
||||||
return masterKeyOrigin
|
return "env"
|
||||||
}
|
|
||||||
|
|
||||||
// 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 / 无填充)
|
return "none"
|
||||||
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
|
// EncryptSecret 加密敏感字段;空串直接返回空串 // Logan.Li
|
||||||
@@ -98,24 +31,7 @@ func EncryptSecret(plain string) (string, error) {
|
|||||||
if strings.TrimSpace(plain) == "" {
|
if strings.TrimSpace(plain) == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
key, err := masterKey()
|
return secretbox.Encrypt(plain)
|
||||||
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
|
// DecryptSecret 解密敏感字段;无前缀视为历史明文直接返回 // Logan.Li
|
||||||
@@ -124,34 +40,7 @@ func DecryptSecret(stored string) (string, error) {
|
|||||||
if raw == "" {
|
if raw == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(raw, cipherPrefix) {
|
return secretbox.Decrypt(raw)
|
||||||
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
|
// MaskTail 脱敏展示:仅保留末尾 keep 位,其余用掩码填充到 total 位 // Logan.Li
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
package payment
|
package payment
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"backend/internal/secretbox"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestMain 固定测试用加密主密钥(masterKey 只会解析一次) // Logan.Li
|
// TestMain 固定测试用加密主密钥并装载信封加密(全站共用 OPSLIC_MASTER_KEY) // Logan.Li
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
os.Setenv(EnvPaymentMasterKey, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
os.Setenv(EnvPaymentMasterKey, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||||
os.Setenv("PAYMENT_CERT_DIR", os.TempDir()+"/opsx-licence-test-certs")
|
os.Setenv("PAYMENT_CERT_DIR", os.TempDir()+"/opsx-licence-test-certs")
|
||||||
|
if err := secretbox.LoadMasterKey(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +24,7 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("加密失败: %v", err)
|
t.Fatalf("加密失败: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(enc, cipherPrefix) {
|
if !strings.HasPrefix(enc, secretbox.Prefix) {
|
||||||
t.Fatalf("密文缺少前缀: %s", enc)
|
t.Fatalf("密文缺少前缀: %s", enc)
|
||||||
}
|
}
|
||||||
if strings.Contains(enc, plain) {
|
if strings.Contains(enc, plain) {
|
||||||
|
|||||||
Reference in new issue
Block a user