218 lines
7.7 KiB
Go
218 lines
7.7 KiB
Go
package wechat
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/aes"
|
||
"crypto/cipher"
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"encoding/binary"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"time"
|
||
)
|
||
|
||
// 微信消息推送「安全模式」加解密,与公众号/服务号的消息加解密协议一致:
|
||
//
|
||
// AESKey = base64.StdEncoding.Decode(EncodingAESKey + "=") // 32 字节
|
||
// IV = AESKey[:16](CBC 模式)
|
||
// 填充 = PKCS#7(块大小 32)
|
||
// 明文结构 = random(16B) + msgLen(4B, 大端) + msg + appid
|
||
// 接收签名 = sha1(sort(token, timestamp, nonce, Encrypt)) -> URL 参数 msg_signature
|
||
// 回包签名 = sha1(sort(token, TimeStamp, Nonce, Encrypt)) -> 回包字段 MsgSignature
|
||
//
|
||
// 安全模式下微信推送的包体只含密文(JSON 信封 {"Encrypt":"..."}),开发者的回包也必须为密文;
|
||
// 未配置 EncodingAESKey 时按明文模式处理(包体即业务明文,回包为明文应答)。
|
||
const (
|
||
// wxPKCS7BlockSize 微信 PKCS#7 填充块大小(固定 32,与 AES 的 16 字节分组兼容)。
|
||
wxPKCS7BlockSize = 32
|
||
// wxEncodingAESKeyLen EncodingAESKey 固定长度(43 位,base64 补 "=" 后解出 32 字节)。
|
||
wxEncodingAESKeyLen = 43
|
||
)
|
||
|
||
// IsEncryptedNotify 判断本次推送是否为安全模式(微信会带 msg_signature 参数)。
|
||
func IsEncryptedNotify(msgSignature string) bool {
|
||
return msgSignature != ""
|
||
}
|
||
|
||
// HasMsgEncryptKey 是否已配置 EncodingAESKey(安全模式可用)。
|
||
func HasMsgEncryptKey() bool {
|
||
c, err := getVirtualPayClient()
|
||
return err == nil && len(c.aesKey) == 32
|
||
}
|
||
|
||
// DecodeEchoStr 安全模式 URL 配置验证:校验 msg_signature 并解密 echostr,返回明文供原样回显。
|
||
// echostr 仅用于验证地址归属,不做 appid 校验(避免因 appid 形态差异导致后台无法保存配置)。
|
||
func DecodeEchoStr(echostr, timestamp, nonce, msgSignature string) (string, error) {
|
||
c, err := getVirtualPayClient()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if !c.checkMsgSignature(timestamp, nonce, echostr, msgSignature) {
|
||
return "", errors.New("xpay verify msg_signature mismatch")
|
||
}
|
||
msg, _, derr := c.decrypt(echostr)
|
||
if derr != nil {
|
||
return "", fmt.Errorf("decrypt echostr: %w", derr)
|
||
}
|
||
return msg, nil
|
||
}
|
||
|
||
// DecodeEncryptedNotify 安全模式接收:校验 msg_signature 并解密出明文业务报文。
|
||
// 任一环节失败都返回错误,调用方应回失败应答让微信重试(fail-closed)。
|
||
func DecodeEncryptedNotify(raw []byte, timestamp, nonce, msgSignature string) ([]byte, error) {
|
||
c, err := getVirtualPayClient()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
encrypt, err := extractEncryptField(raw)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !c.checkMsgSignature(timestamp, nonce, encrypt, msgSignature) {
|
||
return nil, errors.New("xpay notify msg_signature mismatch")
|
||
}
|
||
msg, appID, derr := c.decrypt(encrypt)
|
||
if derr != nil {
|
||
return nil, fmt.Errorf("decrypt xpay notify: %w", derr)
|
||
}
|
||
// 防伪造:密文内的 appid 必须是本小程序。
|
||
if appID != c.appID {
|
||
return nil, fmt.Errorf("xpay notify appid mismatch: got %q, want %q", appID, c.appID)
|
||
}
|
||
return []byte(msg), nil
|
||
}
|
||
|
||
// EncodeEncryptedNotify 安全模式回包:把明文业务应答加密为信封
|
||
// {"Encrypt":..,"MsgSignature":..,"TimeStamp":..,"Nonce":..}。
|
||
func EncodeEncryptedNotify(plain []byte, nonce string) ([]byte, error) {
|
||
c, err := getVirtualPayClient()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
encrypt, eerr := c.encrypt(string(plain))
|
||
if eerr != nil {
|
||
return nil, fmt.Errorf("encrypt xpay reply: %w", eerr)
|
||
}
|
||
now := time.Now().Unix()
|
||
return json.Marshal(map[string]any{
|
||
"Encrypt": encrypt,
|
||
"MsgSignature": sortedSHA1Hex(c.token, strconv.FormatInt(now, 10), nonce, encrypt),
|
||
"TimeStamp": now,
|
||
"Nonce": nonce,
|
||
})
|
||
}
|
||
|
||
// extractEncryptField 从安全模式信封中取出 Encrypt 字段(只支持 JSON 信封)。
|
||
func extractEncryptField(raw []byte) (string, error) {
|
||
if trimmed := bytes.TrimLeft(raw, " \t\r\n"); len(trimmed) > 0 && trimmed[0] == '<' {
|
||
return "", errors.New("xpay notify envelope is XML but only JSON is supported; " +
|
||
"please set 消息推送 → 数据格式 = JSON in MP console")
|
||
}
|
||
var env struct {
|
||
Encrypt string `json:"Encrypt"`
|
||
}
|
||
if err := json.Unmarshal(raw, &env); err != nil {
|
||
return "", fmt.Errorf("parse xpay notify envelope: %w", err)
|
||
}
|
||
if env.Encrypt == "" {
|
||
return "", errors.New("xpay notify envelope missing Encrypt")
|
||
}
|
||
return env.Encrypt, nil
|
||
}
|
||
|
||
// checkMsgSignature 校验安全模式接收签名:sha1(sort(token, timestamp, nonce, encrypt))。
|
||
func (c *virtualPayClient) checkMsgSignature(timestamp, nonce, encrypt, msgSignature string) bool {
|
||
if c.token == "" || msgSignature == "" {
|
||
return false
|
||
}
|
||
return sortedSHA1Hex(c.token, timestamp, nonce, encrypt) == msgSignature
|
||
}
|
||
|
||
// decrypt 解密微信密文:AES-256-CBC(IV=AESKey[:16])→ 去 PKCS#7 → 切出 msg 与 appid。
|
||
func (c *virtualPayClient) decrypt(encrypt string) (msg, appID string, err error) {
|
||
if len(c.aesKey) != 32 {
|
||
return "", "", errors.New("virtual pay encoding_aes_key not configured")
|
||
}
|
||
cipherText, err := base64.StdEncoding.DecodeString(encrypt)
|
||
if err != nil {
|
||
return "", "", fmt.Errorf("base64 decode cipher text: %w", err)
|
||
}
|
||
block, err := aes.NewCipher(c.aesKey)
|
||
if err != nil {
|
||
return "", "", fmt.Errorf("new aes cipher: %w", err)
|
||
}
|
||
if len(cipherText) == 0 || len(cipherText)%block.BlockSize() != 0 {
|
||
return "", "", fmt.Errorf("invalid cipher text length %d", len(cipherText))
|
||
}
|
||
|
||
plain := make([]byte, len(cipherText))
|
||
cipher.NewCBCDecrypter(block, c.aesKey[:block.BlockSize()]).CryptBlocks(plain, cipherText)
|
||
plain, err = pkcs7Unpad(plain, wxPKCS7BlockSize)
|
||
if err != nil {
|
||
return "", "", err
|
||
}
|
||
if len(plain) < 20 {
|
||
return "", "", fmt.Errorf("decrypted payload too short: %d bytes", len(plain))
|
||
}
|
||
msgLen := int(binary.BigEndian.Uint32(plain[16:20]))
|
||
if 20+msgLen > len(plain) {
|
||
return "", "", fmt.Errorf("invalid msg length %d in decrypted payload %d", msgLen, len(plain))
|
||
}
|
||
return string(plain[20 : 20+msgLen]), string(plain[20+msgLen:]), nil
|
||
}
|
||
|
||
// encrypt 加密:random(16B) + msgLen(4B, 大端) + msg + appid → PKCS#7 → AES-256-CBC → base64。
|
||
func (c *virtualPayClient) encrypt(msg string) (string, error) {
|
||
if len(c.aesKey) != 32 {
|
||
return "", errors.New("virtual pay encoding_aes_key not configured")
|
||
}
|
||
random := make([]byte, 16)
|
||
if _, err := rand.Read(random); err != nil {
|
||
return "", fmt.Errorf("read random bytes: %w", err)
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
buf.Write(random)
|
||
msgBytes := []byte(msg)
|
||
var lenBuf [4]byte
|
||
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(msgBytes)))
|
||
buf.Write(lenBuf[:])
|
||
buf.Write(msgBytes)
|
||
buf.WriteString(c.appID)
|
||
|
||
plain := pkcs7Pad(buf.Bytes(), wxPKCS7BlockSize)
|
||
block, err := aes.NewCipher(c.aesKey)
|
||
if err != nil {
|
||
return "", fmt.Errorf("new aes cipher: %w", err)
|
||
}
|
||
out := make([]byte, len(plain))
|
||
cipher.NewCBCEncrypter(block, c.aesKey[:block.BlockSize()]).CryptBlocks(out, plain)
|
||
return base64.StdEncoding.EncodeToString(out), nil
|
||
}
|
||
|
||
// pkcs7Pad 按微信约定填充到 blockSize 的整数倍(每个填充字节的值即填充长度)。
|
||
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||
padding := blockSize - len(data)%blockSize
|
||
return append(data, bytes.Repeat([]byte{byte(padding)}, padding)...)
|
||
}
|
||
|
||
// pkcs7Unpad 去除 PKCS#7 填充并校验填充合法性。
|
||
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
|
||
if len(data) == 0 || len(data)%blockSize != 0 {
|
||
return nil, fmt.Errorf("invalid padded data length %d", len(data))
|
||
}
|
||
padding := int(data[len(data)-1])
|
||
if padding == 0 || padding > blockSize {
|
||
return nil, fmt.Errorf("invalid pkcs7 padding value %d", padding)
|
||
}
|
||
for _, b := range data[len(data)-padding:] {
|
||
if int(b) != padding {
|
||
return nil, errors.New("invalid pkcs7 padding bytes")
|
||
}
|
||
}
|
||
return data[:len(data)-padding], nil
|
||
}
|