Web

ハッシュの使い分けは、値のエントロピーで決まる

  • セキュリティ
  • Argon2
  • ハッシュ

Argon2とSHA3-256は、どちらもハッシュですが設計目的が違います。Argon2はソルトが呼び出しごとに変わり、メモリーと時間を意図的に食います。SHA3-256は決定的で、速く終わります。片方をもう片方の代わりに使えるかどうかは、ハッシュ化する値が何ビットの乱数かで決まります。

// Argon2: ソルトが呼び出しごとに変わるので、同じ入力でも返る値は毎回違う
const a = await argon2.hash(value);
const b = await argon2.hash(value);
a === b; // false

// SHA3-256: ソルトが無いので、同じ入力からは必ず同じ値
const c = createHmac("sha3-256", pepper).update(value).digest("hex");
const d = createHmac("sha3-256", pepper).update(value).digest("hex");
c === d; // true

この差は速さの問題ではありません。何を守るために作られたハッシュか、という違いです。

ソルトは呼び出しごとに変わる

これは実装の都合ではなく、仕様どおりの挙動です。RFC 9106 の Section 3.1 はソルトについてこう書いています。

16 bytes is RECOMMENDED for password hashing. The salt SHOULD be unique for each password.

ライブラリーもそのとおりに作られています。たとえば node-argon2 v0.43.0 は、ソルトを渡さなければ呼び出しのたびに16バイトの乱数を生成します。

salt = salt ?? (await generateSalt(16));

返ってくるのは、そのソルトを埋め込んだ1本の文字列です。リファレンス実装である phc-winner-argon2 の README が載せている例がこの形です。

$argon2i$v=19$m=65536,t=2,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG

$ で区切られた各フィールドに、ソルトがそのまま入っています。

Argon2が返す文字列はドル記号区切りで、アルゴリズム名・バージョン・パラメーター・ソルト・ハッシュ本体が並ぶ

検証するときは、このフィールドからソルトとパラメーターを取り出し、入力を同じ条件で計算し直して突き合わせます。

毎回変える理由は OWASP Password Storage Cheat Sheet の Salting にあります。

Salting also protects against an attacker’s pre-computing hashes using rainbow tables or database-based lookups.

Finally, salting means that it is impossible to determine whether two users have the same password without cracking the hashes, as the different salts will result in different hashes even if the passwords are the same.

同じ値を保存しているレコードがあっても、保存された文字列を見比べただけでは分かりません。それがソルトの仕事です。

遅いのはわざとそうしている

もうひとつの性質が、メモリーと時間を意図的に食うことです。RFC 9106 の Section 4 は、パラメーターを決める手順を並べています。そのひとつがこれです。

Figure out the maximum amount of time (in seconds) that each call can afford.

許容できる時間を先に決め、そこを使い切るようにパラメーターを上げていく、という決め方です。緩めて速く終わらせれば、その分だけ総当たりも速く進みます。遅さそのものが防御になっています。

パスワードの保管では、この設計がそのまま効きます。1回のログインにつきハッシュ計算は1回で、人間が入力したあとに待てる時間が、そのまま総当たりのコストに変わります。

普通のハッシュのつもりで使うと壊れる

問題が出るのは、SHA-256やMD5と同じ感覚で使ったときです。ハッシュを保存しておいて、あとで同じ計算をして該当するレコードを探す。この手順はArgon2では成立しません。

ソルトを固定していない場合、同じ値からでも毎回違う文字列が出るので、WHERE hash = ? に渡す値そのものを作れないからです。検証は、保存された文字列からソルトを取り出して計算し直す手順なので、探したいレコードが先に手元にある前提で組まれています。

その前提を踏まえずに書くと、こうなります。

const rows = await db.selectAll(table);
for (const row of rows) {
  if (await argon2.verify(row.hash, presented)) return row;
}
return null;

登録が100件あれば、1リクエストにつき最大100回のArgon2検証が走ります。メモリーを確保して解放する処理を、件数の分だけ繰り返すことになります。

ハッシュを入れている列にインデックスを張ってあっても、等値で比較できない以上は使われません。同じ理由で、その列のUNIQUE制約も期待どおりには効きません。同じ値を2回ハッシュ化しても違う文字列になるので、重複を弾く役には立たないからです。

ソルトを固定しないArgon2はレコードごとにハッシュ計算が走り、決定的なハッシュは1回計算して該当する1件だけを引く

ソルトを固定しても割に合わない

ここで、ソルトを固定すればいいという手が浮かびます。実際、ソルトは呼び出すときに渡せます。上で引いた salt = salt ?? (await generateSalt(16)); は「渡さなければ乱数」という意味で、salt オプションを渡せばその値が使われます。固定すれば出力も決定的になり、WHERE hash = ? は書けるようになります。

それでも勧めません。RFC 9106 が The salt SHOULD be unique for each password と書いているとおり、レコードごとに変えるのが前提の設計です。固定すれば、同じ値を保存しているレコードが見分けられるようになります。ソルトが防いでいたものを、そのまま失うわけです。

もう一方の性質も残ります。全件の検証は消えても、メモリーハードな計算1回分のコストは、リクエストごとに払い続けることになります。許容時間を使い切るように調整した計算が、リクエストのたびに走ります。

雑に使えば壊れ、正しく使おうとすればコストが見合わない。そういう位置にあります。

一般的なハッシュアルゴリズムなら決定的で速い

SHA3-256のような普通のハッシュを使えば、同じ入力からは必ず同じ値が出ます。

function hashValue(value: string): string {
  return createHmac("sha3-256", pepper).update(value).digest("hex");
}

これで WHERE hash = ? が書けるようになり、照合は1件を取得するだけになります。ハッシュを入れた列のインデックスが、ここで初めて効きます。

失うものもはっきりしています。ソルトが無いので、同じ値を保存しているレコードは同じ文字列になり、見比べれば分かります。時間もかからないので、値の候補が数え上げられる程度なら総当たりで割られます。ソルトと遅さが守っていたものが、両方とも無くなります。

ソルトの代わりに置くのが pepper です。レコードごとに変えず、データベースの外に置きます。OWASPの Peppering はこう書いています。

Unlike a password salt, the pepper should not be public and should not be stored along with the generated hash. The pepper should be stored separately from the password database.

置き場所として同ページが挙げているのは secrets vault や HSM です。データベースのダンプだけが漏れても、pepper が無ければ総当たりを始められません。ただし pepper は後から替えられません。替えるなら、保存してある値を全部作り直すことになります。

鍵の混ぜ方を単純な連結ではなくHMACにしているのは、鍵付きハッシュとして枯れた構成だからです。SHA-3で鍵付きハッシュを使うなら、それに当たるのは NIST SP 800-185 のKMACです。

なお、SHA3-256より新しくて推奨されているハッシュがあるわけではありません。NISTのHash Functionsのページは、SHA-3を「providing alternatives to the SHA-2 family」と位置づけていて、SHA-2の置き換えとしては扱っていません。どちらも approved なので、決定的で速いハッシュが要るならSHA-256でも構いません。

使い分けはエントロピーで決まる

2つを並べると、選び方は「どちらが安全か」ではなくなります。決め手は、ハッシュ化する値がどう作られているかです。

線引きの数字は NIST SP 800-63B の Section 5.1.2.2 にあります。

Look-up secrets having at least 112 bits of entropy SHALL be hashed with an approved one-way function as described in Section 5.1.1.2. Look-up secrets with fewer than 112 bits of entropy SHALL be salted and hashed using a suitable one-way key derivation function, also described in Section 5.1.1.2.

NIST SP 800-63B, Digital Identity Guidelines: Authentication and Lifecycle Management(SP 800-63-3 版)

この節が対象にしているのは使い捨ての復旧コードのようなルックアップシークレットですが、判断の基準になっているのは値のエントロピーだけなので、ほかの秘密値にも読み替えられます。

crypto.randomBytes のような暗号論的乱数から24バイト取れば192ビットあり、112ビットの線を大きく超えます。1回のハッシュが何ナノ秒で終わろうと総当たりは終わりません。サーバーが発行するAPIキーやトークンは、たいていここに入ります。ストレッチが効くのは、候補が数え上げられる大きさに収まっているときだけです。

112ビットに届かない値もあります。パスワードでなければ何でもよい、ではありません。利用者が自分で文字列を決められる値、招待コード、桁数の決まったワンタイムコード、連番やタイムスタンプを混ぜて作った識別子は、パスワードでなくてもこの線を越えません。

112ビットを境に、下回る値はソルト付きのKDF、上回る値は承認された一方向関数でよいと分かれる

決めるのは文字列の長さではなく、作り方です。届かない値にはArgon2を使います。OWASPも「Fast hashing algorithms such as SHA-256 are not suitable for password storage」と明記しています。