-
Notifications
You must be signed in to change notification settings - Fork 16
/
Copy pathwechatClient.ts
286 lines (249 loc) · 8.56 KB
/
wechatClient.ts
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
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
import { XMLParser } from 'fast-xml-parser'
import crypto from 'crypto'
import type { webcrypto } from 'crypto'
import {
getTextDecoder,
getTextEncoder,
concatUint8Array,
base64ToUint8Array,
uint8ArrayToBase64,
} from '../src/utils'
async function shaDigest(algorithm: string, input: string) {
const buffer = await crypto.subtle.digest(
algorithm,
new TextEncoder().encode(input)
)
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
class Client {
private _aesKeyInfo: {
key: webcrypto.CryptoKey // 已经导入的 CryptoKey 格式密钥
iv: Uint8Array // 初始向量大小为16字节,取AESKey前16字节
} | null = null
xmlParser = new XMLParser({
processEntities: false,
})
constructor(
readonly toUserName: string,
readonly appid: string,
readonly token: string,
readonly encodingAESKey: string,
readonly targetUrl: string
) {}
async sendTextMsg({
msg,
fromUserName,
encrypt,
}: {
msg: string
fromUserName: string
encrypt: boolean
}) {
const timestamp = `${Math.floor(Date.now() / 1000)}`
const nonce = `${Math.floor(Math.random() * 10 ** 10)}`
const msgId = nonce
const xml = `<xml><ToUserName><![CDATA[${this.toUserName}]]></ToUserName>
<FromUserName><![CDATA[${fromUserName}]]></FromUserName>
<CreateTime>${timestamp}</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[${msg}]]></Content>
<MsgId>${msgId}</MsgId>
</xml>`
const signature = await this.getSignature(timestamp, nonce)
const targetUrl = new URL(this.targetUrl)
targetUrl.searchParams.set('signature', signature)
targetUrl.searchParams.set('timestamp', timestamp)
targetUrl.searchParams.set('nonce', nonce)
targetUrl.searchParams.set('openid', fromUserName)
if (!encrypt) {
const resp = await fetch(targetUrl, { body: xml, method: 'POST' })
const text = await resp.text()
console.log('收到明文回复 ', text)
return text
}
const encryptContent = await this.encryptContent(xml)
const msg_signature = await this.getSignature(
timestamp,
nonce,
encryptContent
)
const encryptXml = `<xml><ToUserName><![CDATA[${this.toUserName}]]></ToUserName><Encrypt><![CDATA[${encryptContent}]]></Encrypt></xml>`
targetUrl.searchParams.set('msg_signature', msg_signature)
targetUrl.searchParams.set('encrypt_type', 'aes')
const resp = await fetch(targetUrl, { body: encryptXml, method: 'POST' })
const encryptText = await resp.text()
console.log('收到加密回复 ', encryptText)
const text = await this.parseRecvXmlMsg(encryptText)
console.log('解密收到回复 ', text)
return text
}
async getSignature(...args: string[]) {
const tmpArr = [this.token, ...args]
tmpArr.sort()
const tmpStr = tmpArr.join('')
console.log(tmpStr)
return shaDigest('SHA-1', tmpStr)
}
/**
* 导入 AES 解密微信 xml 消息中 <Encrypt> 块内容
* @see https://developer.work.weixin.qq.com/document/path/96211
*/
async importWeChatAesKey() {
const keyInUint8Array = base64ToUint8Array(this.encodingAESKey + '=')
const key = await crypto.subtle.importKey(
'raw',
keyInUint8Array,
// 只能在 node 使用
// Buffer.from(encodingAESKey + '=', 'base64'),
{ name: 'AES-CBC' },
true,
['decrypt', 'encrypt']
)
return {
key,
keyInUint8Array,
iv: keyInUint8Array.slice(0, 16),
}
}
async getAesKeyInfo() {
if (!this._aesKeyInfo) {
const r = await this.importWeChatAesKey()
this._aesKeyInfo = r
}
return this._aesKeyInfo
}
/**
* AES 加密明文为微信 xml 消息中 <Encrypt> 块内容
* @see https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Message_encryption_and_decryption_instructions.html
* @see https://developer.work.weixin.qq.com/document/path/96211
* @see https://github.com/keel/aes-cross/tree/master/info-cn
* @param plainContent utf8 明文
*/
async encryptContent(plainContent: string) {
// 加密后的结果 = 16个字节的随机字符串 + 4个字节的msg长度(网络字节序) + 明文msg + receiveid + 填充
// 16B 随机字符串
const random16 = crypto.getRandomValues(new Uint8Array(16))
const contentUint8Array = getTextEncoder().encode(plainContent)
// 获取4B的内容长度的网络字节序
const msgUint8Array = new Uint8Array(4)
new DataView(msgUint8Array.buffer).setUint32(
0,
contentUint8Array.byteLength,
false
)
const appidUint8Array = getTextEncoder().encode(this.appid)
// 补位
const blockSize = 32
const msgLength =
random16.length +
contentUint8Array.length +
msgUint8Array.length +
appidUint8Array.length
// 计算需要填充的位数
const amountToPad = blockSize - (msgLength % blockSize)
const padUint8Array = new Uint8Array(amountToPad)
padUint8Array.fill(amountToPad)
const concatenatedArray = concatUint8Array([
random16,
msgUint8Array,
contentUint8Array,
appidUint8Array,
padUint8Array,
])
const { iv, key } = await this.getAesKeyInfo()
const arrBuffer = await crypto.subtle.encrypt(
{ name: 'AES-CBC', iv },
key,
concatenatedArray
)
return uint8ArrayToBase64(new Uint8Array(arrBuffer))
}
/**
* AES 解密微信 xml 消息中 <Encrypt> 块内容
* @see https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Message_encryption_and_decryption_instructions.html
* @see https://developer.work.weixin.qq.com/document/path/96211
* @see https://github.com/keel/aes-cross/tree/master/info-cn
* @param encryptContent 从 xml <Encrypt> 块中取出的内容,加密处理后的Base64编码
*/
async decryptContent(encryptContent: string) {
const { iv, key } = await this.getAesKeyInfo()
const arrBuffer = await crypto.subtle.decrypt(
{ name: 'AES-CBC', iv },
key,
base64ToUint8Array(encryptContent) // base64 到 Uint8Array
// 只能在 node 使用
// Buffer.from(encryptContent, 'base64')
)
// 数据采用PKCS#7填充至32字节的倍数
// = 16个字节的随机字符串 + 4个字节的msg长度(网络字节序) + 明文msg + receiveid + 填充
const uint8Array = new Uint8Array(arrBuffer)
// 加密后数据块中填充的字节数
let pad = uint8Array[uint8Array.length - 1]
if (pad < 1 || pad > 32) {
pad = 0
}
// 去掉头部16个随机字节和尾部填充字节
const content = uint8Array.slice(16, uint8Array.length - pad)
// 4字节的msg长度
const msgLen = new DataView(content.slice(0, 4).buffer).getInt32(0)
// 截取msg_len长度的部分即为msg
const plainXmlMsg = getTextDecoder().decode(content.slice(4, msgLen + 4))
// 剩下的为尾部的receiveid,即为公众号开发者ID
const appid = getTextDecoder().decode(content.slice(msgLen + 4))
return { plainXmlMsg, appid }
}
/**
* 解析微信 xml 消息
* @param xmlMsg 微信 xml 消息
*/
async parseRecvXmlMsg(xmlMsg: string) {
const root = this.xmlParser.parse(xmlMsg) as { xml: Record<string, string> }
const xmlObj = root.xml
const {
Encrypt: encrypt,
MsgSignature: msgSignature,
TimeStamp: timestamp,
Nonce: nonce,
} = xmlObj
const mySignature = await this.getSignature(
`${timestamp}`,
`${nonce}`,
encrypt
)
if (mySignature !== msgSignature) {
throw new Error(
`消息签名不对 mySignature ${mySignature} msgSignature ${msgSignature}`
)
}
const { appid, plainXmlMsg } = await this.decryptContent(encrypt)
if (appid !== this.appid) {
throw new Error(`appid 不对 appid ${appid} this.appid ${this.appid}`)
}
return plainXmlMsg
}
}
async function test() {
const toUserName = 'user1'
const id = 'id1'
const appid = 'appid1'
const token = 'token1'
const encodingAESKey = '0GSZBonm7NPFCQeS2VveeOBZOHDOLHQtAP5Ai96fTwT'
const targetUrl = `http://localhost:8787/openai/wechat/${id}`
const client = new Client(toUserName, appid, token, encodingAESKey, targetUrl)
const fromUserName = 'user2'
const testApiKey = ''
const msg = '/help'
// const msg = `/bindKey ${testApiKey}`
// const msg = '/system'
// const msg = '/faq'
// const msg = '/unbindKey'
// const msg = '/testKey'
// const msg = '/usage'
// const msg = '/freeUsage'
// const msg = 'can you connect network'
const encrypt = true
const recv = await client.sendTextMsg({ msg, fromUserName, encrypt })
}
// test()