deviseで使用しているbcryptによるencrypted_passwordの挙動


devise/bcrypt/encrypted_password

以下のバージョンで確認
devise v4.2.1
bcrypt-ruby v3.1.6

encrypted_passwordの生成

deviseからbcryptの呼び出し

    def self.digest(klass, password)
      if klass.pepper.present?
        password = "#{password}#{klass.pepper}"
      end
      ::BCrypt::Password.create(password, cost: klass.stretches).to_s
    end

self.digestに平文パスワードを渡して、暗号化されたパスワードを生成している。

ソルトアンドペッパー (salt & pepper)

平文パスワードにあらかじめ設定されている文字列(pepper)をくっつけ、結合された文字列をハッシュ化する。
さらに、ランダムに生成されたハッシュ(salt)を追加して、encrypted_passwordとする。
平文パスワードに塩こしょうをして暗号化するという洒落。

https://github.com/plataformatec/devise/blob/v4.2.1/README.md#configuring-models
READMEによると、deviseを利用しているmodelにpepperを設定出来る様になっている。特に設定がなければ、pepperは付加されない。

bcrypt側の処理

      def create(secret, options = {})
        cost = options[:cost] || BCrypt::Engine.cost
        raise ArgumentError if cost > 31
        Password.new(BCrypt::Engine.hash_secret(secret, BCrypt::Engine.generate_salt(cost)))
      end

costは4~31で設定可能。ハッシュ化処理の繰り返し回数で、数字が高い方が暗号強度は高いが、計算リソースを食う。デフォルトは10。

saltを何度か生成すると、その度にランダムに文字列が作成される事が分かる。

pry(main)> BCrypt::Engine.generate_salt(10)
=> "$2a$10$ei3qRUGN7BpBV7Y4MrvMvu"
pry(main)> BCrypt::Engine.generate_salt(10)
=> "$2a$10$gEQOmrnPdAzf2QYAtoq6ye"
pry(main)> BCrypt::Engine.generate_salt(10)
=> "$2a$10$v3KliXylcyE0uHWOy4WvMu"

上の3つのsaltを使って、hash_secretに平文のパスワードを渡してハッシュ化してみる。
3回ともバラバラなハッシュが生成され、0~29文字目まではsaltがそのまま使われている事が分かる。30文字目からはランダムに変化している。
つまり、
ランダムに生成したsalt + 生成されたsaltによって変化するパスワードのハッシュ値が生成されている、という事になる。

pry(main)> BCrypt::Engine.hash_secret('PASSWORD', '$2a$10$ei3qRUGN7BpBV7Y4MrvMvu')
=> "$2a$10$ei3qRUGN7BpBV7Y4MrvMvuk7yDRNVPT/c.ZNQHMXYx47FyJ4sinSO"
pry(main)> BCrypt::Engine.hash_secret('PASSWORD', '$2a$10$gEQOmrnPdAzf2QYAtoq6ye')
=> "$2a$10$gEQOmrnPdAzf2QYAtoq6yeyh19ReHlj1Ca1kpOWIb4iU150yGDdm2"
pry(main)> BCrypt::Engine.hash_secret('PASSWORD', '$2a$10$v3KliXylcyE0uHWOy4WvMu')
=> "$2a$10$v3KliXylcyE0uHWOy4WvMupgumCudZe6wVxSmV/WeqgVWM54HjZmO"

saltが一緒の場合は何度やっても同じハッシュが生成される。

pry(main)> BCrypt::Engine.hash_secret('PASSWORD', '$2a$10$v3KliXylcyE0uHWOy4WvMu')
=> "$2a$10$v3KliXylcyE0uHWOy4WvMupgumCudZe6wVxSmV/WeqgVWM54HjZmO"
pry(main)> BCrypt::Engine.hash_secret('PASSWORD', '$2a$10$v3KliXylcyE0uHWOy4WvMu')
=> "$2a$10$v3KliXylcyE0uHWOy4WvMupgumCudZe6wVxSmV/WeqgVWM54HjZmO"
pry(main)> BCrypt::Engine.hash_secret('PASSWORD', '$2a$10$v3KliXylcyE0uHWOy4WvMu')
=> "$2a$10$v3KliXylcyE0uHWOy4WvMupgumCudZe6wVxSmV/WeqgVWM54HjZmO"

当たり前だが、パスワードだけを変えるとsaltは同じで、パスワード部分だけが変化していく。

pry(main)> BCrypt::Engine.hash_secret('PASSWORD0', '$2a$10$v3KliXylcyE0uHWOy4WvMu')
=> "$2a$10$v3KliXylcyE0uHWOy4WvMuaC1SYeccEI1vnZeRJa3M.xVZx2tYaCO"
pry(main)> BCrypt::Engine.hash_secret('PASSWORD1', '$2a$10$v3KliXylcyE0uHWOy4WvMu')
=> "$2a$10$v3KliXylcyE0uHWOy4WvMugmN.mRm/mPQbFmhzfnu.HN/zZ7zYkt2"
pry(main)> BCrypt::Engine.hash_secret('PASSWORD2', '$2a$10$v3KliXylcyE0uHWOy4WvMu')
=> "$2a$10$v3KliXylcyE0uHWOy4WvMuUOgLYcUISiFlWy7809cjZwtwbiHJneC"

ハッシュ値の中身

"$2a$10$v3KliXylcyE0uHWOy4WvMupgumCudZe6wVxSmV/WeqgVWM54HjZmO"

$でsplitしている。それぞれ以下の意味を持つ。

2a : バージョン
10 : コスト
v3KliXylcyE0uHWOy4WvMupgumCudZe6wVxSmV/WeqgVWM54HjZmO : パスワード部分(前述の通り、前半部分はsaltの一部)

encrypted_passwordの比較

パスワードの比較

ログイン時にデータベースに保存されているencrypted_passwordと、ユーザーが入力して来た平文のパスワードの比較をする為に以下のメソッドが定義されている。
https://github.com/plataformatec/devise/blob/v4.2.1/lib/devise/encryptor.rb#L12-L20

    def self.compare(klass, hashed_password, password)
      return false if hashed_password.blank?
      bcrypt   = ::BCrypt::Password.new(hashed_password)
      if klass.pepper.present?
        password = "#{password}#{klass.pepper}"
      end
      password = ::BCrypt::Engine.hash_secret(password, bcrypt.salt)
      Devise.secure_compare(password, hashed_password)
    end

データベースに保存されているencrypted_passwordのsaltを取得する

https://github.com/codahale/bcrypt-ruby/blob/v3.1.6/lib/bcrypt/password.rb#L27
これは単純に、encrypted_passwordの0~29文字目を取るだけ。

取得したsaltを使って平文パスワードをハッシュ化する

encrypted_passwordの生成部分で書いた様に、saltが同じであればencrypted_passwordは一致するので、これによって比較が可能になっている。

実際に試してみるとこんな感じ。Userはdeviseを利用しているClass。

pry(main)> BCrypt::Engine.hash_secret('PASSWORD', '$2a$10$v3KliXylcyE0uHWOy4WvMu')
=> "$2a$10$v3KliXylcyE0uHWOy4WvMupgumCudZe6wVxSmV/WeqgVWM54HjZmO"
pry(main)> Devise::Encryptor.compare(User, '$2a$10$v3KliXylcyE0uHWOy4WvMupgumCudZe6wVxSmV/WeqgVWM54HjZmO', 'PASSWORD')
=> true

注意した方がいい点?

encrypted_passwordを決定するポイント

cost, pepper, salt

複数のアプリケーションがユーザーアカウントを共有しているような場合

encrypted_passwordを生成する場所は一カ所にまとめるか、cost, peeperが必ず一致する様に注意しておく必要がある。
saltは保存されているencrypted_passwordから取得するので特に気にする必要は無い様な気はする。