写点什么

「Go 工具箱」web 中想做到 cookie 值安全?securecookie 库的使用和实现原理

作者:Go学堂
  • 2022-11-09
    北京
  • 本文字数:6035 字

    阅读完需:约 20 分钟

在工作中,主动性不仅体现在像老黄牛一样把本职工作做好,还要主动和领导沟通,承担更多、更重要的任务。 --- 吴军 《格局》


大家好,我是渔夫子。「Go 学堂」新推出“Go 工具箱”系列,意在给大家分享使用 go 语言编写的、实用的、好玩的工具。


今天给大家推荐的是 web 应用安全防护方面的另一个包:securecookie。该包给 cookie 中存储的敏感信息进行编、解码及解密、解密功能,以保证数据的安全。

securecookie 小档案

一、安装


 go get github.com/gorilla/securecookie 
复制代码

二、使用示例

明文的 cookie 值输出

我们先来看下未进行编码或未加密的 cookie 输出是什么样的。本文以 beego 框架为例,当然在 beego 中已经实现了安全的 cookie 输出,稍后再看其具体的实现。这里主要是来说明 cookie 中未编码的输出和使用 securecookie 包后 cookie 的值输出。


package main
import ( "github.com/beego/beego")

func main() { beego.Router("/", &MainController{})
beego.RunWithMiddleWares(":8080")}
type MainController struct { beego.Controller}
func (this *MainController) Get() { this.Ctx.Output.Cookie("userid", "1234567") this.Ctx.Output.Body([]byte("Hello World"))}
复制代码


执行 go run main.go,然后在浏览器中输入http://localhost:8080/,查看cookie的输出是明文的。如下:


使用 securecookie 包对 cookie 值进行编码

securecookie 包的使用也很简单。首先使用 securecookie.New 函数实例化一个 securecookie 实例,在实例化的时候需要传入一个 32 位或 64 位的 hashkey 值。然后调用 securecookie 实例的 Encode 对明文值进行编码即可。如下示例:


package main
import ( "github.com/beego/beego" "github.com/gorilla/securecookie")

func main() { beego.Router("/", &MainController{})
beego.RunWithMiddleWares(":8080")}
type MainController struct { beego.Controller}
func (this *MainController) Get() { // Hash keys should be at least 32 bytes long var hashKey = []byte("keep-it-secret-keep-it-safe-----") // 实例化securecookie var s = securecookie.New(hashKey, nil)
name := "userid" value := "1234567"
// 对value进行编码 encodeValue, _ := s.Encode(name, value)
// 输出编码后的cookie值 this.Ctx.Output.Cookie(name, encodeValue) this.Ctx.Output.Body([]byte("Hello World"))}
复制代码


以下是经过 securecookie 编码后的 cookie 值输出结果:



在调用 securecookie.New 时,第一个参数 hashKey 是必须的,推荐使用 32 字节或 64 字节长度的 key。因为 securecookie 底层编码时是使用 HMAC 算法实现的,hmac 算法在对数据进行散列操作时会进行加密。


securecookie 包不仅支持对字符串的编码和加密。还支持对结构体及自定义类型进行编码和加密。下面示例是对一个 map[string]string 类型进行编/解码的实例。


package main
import ( "fmt" "github.com/beego/beego" "github.com/gorilla/securecookie")

func main() { beego.Router("/", &MainController{})
beego.RunWithMiddleWares(":8080")}
type MainController struct { beego.Controller}
func (this *MainController) Get() { // Hash keys should be at least 32 bytes long var hashKey = []byte("keep-it-secret-keep-it-safe-----")
// Block keys should be 16 bytes (AES-128) or 32 bytes (AES-256) long. // Shorter keys may weaken the encryption used. var blockKey = []byte("1234567890123456") // 实例化securecookie var s = securecookie.New(hashKey, blockKey)
value := map[string]string{ "id": "1234567", }
name := "userid" //value := "1234567" // encodeValue, err := s.Encode(name, value) fmt.Println("encodeValue:", encodeValue, err) // 解析到decodeValue中 decodeValue := make(map[string]string) s.Decode(name, encodeValue, &decodeValue) fmt.Println("decodeValue:", decodeValue)
this.Ctx.Output.Cookie(name, encodeValue) this.Ctx.Output.Body([]byte("Hello World"))}
复制代码


当然,其他类型也是支持的。大家有兴趣的可以自行看下源码。

使用 securecookie 对 value 加密

securecookie 不止可以对明文值进行编码,而且还可以对编码后的值进一步加密,使 value 值更安全。加密也很简单,就是在调用 securecookie.New 的时候传入第二个参数:加密秘钥即可。如下:



// Hash keys should be at least 32 bytes long var hashKey = []byte("keep-it-secret-keep-it-safe-----")
// Block keys should be 16 bytes (AES-128) or 32 bytes (AES-256) long. // Shorter keys may weaken the encryption used. var blockKey = []byte("1234567890123456") // 实例化securecookie var s = securecookie.New(hashKey, blockKey)
name := "userid" value := "1234567"
encodeValue, err := s.Encode(name, value)
复制代码


以下是经过 securecookie 加密后的 cookie 值输出结果:



在 securecookie 包中,是否对 cookie 值进行加密是可选的。在调用 New 时,如果第二个参数传 nil,则 cookie 值只进行 hash,而不加密。如果给第二个参数传了一个值,即秘钥,则该包还会对 hash 后的值再进行加密处理。这里需要注意,加密秘钥的长度必须是 16 字节或 32 字节,否则会加密失败。

对 cookie 值进行解码

有编码就有解码。在收到请求中的 cookie 值后,就可以使用相同的 securecookie 实例对 cookie 值进行解码了。如下:


package main
import ( "fmt" "github.com/beego/beego" "github.com/gorilla/securecookie")

func main() { beego.Router("/", &MainController{})
beego.RunWithMiddleWares(":8080")}
type MainController struct { beego.Controller}
func (this *MainController) Get() { // Hash keys should be at least 32 bytes long var hashKey = []byte("keep-it-secret-keep-it-safe-----")
// Block keys should be 16 bytes (AES-128) or 32 bytes (AES-256) long. // Shorter keys may weaken the encryption used. var blockKey = []byte("1234567890123456") // 实例化securecookie var s = securecookie.New(hashKey, blockKey)
encodeValue := this.Ctx.GetCookie("userid")
value := "" s.Decode("userid", encodeValue, &value) fmt.Println("decode value is :", value, encodeValue)
this.Ctx.Output.Cookie("userid", value)
this.Ctx.Output.Body([]byte("Hello World"))}
复制代码


该示例是我们把上次加密的 cookie 值发送给本次请求,服务端进行解码后写入到 cookie 中。本次输出正好是明文“1234567”。



这里需要注意的是,解码的时候 Decode 的第一个参数是 cookie 的 name 值。第二个参数才是 cookie 的 value 值。这是成对出现的。后面在讲编码的实现原理时会详细讲解。

三、实现原理

securecookie 包 Encode 函数的实现主要有两点:加密和 hash 转换。同样 Decode 的过程与 Encode 是相反的。


Encode 函数的实现流程如下:


序列化

第一步为什么要把 value 值进行序列化呢?我们看 securecookie.Encode 接口,如下:


func (s *SecureCookie) Encode(name string, value interface{}) (string, error)
复制代码


我们知道 cookie 中的值是 key-value 形式的。这里 name 就是 cookie 中的 key,value 是 cookie 中的值。我们注意到 value 的类型是 interface{}接口,也就是说 value 可以是任意数据类型(结构体,map,slice 等)。但 cookie 中的 value 只能是字符串。所以,Encode 的第一步就是把 value 值进行序列化。


序列化有两种方式,分别是内建的包 encoding/json 和 encoding/gob。securecookie 包默认使用 gob 包进行序列化:


func (e GobEncoder) Serialize(src interface{}) ([]byte, error) {    buf := new(bytes.Buffer)    enc := gob.NewEncoder(buf)    if err := enc.Encode(src); err != nil {        return nil, cookieError{cause: err, typ: usageError}    }    return buf.Bytes(), nil}
复制代码


知识点:encoding/json 和 encoding/gob 的区别:gob 包比 json 包生成的序列化数据体积更小、性能更高。但 gob 序列化的数据只适用于 go 语言编写的程序之间传递(编码/解码)。而 json 包适用于任何语言程序之间的通信。


如果在编码过程中想使用 json 对 value 值进行序列化,那么可以通过 SetSerialize 方法进行设置,如下:


cookie := securecookie.New([]byte("keep-it-secret-keep-it-safe-----")cookie.SetSerializer(securecookie.JSONEncoder{})
复制代码

加密

加密是可选的。如果在调用 secrecookie.New 的时候指定了第 2 个参数,那么就会对序列化后的数据加密操作。如下:


    // 2. Encrypt (optional).    if s.block != nil {        if b, err = encrypt(s.block, b); err != nil {            return "", cookieError{cause: err, typ: usageError}        }    }
复制代码


加密使用的 AES 对称加密。在 Go 的内建包 crypto/aes 中。该包有 5 种加密模式,5 种模式之间采用的分块算法不同。有兴趣的同学可以自行深入研究。而 securecookie 包采用的是 CTR 模式。如下是加密相关代码:


func encrypt(block cipher.Block, value []byte) ([]byte, error) {    iv := GenerateRandomKey(block.BlockSize())    if iv == nil {        return nil, errGeneratingIV    }    // Encrypt it.    stream := cipher.NewCTR(block, iv)    stream.XORKeyStream(value, value)    // Return iv + ciphertext.    return append(iv, value...), nil}
复制代码


该对称加密算法其实还可以应用其他具有敏感信息的传输中,比如价格信息、密码等。

base64 编码

经过上述编码(或加密)后的数据实际上是一串字节序列。如果转换成字符串大家可以看到会有乱码的出现。这里的乱码实际上是不可见字符。如果想让不可见字符变成可见字符,最常用的就是使用 base64 编码。base64 编码是将二进制字节转换成文本的一种编码方式。该编码方式是将二进制字节转换成可打印的 asc 码。就是先预定义一个可见字符的编码表,参考 RFC4648 文档。然后将原字符串的二进制字节序列以每 6 位为一组进行分组,然后再将每组转换成十进制对应的数字,在根据该数字从预定义的编码表中找到对应的字符,最终组成的字符串就是经过 base64 编码的字符串。在 base64 编码中有 4 种模式:


  • base64.StdEncoding:标准模式是依据 RFC 4648 文档实现的,最终转换成的字符由 A 到 Z、a-z、0-9 以及+和 / 符号组成的。

  • base64.URLEncoding: URLEncoding 模式最终转成的字符是由 A 到 Z、a-z、0-9 以及 - 和 _ 组成的。就是把标准模式中的+和/字符替换成了-和/。因为该模式主要应用于 URL 地址传输中,而在 URL 中+和/是保留字符,不能出现,所以讲其做了替换。

  • base64.RawEncoding: 该模式使用的字符集和 StdEncoding 一样。但该模式是按照位数来的,每 6bits 换为一个 base64 字符,就没有在尾部补齐到 4 的倍数字节了。

  • base64.RawURLEncoding: 该模式使用的字符集和 URLEncoding 模式一样。同样该模式也是按照位数来的,每 6bits 换为一个 base64 字符,就没有在尾部补齐到 4 的倍数字节了。


base64 编码的具体应用和实现原理大家可参考我的另外一篇文章:

使用 hmac 做 hash

简单来讲就是对字符串做了加密的 hash 转换。在上文中我们提到,加密是可选的,hmac 才是必需的。如果没有使用加密,那么经过上述序列化、base64 编码后的字符串依然是明文的。所以无论有没有加密,都要做一次 hash。这里使用的是内建包 crypto/hmac。


做 hmac 操作时,不是只对 value 值进行 hash,而是经过了字符串的拼接。实际上是对 cookie 名、日期、value 值三部分进行拼接,并用 "|"隔开进行的:



代码如下:


    // 3. Create MAC for "name|date|value". Extra pipe to be used later.    b = []byte(fmt.Sprintf("%s|%d|%s|", name, s.timestamp(), b))    mac := createMac(hmac.New(s.hashFunc, s.hashKey), b[:len(b)-1])
// Append mac, remove name. b = append(b, mac...)[len(name)+1:]
// 4. Encode to base64. b = encode(b)
复制代码


这里将 name 值拼接进字符串是因为在加码验证的时候可以对 key-value 对进行验证,说明该 value 是属于该 name 值的。 将时间戳拼接进去,主要是为了对 cookie 的有效期做验证。在解密后,用当前时间和字符串中的时间做比较,就能知道该 cookie 值是否已经过期了。


最后,将经过 hmac 的 hash 值除去 name 值后再和 b 进行拼接。拼接完,为了在 url 中传输,所以再做一次 base64 的编码。


相关知识:HMAC 是密钥相关的哈希运算消息认证码(Hash-based Message Authentication Code)的缩写,由 H.Krawezyk,M.Bellare,R.Canetti 于 1996 年提出的一种基于 Hash 函数和密钥进行消息认证的方法。其能提供两方面的内容:① 消息完整性认证:能够证明消息内容在传送过程没有被修改。② 信源身份认证:因为通信双方共享了认证的密钥,接收方能够认证发送该数据的信源与所宣称的一致,即能够可靠地确认接收的消息与发送的一致。

四、beego 框架中的 cookie 安全

笔者查看了常用的 web 框架 echo、gin、beego,发现只有在 beego 框架中集成了安全的 cookie 设置。但也只实现了用 hmac 算法对 value 值和时间戳做加密 hash。该实现在 Controller 的 SetSecureCookie 函数中,如下:


// SetSecureCookie puts value into cookie after encoded the value.func (c *Controller) SetSecureCookie(Secret, name, value string, others ...interface{}) {    c.Ctx.SetSecureCookie(Secret, name, value, others...)}
// SetSecureCookie Set Secure cookie for response.func (ctx *Context) SetSecureCookie(Secret, name, value string, others ...interface{}) { vs := base64.URLEncoding.EncodeToString([]byte(value)) timestamp := strconv.FormatInt(time.Now().UnixNano(), 10) h := hmac.New(sha256.New, []byte(Secret)) fmt.Fprintf(h, "%s%s", vs, timestamp) sig := fmt.Sprintf("%02x", h.Sum(nil)) cookie := strings.Join([]string{vs, timestamp, sig}, "|") ctx.Output.Cookie(name, cookie, others...)}
复制代码


五、总结

经过 securecookie 编码过的 cookie 值是不会被伪造的,因为该值是经过 hmac 进行编码的。而且还可以对编码过的值再进行一次对称加密。如果是敏感信息的话,建议不要存储在 cookie 中。同时,敏感的信息也一定使用 https 进行传输,以降低泄露的风险。


---特别推荐---

特别推荐:一个专注 go 项目实战、项目中踩坑经验及避坑指南、各种好玩的 go 工具的公众号。「Go 学堂」,专注实用性,非常值得大家关注。点击下方公众号卡片,直接关注。关注送《100 个 go 常见的错误》pdf 文档。


发布于: 刚刚阅读数: 5
用户头像

Go学堂

关注

关注「Go学堂」,学习更多编程知识 2019-08-06 加入

还未添加个人简介

评论

发布
暂无评论
「Go工具箱」web中想做到cookie值安全?securecookie库的使用和实现原理_golang_Go学堂_InfoQ写作社区