Files
2026-09-14 02:39:00 +08:00

218 lines
7.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}