Table of Contents

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

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

Rails Cookie 加密
Rails Cookie 加密

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 才会生效。