memorandums

日々の生活で問題解決したこと、知ってよかったことなどを自分が思い出すために記録しています。

bcrypt-rubyについて調べたメモ

Railsでログイン機能を実装するときに、パスワードを暗号化するのに表記のライブラリを利用するのが普通らしいです。

授業のネタ本にしている以下の書籍の361ページに以下の図で説明がありました。

改訂3版基礎 Ruby on Rails (KS IMPRESS KISO SERIES)

改訂3版基礎 Ruby on Rails (KS IMPRESS KISO SERIES)

f:id:ke_takahashi:20181011231355p:plain

暗号強度を高めるため生パスワードにソルトと呼ばれるランダムな文字列を付加してハッシュ化してDBに保存しておきます。なるほどなるほど。

ログインするときに、入力されたパスワードにソルトを加えてハッシュ化して暗号同士が等しいか比較する。。。という流れです。これもわかります。

で、僕がよくわからないな。。。と思ったのはこの「ソルト」。

パスワードをDBに登録するときにランダムで生成される文字列と、ログインするときまたまたランダムで生成される文字列が同じになるわけないじゃん。。。と思ったわけです。少なくともそう読み取ってしまいました。

で、調べたんですね。

でも、「rails bcrypt」で検索しても出てくるのは使い方だけ。

中にはこういうのもありましたが、やはり異なるタイミングで生成されるはずの(←この時点でのも僕の理解はそうだった)ソルトが同じになる仕組みがわからない。。。

帰りの電車であちこちのサイトを探しましたが詳しいアルゴリズムに関する説明がどこにもない。

結局、bcrypt-rubyのレポジトリを見るしかない。。。と行き着き、最初にREADME.mdを読みましたが、まだわからない。。。

一応、以下のような仕組みの説明もしてくれているんです。。。でも肝心のソルトがどこからくるのかわからない。。。「ソルトはハッシュと一緒にDBに保存するぜ」ってどこだよ。。。

f:id:ke_takahashi:20181011232527p:plain

結局、ソースを読みました。構成ファイルも2,3個しかなく、ソースコードは短く読みやすいものでした。

ソースコードを読んでやっとわかりました。

ちょっと例を使って説明します。簡略化するため実際のbcrypt-rubyの文字数とは異なりますのでご了承ください。また、もし、理解が間違っていたら教えてください。

パスワードをハッシュ化するときの入力情報が以下とします。

パスワード:a
ソルト:A

このソルトとパスワードを連結してハッシュ関数に渡します。すると結果として"A123"が返ってきます。ハッシュ関数の答えは本来は"123"なのですが、ハッシュ関数に入力したソルトがわかるように"123"の前にソルトの文字列をつけて"A123"という文字列が返ってくるという寸法です。ここで僕の疑問は晴れました。

hash("A"+"a")  =>  "A123"

あとは、ログインするときに暗号化されたパスワード "A123" からソルト"A" を取り出し、入力されたパスワードが"a"だったら、ハッシュ化後のパスワードは"A123"になるので合致する。。。という感じです。

実装上はもっと込み入っているようです。bcrypt-rubyのドキュメントの例を使って説明してみます。

以下を実行すると、pに暗号化後のデータが入ったBCrypt::Passwordのインスタンスが生成されます。

p = BCrypt::Password.create("my password")

具体的には暗号文は以下のようになります(createメソッドを実行するたびにソルトが変わるので同じ文字列になることはありませんので、当然ですが。。。)。

$2a$10$vI8aWBnW3fID.ZQ4/zo1G.q1lRps.9cGLcZEiGDMVr5yUP1KUOYTa

$が区切り記号で、2aがバージョン番号、10がコスト、ソルトは先頭から左から29番目の文字列(ピリオド)までの文字列のようです。コードによると。ただ「$2a$10$」は固定の文字列ですから本来の意味のソルトは29−7=22文字ということになります。残りはチェックサムと書かれていました。

で、このあと、上記の暗号文を入力として、ユーザが入力したパスワードと照合するのが以下のコードになります。

p2 = BCrypt::Password.new("$2a$10$vI8aWBnW3fID.ZQ4/zo1G.q1lRps.9cGLcZEiGDMVr5yUP1KUOYTa")

このnew(つまりinitializeメソッド)は以下のように書かれています。

@version, @cost, @salt, @checksum = split_hash(self)

そして、split_hashメソッドは以下。つまり、new()に与えられた引数の文字列からソルト(@salt)を取り出してセットしている感じですね。

    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

このあと、ユーザが入力したパスワード(平文)と照合します。ユーザとしては以下のように書きます。

if  p2 ==  "my_password"
    照合成功!
end

これは文字列の照合をしているわけではなく、BCrypt::Passwordクラスに定義された == メソッドが実行されています。

    def ==(secret)
      super(BCrypt::Engine.hash_secret(secret, @salt))
    end

ここではsecretには==の右辺であるsecretに"my_password"が入りますので、"my_password"と上記の暗号文から取り出したソルト("$2a$10$vI8aWBnW3fID.ZQ4/zo1G.")の2つの引数をもとにハッシュ関数で計算した結果が返ってくるという感じです。

なるほど。。。ですね。

レインボーテーブルってのもついでに勉強しました。文字列に対するハッシュ値を予め計算しておいてテーブルにすると。でも、ソルトがあると、このソルトの文字列の組み合わせ数分だけテーブルが必要になり、膨大な大きさになる。。。ユーザが入力したパスワードが8文字だったとしてもソルト(上記の例では22文字も!!)分だけ長くすることができるので、レインボーテーブルも巨大になるっていうことですね。わかります。

ちなみに、このbcrypt-rubyの==メソッドをみて思ったんですが、平文(secret)は必ず右辺に来ないとダメですよね。。。

試しに左辺に持ってきて実行してみました。やはり結果はアウト(false)でした。

if   "my_password"  ==  p2
    照合成功!
end

Rails4ではこうした記述を自分で書かなければならなかったようですが、Rails5では==部分などの細かい実装は書かなくてもハッシュパスワードを利用できるようになっているようです。

わかっている人にとっては効率的だけどなぁ。。。初学者にとっては、なんか知らないけど「こう書けばいい感じにRailsがパスワードを暗号化してくれるんだぜ」っていう世界が広がっていって。。。益々教えにくいな。。。と思ったりしました。

ちょっと後半、オネムの時間になったので説明が粗くなりましたが。。。あとで読んで思い出せる程度の情報は残した感じで。。。

おしまい。ねよ。