書いてあること
Webアプリケーションのパスワードを、SHA256でハッシュ化するためのコードと、そのへんの防御にまつわる疑問点。
「二重」というのは、SHA256ハッシュ化したものに、更にsaltと呼ばれるidに固有の文字列を連結してもう一度ハッシュ化しているためです。
日本語訳して「お塩を振る」と考えてみるとちょっと可愛い。
更新履歴
- 2018/11/12 17:57
- 「疑問点」について、コメントにてご解説頂けたので一部更新しました。
- 2018/11/16 14:20
- 1018/11/19 10:47
- 打ち消し線で修正していた部分を直しました(更新差分を見られることを知りませんでした…)
基本的なしくみ
DBにはユーザのidのほかに、
- salt値
- 二重にハッシュ化したパスワード
を持っておき、認証の際には
- 平文パスワードをSHA256でハッシュ化
- 1に「salt値」を文字列連結
- 2を更にSHA256でハッシュ化
- 3とDBの「二重にハッシュ化したパスワード」が一致していればログイン
という手順を踏みます。
(どこまでクライアントでどこまでサーバなのかは私はよくわかっていません。以下の「疑問点」差参照)
タッチした経緯
以前Redmine1.1⇒3.2という劇的なバージョンアップを行った際、パスワードの保存方法が変わっていて一手間必要だったもので、その時書いたコードの流用です。
1.1ではパスワードを一回ハッシュ化しただけの文字列がDBに保存されていたのですが、3.2では上記のようにsaltという値を新しく持って二重にハッシュ化するという仕様になっていました。
単純にバージョンアップするとテーブルの中のsaltカラムは空なわけで、ログインできないため、saltを任意に設定し、二重暗号化した後のハッシュ値で値を更新しました。
コード
JavaScriptでハッシュ化
https://github.com/Caligatio/jsSHAを使わせてもらいました。
sha256.jsを読み込んでおいて、
// 入力されたパスワード
var pass = "passworddayo";
// ハッシュ化
var shaObj = new jsSHA("SHA-256", "TEXT");
shaObj.update(pass);
var passhash = shaObj.getHash("HEX");
※GitHubにあるサンプルそのまんまです。
Javaでハッシュ化・二重ハッシュ化
java.security.*とjava.util.UUIDをimportしといて、
// 文字列にSHA256をかけてハッシュ化するメソッド
public static String sha256(String orgStr) {
String hashed = "";
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(orgStr.getBytes());
BigInteger bi = new BigInteger(1, hash);
hashedStr = String.format("%0" + (hash.length << 1) + "x", bi);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
return hashedStr;
}
// 上記メソッドの呼び出し方(二重)
public static void main(String[] args) {
// ※本当は平文のパスワードがサーバに送られてくることはありません
String pass = "passworddayo";
// 一回ハッシュ化。JavaScriptでHash化したものと同じ文字列になる
// なので本当はこの文字列がサーバに送られてくる
String tmpHash = CryptUtil.sha256(pass);
System.out.println("tmpHash : " + tmpHash);
// 桁数がRedmineの仕様にぴったりなので、今回saltにはUUIDを使用
// 本当ならsaltはDBに登録してある値を利用する
String salt = UUID.randomUUID().toString().replaceAll("-", "");
System.out.println("salt : " + salt);
// ハッシュとsaltを文字列連結して再度ハッシュ化
// これがDBに登録するhash値になる
String hash = CryptUtil.sha256(salt + tmpHash);
System.out.println("hash : " + hash);
}
Javaの実行結果はこんな感じになります。
tmpHash : 198be4d7f6b01b0e9166cc1fe21ff81aefb7117aa51d3862671aba999fb7320c
salt : 990e3886c87149cf98dcdad1a1cfc85b
hash : 646bcd9ce0db63bf1aea73673b3c9863b1033cb1fbc8d78150fad1437db7941c
疑問点:クライアントからサーバに送られるのはどういうデータなのか。
2018/11/12 17:57
以下の件はコメントにてご教授頂けました!
Qiitaに公開してよかった!
だいぶ恥はかいた気がしますが訊かぬは一生の恥って奴と思いましょう。
(時間があればRedmineのコード読みます)
(詳しい人いらしたらコメントください)
(最新のRedmineではまた仕様変わってるかも)
SHA256ハッシュは不可逆暗号ということになっていますが、よくあるパスワードのハッシュは当然リスト化されています。
saltを加える=(隠し味的に)お塩を加えることでリスト攻撃から守ろうという発想なのでしょう、が…
クライアント-サーバ間の通信は一体以下のどれが適切なのか???
idと、暗号化していないパスワードをサーバに送る?
ダメでしょう。(パケット覗かれたら終わる)
一回ハッシュ化されたパスワードとidが送られてくる?
- クライアントからサーバに、一回ハッシュ化したパスワードとidを送る
- サーバ側で送られてきたidを元にsalt値を引いてきて、二重ハッシュ化
- サーバ側でDBに保存されている二重ハッシュと2を比較して認証
この場合、リスト攻撃としてはハッシュ化パスワードをサーバに送りつけるだけでログイン成否の判定はできてしまう。
あんまり意味なくない?
idだけ送り、salt値をサーバからもらって、二重ハッシュを再度送る?
- クライアントからサーバにidを送る
- サーバ側で送られてきたidを元にsalt値を引いてきて、クライアントに返す
- クライアント側で二重ハッシュを作ってサーバに送る
- サーバ側でDBに保存されている二重ハッシュと3を比較して認証
これでも迂遠になっただけで、やっぱり適当なid(流出したメールアドレスとか)を送りつければあとは戻って来たsaltとパスワードリストを掛け合わせてサーバに送りつければ判定できる。
攻撃側の計算量は増えてしまうので、効率を落とせるというのは利点か。
そもそもDBの中身が流出したら?
saltの値も流出するので、やっぱりリスト照合はできてしまう。
攻撃側の計算量は増える。それだけ。
Redmineのコードを読めば上記のいずれなのかは分かる、けど…
攻撃側の計算量が増えることが無駄だとは言いませんが、saltを加えるという一手間も、「よくあるパスワードはリスト攻撃で破られる」という問題の解決にはなっていないような。
パスワード認証という形式である以上リスト攻撃を完全にブロックするのは無理なんでしょうか。