1
1

RubyのBcrypt gemを理解する

Posted at

はじめに

 Bceyptは、Railsチュートリアル(7.0)の6章で bcrypt gemをインストールしてから、ログイ
 ン機構のバリデーションやトークン関連で活用されています。特にトークンでは、Bcrypt
 に定義されているメソッドを明示的に使用して、トークンのハッシュ化や比較検証を行っ
 ています。それら実装されたコードを見ただけでは、どのような処理が行われているのか
 不明確で扱い辛いと感じました。そこでBcrypt gemについて調べました。

 記事の内容は、まずbcryptの概要を理解した後、Railsチュートリアルのコードを実際に確
 認していきます。

 Railsチュートリアルの9章までのコードを引用させてもらっています。
 実際は、11章でトークン比較検証のロジックが少し変わっています。具体的に
 は、記憶ダイジェストではなく別のものを渡してPassword.newしています。


Bcryptとは?

 パスワードハッシュ化関数のこと。
 パスワードとして使用する平文にsaltを追加して作成した文字列から、ブロック暗号方式を
 基盤としたハッシュ化処理によってハッシュ値を算出します。
saltとは
 saltはランダムに生成される文字列。通常のハッシュ化では、平文に対して毎回同じハッシ
 ュ値を算出します。一方、saltを追加した平文をハッシュ化する場合では、同じ平文でも使
 用するsaltが異なればハッシュ値も異なる値となります。
ハッシュ化は不可逆
 暗号化は復号することで元の値に戻すことができますが、ハッシュ化ではハッシュ値から
 元の平文に戻すことはできない。そのため、パスワード検証の場合ハッシュ値同士が等し
 いことを確認することになります。
cost(stretch)とは
 ハッシュ化を繰り返す回数のことで、2のcost乗繰り返されます。デフォルトでは12が設定
 されているため、4096回ハッシュ化が繰り返されることになります。


ハッシュ文字列

 Bcryptによって生成された文字列の形式は以下のようになっています。

 $2a$08$eabkFHMdr8ZVgBfDGYsjA.hUq4nHOnPISgCrtcEB3NRtFqrk2BEgi

 先頭の$2a$ は、ハッシュ化に用いられたbcryptのバージョンを示します。
 次の08は、cost値です。
 次の$以降のeabkFHMdr8ZVgBfDGYsjA.(22文字)は、saltです。
 最後のhUq4nHOnPISgCrtcEB3NRtFqrk2BEgi(31文字)は、ハッシュ値です。


Bcryptの安全性

saltの役割
 平文にsaltを追加してハッシュ化することで、平文に対して一意のハッシュ値ではなくな
 ります。それによってレインボーテーブル攻撃による解読が困難になります。
レインボーテーブル攻撃とは
 パスワードとして使われやすい文字列のリストを用意し、攻撃対象のハッシュ化と同じア
 ルゴリズムを使ってリストからハッシュ値を生成します。それらの対応表をデータベース
 のテーブルとして保存し、ハッシュ値から元の平文を解読する攻撃方法です。
ハッシュ文字列にsaltを含んでいいのか?
 ハッシュ値とsaltを一緒の文字列にして管理すると、どのsaltを使用したかわかるため簡単
 に平文を解読できるのではないかと思いました。その疑問を解決するための糸口がBcrypt
 のREADME.mdに書かれていました。

Adding a salt means that an attacker has to have a gigantic database for each unique salt -- for a salt made of 4 letters, that's 456,976 different databases. Pretty much no one has that much storage space, so attackers try a different, slower method -- throw a list of potential passwords at each individual password:

 つまり、既存のレインボーテーブルとは異なる、新しくsaltが追加されたハッシュ値用のも
 のを用意しなければならない。さらにsaltを考慮したレインボーテーブルを作成するには、
 膨大なデータベース用のストレージが必要となる。これらの理由が攻撃の対抗手段になる
 ということです。
costの役割
 また、cost回数分繰り返しハッシュ化を行うことで、ブルートフォース攻撃にかかる時間
 を増大させることができます。
ブルートフォース攻撃とは
 パスワードとして利用される文字を組み合わせた全てのパターンを使って、片っ端に試し
 て解読していく攻撃手法です。例えば、スマートフォンやキャッシュカードの4桁の暗証
 番号の場合、0から9999の10,000通りを総当たりで試していくことになります。
costの効果
 Bcryptはデフォルトの場合4096回ハッシュ化が繰り返されるため、その回数分攻撃にかか
 る時間を増やすことができます。また、Bcryptは他のハッシュアルゴリズムに比べて処理
 が低速です。BcryptのREADME.mdに下記のように書かれています。

Hash algorithms aren't usually designed to be slow, they're designed to turn gigabytes of data into secure fingerprints as quickly as possible. bcrypt(), though, is designed
to be computationally expensive:

Ten thousand iterations:

user system total real
MD5 0.070000 0.000000 0.070000 ( 0.070415)
bcrypt 22.230000 0.080000 22.310000 ( 22.493822)

If an attacker was using Ruby to check each password, they could check ~140,000 passwords a second with MD5 but only ~450 passwords a second with bcrypt().

節のまとめ

 つまり、BcryptはMD5など一般的なハッシュアルゴリズムと違い、元々ハッシュ化処理が
 低速になるよう作られている。さらにsaltとcostによって、短時間でハッシュ値から平文を
 解読できない堅牢なハッシュアルゴリズムとして設計されているということです。


RailsチュートリアルのBcryptコード

 Railsチュートリアルのログイン機構で、User.digestauthenticated?の2つのメソッド内
 ロジックにBcryptコードが記述されています。これらのコードがBcryptでどのように処理さ
 れていくのか、ソースコードを元に確認したいと思います。

User.digestメソッド

 1つ目は、Userモデルのクラスメソッドとして定義されているUser.digestメソッドです。
 下記はRailsチュートリアルのログイン機構で実装されているコードです。

app/models/user.rb
  1  def User.digest(string)
  2    cost = ActiveModel::SecurePassword.min_cost ?
  3            BCrypt::Engine::MIN_COST :
  4            BCrypt::Engine.cost
  5    BCrypt::Password.create(string, cost: cost)
  6  end

 2行目のActiveModel::SecurePasswordActiveModelはRails組み込みライブラリで、
 そこに含まれるモジュールの1つがSecurePasswordです。下記はRailsガイド引用です。

ActiveModel::SecurePasswordは、任意のパスワードを暗号化して安全に保存する手段
を提供します。

 Railsのソースコードを確認してみると、ログインユーザー作成ファームのバリデーション
 で使用していたhas_secure_passwordや、パスワードを比較検証するauthenticateメソッ
 ドなどが定義されていました。下記はRailsのGitHubソースコードです。

rails/activemodel/lib/active_model/secure_password.rb
# ソースコードを一部抜粋したものです
 def has_secure_password(options = {})
   begin
     require "bcrypt"
   # ロジックは長いため以下省略
 end

 def authenticate(unencrypted_password)
   BCrypt::Password.new(password_digest).is_password?(unencrypted_password) && self
 end

 また、ここでは長くなるため引用は省略しますがrequire "bcrypt"BCrypt::Password
 BCrypt::EngineといったBcryptクラスメソッドやクラス変数を呼び出している箇所が多数
 見受けられることから下記の通りだと伺えます。下記はRailsガイド引用です。

ActiveModel::SecurePasswordモジュールはbcrypt gemに依存しているので、
ActiveModel::SecurePasswordを正しく使うにはこのgemをGemfileに含める
必要があります。

 話を元に戻してrailsソースコードをもう一度見てみると、min_costはアクセサメソッドを
 用いてクラス変数として定義されています。初期値にfalseが代入されているため、三項演
 算子によってBCrypt::Engine.costが実行されます。

rails/・・・/secure_password.rb
# ソースコードを一部抜粋
  class << self
    attr_accessor :min_cost # :nodoc:
  end
    self.min_cost = false

 但し、下記のソースコードを見るとわかりますが、テスト環境の場合はtrueが代入される
 ためBCrypt::Engine::MIN_COSTが実行されます。Rails::Railtieはrailsアプリケーション
 初期化用のクラスのようです。

rails/activemodel/lib/active_model/railtie.rb
# ソースコードを一部抜粋
  module ActiveModel
    class Railtie < Rails::Railtie # :nodoc:
      config.eager_load_namespaces << ActiveModel

      initializer "active_model.secure_password" do
        ActiveModel::SecurePassword.min_cost = Rails.env.test? #テスト環境ならtrue
      end
    end
  end

 BCrypt::Engineのソースコードを確認すると、DEFAULT_COSTの場合12、MIN_COSTの場合
 4、MAX_COSTの場合31で定義されています。下記はBcryptのソースコードです。

bcrypt-ruby/lib/bcrypt/engine.rb
# ソースコードを一部抜粋
  module BCrypt
    class Engine
      DEFAULT_COST    = 12
      MIN_COST        = 4
      MAX_COST        = 31

 BCrypt::Engine.costを調べると下記のように定義されています。つまり2~4行目は、
 テスト環境ならMIN_COSTの4が、それ以外の環境ならDEFAULT_COSTの12が@costに代
 入されるということです。テスト環境でMIN_COSTを指定する理由は、処理効率を考慮
 しているからだと思います。

bcrypt-ruby/・・・/engine.rb
# ソースコードを一部抜粋
  def self.cost
    @cost || DEFAULT_COST
  end

 5行目のBCrypt::Password.create(string, cost: cost)をBcryptのソースコードで確認して
 みると下記のように記述されていました。

bcrypt-ruby/lib/bcrypt/password.rb
# ソースコードを一部抜粋
1  class Password < String
2    class << self
3    def create(secret, options = {})
4        cost = options[:cost] || BCrypt::Engine.cost
5        raise ArgumentError if cost > BCrypt::Engine::MAX_COST
6        Password.new(BCrypt::Engine.hash_secret(secret, BCrypt::Engine.generate_salt(cost)))
7      end

 まず4行目から確認していきます。createメソッドにオプションとして渡した値をcostと
 して用い、渡さなければ先程説明したBCrypt::Engine.costメソッドによって@cost
 DEFAULT_COSTの値がcostに代入されます。

 5行目は、MAX_COST(31)より大きい数字をオプションで渡すと例外が発生します。
 次に6行目を確認します。まず、BCrypt::Engine.generate_salt(cost)は下記のようにな
 っています。

bcrypt-ruby/・・・/engine.rb
# ソースコードを一部抜粋
  def self.generate_salt(cost = self.cost)
    cost = cost.to_i
    if cost > 0
      if cost < MIN_COST
        cost = MIN_COST
      end
      if RUBY_PLATFORM == "java"
        Java.bcrypt_jruby.BCrypt.gensalt(cost)
      else
        __bc_salt("$2a$", cost, OpenSSL::Random.random_bytes(MAX_SALT_LENGTH))
      end
    else
      raise Errors::InvalidCost.new("cost must be numeric and > 0")
    end
  end

 costに自然数以外が代入されている場合例外が発生します。
 Java.bcrypt_jruby.BCrypt.gensaltもしくは__bc_salt("$2a$", cost,
 OpenSSL::Random.random_bytes(MAX_SALT_LENGTH))によってハッシュ化
 で使用するsaltを生成しているようです。

 次にBCrypt::Engine.hash_secretは下記のようになっています。

bcrypt-ruby/・・・/engine.rb
# ソースコードを一部抜粋
  def self.hash_secret(secret, salt, _ = nil)
    if valid_secret?(secret)
      if valid_salt?(salt)
        if RUBY_PLATFORM == "java"
          Java.bcrypt_jruby.BCrypt.hashpw(secret.to_s.to_java_bytes, salt.to_s)
        else
          secret = secret.to_s
          secret = secret.byteslice(0, MAX_SECRET_BYTESIZE) if secret && secret.bytesize > MAX_SECRET_BYTESIZE
            __bc_crypt(secret, salt)
        end
      else
        raise Errors::InvalidSalt.new("invalid salt")
      end
    else
      raise Errors::InvalidSecret.new("invalid secret")
    end
  end
    
  def self.valid_salt?(salt)
    !!(salt =~ /\A\$[0-9a-z]{2,}\$[0-9]{2,}\$[A-Za-z0-9\.\/]{22,}\z/)
  end

  # Returns true if +secret+ is a valid bcrypt() secret, false if not.
  def self.valid_secret?(secret)
    secret.respond_to?(:to_s)
  end

 valid_secret?ではハッシュとして利用できる文字列かを、valid_salt?ではsaltを正規表
 現で正しい形式かをチェックしています。falseの場合例外が発生します。
 Java.bcrypt_jruby.BCrypt.hashpwもしくは__bc_crypt(secret, salt)でハッシュ値を生成
 しているようです。

 最後にPassword.newを確認します。初期化処理でvalid_hash?によって引数の文字列を
 正規表現を使ってハッシュ値の形式か判定し、異なれば例外が発生します。 ハッシュ
 値ならsplit_hashメソッドによってアルゴリズムバージョン、cost、salt、ハッシュ値の
 4つに分割され、それぞれ対応するインスタンス変数に格納されます。

bcrypt-ruby/・・・/password.rb
# ソースコードを一部抜粋
 def initialize(raw_hash)
   if valid_hash?(raw_hash)
     self.replace(raw_hash)
     @version, @cost, @salt, @checksum = split_hash(self)
   else
     raise Errors::InvalidHash.new("invalid hash")
   end
 end

 def valid_hash?(h)
   /\A\$[0-9a-z]{2}\$[0-9]{2}\$[A-Za-z0-9\.\/]{53}\z/ === h
 end

 def split_hash(h)
   _, v, c, mash = h.split('$')
   return v.to_str, c.to_i, h[0, 29].to_str, mash[-31, 31].to_str
 end

まとめ

 User.digestメソッドのロジックはまず、railsクラス(ActiveModel::SecurePassword)に
 よってアプリケーション実行環境に応じたcost値が選択されます。次に、先程のcostと
 ハッシュ値に変換したい文字列をBcryptライブラリに渡します。BCrypt::Password.create
 は、渡された各々の値が正しい形式を採っているか判断しハッシュ化処理を行い、その値
 を返します。

authenticated?メソッド

 2つ目は、Userモデルのインスタンスメソッドとして定義されているauthenticated?です。
 下記はRailsチュートリアルのログイン機構で実装されているコードです。

app/・・・/user.rb
  1  def authenticated?(remember_token)
  2    return false if remember_digest.nil?
  3    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  4  end

 2行目のreturn false if remember_digest.nil?によって処理を分岐させる理由は、先程説
 明したBCrypt::Password.newが引数の文字列が正しい形式のハッシュ値でない場合例外が
 発生するからです。下記のコードのようにRailsチュートリアルのログアウト処理のforget
 メソッドでは、nilをremember_digestに代入しています。万が一この状態(値がnil)のまま
 BCrypt::Password.newが実行されるとBcryptライブラリ内部で例外が発生してしまうた
 め、nilなら処理を実行させないようにしているのですね。

app/・・・/user.rb
  # ユーザーのログイン情報を破棄する
  def forget
    update_attribute(:remember_digest, nil)  #remember_digestにいnilを代入
  end

 3行目のis.password?メソッドをソースコードで確認してみます。hash_secretメソッドに
 よってハッシュ値を生成しています。それを親クラスのStringに渡し、オーバライド元メ
 ソッドの==で比較処理を行っています。

bcrypt-ruby/・・・/password.rb
# ソースコードを一部抜粋
 class Password < String
   def ==(secret)
     super(BCrypt::Engine.hash_secret(secret, @salt))
   end
   alias_method :is_password?, :==

まとめ

 authenticated?メソッドのロジックはまず、BCrypt::Password.newの初期化処理によって
 ハッシュ値であるremember_digestが4つに分割され各々インスタンス変数に格納されま
 す。インスタンス変数のsaltを使うことで、同じsalt値で引数で渡された平文(secret)をハ
 ッシュ化します。結果的にハッシュ値同士で比較を行っています。


参考文献

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1