RubyのBcrypt gemを理解する

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

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

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




<br>




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




<br>




## ハッシュ文字列
　Bcryptによって生成された文字列の形式は以下のようになっています。
 
　**`$2a$08$eabkFHMdr8ZVgBfDGYsjA.hUq4nHOnPISgCrtcEB3NRtFqrk2BEgi`**
 
　先頭の`$2a$` は、ハッシュ化に用いられたbcryptのバージョンを示します。
　次の`08`は、cost値です。
　次の$以降の`eabkFHMdr8ZVgBfDGYsjA.`(22文字)は、saltです。
　最後の`hUq4nHOnPISgCrtcEB3NRtFqrk2BEgi`(31文字)は、ハッシュ値です。




<br>




## 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によって、短時間でハッシュ値から平文を
　解読できない堅牢なハッシュアルゴリズムとして設計されているということです。




<br>




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

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

```ruby: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::SecurePassword`の`ActiveModel`はRails組み込みライブラリで、
　そこに含まれるモジュールの1つが`SecurePassword`です。下記はRailsガイド引用です。
 
>`ActiveModel::SecurePassword`は、任意のパスワードを暗号化して安全に保存する手段
>を提供します。

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

```ruby: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`が実行されます。
 
```ruby:rails/･･･/secure_password.rb
# ソースコードを一部抜粋
  class << self
    attr_accessor :min_cost # :nodoc:
  end
    self.min_cost = false
```

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

```ruby: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のソースコードです。
```ruby: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`を指定する理由は、処理効率を考慮
　しているからだと思います。

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

　5行目の`BCrypt::Password.create(string, cost: cost)`をBcryptのソースコードで確認して
　みると下記のように記述されていました。
 
```ruby: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)`は下記のようにな
　っています。

```ruby: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`は下記のようになっています。

```ruby: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つに分割され、それぞれ対応するインスタンス変数に格納されます。

 ```ruby: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チュートリアルのログイン機構で実装されているコードです。
 
```ruby: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なら処理を実行させないようにしているのですね。

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

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

 ```ruby: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`)をハ
　ッシュ化します。結果的にハッシュ値同士で比較を行っています。




<br>




## 参考文献
- Wikipedia(2022). 「bcrypt」. https://ja.wikipedia.org/wiki/Bcrypt, (参照 2024-06-15).
- Railsガイド(2024). 「ActiveModel基礎」. https://railsguides.jp/active_model_basics.html,
　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　(参照 2024-06-15).
- railstutorial.jp(2024). 「第7版」. https://railstutorial.jp/chapters/beginning?version=7.0,
　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　(参照 2024-06-15).
- GitHub(2024). 「bcrypt-ruby」. https://github.com/bcrypt-ruby, (参照 2024-06-15).
- GitHub(2024). 「rails」. https://github.com/rails, (参照 2024-06-15).