Skip to content

Commit

Permalink
支持公钥验签 (#226)
Browse files Browse the repository at this point in the history
* feat: 支持使用微信支付公钥验签

* feat: 回调验签同时支持公钥和证书

* Update README.md
  • Loading branch information
xy-peng authored Aug 20, 2024
1 parent f4806f8 commit cd4d263
Show file tree
Hide file tree
Showing 17 changed files with 445 additions and 26 deletions.
72 changes: 60 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 微信支付 API v3 Go SDK

[![GoDoc](http://img.shields.io/badge/godoc-reference-blue.svg)](https://pkg.go.dev/github.com/wechatpay-apiv3/wechatpay-go)
[![huntr](https://cdn.huntr.dev/huntr_security_badge_mono.svg)](https://huntr.dev)
[![licence](https://badgen.net/github/license/wechatpay-apiv3/wechatpay-go)](https://github.com/wechatpay-apiv3/wechatpay-go/blob/main/LICENSE)

[微信支付 APIv3](https://wechatpay-api.gitbook.io/wechatpay-api-v3/) 官方Go语言客户端代码库。
Expand Down Expand Up @@ -28,10 +28,12 @@
go mod init
```

#### 2、无需 clone 仓库中的代码,直接在项目目录中执行:
#### 2、无需 clone 仓库中的代码,直接在项目目录中执行

```shell
go get -u github.com/wechatpay-apiv3/wechatpay-go
```

来添加依赖,完成 `go.mod` 修改与 SDK 下载。

### 发送请求
Expand Down Expand Up @@ -89,8 +91,11 @@ func main() {
#### 名词解释

+ **商户 API 证书**,是用来证实商户身份的。证书中包含商户号、证书序列号、证书有效期等信息,由证书授权机构(Certificate Authority ,简称 CA)签发,以防证书被伪造或篡改。如何获取请见 [商户 API 证书](https://wechatpay-api.gitbook.io/wechatpay-api-v3/ren-zheng/zheng-shu#shang-hu-api-zheng-shu)

+ **商户 API 私钥**。商户申请商户 API 证书时,会生成商户私钥,并保存在本地证书文件夹的文件 apiclient_key.pem 中。

> :warning: 不要把私钥文件暴露在公共场合,如上传到 Github,写在客户端代码等。
+ **微信支付平台证书**。微信支付平台证书是指由微信支付负责申请的,包含微信支付平台标识、公钥信息的证书。商户使用微信支付平台证书中的公钥验证应答签名。获取微信支付平台证书需通过 [获取平台证书列表](https://wechatpay-api.gitbook.io/wechatpay-api-v3/ren-zheng/zheng-shu#ping-tai-zheng-shu) 接口下载。
+ **证书序列号**。每个证书都有一个由 CA 颁发的唯一编号,即证书序列号。扩展阅读 [如何查看证书序列号](https://wechatpay-api.gitbook.io/wechatpay-api-v3/chang-jian-wen-ti/zheng-shu-xiang-guan#ru-he-cha-kan-zheng-shu-xu-lie-hao)
+ **微信支付 APIv3 密钥**,是在回调通知和微信支付平台证书下载接口中,为加强数据安全,对关键信息 `AES-256-GCM` 加密时使用的对称加密密钥。
Expand Down Expand Up @@ -156,7 +161,8 @@ if err == nil {

```

### [图片上传API](https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter2_1_1.shtml) 为例:
### [图片上传API](https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter2_1_1.shtml) 为例

```go
import (
"os"
Expand Down Expand Up @@ -199,10 +205,10 @@ result, err := client.Get(ctx, "https://api.mch.weixin.qq.com/v3/certificates")

以下情况,SDK 发送请求会返回 `error`

- HTTP 网络错误,如应答接收超时或网络连接失败
- 客户端失败,如生成签名失败
- 服务器端返回了**** `2xx` HTTP 状态码
- 应答签名验证失败
+ HTTP 网络错误,如应答接收超时或网络连接失败
+ 客户端失败,如生成签名失败
+ 服务器端返回了**** `2xx` HTTP 状态码
+ 应答签名验证失败

为了方便使用,SDK 将服务器返回的 `4xx``5xx` 错误,转换成了 `APIError`

Expand All @@ -223,6 +229,7 @@ if err != nil {
2. 调用 `handler.ParseNotifyRequest` 验签,并解密报文。

### 初始化

+ 方法一(大多数场景):先手动注册下载器,再获取微信平台证书访问器。

适用场景: 仅需要对回调通知验证签名并解密的场景。例如,基础支付的回调通知。
Expand Down Expand Up @@ -291,7 +298,6 @@ fmt.Println(transaction.TransactionId)

将 SDK 未支持的回调消息体,解析至 `map[string]interface{}`


```go
content := make(map[string]interface{})
notifyReq, err := handler.ParseNotifyRequest(context.Background(), request, &content)
Expand Down Expand Up @@ -453,6 +459,47 @@ func NewCustomClient(ctx context.Context, mchID string) (*core.Client, error) {
}
```

### 使用公钥验证微信支付签名

如果你的商户是全新入驻,且仅可使用微信支付的公钥验证应答和回调的签名,请使用微信支付公钥和公钥 ID 初始化。

```go
var (
wechatpayPublicKeyID string = "00000000000000000000000000000000" // 微信支付公钥ID
)

wechatpayPublicKey, err = utils.LoadPublicKeyWithPath("/path/to/wechatpay/pub_key.pem")
if err != nil {
panic(fmt.Errorf("load wechatpay public key err:%s", err.Error()))
}

// 初始化 Client
opts := []core.ClientOption{
option.WithWechatPayPublicKeyAuthCipher(
mchID,
mchCertificateSerialNumber, mchPrivateKey,
wechatpayPublicKeyID, wechatpayPublicKey),
}
client, err := core.NewClient(ctx, opts...)

// 初始化 notify.Handler
handler := notify.NewNotifyHandler(
mchAPIv3Key,
verifiers.NewSHA256WithRSAPubkeyVerifier(wechatpayPublicKeyID, *wechatPayPublicKey))
```

如果你既有微信支付平台证书,又有公钥。那么,你可以在商户平台自助地从微信支付平台证书切换到公私钥,或者反过来。
在切换期间,回调要同时支持使用平台证书和公钥的验签。

请参考下文,使用微信平台证书访问器和公钥一起初始化 `NotifyHandler`

```go
// 初始化 notify.Handler
handler := notify.NewNotifyHandler(
mchAPIv3Key,
verifiers.NewSHA256WithRSACombinedVerifier(certificateVisitor, wechatpayPublicKeyID, *wechatPayPublicKey))
```

## 常见问题

常见问题请见 [FAQ.md](FAQ.md)
Expand All @@ -461,10 +508,10 @@ func NewCustomClient(ctx context.Context, mchID string) (*core.Client, error) {

微信支付欢迎来自社区的开发者贡献你们的想法和代码。请你在提交 PR 之前,先提一个对应的 issue 说明以下内容:

- 背景(如,遇到的问题)和目的
- **着重**说明你的想法
- 通过代码或者其他方式,简要的说明是如何实现的,或者它会是如何使用
- 是否影响现有的接口
+ 背景(如,遇到的问题)和目的
+ **着重**说明你的想法
+ 通过代码或者其他方式,简要的说明是如何实现的,或者它会是如何使用
+ 是否影响现有的接口

[#35](https://github.com/wechatpay-apiv3/wechatpay-go/issues/35) 是一个很好的参考。

Expand All @@ -485,6 +532,7 @@ go test -gcflags=all=-l ./...
```

## 联系微信支付

如果你发现了 BUG,或者需要的功能还未支持,或者有任何疑问、建议,欢迎通过 [issue](https://github.com/wechatpay-apiv3/wechatpay-go/issues) 反馈。

也欢迎访问微信支付的 [开发者社区](https://developers.weixin.qq.com/community/pay)
Expand Down
3 changes: 2 additions & 1 deletion core/auth/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ import (

// Validator 应答报文验证器
type Validator interface {
Validate(ctx context.Context, response *http.Response) error // 对 HTTP 应答报文进行验证
Validate(ctx context.Context, response *http.Response) error // 对 HTTP 应答报文进行验证
GetAcceptSerial(ctx context.Context) (serial string, err error) // 客户端可以处理的证书或者公钥序列号
}
7 changes: 6 additions & 1 deletion core/auth/validators/null_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package validators

import (
"context"
"fmt"
"net/http"
)

Expand All @@ -14,6 +15,10 @@ type NullValidator struct {
}

// Validate 跳过报文签名验证
func (validator *NullValidator) Validate(context.Context, *http.Response) error {
func (v *NullValidator) Validate(context.Context, *http.Response) error {
return nil
}

func (v *NullValidator) GetAcceptSerial(ctx context.Context) (serial string, err error) {
return "", fmt.Errorf("NullValidator has no serial")
}
4 changes: 4 additions & 0 deletions core/auth/validators/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import (
type mockVerifier struct {
}

func (v *mockVerifier) GetSerial(ctx context.Context) (serial string, err error) {
return "SERIAL1234567890", nil
}

func (v *mockVerifier) Verify(ctx context.Context, serialNumber string, message string, signature string) error {
if "["+serialNumber+"-"+message+"]" == signature {
return nil
Expand Down
5 changes: 5 additions & 0 deletions core/auth/validators/wechat_pay_response_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ func (v *WechatPayResponseValidator) Validate(ctx context.Context, response *htt
return v.validateHTTPMessage(ctx, response.Header, body)
}

// GetAcceptSerial 客户端可以处理的证书或者公钥序列号
func (v *WechatPayResponseValidator) GetAcceptSerial(ctx context.Context) (string, error) {
return v.getAcceptSerial(ctx)
}

// NewWechatPayResponseValidator 使用 auth.Verifier 初始化一个 WechatPayResponseValidator
func NewWechatPayResponseValidator(verifier auth.Verifier) *WechatPayResponseValidator {
return &WechatPayResponseValidator{
Expand Down
6 changes: 5 additions & 1 deletion core/auth/validators/wechat_pay_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ func (v *wechatPayValidator) validateHTTPMessage(ctx context.Context, header htt
return nil
}

func (v *wechatPayValidator) getAcceptSerial(ctx context.Context) (string, error) {
return v.verifier.GetSerial(ctx)
}

// getWechatPayHeader 从 http.Header 中获取 wechatPayHeader 信息
func getWechatPayHeader(ctx context.Context, header http.Header) (wechatPayHeader, error) {
_ = ctx // Suppressing warnings
Expand Down Expand Up @@ -105,7 +109,7 @@ func getWechatPayHeader(ctx context.Context, header http.Header) (wechatPayHeade
// checkWechatPayHeader 对 wechatPayHeader 内容进行检查,看是否符合要求
//
// 检查项:
// - Timestamp 与当前时间之差不得超过 FiveMinute;
// - Timestamp 与当前时间之差不得超过 FiveMinute;
func checkWechatPayHeader(ctx context.Context, args wechatPayHeader) error {
// Suppressing warnings
_ = ctx
Expand Down
1 change: 1 addition & 0 deletions core/auth/verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ import "context"
// Verifier 数字签名验证器
type Verifier interface {
Verify(ctx context.Context, serial, message, signature string) error // 对签名信息进行验证
GetSerial(ctx context.Context) (serial string, err error) // 获取可验签的平台证书或公钥序列号
}
37 changes: 37 additions & 0 deletions core/auth/verifiers/sha256withrsa_combined_verifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package verifiers

import (
"context"
"crypto/rsa"
"github.com/wechatpay-apiv3/wechatpay-go/core"
)

// SHA256WithRSACombinedVerifier 数字签名验证器,组合了公钥和平台证书
type SHA256WithRSACombinedVerifier struct {
publicKeyVerifier SHA256WithRSAPubkeyVerifier
certVerifier SHA256WithRSAVerifier
}

// Verify 验证签名,如果序列号和公钥一致则使用公钥验签,否则使用平台证书验签
func (v *SHA256WithRSACombinedVerifier) Verify(ctx context.Context, serialNumber, message, signature string) error {
if serialNumber == v.publicKeyVerifier.keyID {
return v.publicKeyVerifier.Verify(ctx, serialNumber, message, signature)
}
return v.certVerifier.Verify(ctx, serialNumber, message, signature)
}

// GetSerial 获取可验签的公钥序列号。该验签器只用在回调,所以获取序列号时返回错误
func (v *SHA256WithRSACombinedVerifier) GetSerial(ctx context.Context) (string, error) {
return v.publicKeyVerifier.keyID, nil
}

// NewSHA256WithRSACombinedVerifier 用公钥和平台证书初始化验证器
func NewSHA256WithRSACombinedVerifier(
getter core.CertificateGetter,
keyID string,
publicKey rsa.PublicKey) *SHA256WithRSACombinedVerifier {
return &SHA256WithRSACombinedVerifier{
*NewSHA256WithRSAPubkeyVerifier(keyID, publicKey),
*NewSHA256WithRSAVerifier(getter),
}
}
50 changes: 50 additions & 0 deletions core/auth/verifiers/sha256withrsa_pubkey_verifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright 2024 Tencent Inc. All rights reserved.

// Package verifiers 微信支付 API v3 Go SDK 数字签名验证器
package verifiers

import (
"context"
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"fmt"
)

// SHA256WithRSAPubkeyVerifier 数字签名验证器,使用微信支付提供的公钥验证签名
type SHA256WithRSAPubkeyVerifier struct {
keyID string
publicKey rsa.PublicKey
}

// Verify 使用微信支付提供的公钥验证签名
func (v *SHA256WithRSAPubkeyVerifier) Verify(ctx context.Context, serialNumber, message, signature string) error {
if ctx == nil {
return fmt.Errorf("verify failed: context is nil")
}
if v.keyID != serialNumber {
return fmt.Errorf("verify failed: key-id[%s] does not match serial number[%s]", v.keyID, serialNumber)
}

sigBytes, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
return fmt.Errorf("verify failed: signature is not base64 encoded")
}
hashed := sha256.Sum256([]byte(message))
err = rsa.VerifyPKCS1v15(&v.publicKey, crypto.SHA256, hashed[:], sigBytes)
if err != nil {
return fmt.Errorf("verify signature with public key error:%s", err.Error())
}
return nil
}

// GetSerial 获取可验签的公钥序列号
func (v *SHA256WithRSAPubkeyVerifier) GetSerial(ctx context.Context) (string, error) {
return v.keyID, nil
}

// NewSHA256WithRSAPubkeyVerifier 使用 rsa.PublicKey 初始化验签器
func NewSHA256WithRSAPubkeyVerifier(keyID string, publicKey rsa.PublicKey) *SHA256WithRSAPubkeyVerifier {
return &SHA256WithRSAPubkeyVerifier{keyID: keyID, publicKey: publicKey}
}
Loading

0 comments on commit cd4d263

Please sign in to comment.