RailsのMessageVerifierの内部実装を追ってみた

この記事は Akatsuki Advent Calendar 2017 の 24 日目の記事です。
23日目 [Alternating Direction Method of Multipliers (ADMM) で Lasso 回帰](https://qiita.com/s-capybara/items/48e2829ae7c1d402bff8)

はじめに
---

鈴付き[^1]から赤笛になれるように日々仕事をしている新卒エンジニアです。
数ヶ月前にMessageVerifierで引っかかったことがあり、今更ではありますが内部でどのように実装されているのかを確認しました。

環境
ruby 2.4.1p111
Rails 5.0.5

MessageVerifierについて
---
RailsではMessageVerifierをCookieで改ざんのチェックに利用していて、署名をつけたメッセージを作成と、メッセージの署名を検証するのに利用されています。
他にはパスワード再発行メールでのトークンなどにも使われているそうです。

内部の処理
---
### 作成
`Rails.application.message_verifier(verifier_name).generate(message)`のような形で利用できます。
まず、`message_verifier`メソッドで`secrets.yml`で設定した`secret_key_base`と引数に渡した`verifier_name`で鍵を作ります（[key_generator](https://github.com/rails/rails/blob/48f5cecdb03f697a710169ccef1885fcaca70788/railties/lib/rails/application.rb#L165)）
そして、作成した鍵を引数に渡して`ActiveSupport::MessageVerifier`オブジェクトを作成します。

```ruby
def message_verifier(verifier_name)
  @message_verifiers[verifier_name] ||= begin
    secret = key_generator.generate_key(verifier_name.to_s)
    ActiveSupport::MessageVerifier.new(secret)
  end
end
```

そのあとに`ActiveSupport::MessageVerifier.generate`で引数に渡したRubyオブジェクトを文字列化しBase64エンコードした文字列と、Base64エンコードしたものを鍵と組み合わせてSHA1でハッシュ化した文字列を`--`で繋げます。

```ruby
def generate(value)
  data = encode(@serializer.dump(value))
  "#{data}--#{generate_digest(data)}"
end

def generate_digest(data)
  require 'openssl' unless defined?(OpenSSL)
  OpenSSL::HMAC.hexdigest(OpenSSL::Digest.const_get(@digest).new, @secret, data)
end
```

## 検証
署名付きメッセージの検証とメッセージをRubyオブジェクトに戻す処理を行います。
`Rails.application.message_verifier(verifier_name).verify(message)`のような形で利用できます。
`message_verifier(verifier_name)`は送信時の処理で説明したので割愛しますが、`verifier_name`は送信時に使った値と同じにしないと鍵の作成で異なるものが作成されてしまい正しくチェックできません。

`verified`メソッドの`valid_message?`で改ざんチェックを行い、チェックが通れば`decode(data)`でデコードして`@serializer.load`で元のオブジェクトと同じ状態のオブジェクトを生成します。
改ざんが見つかった場合、`ActiveSupport::MessageVerifier::InvalidSignature`がraiseされます。

```ruby
def verify(signed_message)
  verified(signed_message) || raise(InvalidSignature)
end

def verified(signed_message)
  if valid_message?(signed_message)
    begin
      data = signed_message.split("--".freeze)[0]
      @serializer.load(decode(data))
    rescue ArgumentError => argument_error
      return if argument_error.message =~ %r{invalid base64}
      raise
    end
  end
end

```

チェック処理を追ってみると`ActiveSupport::SecurityUtils.secure_compare(digest, generate_digest(data))`で`digest`と`generate_digest(data)`のバイト毎の比較を行なっています。（[ActiveSupport:: SecurityUtils.secure_compare](https://github.com/rails/rails/blob/48f5cecdb03f697a710169ccef1885fcaca70788/activesupport/lib/active_support/security_utils.rb#L9)）
ここで署名付きメッセージが改ざんされていないかを調べているのですね。

```ruby
def valid_message?(signed_message)
  return if signed_message.nil? || !signed_message.valid_encoding? || signed_message.blank?

  data, digest = signed_message.split("--".freeze)
  data.present? && digest.present? && ActiveSupport::SecurityUtils.secure_compare(digest, generate_digest(data))
end
```

最後に
---
MessageVerifierがどのような処理を行なって署名の作成と検証を行なっているかを調べました。
月並みですが、Railsの内部を読むのは勉強になりますね。
MessageVerifierのコードはそこまで深くコードを追うこともなく、自分にとってはちょうどいいくらいの深さでした。
また、コードを追っていった結果タイミングアタック[^2]という攻撃があることも知りました。
今後は、詰まった時にRails内部を追えるように日頃からRails力を高めていきます。

[^1]: メイドインアビスネタ。
[^2]: http://kengos.jp/2016/01/31/CVE-2015-7576.html