認証処理を実装するならハッシュはSHAファミリよりBCryptを使おう


今回は認証のパスワードをハッシュ化する部分についての話です。

パスワードのハッシュ化をもしSHA256などのハッシュ関数でハッシュ化しているのであれば、できればBCryptに乗り換えた方が良いよ、というのが今回の話です。

詳細はここが参考になります。

ようするにSHAは高速を考えて作られたので、パスワードのハッシュ関数向きではない、と。高速に実行できてしまうとそれだけレインボーテーブルの作成が容易になる、ということですからセキュアじゃないですね。

ただ、それでもBCryptとかを使えばストレッチングがいらないよ、という訳ではないようです。あくまで従来通り考え方はハッシュ関数+ソルト+ストレッチングです。

ただ、BCryptの実装であるjBCryptを使うとかなり扱いが楽になるのではないかと思います。どのように楽になるか書いてきます。

jBCryptの使い方

Scalaでも使えますが、今回はJavaで行きます。まずはpom.xmlに以下を書きます。

<dependency>
  <groupId>org.mindrot</groupId>
  <artifactId>jbcrypt</artifactId>
  <version>0.3m</version>
</dependency>

Gradleとかsbtの人はこちらへ。

dependencyを書いたら後はコードを書くだけです。こんな感じで。

package com.tsukaby.bcrypt;

import org.mindrot.jbcrypt.BCrypt;

public class Main {
  public static void main(String[] args) {
    final String salt = BCrypt.gensalt();
    System.out.println("生成したソルト = " + salt);

    final String hashedPasswordWithSalt = BCrypt.hashpw("hogePassword1234", salt);
    System.out.println("ハッシュ化したパスワード = " + hashedPasswordWithSalt);

    // hashedPasswordWithSaltをDBに格納
    // ここでは省略
    // something(hashedPasswordWithSalt);

    // DBからhashedPasswordWithSaltを取り出す
    // ここでは省略。取り出したことにして代入
    final String passwordInDB = hashedPasswordWithSalt;

    // パスワードの検証
    final boolean checkResult = BCrypt.checkpw("hogePassword1234", passwordInDB);
    System.out.println("hogePassword1234の検証結果 = " + checkResult);

    // 誤ったパスワードの場合
    final boolean checkResult2 = BCrypt.checkpw("hogePassword12345678", passwordInDB);
    System.out.println("hogePassword12345678の検証結果 = " + checkResult2);

  }
}

以下は結果の例です。

生成したソルト = $2a$10$SvWYQgx/AKizANy.tp6zDu
ハッシュ化したパスワード = $2a$10$SvWYQgx/AKizANy.tp6zDulPku8eglSnopMcyi5cIRQY1Efa6cIUW
hogePassword1234の検証結果 = true
hogePassword12345678の検証結果 = false

使うメソッドは以下の3種類だけなので簡単です!

  • BCrypt.genSalt()
  • BCrypt.hashpw(String, String)
  • BCrypt.checkpw(String, String)

一見「あれ、checkpwでsalt指定していないし、saltをDBに保存していないのでダメなのでは?」と思うのですが、実はhashpwで求めたハッシュ値の中にsaltも入っています。

「saltは分かったけど、ストレッチングしてないしダメじゃないの?」と思うかもしれませんが、実はストレッチングの回数もハッシュ値の中に入っています。

コードを読めば分かるのですが$2a$10$という部分の2aがバージョン(特に意味無し)で、10という部分がストレッチングの回数(累乗ですので、2^10 = 1024)です。その後のランダムな文字列がsaltです。もしストレッチングの回数を変更したい場合はBCrypt.genSalt(12)というように別のメソッドを使用します。

hashpwで一気に内容の詰まった文字列を取得できることもさることながら、この値をそのままcheckpwに使える点が非常に良いですね。

これの良いところはDBにsalt列を作らなくてよい、ということです。ユーザ新規作成時にgetSaltを行い、hashpwを行いハッシュ化パスワードを取得したらこれをDBに格納します。ログイン時にはまずIDから当該レコードを引っ張ってきて、その中のPASSWORD列の値とユーザが入力したパスワードをcheckpwにかけるだけです。DBも一緒に考えるとこれはかなり楽で良い感じですね。

気になるhashpwの実行時間

実際のところどれくらい時間がかかるのでしょうか。以下のような感じで計測してみました。

計測マシン

Macbook pro Retina 13inch

2.4 GHz Intel Core i5

計測コード

package com.tsukaby.bcrypt;

import org.mindrot.jbcrypt.BCrypt;

public class Main {
  public static void main(String[] args) {

    // ストレッチング回数を変えて実行 時間計測
    // jBCryptの仕様として4以上、31以下を指定する必要がある。今回は適当な回数で打ち切り
    for (int i = 4; i < 20; i++) {
      generateHashpwWithLogRound(i);
    }
  }

  private static void generateHashpwWithLogRound(int logRound) {
    final long start = System.nanoTime();
    final String hashpw = BCrypt.hashpw("hogePassword1234", BCrypt.gensalt(logRound));
    final long end = System.nanoTime();

    System.out.println(String.format("stretch = %2d hashpw = %s time = %s ms", logRound, hashpw, ((end - start) / 1000.0 / 1000.0)));

  }
}

結果

 4) stretch =       16 hashpw = $2a$04$rWLxPjJflGUn33AVtBaR7eDa2z0Zq7oL1CBOj.S0LcYXM7cWPV5ji time = 24.786393 ms
 5) stretch =       32 hashpw = $2a$05$gqNlb5C39rco1Z8PKKkPrex3SZWReRGpcE.Htm5Gj3i1qsEO1Dzqu time = 6.447721 ms
 6) stretch =       64 hashpw = $2a$06$JvhV1t6rbXHmn/pJyIb3q.fO2U98162Wkx4zWHBu8kJcrrHiYH1DG time = 8.617184 ms
 7) stretch =      128 hashpw = $2a$07$BgCQLniJydVvmUBToEtHpO4TfMr0iKp03vvVmCv3ga1qAQt2dg.ce time = 14.625033 ms
 8) stretch =      256 hashpw = $2a$08$mqyFiWwHb68af4rZNtHTouzYmhvm6juMrIGLffU0QtrfsZakRkRTy time = 25.711727 ms
 9) stretch =      512 hashpw = $2a$09$YFSLK/6eVYJY/VBVVnYq7O41M/QrzQYUf5.tBuqaMVFwXA/ahAKOO time = 49.965218 ms
10) stretch =     1024 hashpw = $2a$10$IPgMMRy2a854BTF5zBDn3uSi0y87ZkYrdUCE1p8gDAQCUy4sEK6rK time = 98.826235 ms
11) stretch =     2048 hashpw = $2a$11$3bx.g1AlGjUIGaWOUNBdNOT6X.oJQPHCeXvm8rbroOG.ZqrE6XRS2 time = 201.440515 ms
12) stretch =     4096 hashpw = $2a$12$kGWJDy8Z7tebKq1h.m7e2OcIfWMwLSZnIa1I2LzHaTf89ytfrS97e time = 418.147059 ms
13) stretch =     8192 hashpw = $2a$13$DoGGCKmbdmRaFg.y6qIaY.GtJAxfQr1aPQHmfcnleHktKJG1USzC. time = 822.526686 ms
14) stretch =    16384 hashpw = $2a$14$mSGjBTiQqdzaptH10geVquVqVN94bY2SUzhJ0QMvXa3e0SvM7quPm time = 1664.954439 ms
15) stretch =    32768 hashpw = $2a$15$NEDKeWmxJmR7hLXJyaZA3utDC6ITtQZkkGRvYlYHulwBeUESh5oz. time = 3221.842111 ms
16) stretch =    65536 hashpw = $2a$16$My8RRobn52MASVPT2mvK0OgbL4g22BNPdl.HwgIVhrrMOVYrGaOsm time = 6549.603303 ms
17) stretch =   131072 hashpw = $2a$17$fV9ZU0b6Qtv6v1Po3aRnfOrab0DRr92Yoe0mqtdQxyJZiKCO7h6Ni time = 13052.4996 ms
18) stretch =   262144 hashpw = $2a$18$VDA2D19e9c8mqU9UohY7v.X3pZeZ993FCx5anSqvdxqIZ.cTlEAJC time = 26370.699136 ms
19) stretch =   524288 hashpw = $2a$19$BHMrf5Mzw8PHLxFKI/aIkOskctiYjo.PoLx.CjxUGB5BA7k3cw5be time = 52618.258914 ms
20) stretch =  1048576 hashpw = $2a$20$Muj02Rt5xhsoN24GPrQ55Ohxso7Ze0FX5XN8dUKffDqlHZCYy3.fu time = 105030.776419 ms

当然ですが2のn乗の回数で増えて行くのでそれに応じて計算時間も倍になっていきます。初回だけなぜか遅いのは多分JITの問題でしょう。ちなみに今回はhashpwの時間を計測しましたが、checkpwもほぼ同じ処理するので計算時間は同じです。実際ほぼ同じでした。

リクエスト数が少ないWebアプリケーションならまだしも、ソーシャルゲームやSNSなどの1秒間にそれなりの数のログインリクエストが飛んでくるシステムの場合、デフォルトの1024回ストレッチングでレスポンスが1秒/1reqだと死ねる気がします。

ここら辺は適切に設定する必要がありますね。