Golang 签名采坑记

共 10035字,需浏览 21分钟

 ·

2021-07-31 20:39

本文来自网友投稿!

前言

最近在接入第三方接口的时候,要验证参数里的签名,签名采用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}

组装待签名字符串:

  1. 获取全部参数,剔除signsign_type参数

  2. 将筛选的参数按照第一个字符的键值ASCII码递增排序(字母升序排序),如果遇到相同字符则按照第二个字符的键值ASCII码递增排序,以此类推

  3. 将排序后的参数与其对应值,组合成“参数=参数值”的格式,并且把这些参数用&字符连接起来,此时生成的字符串为待签名字符串

按照要求,则获取的待签字符串为: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([]string0, num)
 var field = make([]string0, 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}入参,会发现签名验证失败了!是的!就是调换了sizepage的位置,这种方式的问题就暴露出来了。

结构体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,按照要求组装待签字符串即可。

最后把这些步骤都封装成一个中间件使用,验签功能完成。

总结

来看看最终都有哪些知识:

  • 签名有base64hex的编码方式,验签的时候,要对应解码
  • 使用反射遍历结构体
  • 结构体序列化成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!



推荐阅读


福利

我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。

浏览 37
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报