WeChat custom robot background express message decoding plugin

Recently, in the background of building our own WeChat official account robot, after the user sends a small message to the official account, it will be forwarded to the server set by the WeChat server level. At the beginning, we can choose the plaintext mode, but for safety reasons, we will still turn on Safe mode, in this mode, all messages will be encrypted as a whole, we need to decrypt them at the server level, and the WeChat official doc is not well written, and there is no example code for the nodejs version, so I combined the example code to make a version of express plug-in, record it:

WeChat official account message decryption plugin

Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
const crypto = require('crypto');
const { getConfig } = require('../utils/getConfig');
const { includes } = require('lodash');
const parseString = require('xml2js').parseString;

class PKCS7 {
/**
* Delete replacement
* @param {String} text The decrypted plaintext
*/
decode(text) {
let pad = text[text.length - 1]
if (pad < 1 || pad > 32) {
pad = 0
}
return text.slice(0, text.length - pad)
}
/**
* Fill and fill
* @param {String} text The plaintext that needs to be filled with padding
*/
encode(text) {
const blockSize = 32
const textLength = text.length
//Calculate the number of bits to be filled
const amountToPad = blockSize - (textLength % blockSize)
const result = Buffer.alloc(amountToPad)
result.fill(amountToPad)
return Buffer.concat([text, result])
}
}

/**
* WeChat official account message encryption and decryption
* Official doc (badly written): https://developers.weixin.qq.com/doc/oplatform/Third-party_Platforms/Message_Encryption/Technical_Plan.html
*/
class WXMsgCrypto {
/**
* The following information in the official account - development - basic configuration
* @param {String} token 令牌(Token)
* @param {String} encodingAESKey message encryption and decryption key
* @param {String} appId AppId of official account
*/
constructor(token, encodingAESKey, appId) {
if (!token || !encodingAESKey || !appId) {
throw new Error('please check arguments')
}
this.token = token
this.appId = appId

let AESKey = Buffer.from(encodingAESKey + '=', 'base64')
if (AESKey.length ! 32) {
throw new Error('encodingAESKey invalid')
}
this.key = AESKey
this.iv = AESKey.slice(0, 16)
this.pkcs7 = new PKCS7()
}
/**
* Obtain signatures
* @param {String} timestamp timestamp
* @param {String} nonce random number
* @param {String} encrypt text
*/
getSignature(timestamp, nonce, encrypt) {
const sha = crypto.createHash('sha1')
const arr = [this.token, timestamp, nonce, encrypt].sort()
sha.update(arr.join(''))
return sha.digest('hex')
}
/**
* Decrypt the ciphertext
* @param {String} text The ciphertext to be decrypted
*/
decrypt(text) {
//Create decryption object, AES uses CBC model, data is filled with PKCS #7; IV initial vector size is 16 bytes, take the first 16 bytes of AESKey
const decipher = crypto.createDecipheriv('aes-256-cbc', this.key, this.iv)
decipher.setAutoPadding(false)

let deciphered = Buffer.concat([decipher.update(text, 'base64'), decipher.final()])

deciphered = this.pkcs7.decode(deciphered)
// 算法:AES_Encrypt[random(16B) + msg_len(4B) + msg + $CorpID]
//remove 16-bit random numbers
const content = deciphered.slice(16)
const length = content.slice(0, 4).readUInt32BE(0)

return {
message: content.slice(4, length + 4).toString(),
appId: content.slice(length + 4).toString()
}
}
/**
* Encrypt plaintext
* 算法:Base64_Encode(AES_Encrypt[random(16B) + msg_len(4B) + msg + $appId])
* @param {String} text Plain text to be encrypted
*/
encrypt(text) {
//16B random string
const randomString = crypto.pseudoRandomBytes(16)

const msg = Buffer.from(text)
//Get the network byte order of the content length of 4B
const msgLength = Buffer.alloc(4)
msgLength.writeUInt32BE(msg.length, 0)

const id = Buffer.from(this.appId)

const bufMsg = Buffer.concat([randomString, msgLength, msg, id])

//Patch the plaintext
const encoded = this.pkcs7.encode(bufMsg)

//Create an encrypted object, AES uses the CBC model, and the data is filled with PKCS #7; IV initial vector size is 16 bytes, and the first 16 bytes of AESKey are taken
const cipher = crypto.createCipheriv('aes-256-cbc', this.key, this.iv)
cipher.setAutoPadding(false)

const cipheredMsg = Buffer.concat([cipher.update(encoded), cipher.final()])

return cipheredMsg.toString('base64')
}
}

const { token, WechatAppID, EncodingAESKey } = getConfig();

const wxmc = new WXMsgCrypto(token, EncodingAESKey, WechatAppID)


function WXMsgCryptoMiddleware(req, res, next) {
if (includes(req.path, '/wechatAccess') && req.method.toLowerCase() = 'post') {
let query = req.query
let xml = req.body.xml
//verification
let msgSignature = wxmc.getSignature(query.timestamp, query.nonce, xml.encrypt)
if (msgSignature ! query.msg_signature) {
Res.send ('Server error, please try again later!');
} else {
parseString(wxmc.decrypt(xml.encrypt).message, {
explicitArray: false
}, function(err, res) {
req.body.xml = res.xml
next()
})
}
} else {
next();
}
}

module.exports = WXMsgCryptoMiddleware;

Use

1
2
3
4
5
const express = require('express');
const app = express();
const WXMsgCryptoMiddleware = require('./middlewares/WXMsgCrypto');

app.use(WXMsgCryptoMiddleware)

Then in the code, you can get the decrypted message through’req.body.xml’