215 lines
6.9 KiB
Go
215 lines
6.9 KiB
Go
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
|
||
}
|