Rails Rails Cookie 如何解密

doitian · 2017年09月23日 · 最后由 StephenZzz 回复于 2019年08月17日 · 10994 次阅读
本帖已被管理员设置为精华贴

CC-BY-SA 原文:http://blog.iany.me/zh/2017/09/rails-cookie-encryption/

如果想在已有的 Rails app 上使用其它语言加些 API,同时能直接使用 Rails 的登陆信息,最简单的就是用 Nginx 等代理将不同的服务映射到相同的域名下,其它的 App 解密 Cookie 获得登陆信息。

本文以 Ruby 代码为例说明 Rails 的 Cookie 是如何加密,然后以 Go 为例说明如何解密的。

rails-cookie-encryption

Rails 的实现可以参考 ActiveSupport::MessageEncryptorActiveSupport::MessageVerifier 和相应的单元测试。

加密

上图说明了原始的 Session 对象 Session Data 是如何最终生成 Cookie 的。如果登陆用了 Devise,那么 Session Data 中的登陆信息保存在 warden.user.user.key 中。之后就用下面例子说明加密。

session = { "warden.user.user.key" => [[1],"secret"] }

从 Rails 4.1 开始,默认使用的 JSON,4.1 之前使用的 Ruby Marshal。为了方便其它语言中解析,推荐使用 4.1 或更新的版本并使用 JSON 做为 Cookie 的 serializer。配置在 config/initializers/cookies_serializer.rb

Rails.application.config.action_dispatch.cookies_serializer = :json

JSON 的 serializer 就很直接了

require 'json'
session_json = JSON.dump(session)
puts session_json.inspect
# => "{\"warden.user.user.key\":[[1],\"secret\"]}"

② Padding

下一步的加密要求数据的字节数必须是 16 的倍数,用的算法是 PKCS7。简单说就是如果差 n 个字节到下个 16 的倍数就补 n 个 n。如果刚好是 16 的倍数就补 16 个 16。

def padding(data, block_size = 16)
  n = block_size - data.bytesize % block_size
  return data.force_encoding('ASCII-8BIT') + n.chr * n
end
padded_session = padding(session_json)
puts padded_session.inspect
# => "{\"warden.user.user.key\":[[1],\"secret\"]}\t\t\t\t\t\t\t\t\t"

末尾的 \t ASCII 码是 9,表示补了 9 个字节。

③ 加密 AES-CBC

这一步是最主要的加密了,算法是 AES-CBC。加密需要配置密钥并随机生成 IV (initialization vector)。因为 Ruby 的 OpenSSL::Cipher 封装会自动 padding,所以可以跳过第 ② 步。

我们知道 Rails 需要配置 secret key base,密钥就是通过 secret key base 和 salt 产生的,使用的算法 pbkdf2 在 OpenSSL 里也提供了。

OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, iter, keylen)
  • pass 配置中的 secret key base
  • salt 如果使用默认 Rails 配置的话,加密是 encrypted cookie,后面签名步骤是 signed encrypted cookie
  • iter 默认是 1000, keylen 加密是 32,签名是 64。也可以统一用 64,但是加密的 Key 只取前 32 个字节。
require 'openssl'
SECRET_KEY_BASE = "development_secret"
DEFAULT_SALT     = "encrypted cookie"
DEFAULT_SIGN_SALT = "signed encrypted cookie"
DEFAULT_ITER = 1000
DEFAULT_KEYLEN = 64
def generate_key(secret_key_base, salt, iter = DEFAULT_ITER, keylen = DEFAULT_KEYLEN)
  OpenSSL::PKCS5.pbkdf2_hmac_sha1(secret_key_base, salt, iter, keylen)
end

encrypt_key = generate_key(SECRET_KEY_BASE, DEFAULT_SALT)[0...32]
puts Base64.strict_encode64(encrypt_key)
# => vozBHj31liL/p88es/k7aywa4Po4mwMVkW/eqhFjw/4=

IV 是随机的 16 个字节。解密的时候需要用到,所以需要保存起来下一步拼装的时候用。可以用 SecureRandom.random_bytes 或者 OpenSSL::Cipher.random_iv

使用 OpenSSL 实现如下,IV 应该要随机的,为了方便对照,直接用了 16 个 0

encrypt_key = generate_key(SECRET_KEY_BASE, DEFAULT_SALT)[0...32]
puts Base64.strict_encode64(encrypt_key)

cipher = OpenSSL::Cipher.new("aes-256-cbc")
cipher.encrypt
cipher.key = encrypt_key

iv = "\0" * 16
# iv = cipher.random_iv

encrypted_content = cipher.update(session_json)
encrypted_content << cipher.final
puts Base64.strict_encode64(encrypted_content)
# => t7c1ncaCXhZAOPRtX0BI8eceOmx/Qg3Jrg6uwmgJuSNosKIc7M4KRfOw1q3mFWv7ZSiNO3ZRPxJMGI1cDvu+PQ==

④ 拼装加密内容和 IV

得到 encrypted_contentiv 后,分别 base64 后用 -- 连接,然后再做一次 base64 得到 encrypted_data

encrypted_data = Base64.strict_encode64(
  Base64.strict_encode64(encrypted_content) +
  "--" +
  Base64.strict_encode64(iv)
)
puts encrypted_data
# => dDdjMW5jYUNYaFpBT1BSdFgwQkk4ZWNlT214L1FnM0pyZzZ1d21nSnVTTm9zS0ljN000S1JmT3cxcTNtRld2Ny0tQUFBQUFBQUFBQUFBQUFBQUFBQUFBQT09

⑤ 签名 HMAC-SHA1

签名用的 HMAC-SHA1,结果转成 16 进制字符串。Key 参考 加密步骤中的说明。

sign_key = generate_key(SECRET_KEY_BASE, DEFAULT_SIGN_SALT)
sign = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, sign_key, encrypted_data)
puts sign
# => 75d8323b0f0e41cf4d5aabee1b229b1be76b83b6

⑥ 拼装签名

最后把 encrypted_datasign-- 连接然后做一次 URL Query Escape 就可以了

require "uri"
cookie_content = URI.encode_www_form_component(encrypted_data + "--" + sign)
puts cookie_content
# => dDdjMW5jYUNYaFpBT1BSdFgwQkk4ZWNlT214L1FnM0pyZzZ1d21nSnVTTm9zS0ljN000S1JmT3cxcTNtRld2Ny0tQUFBQUFBQUFBQUFBQUFBQUFBQUFBQT09--75d8323b0f0e41cf4d5aabee1b229b1be76b83b6

完整的代码:rails-cookie-encrypt.rb

如果用 ActiveSupport 可以简化成

require "active_support/key_generator"
require "active_support/message_encryptor"
encrypt_key = ActiveSupport::KeyGenerator.new(SECRET_KEY_BASE, iterations: DEFAULT_ITER).generate_key(DEFAULT_SALT, 32)
sign_key = ActiveSupport::KeyGenerator.new(SECRET_KEY_BASE, iterations: DEFAULT_ITER).generate_key(DEFAULT_SIGN_SALT, 64)
encryptor = ActiveSupport::MessageEncryptor.new(encrypt_key, sign_key, serializer: JSON)
puts encryptor.encrypt_and_sign(session)

解密

解密就是把 6 个步骤反过来,输入就是

cookieContent := "dDdjMW5jYUNYaFpBT1BSdFgwQkk4ZWNlT214L1FnM0pyZzZ1d21nSnVTTm9zS0ljN000S1JmT3cxcTNtRld2Ny0tQUFBQUFBQUFBQUFBQUFBQUFBQUFBQT09--75d8323b0f0e41cf4d5aabee1b229b1be76b83b6"

⑥ 分离签名

URL Query Unescape 然后以 -- 分成 encryptedDatasign

var err error
var unescapedCookieContent string
if unescapedCookieContent, err = url.QueryUnescape(cookieContent); err != nil {
  panic(err)
}
encryptedDataSignVectors := strings.SplitN(unescapedCookieContent, "--", 2)
encryptedData := encryptedDataSignVectors[0]
sign := encryptedDataSignVectors[1]
fmt.Printf("encrypted_data = %v\n", encryptedData)
fmt.Printf("sign = %v\n", sign)
// => encrypted_data = dDdjMW5jYUNYaFpBT1BSdFgwQkk4ZWNlT214L1FnM0pyZzZ1d21nSnVTTm9zS0ljN000S1JmT3cxcTNtRld2Ny0tQUFBQUFBQUFBQUFBQUFBQUFBQUFBQT09
// sign = 75d8323b0f0e41cf4d5aabee1b229b1be76b83b6

⑤ 验证签名

验证签名其实就是再签一次然后对比结果。为了安全,可以使用 hmac.Equal 来比较签名是否一致。

Key 的生成可以使用 golang.org/x/crypto/pbkdf2

const (
  keyIterNum = 1000
  keySize    = 64
)

func generateKey(base, salt string) []byte {
  return pbkdf2.Key([]byte(base), []byte(salt), keyIterNum, keySize, sha1.New)
}

验证实现如下

secretKeyBase := "development_secret"
defaultSignSalt := "signed encrypted cookie"
signKey := generateKey(secretKeyBase, defaultSignSalt)
signHmac := hmac.New(sha1.New, signKey)
signHmac.Write([]byte(encryptedData))
verifySign := signHmac.Sum(nil)
fmt.Printf("verifySign = %v\n", hex.EncodeToString(verifySign))
// verifySign = 75d8323b0f0e41cf4d5aabee1b229b1be76b83b6
var signDecoded []byte
if signDecoded, err = hex.DecodeString(sign); err != nil {
  panic(err)
}
if !hmac.Equal(verifySign, signDecoded) {
  panic(fmt.Errorf("verification failed"))
}

④ 分离加密内容和 IV

Base64 解码一次,用 -- 分离并分别 Base64 解码得到 encryptedContentiv

var encryptedDataBase64Decoded []byte
if encryptedDataBase64Decoded, err = base64.StdEncoding.DecodeString(encryptedData); err != nil {
  panic(err)
}
encryptedContentIvVectors := strings.SplitN(string(encryptedDataBase64Decoded), "--", 2)
var encryptedContent []byte
var iv []byte
if encryptedContent, err = base64.StdEncoding.DecodeString(encryptedContentIvVectors[0]); err != nil {
  panic(err)
}
if iv, err = base64.StdEncoding.DecodeString(encryptedContentIvVectors[1]); err != nil {
  panic(err)
}
fmt.Printf("encrypted_content = %s\n", base64.StdEncoding.EncodeToString(encryptedContent))
fmt.Printf("iv = %v\n", iv)
// encrypted_content = t7c1ncaCXhZAOPRtX0BI8eceOmx/Qg3Jrg6uwmgJuSNosKIc7M4KRfOw1q3mFWv7
// iv = [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]

③ 解密

用 Key 和 iv 来解密

defaultSalt := "encrypted cookie"
encryptKey := generateKey(secretKeyBase, defaultSalt)[:32]
c, err := aes.NewCipher(encryptKey)
if err != nil {
  panic(err)
}

cfb := cipher.NewCBCDecrypter(c, iv)
paddedSession := make([]byte, len(encryptedContent))
cfb.CryptBlocks(paddedSession, encryptedContent)
fmt.Printf("padded_session = %s\n", strconv.QuoteToASCII(string(paddedSession)))
// padded_session = "{\"warden.user.user.key\":[[1],\"secret\"]}\t\t\t\t\t\t\t\t\t"

② Un-padding

去除 padding 只需要看最后一个字节是多少就移除多少个字节。

padding := int(paddedSession[len(paddedSession) - 1])
sessionJSON := string(paddedSession[:(len(paddedSession) - padding)])
fmt.Printf("session_json = %s\n", sessionJSON)
// session_json = {"warden.user.user.key":[[1],"secret"]}

如果是 JSON 用 go JSON 库解析就可以了。如果是 Ruby Marshal 也不用完整实现,可以用正则提取需要的信息。

var jsonData map[string]interface{}
if err := json.Unmarshal([]byte(sessionJSON), &jsonData); err != nil {
  panic(err)
}
fmt.Printf("%+v\n", jsonData)
// map[warden.user.user.key:[[1] secret]]

完整的代码:rails-cookie-decrypt.go

如果 Rails 里用的 Devise,可以在 config/initializers/devise.rb 增加下面的配置来在 Cookie 中包含更多的字段,比如用户名或邮箱

Warden::Manager.after_authentication do |user, auth, opts|
  auth.raw_session['warden.user.user.email'] = user.email
end

需要用户重新登陆或者更换 secret key base 才会生效。

jasl 将本帖设为了精华贴。 09月23日 16:19

请教一下 ③ 加密 AES-CBC 中的 IV 是否应该也像 encrypt_key( cipher.key = encrypt_key ) 一样应该传给 cipher?

Go 蜜汁出镜~撇开技术讨论以外,这种 case 是不是改用 JWT 更合适点~

很好,学习了,用的时候再看看

才注意到是 Go server 和 Rails 共享 session,我也觉得 jwt 或者 fernet 可能更方便

arthur_h 回复

是的,JWT 更正规,更安全,也不依赖 Rails 内部实现。

doitian 回复

JWT 算是比较通用的、独立的方案,但是这个在 Go 项目中读取 Rails 的 session 也有人发过包,也还是有价值的。

这里是一个 fork 的版本: gorails.

好详细的讲解 我们之前也写过一个 PHP 版本的 Ruby Marshal 用来解 Rails Cookie

搭个车 Ruby Marshal in PHP

gingerhot 回复

看过这个,这个库没有做签名验证,但是实现了 Ruby Marshal 的解码。

doitian 回复

是没有做签名验证。我看了你下你的实现,给加上了, 同时增加了测试

另外又用它写了一个读取 Devise session 的例子

需要 登录 后方可回复, 如果你还没有账号请 注册新账号