bcryptの仕組み(とbcrypt-rubyの中身を見る)
Ruby(主にRails)でパスワードを扱う際のハッシュ化には bcrypt-ruby を使うことがほとんどだと思います。
bcryptについて勉強しつつ、さらに実装を確認してみようと思います。
bcrypt-ruby
は v3.1.15
を利用しています。
bcrypt
説明はwikipediaにあります。
https://www.usenix.org/legacy/event/usenix99/provos/provos.pdf
Blowfishの鍵セットアップ関数に Eksblowfish
を使っていて、このセットアップにcostとソルトとパスワードを利用しています。
セットアップ後、 OrpheanBeholderScryDoubt
という24 byteの文字列を64回繰り返し暗号化しバイト列を生成します。
bcrypt-ruby
ではここで定義されています。
そして、costとソルトと暗号化して生成されてバイト列を結合したものがbcryptがハッシュ値として生成する文字列になります。
bcrypt-ruby
でハッシュ化された文字列
bcrypt-ruby
でハッシュ化に使われるバージョンは 2a
のみぽいです。
生成されるハッシュは接頭辞は $2a$
になっています。
irb(main):002:0> my_password = BCrypt::Password.create("my password") => "$2a$12$zafAQ3jKUpR7TXwYRm8nmeO.3p/1iZN9a09F7cFs7M6kNZxnbvf9C" irb(main):003:0> my_password.version => "2a"
ただし、他のバージョンでハッシュ化されたものも読み込めそうです。
irb(main):006:0> new_password = BCrypt::Password.new("$2y$05$/OK.fbVrR/bpIqNJ5ianF.CE5elHaaO4EbggVDjb8P19RukzXSM3e") => "$2y$05$/OK.fbVrR/bpIqNJ5ianF.CE5elHaaO4EbggVDjb8P19RukzXSM3e" irb(main):007:0> new_password.version => "2y"
ハッシュ化されている文字列はいくつかの区分に分けられます。
まず 最初の $ $
で 囲われている部分はバージョンです。
次に $ $
で囲われているのは、コストの部分です。
これは、2を基数とした指数となり、この数の2の累乗がEksblowfishでのセットアップ中での繰り返し回数になります。(ストレッチング)
この回数が多ければ、ブルートフォース攻撃になどに対する対抗が強くなりますが、その分計算量が増えて計算に時間がかかるようになります。
bcrypt-ruby
では BCrypt::Engine.calibrate()
といいメソッドが用意されており、どれくらいのコストになるかをざっくりと計算できます。
irb(main):002:0> BCrypt::Engine.calibrate(5000) # この場合は5000ms以内で終わる最大のコストを算出してくる => 16
bcrypt-ruby
ではハッシュ化のcostを指定できます。
irb(main):004:0> my_password = BCrypt::Password.create("my password", cost: 4) => "$2a$04$KV38A7SDgPKjmVi7ir59muRmquFxR/KmX/HtgWY97qC1tBryajHp."
ただし、4以下の場合は全て4に置き換えられます。
https://github.com/codahale/bcrypt-ruby/blob/master/lib/bcrypt/engine.rb#L67-L69
irb(main):005:0> my_password = BCrypt::Password.create("my password", cost: 1) => "$2a$04$vLCmqS2n9tnYpnKT.G9wJuhBPjtKakH2/9zOQuU1IIv.HIDj6q27e" # コストが04になっている
また、costが31より大きいと ArgumentError
の例外が発生します。
https://github.com/codahale/bcrypt-ruby/blob/master/lib/bcrypt/password.rb#L45
コスト以降の部分はソルトとチェックサムになります。
128ビットのソルトになるのですが、Radix-64エンコードされて22バイトになっています。
チェックサムも184ビットなのですが、Radix-64エンコードされて31バイトになっています。
パスワードの確認する際は、 bcrypt-ruby
ではハッシュ化された文字列のバージョンやコストも含む先頭から29バイトをソルトとして渡して処理をしていきます。
ハッシュ化
bcrypt-ruby
ではソルトの生成におけるランダムな16バイトを生成するために OpenSSL::Random.random_bytes()
を利用しています。
https://github.com/codahale/bcrypt-ruby/blob/master/lib/bcrypt/engine.rb#L74
生成されたソルトを利用してハッシュ化をします。
https://github.com/codahale/bcrypt-ruby/blob/master/lib/bcrypt/password.rb#L46
同じソルトを使えば、同じ文字列からは同じハッシュ値が生成されます。
irb(main):032:0> my_password = BCrypt::Password.create("my password") => "$2a$12$VoGypMhauXeq4LZcLMbk6uGo/moLdWs/OAGUNbq9gLe9p.YZyOqXm" irb(main):033:0> BCrypt::Engine.hash_secret("my password", "$2a$12$VoGypMhauXeq4LZcLMbk6u") => "$2a$12$VoGypMhauXeq4LZcLMbk6uGo/moLdWs/OAGUNbq9gLe9p.YZyOqXm"
bcrypt-ruby
の利用で気をつけること
Nullバイトの対応
bcrypt-ruby
は Nullバイトの対応ができてないです。
irb(main):039:0> password = BCrypt::Password.create("foo\0bar") => "$2a$12$YPoekx0N24xH50Z0y9JfUeBpEBvYLNISVaSaH4oWJKAWnoTHa46RO" irb(main):040:0> password == "foo" => true
PRはあるのですが、mergeされていませんね、、
なので、下記2つのブログで言及されているようなことで問題になります。
パスワードの文字数は72文字まで
72文字以降は切り捨てられます。
irb(main):043:0> password = BCrypt::Password.create("a" * 73) => "$2a$12$CnOALTas/7Yj0JuzBW3GuOE1rKm3DZfN7N6OLwEGH8lnELV7H3iEC" irb(main):044:0> password == "a" * 72 => true
これは他の言語の実装でも同じような挙動になることが多いとのことです。
SecretSalt
bcryptでは OrpheanBeholderScryDoubt
という文字列を暗号化することですが、この固定の文字列も変えてハッシュ値を取るのが SecretSalt
になります。