diff --git a/backend/internal/payment/provider/wxpay.go b/backend/internal/payment/provider/wxpay.go index b53e566fa9..4df764526e 100644 --- a/backend/internal/payment/provider/wxpay.go +++ b/backend/internal/payment/provider/wxpay.go @@ -3,7 +3,6 @@ package provider import ( "bytes" "context" - "crypto/rsa" "fmt" "io" "log/slog" @@ -16,9 +15,7 @@ import ( "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" "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" - "github.com/wechatpay-apiv3/wechatpay-go/core/downloader" "github.com/wechatpay-apiv3/wechatpay-go/core/notify" "github.com/wechatpay-apiv3/wechatpay-go/core/option" "github.com/wechatpay-apiv3/wechatpay-go/services/payments" @@ -63,7 +60,10 @@ type Wxpay struct { const wxpayAPIv3KeyLength = 32 func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) { - required := []string{"appId", "mchId", "privateKey", "apiV3Key", "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, infraerrors.BadRequest("WXPAY_CONFIG_MISSING_KEY", "missing_required_key"). @@ -78,13 +78,13 @@ func NewWxpay(instanceID string, config map[string]string) (*Wxpay, error) { "actual": strconv.Itoa(len(config["apiV3Key"])), }) } - // Pubkey verifier mode is opt-in via publicKeyId. If publicKeyId is set, - // publicKey must also be set so the verifier can load it. - // A leftover publicKey without publicKeyId is treated as unused legacy data, - // not an error, so admins can switch back to platform-certificate mode without - // having to manually clear the old publicKey field. - if config["publicKeyId"] != "" && config["publicKey"] == "" { - return nil, infraerrors.BadRequest("WXPAY_CONFIG_MISSING_KEY", "missing_required_key"). + // 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 @@ -112,12 +112,15 @@ func (w *Wxpay) ensureClient() (*core.Client, error) { } privateKey, err := utils.LoadPrivateKey(formatPEM(w.config["privateKey"], "PRIVATE KEY")) if err != nil { - return nil, fmt.Errorf("wxpay load private key: %w", err) + return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key"). + WithMetadata(map[string]string{"key": "privateKey"}) } - verifier, err := w.buildVerifier(privateKey) + publicKey, err := utils.LoadPublicKey(formatPEM(w.config["publicKey"], "PUBLIC KEY")) if err != nil { - return nil, err + return nil, infraerrors.BadRequest("WXPAY_CONFIG_INVALID_KEY", "invalid_key"). + WithMetadata(map[string]string{"key": "publicKey"}) } + verifier := verifiers.NewSHA256WithRSAPubkeyVerifier(w.config["publicKeyId"], *publicKey) client, err := core.NewClient(context.Background(), option.WithMerchantCredential(w.config["mchId"], w.config["certSerial"], privateKey), option.WithVerifier(verifier)) @@ -133,25 +136,6 @@ func (w *Wxpay) ensureClient() (*core.Client, error) { return w.coreClient, nil } -// buildVerifier picks the verifier mode based on whether publicKeyId is configured: -// - publicKeyId set → new pubkey verifier using the configured publicKey -// - publicKeyId empty → legacy mode: auto-download platform certs with apiV3Key -func (w *Wxpay) buildVerifier(privateKey *rsa.PrivateKey) (auth.Verifier, error) { - if w.config["publicKeyId"] != "" { - publicKey, err := utils.LoadPublicKey(formatPEM(w.config["publicKey"], "PUBLIC KEY")) - if err != nil { - return nil, fmt.Errorf("wxpay load public key: %w", err) - } - return verifiers.NewSHA256WithRSAPubkeyVerifier(w.config["publicKeyId"], *publicKey), nil - } - mgr := downloader.MgrInstance() - err := mgr.RegisterDownloaderWithPrivateKey(context.Background(), privateKey, w.config["certSerial"], w.config["mchId"], w.config["apiV3Key"]) - if err != nil { - return nil, fmt.Errorf("wxpay register platform cert downloader: %w", err) - } - return verifiers.NewSHA256WithRSAVerifier(mgr.GetCertificateVisitor(w.config["mchId"])), nil -} - func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) { client, err := w.ensureClient() if err != nil { diff --git a/backend/internal/payment/provider/wxpay_test.go b/backend/internal/payment/provider/wxpay_test.go index 960988b9ef..707fec18c8 100644 --- a/backend/internal/payment/provider/wxpay_test.go +++ b/backend/internal/payment/provider/wxpay_test.go @@ -3,12 +3,36 @@ package provider import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/payment" ) +// 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() @@ -149,13 +173,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", } @@ -213,21 +238,28 @@ func TestNewWxpay(t *testing.T) { errSubstr: "certSerial", }, { - name: "legacy mode: publicKey+publicKeyId both empty", - config: withOverride(map[string]string{"publicKey": "", "publicKeyId": ""}), - wantErr: false, - }, - { - // Leftover publicKey from a former pubkey-mode setup is ignored; treated as legacy mode. - name: "publicKey leftover without publicKeyId is allowed", - config: withOverride(map[string]string{"publicKeyId": ""}), - wantErr: false, - }, - { - name: "publicKeyId without publicKey", + name: "missing publicKey", config: withOverride(map[string]string{"publicKey": ""}), wantErr: true, - errSubstr: "WXPAY_CONFIG_MISSING_KEY", + errSubstr: "publicKey", + }, + { + name: "missing publicKeyId", + config: withOverride(map[string]string{"publicKeyId": ""}), + 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", diff --git a/frontend/src/components/payment/providerConfig.ts b/frontend/src/components/payment/providerConfig.ts index 8b8334a488..f4f5acdccc 100644 --- a/frontend/src/components/payment/providerConfig.ts +++ b/frontend/src/components/payment/providerConfig.ts @@ -100,8 +100,8 @@ export const PROVIDER_CONFIG_FIELDS: Record = { { key: 'privateKey', label: '', sensitive: true }, { key: 'apiV3Key', label: '', sensitive: true }, { key: 'certSerial', label: '', sensitive: false }, - { key: 'publicKey', label: '', sensitive: true, optional: true }, - { key: 'publicKeyId', label: '', sensitive: false, optional: true }, + { key: 'publicKey', label: '', sensitive: true }, + { key: 'publicKeyId', label: '', sensitive: false }, ], stripe: [ { key: 'secretKey', label: '', sensitive: true }, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 1382c4dfb0..b0990dcc8e 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5340,7 +5340,7 @@ export default { PAYMENT_PROVIDER_MISCONFIGURED: 'Payment provider misconfigured. Please contact an administrator.', WXPAY_CONFIG_MISSING_KEY: 'WeChat Pay config missing required key: {key}.', WXPAY_CONFIG_INVALID_KEY_LENGTH: 'WeChat Pay {key} length is invalid (expected {expected} bytes, got {actual}).', - WXPAY_CONFIG_PAIR_VIOLATION: 'WeChat Pay {keys} must be provided together.', + WXPAY_CONFIG_INVALID_KEY: 'WeChat Pay {key} is malformed. Make sure you copied the full PEM content.', PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.', CANCEL_RATE_LIMITED: 'Too many cancellations. Please try again later.', NOT_FOUND: 'Order not found.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 9188b50307..1a9d8893e2 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5536,7 +5536,7 @@ export default { PAYMENT_PROVIDER_MISCONFIGURED: '支付通道配置错误,请联系管理员', WXPAY_CONFIG_MISSING_KEY: '微信支付配置缺少必填项:{key}', WXPAY_CONFIG_INVALID_KEY_LENGTH: '微信支付 {key} 长度错误,应为 {expected} 字节(实际 {actual})', - WXPAY_CONFIG_PAIR_VIOLATION: '微信支付 {keys} 必须同时配置', + WXPAY_CONFIG_INVALID_KEY: '微信支付 {key} 格式错误,请确认复制了完整的 PEM 内容', PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作', CANCEL_RATE_LIMITED: '取消订单过于频繁,请稍后再试', NOT_FOUND: '订单不存在',