签名验证
更新时间:2025.01.02商户可以按照下述步骤验证应答或者回调的签名。
如果验证商户的请求签名正确,微信支付会在应答的HTTP头部中包括应答签名。我们建议商户验证应答签名。
同样的,微信支付会在回调的HTTP头部中包括回调报文的签名。商户必须 验证回调的签名,以确保回调是由微信支付发送。
1. 获取平台证书
微信支付API V3使用微信支付 的平台私钥(不是商户私钥 )进行应答签名。相应的,商户的技术人员应使用微信支付平台证书中的公钥验签。目前平台证书只提供API进行下载,请参考 获取平台证书列表。
|
2. 检查平台证书序列号
微信支付的平台证书序列号位于HTTP头Wechatpay-Serial
。验证签名前,请商户先检查序列号是否跟商户当前所持有的 微信支付平台证书的序列号一致。如果不一致,请重新获取证书。否则,签名的私钥和证书不匹配,将无法成功验证签名。
3. 构造验签名串
首先,商户先从应答中获取以下信息。
HTTP头
Wechatpay-Timestamp
中的应答时间戳。
HTTP头
Wechatpay-Nonce
中的应答随机串。
应答主体(response Body),需要按照接口返回的顺序进行验签,错误的顺序将导致验签失败。
然后,请按照以下规则构造应答的验签名串。签名串共有三行,行尾以\n
结束,包括最后一行。\n
为换行符(ASCII编码值为0x0A)。若应答报文主体为空(如HTTP状态码为204 No Content
),最后一行仅为一个\n
换行符。
1应答时间戳\n 2应答随机串\n 3应答报文主体\n 4
如某个应答的HTTP报文为(省略了ciphertext的具体内容):
1HTTP/1.1 200 OK 2Server: nginx 3Date: Tue, 02 Apr 2019 12:59:40 GMT 4Content-Type: application/json; charset=utf-8 5Content-Length: 2204 6Connection: keep-alive 7Keep-Alive: timeout=8 8Content-Language: zh-CN 9Request-ID: e2762b10-b6b9-5108-a42c-16fe2422fc8a 10Wechatpay-Nonce: c5ac7061fccab6bf3e254dcf98995b8c 11Wechatpay-Signature: CtcbzwtQjN8rnOXItEBJ5aQFSnIXESeV28Pr2YEmf9wsDQ8Nx25ytW6FXBCAFdrr0mgqngX3AD9gNzjnNHzSGTPBSsaEkIfhPF4b8YRRTpny88tNLyprXA0GU5ID3DkZHpjFkX1hAp/D0fva2GKjGRLtvYbtUk/OLYqFuzbjt3yOBzJSKQqJsvbXILffgAmX4pKql+Ln+6UPvSCeKwznvtPaEx+9nMBmKu7Wpbqm/+2ksc0XwjD+xlvlECkCxfD/OJ4gN3IurE0fpjxIkvHDiinQmk51BI7zQD8k1znU7r/spPqB+vZjc5ep6DC5wZUpFu5vJ8MoNKjCu8wnzyCFdA== 12Wechatpay-Timestamp: 1554209980 13Wechatpay-Serial: 5157F09EFDC096DE15EBE81A47057A7232F1B8E1 14Cache-Control: no-cache, must-revalidate 15 16{"data":[{"serial_no":"5157F09EFDC096DE15EBE81A47057A7232F1B8E1","effective_time":"2018-03-26T11:39:50+08:00","expire_time":"2023-03-25T11:39:50+08:00","encrypt_certificate":{"algorithm":"AEAD_AES_256_GCM","nonce":"4de73afd28b6","associated_data":"certificate","ciphertext":"..."}}]} 17
则验签名串为
11554209980 2c5ac7061fccab6bf3e254dcf98995b8c 3{"data":[{"serial_no":"5157F09EFDC096DE15EBE81A47057A7232F1B8E1","effective_time":"2018-03-26T11:39:50+08:00","expire_time":"2023-03-25T11:39:50+08:00","encrypt_certificate":{"algorithm":"AEAD_AES_256_GCM","nonce":"4de73afd28b6","associated_data":"certificate","ciphertext":"..."}}]} 4
4. 获取应答签名
微信支付的应答签名通过HTTP头Wechatpay-Signature
传递。(注意,示例因为排版可能存在换行,实际数据应在一行)
1Wechatpay-Signature: CtcbzwtQjN8rnOXItEBJ5aQFSnIXESeV28Pr2YEmf9wsDQ8Nx25ytW6FXBCAFdrr0mgqngX3AD9gNzjnNHzSGTPBSsaEkIfhPF4b8YRRTpny88tNLyprXA0GU5ID3DkZHpjFkX1hAp/D0fva2GKjGRLtvYbtUk/OLYqFuzbjt3yOBzJSKQqJsvbXILffgAmX4pKql+Ln+6UPvSCeKwznvtPaEx+9nMBmKu7Wpbqm/+2ksc0XwjD+xlvlECkCxfD/OJ4gN3IurE0fpjxIkvHDiinQmk51BI7zQD8k1znU7r/spPqB+vZjc5ep6DC5wZUpFu5vJ8MoNKjCu8wnzyCFdA== 2
具体组成为:
对 Wechatpay-Signature
的字段值使用Base64进行解码,得到应答签名。
|
5. 验证签名
很多编程语言的签名验证函数支持对验签名串和签名 进行签名验证。强烈建议商户调用该类函数,使用微信支付平台公钥对验签名串和签名进行SHA256 with RSA签名验证。
下面展示使用命令行演示如何进行验签。假设我们已经获取了平台证书并保存为1900009191_wxp_cert.pem
。
首先,从微信支付平台证书导出微信支付平台公钥
1$ openssl x509 -in 1900009191_wxp_cert.pem -pubkey -noout > 1900009191_wxp_pub.pem 2$ cat 1900009191_wxp_pub.pem 3-----BEGIN PUBLIC KEY----- 4MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4zej1cqugGQtVSY2Ah8R 5MCKcr2UpZ8Npo+5Ja9xpFPYkWHaF1Gjrn3d5kcwAFuHHcfdc3yxDYx6+9grvJnCA 62zQzWjzVRa3BJ5LTMj6yqvhEmtvjO9D1xbFTA2m3kyjxlaIar/RYHZSslT4VmjIa 7tW9KJCDKkwpM6x/RIWL8wwfFwgz2q3Zcrff1y72nB8p8P12ndH7GSLoY6d2Tv0OB 82+We2Kyy2+QzfGXOmLp7UK/pFQjJjzhSf9jxaWJXYKIBxpGlddbRZj9PqvFPTiep 98rvfKGNZF9Q6QaMYTpTp/uKQ3YvpDlyeQlYe4rRFauH3mOE6j56QlYQWivknDX9V 10rwIDAQAB 11-----END PUBLIC KEY----- 12
|
然后,把签名base64解码后保存为文件signature.txt
1$ openssl base64 -d -A <<< \ 'CtcbzwtQjN8rnOXItEBJ5aQFSnIXESeV28Pr2YEmf9wsDQ8Nx25ytW6FXBCAFdrr0mgqngX3AD9gNzjnNHzSGTPBSsaEkIfhPF4b8YRRTpny88tNLyprXA0GU5ID3DkZHpjFkX1hAp/D0fva2GKjGRLtvYbtUk/OLYqFuzbjt3yOBzJSKQqJsvbXILffgAmX4pKql+Ln+6UPvSCeKwznvtPaEx+9nMBmKu7Wpbqm/+2ksc0XwjD+xlvlECkCxfD/OJ4gN3IurE0fpjxIkvHDiinQmk51BI7zQD8k1znU7r/spPqB+vZjc5ep6DC5wZUpFu5vJ8MoNKjCu8wnzyCFdA==' > signature.txt 2
最后,验证签名
1$ openssl dgst -sha256 -verify 1900009191_wxp_pub.pem -signature signature.txt << EOF 21554209980 3c5ac7061fccab6bf3e254dcf98995b8c 4{"data":[{"serial_no":"5157F09EFDC096DE15EBE81A47057A7232F1B8E1","effective_time":"2018-03-26T11:39:50+08:00","expire_time":"2023-03-25T11:39:50+08:00","encrypt_certificate":{"algorithm":"AEAD_AES_256_GCM","nonce":"d215b0511e9c","associated_data":"certificate","ciphertext":"..."}}]} 5EOF 6Verified OK 7
JAVA
1package com.wechat.v3; 2 3import org.apache.http.Header; 4import org.apache.http.HttpEntity; 5import org.apache.http.client.methods.CloseableHttpResponse; 6import org.apache.http.util.EntityUtils; 7import org.junit.Test; 8 9import javax.security.auth.login.CredentialException; 10import java.io.ByteArrayInputStream; 11import java.io.IOException; 12import java.io.InputStream; 13import java.nio.charset.StandardCharsets; 14import java.security.InvalidKeyException; 15import java.security.NoSuchAlgorithmException; 16import java.security.Signature; 17import java.security.SignatureException; 18import java.security.cert.*; 19import java.time.DateTimeException; 20import java.time.Duration; 21import java.time.Instant; 22import java.util.Base64; 23 24import static com.wechat.pay.contrib.apache.httpclient.constant.WechatPayHttpHeaders.*; 25 26public class Verifier { 27 28 private static int RESPONSE_EXPIRED_MINUTES = 5; 29 private static final String certificate = "-----BEGIN CERTIFICATE-----" + 30 "-----END CERTIFICATE-----"; 31 32 protected static IllegalArgumentException parameterError(String message, Object... args) { 33 message = String.format(message, args); 34 return new IllegalArgumentException("parameter error: " + message); 35 } 36 37 // 38 public final boolean validate(CloseableHttpResponse response) throws IOException { 39 validateParameters(response); 40 String message = buildMessage(response); 41 String serial = response.getFirstHeader(WECHAT_PAY_SERIAL).getValue(); //Should be used to find which cert should be used to verify 42 String signature = response.getFirstHeader(WECHAT_PAY_SIGNATURE).getValue(); 43 return verify(loadCertificate(certificate), message.getBytes(StandardCharsets.UTF_8), signature); 44 } 45 46 public X509Certificate loadCertificate(String certificate) { 47 InputStream certStream = new ByteArrayInputStream(certificate.getBytes()); 48 X509Certificate cert = null; 49 try{ 50 CertificateFactory cf = CertificateFactory.getInstance("X509"); 51 cert = (X509Certificate) cf.generateCertificate(certStream); 52 cert.checkValidity(); 53 } catch (CertificateException e) { 54 throw new RuntimeException("Fail to load and vailid the certificate", e); 55 } 56 return cert; 57 } 58 59 protected boolean verify(X509Certificate certificate, byte[] message, String signature) { 60 try { 61 Signature sign = Signature.getInstance("SHA256withRSA"); 62 sign.initVerify(certificate); 63 sign.update(message); 64 return sign.verify(Base64.getDecoder().decode(signature)); 65 66 } catch (NoSuchAlgorithmException e) { 67 throw new RuntimeException("当前Java环境不支持SHA256withRSA", e); 68 } catch (SignatureException e) { 69 throw new RuntimeException("签名验证过程发生了错误", e); 70 } catch (InvalidKeyException e) { 71 throw new RuntimeException("无效的证书", e); 72 } 73 } 74 75 protected final void validateParameters(CloseableHttpResponse response) { 76 Header firstHeader = response.getFirstHeader(REQUEST_ID); 77 if (firstHeader == null) { 78 throw parameterError("empty " + REQUEST_ID); 79 } 80 String requestId = firstHeader.getValue(); 81 82 // NOTE: ensure HEADER_WECHAT_PAY_TIMESTAMP at last 83 String[] headers = {WECHAT_PAY_SERIAL, WECHAT_PAY_SIGNATURE, WECHAT_PAY_NONCE, WECHAT_PAY_TIMESTAMP}; 84 85 Header header = null; 86 for (String headerName : headers) { 87 header = response.getFirstHeader(headerName); 88 if (header == null) { 89 throw parameterError("empty [%s], request-id=[%s]", headerName, requestId); 90 } 91 } 92 93 String timestampStr = header.getValue(); 94 try { 95 Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestampStr)); 96 // 拒绝过期应答 97 if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= RESPONSE_EXPIRED_MINUTES) { 98 throw parameterError("timestamp=[%s] expires, request-id=[%s]", timestampStr, requestId); 99 } 100 } catch (DateTimeException | NumberFormatException e) { 101 throw parameterError("invalid timestamp=[%s], request-id=[%s]", timestampStr, requestId); 102 } 103 } 104 105 protected final String buildMessage(CloseableHttpResponse response) throws IOException { 106 String timestamp = response.getFirstHeader(WECHAT_PAY_TIMESTAMP).getValue(); 107 String nonce = response.getFirstHeader(WECHAT_PAY_NONCE).getValue(); 108 String body = getResponseBody(response); 109 return timestamp + "\n" 110 + nonce + "\n" 111 + body + "\n"; 112 } 113 114 protected final String getResponseBody(CloseableHttpResponse response) throws IOException { 115 HttpEntity entity = response.getEntity(); 116 return (entity != null && entity.isRepeatable()) ? EntityUtils.toString(entity) : ""; 117 } 118}
PHP
1<?php 2require_once('vendor/autoload.php'); 3 4use WeChatPay\Crypto\Rsa; 5use WeChatPay\Crypto\AesGcm; 6use WeChatPay\Formatter; 7 8$inWechatpaySignature = '';// Get this value from the header in response 9$inWechatpayTimestamp = '';// Get this value from the header in response 10$inWechatpaySerial = '';// Get this value from the header in response 11$inWechatpayNonce = '';// Get this value from the header in response 12$inBody = '';// Get this value from the body in response 13$apiv3Key = ''; 14$platformPublicKeyInstance = Rsa::from('file:///path/to/wechatpay/inWechatpaySerial.pem', Rsa::KEY_TYPE_PUBLIC); 15 16$timeOffsetStatus = 300 >= abs(Formatter::timestamp() - (int)$inWechatpayTimestamp); 17$verifiedStatus = Rsa::verify( 18 Formatter::joinedByLineFeed($inWechatpayTimestamp, $inWechatpayNonce, $inBody), 19 $inWechatpaySignature, 20 $platformPublicKeyInstance 21);
GO
1package validators 2 3import ( 4 "bytes" 5 "context" 6 "crypto" 7 "crypto/rsa" 8 "crypto/sha256" 9 "crypto/x509" 10 "encoding/base64" 11 "fmt" 12 "github.com/wechatpay-apiv3/wechatpay-go/core" 13 "github.com/wechatpay-apiv3/wechatpay-go/core/consts" 14 "io/ioutil" 15 "math" 16 "net/http" 17 "strconv" 18 "strings" 19 "time" 20) 21 22type SHA256WithRSAVerifier struct { 23 certGetter core.CertificateGetter 24} 25 26type wechatPayHeader struct { 27 RequestID string 28 Serial string 29 Signature string 30 Nonce string 31 Timestamp int64 32} 33 34var verifyCert *x509.Certificate // the platform certificate need to download from Certificate API and store locally. 35 36func ValidateR(ctx context.Context, response *http.Response) error { 37 body, err := ioutil.ReadAll(response.Body) 38 if err != nil { 39 return fmt.Errorf("read response body err:[%s]", err.Error()) 40 } 41 response.Body = ioutil.NopCloser(bytes.NewBuffer(body)) 42 43 return validateHTTPMessage(ctx, response.Header, body) 44} 45 46func validateHTTPMessage(ctx context.Context, header http.Header, body []byte) error { 47 48 headerArgs, err := getWechatPayHeader(ctx, header) 49 if err != nil { 50 return err 51 } 52 53 if err := checkWechatPayHeader(ctx, headerArgs); err != nil { 54 return err 55 } 56 57 message := buildMessage(ctx, headerArgs, body) 58 59 if err := verify(ctx, headerArgs.Serial, message, headerArgs.Signature); err != nil { 60 return fmt.Errorf( 61 "validate verify fail serial=[%s] request-id=[%s] err=%w", 62 headerArgs.Serial, headerArgs.RequestID, err, 63 ) 64 } 65 return nil 66} 67 68func verify(ctx context.Context, serialNumber, message, signature string) error { 69 err := checkParameter(ctx, serialNumber, message, signature) 70 if err != nil { 71 return err 72 } 73 sigBytes, err := base64.StdEncoding.DecodeString(signature) 74 if err != nil { 75 return fmt.Errorf("verify failed: signature not base64 encoded") 76 } 77 hashed := sha256.Sum256([]byte(message)) 78 err = rsa.VerifyPKCS1v15(verifyCert.PublicKey.(*rsa.PublicKey), crypto.SHA256, hashed[:], sigBytes) 79 if err != nil { 80 return fmt.Errorf("verifty signature with public key err:%s", err.Error()) 81 } 82 return nil 83} 84 85func getWechatPayHeader(ctx context.Context, header http.Header) (wechatPayHeader, error) { 86 _ = ctx // Suppressing warnings 87 88 requestID := strings.TrimSpace(header.Get(consts.RequestID)) 89 90 getHeaderString := func(key string) (string, error) { 91 val := strings.TrimSpace(header.Get(key)) 92 if val == "" { 93 return "", fmt.Errorf("key `%s` is empty in header, request-id=[%s]", key, requestID) 94 } 95 return val, nil 96 } 97 98 getHeaderInt64 := func(key string) (int64, error) { 99 val, err := getHeaderString(key) 100 if err != nil { 101 return 0, nil 102 } 103 ret, err := strconv.ParseInt(val, 10, 64) 104 if err != nil { 105 return 0, fmt.Errorf("invalid `%s` in header, request-id=[%s], err:%w", key, requestID, err) 106 } 107 return ret, nil 108 } 109 110 ret := wechatPayHeader{ 111 RequestID: requestID, 112 } 113 var err error 114 115 if ret.Serial, err = getHeaderString(consts.WechatPaySerial); err != nil { 116 return ret, err 117 } 118 119 if ret.Signature, err = getHeaderString(consts.WechatPaySignature); err != nil { 120 return ret, err 121 } 122 123 if ret.Timestamp, err = getHeaderInt64(consts.WechatPayTimestamp); err != nil { 124 return ret, err 125 } 126 127 if ret.Nonce, err = getHeaderString(consts.WechatPayNonce); err != nil { 128 return ret, err 129 } 130 131 return ret, nil 132} 133 134func checkWechatPayHeader(ctx context.Context, args wechatPayHeader) error { 135 // Suppressing warnings 136 _ = ctx 137 138 if math.Abs(float64(time.Now().Unix()-args.Timestamp)) >= consts.FiveMinute { 139 return fmt.Errorf("timestamp=[%d] expires, request-id=[%s]", args.Timestamp, args.RequestID) 140 } 141 return nil 142} 143 144func buildMessage(ctx context.Context, headerArgs wechatPayHeader, body []byte) string { 145 // Suppressing warnings 146 _ = ctx 147 148 return fmt.Sprintf("%d\n%s\n%s\n", headerArgs.Timestamp, headerArgs.Nonce, string(body)) 149} 150 151func checkParameter(ctx context.Context, serialNumber, message, signature string) error { 152 if ctx == nil { 153 return fmt.Errorf("context is nil, verifier need input context.Context") 154 } 155 if strings.TrimSpace(serialNumber) == "" { 156 return fmt.Errorf("serialNumber is empty, verifier need input serialNumber") 157 } 158 if strings.TrimSpace(message) == "" { 159 return fmt.Errorf("message is empty, verifier need input message") 160 } 161 if strings.TrimSpace(signature) == "" { 162 return fmt.Errorf("signature is empty, verifier need input signature") 163 } 164 return nil 165}