Golang 簽名採坑記
前言
最近在接入第三方接口的時候,要驗證參數裏的簽名,簽名採用SHA256withRSA
(RSA2),以確認數據是不是被修改了。具體SHA256withRSA
的原理不在這裏講解,本文主要記錄在 go(gin 框架) 驗籤時,踩到的一些坑,加以總結和記錄。
ps: 以下的代碼有些沒有做錯誤處理,實際開發中不可取。
SHA256withRSA
待簽字符串
接口參數以 x-www-form-urlencoded 的形式傳入, 如下的形式
utc_timestamp:1624864579690
sign:LPyc1kQNle9fTNfPi7zDz77eZFG0XD0YBXsRQNw/cCq00YE2dISzZIizi5S30ssHfVS2uuQsOyYYZoI8BgT1VR3vcf3CdOY8rkPPdqhBgcEJyKNRvQ3z+3VnM33gP84J5Ntg/LS8ZAlGpGjL9xTWtKVUbHZk0oy1qJwt3Da+wqchk5oh/cYeQnTyyUheQBf2WwPeNYCoauUS6R3KCtF3X8d2qUjx2ZEMkAMQhqGG9DwapWdTdoStjDZt+/Uz2wNT/4ctTa0iTvKPh5Zn1fBhBEKiflXlC32tRjS5hC2RfXR/1JR/AF+u937THwZmWv4xDPAQwRNcNwIH+a6mafygKg==
sign_type:RSA2
app_id:20210701
content:{"page":1,"size":20}
組裝待簽名字符串:
-
獲取全部參數,剔除
sign
與sign_type
參數 -
將篩選的參數按照第一個字符的鍵值
ASCII
碼遞增排序(字母升序排序),如果遇到相同字符則按照第二個字符的鍵值ASCII碼
遞增排序,以此類推 -
將排序後的參數與其對應值,組合成 “參數 = 參數值” 的格式,並且把這些參數用 & 字符連接起來,此時生成的字符串爲待簽名字符串
按照要求,則獲取的待簽字符串爲:app_id=20210701&content={"page":1,"size":20}&utc_timestamp=1624864579690
簽名與驗籤
雖然是作爲驗籤方,但是爲了方便測試,也實現了簽名方法,先準備公鑰與私鑰,私鑰簽名,公鑰驗籤。
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCyPWejY7A+stkupI5Ow1aqlDgQ8g04gByyuyOiqw/wl8j8maerG1e7YKiF5qGOKr+Jw83HPdMFLCZDZebS63taPA2aIA+2x1CpIVfss5jSRQNsVzez9eDW7HTI+Nplx95BLl8OVE724hCgWFEjpwZ4GzORQMzmIXxxw67sdo9iuwIDAQAB
-----END PUBLIC KEY-----
-----BEGIN PRIVATE KEY-----
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALI9Z6NjsD6y2S6kjk7DVqqUOBDyDTiAHLK7I6KrD/CXyPyZp6sbV7tgqIXmoY4qv4nDzcc90wUsJkNl5tLre1o8DZogD7bHUKkhV+yzmNJFA2xXN7P14NbsdMj42mXH3kEuXw5UTvbiEKBYUSOnBngbM5FAzOYhfHHDrux2j2K7AgMBAAECgYBXtQGfk/lxEN7wJcdlGJg3/hGMvR8mU1xL0uyZKiYA1R/wtMed2imUqd6jbTbIV17DMte6mECThgMaHTW1Smz6yrXYwPLmorkZmDxC4ggpvriH7sDgvBL++lOlLfRQqL7XLx72ZDaFWC0qFokKc5vviXBqWnTVMf/SQenSZGkgEQJBAN5z1x9Dyv2XyYwyJqXzEHWmvx7jjwqGQx6nFWnIVfeXQyJSSY7tqT6J4fGHe9eq5nbnqQo964RrR91Q+2iRGMkCQQDNHqjvgoT/skAXy80BP2Mt5W5pFjjeVlaCoaf006mTngkfB24ZmvxoxX5NfNBEGB/iS2KCsU5/h1ykpU3Lj+VjAkA9MwVl9pKr/cxXI5z6XsqSc5N0/gnmTVW94x3DAniUKysvEBBon/3F1M0yU6HAjaXl5Ine5XYb8h/NRXBFLlXxAkEAub1muqOU7bmqoiGxPMz6cWgNh+lQi7zgz5+06FT2fK6hkdB3mYYnxHP5wA8ixFaYIKGkzbXi4EZh1NG/VXKzAwJAFp+hcKz9oRO1LodExpdmATTd031g53X+3MMKG+PJREjAnC9wQL4RsmbzYP5NZ2dORIpNgRWawF2b1KJxWiiCsg==
-----END PRIVATE KEY-----
實現簽名函數
func RsaSignWithSha256(data []byte, keyBytes []byte) ([]byte, error) {
h := sha256.New()
h.Write(data)
hashed := h.Sum(nil)
block, _ := pem.Decode(keyBytes)
if block == nil {
return nil, errors.New("private key error")
}
privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey.(*rsa.PrivateKey), crypto.SHA256, hashed)
if err != nil {
return nil, err
}
return signature, nil
}
最終返回的結果是一個字節數組,但是參數是以字符串的形式傳遞的,因此需要把這個字節數組轉爲字符串,有兩種形式 :
-
hex
hex.EncodeToString
-
base64
base64.StdEncoding.EncodeToString
編碼之後的數據,也會有明顯的差異
// base64
AezhDSynfsTMrU517zHK12e2SzczNczm+yRht+Dr+I0K7VE+TLeUbpB1SiMbxLIdT2SsunIm0h5vaeHAyf9QwAFvjlcPG6JhJBOo58AtXx2moVVuu2pAEtO/tJw61VKbT4j5nAIiC1Ac2i1+u5BdbYoAV6Fc+HtfAJBS1iWinwQ=
// hex
01ece10d2ca77ec4ccad4e75ef31cad767b64b373335cce6fb2461b7e0ebf88d0aed513e4cb7946e90754a231bc4b21d4f64acba7226d21e6f69e1c0c9ff50c0016f8e570f1ba2612413a8e7c02d5f1da6a1556ebb6a4012d3bfb49c3ad5529b4f88f99c02220b501cda2d7ebb905d6d8a0057a15cf87b5f009052d625a29f04
不管採用哪種編碼,在驗證簽名的時候都要先解碼轉成字節數組,否則驗籤不會通過,以下是驗籤函數,採用hex
func RsaVerySignWithSha256(data, signData, keyBytes []byte) bool {
block, _ := pem.Decode(keyBytes)
if block == nil {
panic(errors.New("public key error"))
}
pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
panic(err)
}
hashed := sha256.Sum256(data)
// 注意這裏簽名字符串要解碼
//sig, _ := base64.StdEncoding.DecodeString(string(signData)) base64解碼
sig, _ := hex.DecodeString(string(signData)) // hex解碼
err = rsa.VerifyPKCS1v15(pubKey.(*rsa.PublicKey), crypto.SHA256, hashed[:], sig)
if err != nil {
panic(err)
}
return true
}
最後整個簽名與驗籤的過程就完成了
func main (){
s := `app_id=20210701&content={"page":1,"size":20}&utc_timestamp=1624864579690`
sign, _ := RsaSignWithSha256([]byte(s), prvKey)
sigs := hex.EncodeToString(sign)
fmt.Println(RsaVerySignWithSha256([]byte(s), []byte(sigs), pubKey)) // true
}
既然驗簽過程完成了,接下來就是應用在項目中了,這裏使用的是gin
框架,而接下來的這部分,踩了不少的坑 Orz
go(Gin) 踩坑
從參數到待簽字符串
在使用gin
中,我都會使用ShouldBind
將參數與結構體綁定,這次也自然而然的這麼使用。將參數綁定到結構體之後,想拼接待簽字符串,那就要遍歷結構體,要遍歷結構體,就要靠反射
func GetPendingSign(p interface{}) []byte {
var typeInfo = reflect.TypeOf(p)
var valInfo = reflect.ValueOf(p)
num := typeInfo.NumField()
var keys = make([]string, 0, num)
var field = make([]string, 0, num)
for i := 0; i < num; i++ {
key := typeInfo.Field(i).Tag.Get("form") // 結構體的form tag纔是待簽字符串的key
if key != "sign" && key != "sign_type" {
keys = append(keys, key)
field[key] = typeInfo.Field(i).Name // 找不通過tagName獲取值的,因此做了一個form tag和屬性名的對應
}
}
sort.Strings(keys)
s := ""
for i, k := range keys {
temp := valInfo.FieldByName(field[i]).Interface() // 通過上面的對應,獲取值
if k == "content" {
// 因爲content被反序列化了,所以這裏重新序列化成json,以拼接字符串
b, _ := json.Marshal(temp)
s = fmt.Sprintf("%s%s=%s&", s, k, string(b))
} else {
s = fmt.Sprintf("%s%s=%v&", s, k, temp)
}
}
s = s[:len(s)-1] //待簽名字符串
return []byte(s)
}
上面的方法中,有幾個部分做了註釋,這幾個地方也是比較關鍵的。接着用postman
進行測試,將最開始的參數傳入,會得到和預期一樣的待簽字符串。
r := gin.Default()
r.POST("/test", func(c *gin.Context) {
var body Body
c.ShouldBind(&body)
fmt.Println(string(GetPendingSign(body)))
})
r.Run(":8081")
然而就這樣結束了嗎?不!這種方式,有一個很大的問題!如果把content:{"page":1,"size":20}
改成content:{"size":20,"page":1}
入參,會發現簽名驗證失敗了!是的!就是調換了size
和page
的位置,這種方式的問題就暴露出來了。
結構體 map 和 json
爲什麼會驗籤失敗呢?第一個想法就是待簽字符串是否一致?再次請求,打印拼接出來的字符串,會發現仍然是content={"page":1,"size":20}
而不是傳入的content:{"size":20,"page":1}
看一看結構體的定義
type Content struct {
Page int `json:"page" form:"page"`
Size int `json:"size" form:"size"`
}
明顯的發現,序列化之後 key 的順序和定義結構體屬性的順序保持了一致,而不是以最開始的 json 爲準了,即:結構體序列化成 json 時,json 的 key 值順序以定義結構體時,屬性的順序爲準
既然說到了結構體,再來看看 map
s := `{"size":20,"page":1}`
m := make(map[string]int)
json.Unmarshal([]byte(s), &m)
b, _ := json.Marshal(&m)
fmt.Println(s)
fmt.Println(string(b)) // {"page":1,"size":20}
key 的順序也是被調整了,那麼 map 又是以什麼規則來調整 key 的順序呢?
從源碼的 encoding/json/encode.go
第 793 行中看到這行代碼
sort.Slice(sv, func(i, j int) bool { return sv[i].s < sv[j].s })
即:map 轉 json 是有序的,按照 ASCII 碼升序排列 key。
好吧,既然兩個方式都會改變 key 的順序,那麼這種先綁定結構體再遍歷拼接的方式就不可取了。
解決方案
既然不能先反序列化,那麼就要採取其他的方案了。不以ShouldBind
的形式獲取參數,那麼就用ioutil.ReadAll
的方式來獲取參數,打印看看獲取到的參數
utc_timestamp=1624864579690&sign=LPyc1kQNle9fTNfPi7zDz77eZFG0XD0YBXsRQNw%2FcCq00YE2dISzZIizi5S30ssHfVS2uuQsOyYYZoI8BgT1VR3vcf3CdOY8rkPPdqhBgcEJyKNRvQ3z%2B3VnM33gP84J5Ntg%2FLS8ZAlGpGjL9xTWtKVUbHZk0oy1qJwt3Da%2Bwqchk5oh%2FcYeQnTyyUheQBf2WwPeNYCoauUS6R3KCtF3X8d2qUjx2ZEMkAMQhqGG9DwapWdTdoStjDZt%2B%2FUz2wNT%2F4ctTa0iTvKPh5Zn1fBhBEKiflXlC32tRjS5hC2RfXR%2F1JR%2FAF%2Bu937THwZmWv4xDPAQwRNcNwIH%2Ba6mafygKg%3D%3D&sign_type=RSA2&app_id=20210701&content=%7B%22size%22%3A20%2C%22page%22%3A1%7D
x-www-form-urlencoded
的參數形式,和query
的參形式類似,都是用 & 和 = 來拼接,既然都是字符串,那麼就手動切割,然後拼成 map,最後遍歷 map,拼接成待簽字符串。
bodyArray := strings.Split(string(body), "&") //1、先按&切割
data := make(map[string]string)
for _, v := range bodyArray {
// 2、按照 = 切分組裝map
vs := strings.Split(v, "=")
if len(vs) == 2 {
value, err := url.QueryUnescape(vs[1]) // 從上面打印的字符,可以看出被urlescape過,因此要Unescape
if err != nil {
c.Abort()
return
}
data[vs[0]] = value
}
}
按照上面的形式,就可以得到一個map
, 而content
的值,因爲只是個字符串沒有被重新處理。因此就不會再出現key
順序不一致的問題。接着只需要遍歷這個map
,按照要求組裝待簽字符串即可。
最後把這些步驟都封裝成一箇中間件使用,驗籤功能完成。
總結
來看看最終都有哪些知識:
-
簽名有
base64
和hex
的編碼方式,驗籤的時候,要對應解碼 -
使用反射遍歷結構體
-
結構體序列化成
json
時,json key
按照結構體的屬性順序重新排序 -
map
序列化成json
時,json key
按照ASCII
碼升序排列 -
x-www-form-urlencoded
的參數形式,以 & 和 = 拼接,並且會被urlescape
,處理的時候要unescape
-
最後一個小知識點, 在中間件中用
ioutil.ReadAll
讀完 body,記得重新把 body 寫回去c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body))
,否則後面的路由就讀不到body
了
go 路漫漫~,感謝閱讀 Thanks!
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/TQdACnFHwFu-ioScevwtJQ