Merge branch 'main' into rebuild/auth-identity-foundation

This commit is contained in:
IanShaw027
2026-04-22 00:35:34 +08:00
67 changed files with 1550 additions and 613 deletions

View File

@@ -10,12 +10,20 @@ import (
"strings"
)
// AES256KeySize is the required key length (in bytes) for AES-256-GCM.
const AES256KeySize = 32
// Encrypt encrypts plaintext using AES-256-GCM with the given 32-byte key.
// The output format is "iv:authTag:ciphertext" where each component is base64-encoded,
// matching the Node.js crypto.ts format for cross-compatibility.
//
// Deprecated: payment provider configs are now stored as plaintext JSON.
// This function is kept only for seeding legacy ciphertext in tests and for
// the transitional Decrypt fallback. Scheduled for removal after all live
// deployments complete migration by re-saving their configs.
func Encrypt(plaintext string, key []byte) (string, error) {
if len(key) != 32 {
return "", fmt.Errorf("encryption key must be 32 bytes, got %d", len(key))
if len(key) != AES256KeySize {
return "", fmt.Errorf("encryption key must be %d bytes, got %d", AES256KeySize, len(key))
}
block, err := aes.NewCipher(key)
@@ -51,9 +59,14 @@ func Encrypt(plaintext string, key []byte) (string, error) {
// Decrypt decrypts a ciphertext string produced by Encrypt.
// The input format is "iv:authTag:ciphertext" where each component is base64-encoded.
//
// Deprecated: payment provider configs are now stored as plaintext JSON.
// This function remains only as a read-path fallback for pre-migration
// ciphertext records. Scheduled for removal once all deployments re-save
// their provider configs through the admin UI.
func Decrypt(ciphertext string, key []byte) (string, error) {
if len(key) != 32 {
return "", fmt.Errorf("encryption key must be 32 bytes, got %d", len(key))
if len(key) != AES256KeySize {
return "", fmt.Errorf("encryption key must be %d bytes, got %d", AES256KeySize, len(key))
}
parts := strings.SplitN(ciphertext, ":", 3)

View File

@@ -297,6 +297,9 @@ func (lb *DefaultLoadBalancer) buildSelection(selected *dbent.PaymentProviderIns
if err != nil {
return nil, fmt.Errorf("decrypt instance %d config: %w", selected.ID, err)
}
if config == nil {
config = map[string]string{}
}
if selected.PaymentMode != "" {
config["paymentMode"] = selected.PaymentMode
@@ -311,16 +314,36 @@ func (lb *DefaultLoadBalancer) buildSelection(selected *dbent.PaymentProviderIns
}, nil
}
func (lb *DefaultLoadBalancer) decryptConfig(encrypted string) (map[string]string, error) {
plaintext, err := Decrypt(encrypted, lb.encryptionKey)
if err != nil {
return nil, err
// decryptConfig parses a stored provider config.
// New records are plaintext JSON; legacy records are AES-256-GCM ciphertext.
// Unreadable values (legacy ciphertext without a valid key, or malformed data)
// are treated as empty so the service keeps running while the admin re-enters
// the config via the UI.
//
// TODO(deprecated-legacy-ciphertext): The AES fallback branch below is a
// transitional compatibility shim for pre-plaintext records. Remove it (and
// the encryptionKey field + the Decrypt import) after a few releases once all
// live deployments have re-saved their provider configs through the UI.
func (lb *DefaultLoadBalancer) decryptConfig(stored string) (map[string]string, error) {
if stored == "" {
return nil, nil
}
var config map[string]string
if err := json.Unmarshal([]byte(plaintext), &config); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
if err := json.Unmarshal([]byte(stored), &config); err == nil {
return config, nil
}
return config, nil
// Deprecated: legacy AES-256-GCM ciphertext fallback — scheduled for removal.
if len(lb.encryptionKey) == AES256KeySize {
//nolint:staticcheck // SA1019: intentional legacy fallback, scheduled for removal
if plaintext, err := Decrypt(stored, lb.encryptionKey); err == nil {
if err := json.Unmarshal([]byte(plaintext), &config); err == nil {
return config, nil
}
}
}
slog.Warn("payment provider config unreadable, treating as empty for re-entry",
"stored_len", len(stored))
return nil, nil
}
// GetInstanceDailyAmount returns the total completed order amount for an instance today.

View File

@@ -474,6 +474,103 @@ func TestStartOfDay(t *testing.T) {
}
}
func TestDecryptConfig_PlaintextAndLegacyCompat(t *testing.T) {
t.Parallel()
key := make([]byte, AES256KeySize)
for i := range key {
key[i] = byte(i + 1)
}
wrongKey := make([]byte, AES256KeySize)
for i := range wrongKey {
wrongKey[i] = byte(0xFF - i)
}
plaintextJSON := `{"appId":"app-123","secret":"sec-xyz"}`
legacyEncrypted, err := Encrypt(plaintextJSON, key)
if err != nil {
t.Fatalf("seed Encrypt: %v", err)
}
tests := []struct {
name string
stored string
key []byte
want map[string]string
}{
{
name: "empty stored returns nil map",
stored: "",
key: key,
want: nil,
},
{
name: "plaintext JSON parses directly",
stored: plaintextJSON,
key: nil,
want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
},
{
name: "plaintext JSON works even with key present",
stored: plaintextJSON,
key: key,
want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
},
{
name: "legacy ciphertext with correct key decrypts",
stored: legacyEncrypted,
key: key,
want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
},
{
name: "legacy ciphertext with no key treated as empty",
stored: legacyEncrypted,
key: nil,
want: nil,
},
{
name: "legacy ciphertext with wrong key treated as empty",
stored: legacyEncrypted,
key: wrongKey,
want: nil,
},
{
name: "garbage data treated as empty",
stored: "not-json-and-not-ciphertext",
key: key,
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
lb := NewDefaultLoadBalancer(nil, tt.key)
got, err := lb.decryptConfig(tt.stored)
if err != nil {
t.Fatalf("decryptConfig unexpected error: %v", err)
}
if !stringMapEqual(got, tt.want) {
t.Fatalf("decryptConfig = %v, want %v", got, tt.want)
}
})
}
}
// stringMapEqual compares two map[string]string values; nil and empty are equal.
func stringMapEqual(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if bv, ok := b[k]; !ok || bv != v {
return false
}
}
return true
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

View File

@@ -15,8 +15,8 @@ import (
// Alipay product codes.
const (
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
alipayProductCodeWapPay = "QUICK_WAP_WAY"
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
)
// Alipay response constants.
@@ -102,8 +102,13 @@ func (a *Alipay) MerchantIdentityMetadata() map[string]string {
return map[string]string{"app_id": appID}
}
// CreatePayment creates an Alipay payment page URL.
func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
// CreatePayment creates an Alipay payment using redirect-only flow:
// - Mobile (H5): alipay.trade.wap.pay — returns a URL the browser jumps to.
// - PC: alipay.trade.page.pay — returns a gateway URL the browser opens in a
// new window; Alipay's own page then shows login/QR. We intentionally do
// NOT encode the URL into a QR on the client (it isn't a scannable payload
// and would produce an invalid scan result).
func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
client, err := a.getClient()
if err != nil {
return nil, err
@@ -119,44 +124,46 @@ func (a *Alipay) CreatePayment(ctx context.Context, req payment.CreatePaymentReq
}
if req.IsMobile {
return a.createTrade(ctx, client, req, notifyURL, returnURL, true)
return a.createWapTrade(client, req, notifyURL, returnURL)
}
return a.createTrade(ctx, client, req, notifyURL, returnURL, false)
return a.createPagePayTrade(client, req, notifyURL, returnURL)
}
func (a *Alipay) createTrade(ctx context.Context, client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string, isMobile bool) (*payment.CreatePaymentResponse, error) {
if isMobile {
param := alipay.TradeWapPay{}
param.OutTradeNo = req.OrderID
param.TotalAmount = req.Amount
param.Subject = req.Subject
param.ProductCode = alipayProductCodeWapPay
param.NotifyURL = notifyURL
param.ReturnURL = returnURL
payURL, err := alipayTradeWapPay(client, param)
if err != nil {
return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
}
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
PayURL: payURL.String(),
}, nil
}
param := alipay.TradePreCreate{}
func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
param := alipay.TradeWapPay{}
param.OutTradeNo = req.OrderID
param.TotalAmount = req.Amount
param.Subject = req.Subject
param.ProductCode = alipayProductCodeWapPay
param.NotifyURL = notifyURL
param.ReturnURL = returnURL
resp, err := alipayTradePreCreate(ctx, client, param)
payURL, err := client.TradeWapPay(param)
if err != nil {
return nil, fmt.Errorf("alipay TradePreCreate: %w", err)
return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
}
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
QRCode: strings.TrimSpace(resp.QRCode),
PayURL: payURL.String(),
}, nil
}
func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
param := alipay.TradePagePay{}
param.OutTradeNo = req.OrderID
param.TotalAmount = req.Amount
param.Subject = req.Subject
param.ProductCode = alipayProductCodePagePay
param.NotifyURL = notifyURL
param.ReturnURL = returnURL
payURL, err := alipayTradePagePay(client, param)
if err != nil {
return nil, fmt.Errorf("alipay TradePagePay: %w", err)
}
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
PayURL: payURL.String(),
}, nil
}

View File

@@ -3,16 +3,17 @@ package provider
import (
"bytes"
"context"
"crypto/rsa"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
@@ -84,15 +85,35 @@ type Wxpay struct {
notifyHandler *notify.Handler
}
const wxpayAPIv3KeyLength = 32
func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) {
required := []string{"appId", "mchId", "privateKey", "apiV3Key", "publicKey", "publicKeyId", "certSerial"}
// All fields are required. Platform-certificate mode is intentionally unsupported —
// WeChat has been migrating all merchants to the pubkey verifier since 2024-10,
// and newly-provisioned merchants cannot download platform certificates at all.
required := []string{"appId", "mchId", "privateKey", "apiV3Key", "certSerial", "publicKey", "publicKeyId"}
for _, k := range required {
if config[k] == "" {
return nil, fmt.Errorf("wxpay config missing required key: %s", k)
return nil, infraerrors.BadRequest("WXPAY_CONFIG_MISSING_KEY", "missing_required_key").
WithMetadata(map[string]string{"key": k})
}
}
if len(config["apiV3Key"]) != 32 {
return nil, fmt.Errorf("wxpay apiV3Key must be exactly 32 bytes, got %d", len(config["apiV3Key"]))
if len(config["apiV3Key"]) != wxpayAPIv3KeyLength {
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY_LENGTH", "invalid_key_length").
WithMetadata(map[string]string{
"key": "apiV3Key",
"expected": strconv.Itoa(wxpayAPIv3KeyLength),
"actual": strconv.Itoa(len(config["apiV3Key"])),
})
}
// Parse PEMs eagerly so malformed keys surface at save time, not at order creation.
if _, err := utils.LoadPrivateKey(formatPEM(config["privateKey"], "PRIVATE KEY")); err != nil {
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
WithMetadata(map[string]string{"key": "privateKey"})
}
if _, err := utils.LoadPublicKey(formatPEM(config["publicKey"], "PUBLIC KEY")); err != nil {
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
WithMetadata(map[string]string{"key": "publicKey"})
}
return &Wxpay{instanceID: instanceID, config: config}, nil
}
@@ -127,14 +148,19 @@ func (w *Wxpay) ensureClient() (*core.Client, error) {
if w.coreClient != nil {
return w.coreClient, nil
}
privateKey, publicKey, err := w.loadKeyPair()
privateKey, err := utils.LoadPrivateKey(formatPEM(w.config["privateKey"], "PRIVATE KEY"))
if err != nil {
return nil, err
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
WithMetadata(map[string]string{"key": "privateKey"})
}
publicKey, err := utils.LoadPublicKey(formatPEM(w.config["publicKey"], "PUBLIC KEY"))
if err != nil {
return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key").
WithMetadata(map[string]string{"key": "publicKey"})
}
certSerial := w.config["certSerial"]
verifier := verifiers.NewSHA256WithRSAPubkeyVerifier(w.config["publicKeyId"], *publicKey)
client, err := core.NewClient(context.Background(),
option.WithMerchantCredential(w.config["mchId"], certSerial, privateKey),
option.WithMerchantCredential(w.config["mchId"], w.config["certSerial"], privateKey),
option.WithVerifier(verifier))
if err != nil {
return nil, fmt.Errorf("wxpay init client: %w", err)
@@ -148,18 +174,6 @@ func (w *Wxpay) ensureClient() (*core.Client, error) {
return w.coreClient, nil
}
func (w *Wxpay) loadKeyPair() (*rsa.PrivateKey, *rsa.PublicKey, error) {
privateKey, err := utils.LoadPrivateKey(formatPEM(w.config["privateKey"], "PRIVATE KEY"))
if err != nil {
return nil, nil, fmt.Errorf("wxpay load private key: %w", err)
}
publicKey, err := utils.LoadPublicKey(formatPEM(w.config["publicKey"], "PUBLIC KEY"))
if err != nil {
return nil, nil, fmt.Errorf("wxpay load public key: %w", err)
}
return privateKey, publicKey, nil
}
func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
client, err := w.ensureClient()
if err != nil {

View File

@@ -4,6 +4,10 @@ package provider
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"net/url"
"strings"
"testing"
@@ -16,6 +20,26 @@ import (
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
)
// generateTestKeyPair returns a fresh RSA 2048 key pair as PEM strings.
// The wechatpay-go SDK expects PKCS8 private keys and PKIX public keys.
func generateTestKeyPair(t *testing.T) (privPEM, pubPEM string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate rsa key: %v", err)
}
privDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatalf("marshal pkcs8: %v", err)
}
pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
t.Fatalf("marshal pkix: %v", err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
}
func TestMapWxState(t *testing.T) {
t.Parallel()
@@ -183,13 +207,14 @@ func TestFormatPEM(t *testing.T) {
func TestNewWxpay(t *testing.T) {
t.Parallel()
privPEM, pubPEM := generateTestKeyPair(t)
validConfig := map[string]string{
"appId": "wx1234567890",
"mchId": "1234567890",
"privateKey": "fake-private-key",
"privateKey": privPEM,
"apiV3Key": "12345678901234567890123456789012", // exactly 32 bytes
"publicKey": "fake-public-key",
"publicKeyId": "key-id-001",
"publicKey": pubPEM,
"publicKeyId": "PUB_KEY_ID_TEST",
"certSerial": "SERIAL001",
}
@@ -240,6 +265,12 @@ func TestNewWxpay(t *testing.T) {
wantErr: true,
errSubstr: "apiV3Key",
},
{
name: "missing certSerial",
config: withOverride(map[string]string{"certSerial": ""}),
wantErr: true,
errSubstr: "certSerial",
},
{
name: "missing publicKey",
config: withOverride(map[string]string{"publicKey": ""}),
@@ -252,17 +283,29 @@ func TestNewWxpay(t *testing.T) {
wantErr: true,
errSubstr: "publicKeyId",
},
{
name: "malformed privateKey PEM",
config: withOverride(map[string]string{"privateKey": "not-a-valid-pem"}),
wantErr: true,
errSubstr: "WXPAY_CONFIG_INVALID_KEY",
},
{
name: "malformed publicKey PEM",
config: withOverride(map[string]string{"publicKey": "not-a-valid-pem"}),
wantErr: true,
errSubstr: "WXPAY_CONFIG_INVALID_KEY",
},
{
name: "apiV3Key too short",
config: withOverride(map[string]string{"apiV3Key": "short"}),
wantErr: true,
errSubstr: "exactly 32 bytes",
errSubstr: "WXPAY_CONFIG_INVALID_KEY_LENGTH",
},
{
name: "apiV3Key too long",
config: withOverride(map[string]string{"apiV3Key": "123456789012345678901234567890123"}), // 33 bytes
wantErr: true,
errSubstr: "exactly 32 bytes",
errSubstr: "WXPAY_CONFIG_INVALID_KEY_LENGTH",
},
}