這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
在上一篇“接收簡訊”一文中,我們瞭解到:公眾服務與伺服器間的訊息是“裸奔”的(即明文傳輸,通過抓包可以看到)。顯然這對於一些對安 全性要求較高的大企業服務號來說,比如銀行、證券、電信電訊廠商或航空客服等是不能完全滿足要求的。於是乎就有了伺服器與公眾服務間的資料加密 通訊流程。
公眾號管理員可以在公眾號“開發人員中心”選擇是否採用"安全模式"(區別於明文模式):
一旦選擇了“安全模式”,伺服器在向公眾號服務轉寄訊息時會對XML資料包部分內容進行加密處理。這類加密後的請求Body中的XML資料變 成了下面這樣:
xml資料基本結構變成了:
xx
xx
另外在“安全模式”下,Http Post Request line中也增加了兩個欄位:encrypt_type和msg_signuature,用於訊息類型判斷以及加密訊息內容有效性校正:
POST /?signature=891789ec400309a6be74ac278030e472f90782a5×tamp=1419214101&nonce=788148964&encrypt_type=aes&msg_signature=87d7b127fab3771b452bc6a592f530cd8edba950 HTTP/1.1\r\n
其中:
encrypt_type = "aes",說明是加密訊息,否則為"raw”,即未加密訊息。
msg_signature=sha1(sort(Token, timestamp, nonce, msg_encrypt))
對於測試號,測試號配置頁面沒有加密相關配置,因此只能通過“公眾平台介面調試工具”來進行相關加密介面調試。
一、訊息簽名驗證
對於“安全模式”下的訊息互動,首先要做的就是訊息簽名驗證,只有通過驗證的訊息才會進行下一步解密、解析和處理。
訊息簽名驗證的原理是比較平台HTTP Post Line中攜帶的msg_signature與通過Token、timestamp、nonce和msg_encrypt等四個欄位值計算出的 msg_signture是否一致,一致則通過訊息簽名驗證。
我們依舊在procRequest中完成對“安全模式”下訊息的簽名驗證。
//recvencryptedtextmsg.go
type EncryptRequestBody struct {
XMLName xml.Name `xml:"xml"`
ToUserName string
Encrypt string
}
func makeMsgSignature(timestamp, nonce, msg_encrypt string) string {
sl := []string{token, timestamp, nonce, msg_encrypt}
sort.Strings(sl)
s := sha1.New()
io.WriteString(s, strings.Join(sl, ""))
return fmt.Sprintf("%x", s.Sum(nil))
}
func validateMsg(timestamp, nonce, msgEncrypt, msgSignatureIn string) bool {
msgSignatureGen := makeMsgSignature(timestamp, nonce, msgEncrypt)
if msgSignatureGen != msgSignatureIn {
return false
}
return true
}
func parseEncryptRequestBody(r *http.Request) *EncryptRequestBody {
body, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Fatal(err)
return nil
}
requestBody := &EncryptRequestBody{}
xml.Unmarshal(body, requestBody)
return requestBody
}
func procRequest(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
timestamp := strings.Join(r.Form["timestamp"], "")
nonce := strings.Join(r.Form["nonce"], "")
signature := strings.Join(r.Form["signature"], "")
encryptType := strings.Join(r.Form["encrypt_type"], "")
msgSignature := strings.Join(r.Form["msg_signature"], "")
… …
f r.Method == "POST" {
if encryptType == "aes" {
log.Println("Wechat Service: in safe mode")
encryptRequestBody := parseEncryptRequestBody(r)
//Validate msg signature
if !validateMsg(timestamp, nonce, encryptRequestBody.Encrypt, msgSignature) {
log.Println("Wechat Service: msg_signature is invalid")
return
}
log.Println("Wechat Service: msg_signature validation is ok!")
… …
}
… …
}
程式編譯執行結果如下:
$sudo ./recvencryptedtextmsg
2014/12/22 13:15:56 Wechat Service: Start!
用手機發送一條訊息給公眾號,程式輸出如下結果:
2014/12/22 13:17:35 Wechat Service: in safe mode
2014/12/22 13:17:35 Wechat Service: msg_signature validation is ok!
二、資料包解密
到目前為止,我們已經得到了經過訊息驗證ok的加密資料包EncryptRequestBody 的Encrypt。要想得到真正的訊息內容,我們需要對Encrypt欄位的值進行解密處理。採用的是AES加解密方案, 下面我們就來看看如何做AES解密。
在開發人員中心選擇轉換為“安全模式”時,有一個欄位EncodingAESKey需要填寫,這個欄位固定為43個字元,它就是我們在運用AES算 法時需要的那個Key。不過這個EncodingAESKey是被編了碼的,真正用來加解密的AESKey需要我們自己通過解碼得到。解碼方法 為:
AESKey=Base64_Decode(EncodingAESKey + “=”)
Base64 decode後,我們就得到了一個32個位元組的AESKey,可以看出加密解密用的是AES-256演算法(256=32x8bit)。
在Golang中,我們可以通過下面代碼得到真正的AESKey:
const (
token = "wechat4go"
appID = "wx5b5c2614d269ddb2"
encodingAESKey = "kZvGYbDKbtPbhv4LBWOcdsp5VktA3xe9epVhINevtGg"
)
var aesKey []byte
func encodingAESKey2AESKey(encodingKey string) []byte {
data, _ := base64.StdEncoding.DecodeString(encodingKey + "=")
return data
}
func init() {
aesKey = encodingAESKey2AESKey(encodingAESKey)
}
有了AESKey,我們再來解密資料包。公眾平台開發文檔給出了加密資料包的解析步驟:
1. aes_msg=Base64_Decode(msg_encrypt)
2. rand_msg=AES_Decrypt(aes_msg)
3. 驗證尾部$AppId是否是自己的AppId,相同則表示訊息沒有被篡改,這裡進一步加強了訊息簽名驗證
4. 去掉rand_msg頭部的16個隨機位元組,4個位元組的msg_len和尾部的$AppId即為最終的xml訊息體
Wiki中如果能用一個簡單的圖來說明Base64_Decode後的資料格式就更好了。這裡進一步說明一下,解密後的資料,我們稱之 plainData,它由四部分組成,按先後順序排列分別是:
1、隨機值 16位元組
2、xml包長度 4位元組 (注意以BIG_ENDIAN方式讀取)
3、xml包 (*這部分資料的長度由上一個欄位標識,這個包等價於一個完整的文本接收訊息體資料,從ToUsername到MsgID都 有)
4、appID
其中第三段xml包是一個完整的接收文本資料包,與“接收訊息”一文中的標準文本資料包格式一致,這就方便我們解析了。好了,下面用代碼闡述解 密、解析過程以及appid驗證:
在procRequest中,增加如下代碼:
// Decode base64
cipherData, err := base64.StdEncoding.DecodeString(encryptRequestBody.Encrypt)
if err != nil {
log.Println("Wechat Service: Decode base64 error:", err)
return
}
// AES Decrypt
plainData, err := aesDecrypt(cipherData, aesKey)
if err != nil {
fmt.Println(err)
return
}
//Xml decoding
textRequestBody, _ := parseEncryptTextRequestBody(plainData)
fmt.Printf("Wechat Service: Recv text msg [%s] from user [%s]!",
textRequestBody.Content,
textRequestBody.FromUserName)
根據解密方法,我們先對encryptRequestBody.Encrypt進行base64 decode操作得到cipherData,再用aesDecrypt對cipherData進行解密得到上面提到的由四部分組成的plainData。plainData經過xml decoding後就得到我們的TextRequestBody struct。
這裡痛點顯然在aesDecrypt的實現上了。的加密包採用aes-256演算法,秘鑰長度32B,採用PKCS#7 Padding方式。Golang提供了強大的AES加密解密方法,我們利用這些方法實現包的解密:
func aesDecrypt(cipherData []byte, aesKey []byte) ([]byte, error) {
k := len(aesKey) //PKCS#7
if len(cipherData)%k != 0 {
return nil, errors.New("crypto/cipher: ciphertext size is not multiple of aes key length")
}
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, err
}
iv := make([]byte, aes.BlockSize)
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return nil, err
}
blockMode := cipher.NewCBCDecrypter(block, iv)
plainData := make([]byte, len(cipherData))
blockMode.CryptBlocks(plainData, cipherData)
return plainData, nil
}
對於解密後的plainData做appID校正以及xml Decoding處理如下:
func parseEncryptTextRequestBody(plainText []byte) (*TextRequestBody, error) {
fmt.Println(string(plainText))
// Read length
buf := bytes.NewBuffer(plainText[16:20])
var length int32
binary.Read(buf, binary.BigEndian, &length)
fmt.Println(string(plainText[20 : 20+length]))
// appID validation
appIDstart := 20 + length
id := plainText[appIDstart : int(appIDstart)+len(appID)]
if !validateAppId(id) {
log.Println("Wechat Service: appid is invalid!")
return nil, errors.New("Appid is invalid")
}
log.Println("Wechat Service: appid validation is ok!")
// xml Decoding
textRequestBody := &TextRequestBody{}
xml.Unmarshal(plainText[20:20+length], textRequestBody)
return textRequestBody, nil
}
編譯執行輸出textRequestBody:
&{{ xml} gh_6ebaca4bb551 on95ht9uPITsmZmq_mvuz4h6f6CI 1.419239875s text Hello, Wechat 6095588848508047134}
三、響應訊息的資料包加密
公眾平台開發文檔要求:公眾帳號對密文訊息的回複也要求加密。
對比一下普通的響應訊息格式和加密後的響應訊息格式:
加密後:
我們定義一個結構體映射響應訊息資料包:
type EncryptResponseBody struct {
XMLName xml.Name `xml:"xml"`
Encrypt CDATAText
MsgSignature CDATAText
TimeStamp string
Nonce CDATAText
}
type CDATAText struct {
Text string `xml:",innerxml"`
}
我們要做的就是給EncryptResponseBody的執行個體逐一賦值,然後通過xml.MarshalIndent轉成xml資料流即可,各字 段值建置規則如下:
Encrypt = Base64_Encode(AES_Encrypt [random(16B)+ msg_len(4B) + msg + $AppId])
MsgSignature=sha1(sort(Token, timestamp, nonce, msg_encrypt))
TimeStamp = 用請求中的值或新產生
Nonce = 用請求中的值或新產生
公眾介面的加密複雜度要比解密高一些,關鍵問題在於加密結果的判定和加密邏輯的調試,AES加密出的結果每次都不同,我們要麼通過平台真實操作驗證,要麼通過提供的線上調試工具驗證加密是否正確。這裡強烈建議使用線上調試工具(測試號只能選擇這一種)。
線上調試工具的配置參考如下,ToUserName和FromUserName建議填寫真實的(通過解密Post包列印輸出得到):
如果線上調試工具收到你的應答,並解密成功,會給出如下反饋:
在procRequest中,我們在接收解析完Http Request後,通過下面幾行代碼構造一個加密的Response返回給平台或調試工具:
responseEncryptTextBody, _ := makeEncryptResponseBody(textRequestBody.ToUserName,
textRequestBody.FromUserName,
"Hello, "+textRequestBody.FromUserName,
nonce,
timestamp)
w.Header().Set("Content-Type", "text/xml")
fmt.Fprintf(w, string(responseEncryptTextBody))
func makeEncryptResponseBody(fromUserName, toUserName, content, nonce, timestamp string) ([]byte, error) {
encryptBody := &EncryptResponseBody{}
encryptXmlData, _ := makeEncryptXmlData(fromUserName, toUserName, timestamp, content)
encryptBody.Encrypt = value2CDATA(encryptXmlData)
encryptBody.MsgSignature = value2CDATA(makeMsgSignature(timestamp, nonce, encryptXmlData))
encryptBody.TimeStamp = timestamp
encryptBody.Nonce = value2CDATA(nonce)
return xml.MarshalIndent(encryptBody, " ", " ")
}
應答Xml包中只有Encrypt欄位是加密的,該欄位的產生方式如下:
func makeEncryptXmlData(fromUserName, toUserName, timestamp, content string) (string, error) {
// Encrypt part3: Xml Encoding
textResponseBody := &TextResponseBody{}
textResponseBody.FromUserName = value2CDATA(fromUserName)
textResponseBody.ToUserName = value2CDATA(toUserName)
textResponseBody.MsgType = value2CDATA("text")
textResponseBody.Content = value2CDATA(content)
textResponseBody.CreateTime = timestamp
body, err := xml.MarshalIndent(textResponseBody, " ", " ")
if err != nil {
return "", errors.New("xml marshal error")
}
// Encrypt part2: Length bytes
buf := new(bytes.Buffer)
err = binary.Write(buf, binary.BigEndian, int32(len(body)))
if err != nil {
fmt.Println("Binary write err:", err)
}
bodyLength := buf.Bytes()
// Encrypt part1: Random bytes
randomBytes := []byte("abcdefghijklmnop")
// Encrypt Part, with part4 - appID
plainData := bytes.Join([][]byte{randomBytes, bodyLength, body, []byte(appID)}, nil)
cipherData, err := aesEncrypt(plainData, aesKey)
if err != nil {
return "", errors.New("aesEncrypt error")
}
return base64.StdEncoding.EncodeToString(cipherData), nil
}
func aesEncrypt(plainData []byte, aesKey []byte) ([]byte, error) {
k := len(aesKey)
if len(plainData)%k != 0 {
plainData = PKCS7Pad(plainData, k)
}
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, err
}
iv := make([]byte, aes.BlockSize)
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return nil, err
}
cipherData := make([]byte, len(plainData))
blockMode := cipher.NewCBCEncrypter(block, iv)
blockMode.CryptBlocks(cipherData, plainData)
return cipherData, nil
}
根據官方文檔: 所用的AES採用的時CBC模式,秘鑰長度為32個位元組(aesKey),資料採用PKCS#7填充;PKCS#7:K為秘鑰位元組數(採用32),buf為待加密的內容,N為其位元組數。Buf需要被填充為K的整數倍。因此我們pad要加密的資料時,務必pad為k(=32)的整數倍,而不是aes.BlockSize(=16)的整數倍。
採用安全模式後的公眾號訊息互動效能似乎下降了,發送"hello, wechat"給公眾號後好長時間才收到響應。
公眾號接收加密訊息的代碼在這裡可以下載。這些代碼只是示範代碼,結構上絕不算最佳化,大家可以將這些代碼封裝成通用的介面為後續公眾平台介面開發奠定基礎。
2014, bigwhite. 著作權.