Files
2026-09-16 18:06:18 +08:00

215 lines
6.9 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 wechatmp
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"io"
"sort"
"strings"
)
// =============================================================
// 微信被动消息:签名校验 / 安全模式 AES 加解密 / XML 解析与回复构造
// 规范参考:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Message_encryption_and_decryption_instructions.html
// =============================================================
// pkcs7BlockSize 微信官方填充块大小(注意不是 16,是 32)
const pkcs7BlockSize = 32
// InboundMessage 接收到的消息/事件(已解密后的 XML 内容)
type InboundMessage struct {
XMLName xml.Name `xml:"xml"`
ToUserName string `xml:"ToUserName"`
FromUserName string `xml:"FromUserName"`
CreateTime int64 `xml:"CreateTime"`
MsgType string `xml:"MsgType"` // text / image / event ...
Content string `xml:"Content"`
Event string `xml:"Event"` // subscribe / unsubscribe / SCAN / CLICK ...
EventKey string `xml:"EventKey"` // qrscene_<scene> 或 <scene>
Ticket string `xml:"Ticket"`
MsgID int64 `xml:"MsgId"`
Encrypt string `xml:"Encrypt"` // 安全模式下外层 XML 携带的密文
}
// ParseInboundXML 解析(明文/已解密的)消息 XML
func ParseInboundXML(raw string) (*InboundMessage, error) {
var msg InboundMessage
if err := xml.Unmarshal([]byte(raw), &msg); err != nil {
return nil, fmt.Errorf("解析微信消息失败: %w", err)
}
return &msg, nil
}
// CheckSignature 明文模式签名校验:sha1(sort(token, timestamp, nonce))
func CheckSignature(token, timestamp, nonce, signature string) bool {
if token == "" || signature == "" {
return false
}
arr := []string{token, timestamp, nonce}
sort.Strings(arr)
sum := sha1.Sum([]byte(strings.Join(arr, "")))
return hex.EncodeToString(sum[:]) == signature
}
// CheckMsgSignature 安全/兼容模式签名校验:sha1(sort(token, timestamp, nonce, encrypt))
func CheckMsgSignature(token, timestamp, nonce, encrypt, msgSignature string) bool {
if token == "" || msgSignature == "" {
return false
}
arr := []string{token, timestamp, nonce, encrypt}
sort.Strings(arr)
sum := sha1.Sum([]byte(strings.Join(arr, "")))
return hex.EncodeToString(sum[:]) == msgSignature
}
// MsgSignature 计算安全模式消息签名(回复加密时使用)
func MsgSignature(token, timestamp, nonce, encrypt string) string {
arr := []string{token, timestamp, nonce, encrypt}
sort.Strings(arr)
sum := sha1.Sum([]byte(strings.Join(arr, "")))
return hex.EncodeToString(sum[:])
}
// ============================ AES 加解密 ============================
func decodeAESKey(encodingAESKey string) ([]byte, error) {
key := strings.TrimSpace(encodingAESKey)
if len(key) != 43 {
return nil, fmt.Errorf("EncodingAESKey 长度应为 43 位(当前 %d)", len(key))
}
return base64.StdEncoding.DecodeString(key + "=")
}
// DecryptMessage 安全模式消息解密,返回明文 XML;appID 非空时会校验一致性
func DecryptMessage(aesKey, appID, encryptBase64 string) (string, error) {
key, err := decodeAESKey(aesKey)
if err != nil {
return "", err
}
raw, err := base64.StdEncoding.DecodeString(encryptBase64)
if err != nil {
return "", errors.New("密文不是有效的 base64")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
if len(raw) < aes.BlockSize || len(raw)%aes.BlockSize != 0 {
return "", errors.New("密文长度不合法")
}
iv := key[:aes.BlockSize]
plain := make([]byte, len(raw))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, raw)
plain, err = pkcs7Unpad(plain)
if err != nil {
return "", err
}
if len(plain) < 20 {
return "", errors.New("解密内容长度不合法")
}
msgLen := int(binary.BigEndian.Uint32(plain[16:20]))
if msgLen < 0 || 20+msgLen > len(plain) {
return "", errors.New("解密内容长度不合法")
}
msg := string(plain[20 : 20+msgLen])
gotAppID := strings.TrimSpace(string(plain[20+msgLen:]))
if appID != "" && gotAppID != appID {
return "", fmt.Errorf("AppID 校验失败(收到 %s)", gotAppID)
}
return msg, nil
}
// EncryptMessage 安全模式消息加密,返回 base64 密文
func EncryptMessage(aesKey, appID, msg string) (string, error) {
key, err := decodeAESKey(aesKey)
if err != nil {
return "", err
}
random := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, random); err != nil {
return "", err
}
msgBytes := []byte(msg)
lenBytes := make([]byte, 4)
binary.BigEndian.PutUint32(lenBytes, uint32(len(msgBytes)))
buf := make([]byte, 0, 16+4+len(msgBytes)+len(appID))
buf = append(buf, random...)
buf = append(buf, lenBytes...)
buf = append(buf, msgBytes...)
buf = append(buf, []byte(appID)...)
buf = pkcs7Pad(buf)
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
iv := key[:aes.BlockSize]
ciphertext := make([]byte, len(buf))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, buf)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
func pkcs7Pad(data []byte) []byte {
pad := pkcs7BlockSize - len(data)%pkcs7BlockSize
if pad <= 0 {
pad = pkcs7BlockSize
}
out := make([]byte, 0, len(data)+pad)
out = append(out, data...)
for i := 0; i < pad; i++ {
out = append(out, byte(pad))
}
return out
}
func pkcs7Unpad(data []byte) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("空数据")
}
pad := int(data[len(data)-1])
if pad < 1 || pad > pkcs7BlockSize || pad > len(data) {
return nil, errors.New("PKCS7 填充不合法")
}
for i := len(data) - pad; i < len(data); i++ {
if int(data[i]) != pad {
return nil, errors.New("PKCS7 填充不合法")
}
}
return data[:len(data)-pad], nil
}
// ============================ 被动回复构造 ============================
// BuildTextReply 构造文本消息回复 XML(明文模式直接返回)
func BuildTextReply(toUser, fromUser, content string, timestamp int64) string {
esc := func(s string) string {
return strings.NewReplacer(
"]]>", "]] >",
).Replace(s)
}
return fmt.Sprintf(
`<xml><ToUserName><![CDATA[%s]]></ToUserName><FromUserName><![CDATA[%s]]></FromUserName><CreateTime>%d</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[%s]]></Content></xml>`,
esc(toUser), esc(fromUser), timestamp, esc(content))
}
// BuildEncryptedReply 安全模式回复:对明文回复加密并组装外层 XML
func BuildEncryptedReply(cfg *Config, plainReply, timestamp, nonce string) (string, error) {
encrypt, err := EncryptMessage(cfg.AESKey, cfg.AppID, plainReply)
if err != nil {
return "", err
}
sig := MsgSignature(cfg.Token, timestamp, nonce, encrypt)
return fmt.Sprintf(
`<xml><Encrypt><![CDATA[%s]]></Encrypt><MsgSignature><![CDATA[%s]]></MsgSignature><TimeStamp>%s</TimeStamp><Nonce><![CDATA[%s]]></Nonce></xml>`,
encrypt, sig, timestamp, nonce), nil
}