MessageEncryptor の互換性を調べてみた

こんにちは、masm11 です。

Rails に MessageEncryptor というクラスがあります。 何かを暗号化/復号する際に便利ですね。

例えば、

KEY = 'somekeysomekeysomekeysomekeysomekeysomekeysomekey'
enc = ActiveSupport::MessageEncryptor.new(KEY, cipher: 'aes-256-cbc')

としておけば、

data = enc.encrypt_and_sign(obj)
obj = enc.decrypt_and_verify(data)

で暗号化と復号ができます。

ところが、Ruby 2.4 で仕様が変わってしまったんです。 KEY がちょうど 32文字でないといけなくなりました。

ちょっと試したところでは、元のコードだと

key must be 32 bytes

というエラーが出ますし、

KEY = 'somekeysomekeysomekeysomekeysome'

と 32文字で切り捨てたキーを指定すると、

ActiveSupport::MessageVerifier::InvalidSignature

というエラーが出て復号できません。

さぁ困ったどうしよう、ということで調べた結果です。

結論は一番最後 (まとめ) にあります。

サンプルを作る

まずは Ruby 2.3.3 + Rails 5.2.4.4 でサンプルを作ります。

enc = ::ActiveSupport::MessageEncryptor.new('0123456789abcdefghijklmnopqrstuvwxyz', cipher: 'aes-256-cbc')

長い文字列がキーです。

enc.encrypt_and_sign('short string')
=> "MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==--8ae97f3fc117a62513fb8ca242f86d893f601c83"

short string という文字列を暗号化してみました。 その結果、長い文字列が得られています。これが暗号化文字列です。

OpenSSL を直接使って復号してみる

OpenSSL は 1.1.1i を使用しました。

上記の通り、サンプルは -- の左と右に分けられます。 このうち左側を取り出し、base64 デコードします。

$ echo MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ== | base64 -d
0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA=--qWtbnFglWUfMEL1KvT52jQ==

また base64 っぽい文字列が出てきました。

-- の左側を base64 デコードします。

$ echo 0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA= | base64 -d | od -tx1c
0000000  d1  e6  4e  09  25  d8  d3  2e  c1  5d  4c  88  05  42  47  b9
        321 346   N  \t   % 330 323   . 301   ]   L 210 005   B   G 271
0000020  91  8b  85  39  69  e5  36  d8  16  1c  89  b2  4b  20  84  90
        221 213 205   9   i 345   6 330 026 034 211 262   K     204 220
0000040

バイナリなので od に食わせています。 これが暗号化されたデータそのものです。

一方、-- の右側を base64 デコードします。

$ echo qWtbnFglWUfMEL1KvT52jQ== | base64 -d | od -tx1c                    
0000000  a9  6b  5b  9c  58  25  59  47  cc  10  bd  4a  bd  3e  76  8d
        251   k   [ 234   X   %   Y   G 314 020 275   J 275   >   v 215
0000020

こちらもバイナリなので od に食わせています。 これは iv (initialization vector) です。 暗号化の計算に使う初期値です。 擬似乱数のシードみたいなものと考えると良いでしょう。

また、キーの16進表記が必要なので、キーも od に食わせます。

$ echo -n 0123456789abcdefghijklmnopqrstuvwxyz | od -tx1c
0000000  30  31  32  33  34  35  36  37  38  39  61  62  63  64  65  66
          0   1   2   3   4   5   6   7   8   9   a   b   c   d   e   f
0000020  67  68  69  6a  6b  6c  6d  6e  6f  70  71  72  73  74  75  76
          g   h   i   j   k   l   m   n   o   p   q   r   s   t   u   v
0000040  77  78  79  7a
          w   x   y   z
0000044

ここまで準備ができたら、いよいよ openssl コマンドで復号します。

$ echo 0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA= | base64 -d | openssl enc -d -K 303132333435363738396162636465666768696a6b6c6d6e6f70717273747576 -iv a96b5b9c58255947cc10bd4abd3e768d -aes-256-cbc | od -tx1c
0000000  04  08  49  22  11  73  68  6f  72  74  20  73  74  72  69  6e
        004  \b   I   " 021   s   h   o   r   t       s   t   r   i   n
0000020  67  06  3a  06  45  54
          g 006   : 006   E   T
0000026

echo 0eZOCSXY0y7BXUyIBUJHuZGLhTlp5TbYFhyJsksghJA= | base64 -d の部分は先程見ました。 -- の左側を base64 デコードした、暗号化されたデータそのものです。これを openssl の 標準入力に与えています。

openssl enc -d は復号指示です。

-K 303132333435363738396162636465666768696a6b6c6d6e6f70717273747576 はキーの16進表記です。 先程 od でキーの16進表記を調べたので、それを指定します。ただし、キーの32文字までです。

-iv a96b5b9c58255947cc10bd4abd3e768d は iv の16進表記です。 こちらも先程 od で16進表記にしていますので、それを指定します。

-aes-256-cbc はアルゴリズムですね。

その結果を od に食わせています。よく見ると、それらしい文字が見えますね。復号できていそうです。

では、このバイナリから Ruby のオブジェクトを復元してみます。

使用した Ruby は 2.7.2 です。

Marshal.load("\x04\x08\x49\x22\x11\x73\x68\x6f\x72\x74\x20\x73\x74\x72\x69\x6e\x67\x06\x3a\x06\x45\x54")
=> "short string"

復元できました!

署名を検証する

最初に得たサンプル暗号化文字列のうち、-- より右側は署名です。 これを検証してみます。

OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, '0123456789abcdefghijklmnopqrstuvwxyz', 'MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==')
=> "8ae97f3fc117a62513fb8ca242f86d893f601c83"

OpenSSL::HMAC.hexdigest を呼び出しています。

  • SHA1 は固定です。
  • '0123456789abcdefghijklmnopqrstuvwxyz' はキーです。ここではキー全体を指定しています。
  • 'MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==' は暗号化文字列の -- より左です。

このように計算することで、"8ae97f3fc117a62513fb8ca242f86d893f601c83" が得られました。 これはサンプルの暗号化文字列の -- より右側に一致します。

署名が検証できました。

MessageEncryptor の構造

結局、MessageEncryptor はどのような処理をしているのでしょうか? まとめてみました。

  1. 暗号化
    1. シリアライズする (A)
    2. iv を決める (B)
    3. B とキー32文字を使って A を暗号化する (C)
    4. B, C をそれぞれ base64 エンコードする (D, E)
    5. E と D を -- を挟んでつなげる (F)
  2. 署名
    • キー全体を使って F の HMAC を計算する (G)
  3. F と G を -- を挟んでつなげる

上の復号手順は、これを逆にたどったものです。

MessageEncryptor の互換性

ここまで MessageEncryptor のコードを読みながら調べたのですが、 結局、キー32文字とキー全体の両方が必要そうです。 ここに復号できない原因があるわけです。

と、ここまで来て、ふと気づきました。

MessageEncryptor のコンストラクタは以下のようになっています。

def initialize(secret, *signature_key_or_options)
  options = signature_key_or_options.extract_options!
  sign_secret = signature_key_or_options.first
  @secret = secret
  @sign_secret = sign_secret          # <--------------- !!!
  @cipher = options[:cipher] || self.class.default_cipher
  @digest = options[:digest] || "SHA1" unless aead_mode?
  @verifier = resolve_verifier
  @serializer = options[:serializer] || Marshal
end

なんと、署名用のキーが指定できるではないですか。

enc = ::ActiveSupport::MessageEncryptor.new('0123456789abcdefghijklmnopqrstuv', '0123456789abcdefghijklmnopqrstuvwxyz', cipher: 'aes-256-cbc')
enc.decrypt_and_verify('MGVaT0NTWFkweTdCWFV5SUJVSkh1WkdMaFRscDVUYllGaHlKc2tzZ2hKQT0tLXFXdGJuRmdsV1VmTUVMMUt2VDUyalE9PQ==--8ae97f3fc117a62513fb8ca242f86d893f601c83')
=> "short string"

復号できました!

まとめ

Rails がそう簡単に互換性を捨てるはずがありませんね。解ってしまえば簡単なことでした。

enc = ActiveSupport::MessageEncryptor.new(KEY[0..31], KEY, cipher: 'aes-256-cbc')

こうですね。

インゲージではエンジニアを募集しています。 こんなことに熱中できる方、うちで働きませんか? 詳細は以下のページへ!

https://ingage.co.jp/recruit

ではまた!