アカウント移植時、旧システムのパスワードはそのまま使えるのか
古いシステムから新しいシステムへユーザーアカウントを移植するとき、出てくる疑問がある。
「パスワードって引き継げるのか、それとも全員に再設定させるのか」
結論から言うと、password カラムの中身を見れば判断できる。ハッシュ値の先頭にヒントが書いてあるため。
このメモでは、実際に CodeIgniter(tank_auth) から Laravel へ移植した際の判断基準と、そこで踏んだ落とし穴をメモとして残す。
まず結論
DB の password カラムを覗いて、先頭を確認する。
SELECT LEFT(password, 7), COUNT(*) FROM users GROUP BY 1;
$2a$08$ 219
先頭が $2a$ または $2y$ なら bcrypt。そのまま移植できる。
先頭が $P$ なら phpass の独自形式。移植できない。全員パスワード再設定になる。
tank_auth は phpass というライブラリを同梱しているが、設定によって bcrypt モードと独自形式モードが切り替わる。同じ tank_auth でも案件によって結果が変わるため、必ず実データを確認すること。
なぜ別ライブラリ同士で使い回せるのか
tank_auth と Laravel(Breeze) はまったく別物なのに、なぜハッシュが共通で通用するのか。
答えは「bcrypt はライブラリではなくアルゴリズムだから」。
どちらも自前で暗号化しているわけではなく、bcrypt という共通の計算を呼び出しているだけ。最終的には両方とも PHP の crypt() に行き着く。
作った側が誰であっても、出てくる文字列の形式は同じになる。
ハッシュ値の構造を読む
bcrypt のハッシュは、意味のある区画に分かれている。
$2y$08$U081kas7gv3QCO2ZbyvjW.Zozbz5..yyzyDvn/y7Zze.i5lAW4nGe
$ 区切りで分解するとこうなる。
| 区画 | 値 | 意味 |
|---|---|---|
| 1 | 2y |
プレフィックス(アルゴリズム識別子) |
| 2 | 08 |
コスト |
| 3 | U081kas7gv3QCO2ZbyvjW. |
salt(22文字) |
| 4 | Zozbz5..yyzyDvn/y7Zze.i5lAW4nGe |
ハッシュ本体(31文字) |
必要な情報がすべて1本の文字列に畳み込まれている。これが移植を楽にしている最大の理由で、password カラムをコピーするだけで完結する。
この「$識別子$パラメータ$salt$ハッシュ」という書式は Modular Crypt Format と呼ばれる慣習。Unix の crypt() から続く形式で、パスワードハッシュの世界ではほぼ共通ルールになっている。
プレフィックスとは何を示しているか
先頭の $2y$ はアルゴリズムの名札にあたる。よく見かけるものを並べる。
| プレフィックス | 中身 | 移植可否 |
|---|---|---|
$2a$ |
bcrypt(旧表記) | 可(要変換) |
$2b$ |
bcrypt | 可 |
$2y$ |
bcrypt(PHP標準) | 可 |
$argon2i$ $argon2id$
|
Argon2 | 可 |
$P$ $H$
|
phpass 独自形式 | 不可 |
$2a$ と $2y$ はどちらも bcrypt で、計算結果は完全に同一。
2011年に一部の実装でマルチバイト文字の扱いにバグが見つかり、修正版を区別するため $2y$ が新設された。それだけの歴史的事情であり、中身の違いではない。
salt とは何か
salt はユーザーごとに割り当てられるランダムな文字列。パスワードやユーザーIDから生成されるわけではなく、純粋な乱数である。
同じパスワードでも salt が違えば結果が変わる。
Aさん password123 + salt(7f3a...) → $2y$10$7f3a...XXXXX
Bさん password123 + salt(c1e9...) → $2y$10$c1e9...YYYYY
↑ 結果が異なる
これによって防げるのは次の2つ。
| 防げること | 理由 |
|---|---|
| 同一パスワードの露見 | DB を見ても誰と誰が同じか分からない |
| 事前計算表の使い回し | 表が1ユーザー分にしか通用しない |
salt は秘密情報ではない。ハッシュ文字列に平文で書き込まれている。攻撃者に salt が知られても、総当たりの手間は一切減らないため問題にならない。むしろ書かれていないと検証側が再計算できず、照合が成立しない。
秘密にすべきなのは pepper(全ユーザー共通の秘密文字列をアプリ側に持つ手法)のほうで、こちらは DB の外に置く。今回の案件では使われていなかった。
検証時に salt をどう特定するか
「どの salt を使うか」はパスワードから判断しているのではない。先にユーザーが特定されている。
DB から引いた1行の中に salt が入っているので、迷いようがない。
コストとは何か
$2y$08$ の 08 がコスト。内部で計算を繰り返す回数を 2 の累乗で指定する。
| コスト | 反復回数 | 処理時間の目安 |
|---|---|---|
| 08 | 256 | 約 0.01 秒 |
| 10 | 1,024 | 約 0.05 秒 |
| 12 | 4,096 | 約 0.2 秒 |
MD5 や SHA が「速いこと」を目的に設計されているのに対し、bcrypt はわざと遅く作られている。
正規のログインは1回で済むため 0.2 秒かかっても誰も困らない。一方、総当たり攻撃側は全試行がその速度になるため現実的に不可能になる。
マシンが高速化したらコスト値を上げればよい設計になっている。Laravel はログイン成功時に Hash::needsRehash() を確認し、設定値より低ければ平文を握っているそのタイミングで再ハッシュして DB を更新する。
つまり旧システムのコスト 08 は、利用者がログインするたびに自動で現在の水準へ移行していく。移植後に何かする必要はない。
ハッシュを見て種類を見分けるコツ
判別のフローはこうなる。
$ で始まっていれば識別子が読める。始まっていなければ長さで推測するしかない。
| 長さ | 推測 | パスワード用途としての評価 |
|---|---|---|
| 32文字 | MD5 | 使ってはいけない |
| 40文字 | SHA-1 | 使ってはいけない |
| 64文字 | SHA-256 | 単体では不適 |
MD5 や SHA には識別子が付かない。salt もコストも持たないため、書くべきパラメータが存在しないからである。単一の固定的な計算しかしないので、構造化する必要がない。
これらはファイルの改ざん検知が本来の用途であり、速いことが利点になる。パスワード保存に使うとその速さが攻撃者の武器になるため不適切。
実際に踏んだ落とし穴
ここからが本題。bcrypt だと確認できても、$2a$ のまま Laravel に投入すると 500 エラーになる。
ログイン失敗ではなくサーバーエラーが返る。
検証してみる
password_verify() は $2a$ を問題なく処理する。
$hash = '$2a$08$ctrIrsdtQ9zWivv2v8stTe6MA098myWziljCIEd0P/BR9In1OS/em';
var_dump(password_verify('akamisoshiru', $hash));
// bool(true)
これだけ見ると通りそうに思える。しかし別の関数を通すと様子が変わる。
var_dump(password_get_info('$2a$08$ctrIrsdtQ9zW...'));
array(3) {
["algo"] => NULL
["algoName"] => string(7) "unknown"
["options"] => array(0) {}
}
password_get_info() は $2a$ を bcrypt と判定しない。PHP がアルゴリズム名として認識するのは $2y$ だけである。
| 関数 | $2a$ |
$2y$ |
|---|---|---|
password_verify() |
通る | 通る |
password_get_info() |
unknown | bcrypt |
なぜ例外になるのか
Laravel の BcryptHasher::check() は、照合の前にアルゴリズム名を検証する実装になっている。
public function check($value, $hashedValue, array $options = [])
{
if ($this->verifyAlgorithm && ! $this->isUsingCorrectAlgorithm($hashedValue)) {
throw new RuntimeException('This password does not use the Bcrypt algorithm.');
}
return parent::check($value, $hashedValue, $options);
}
protected function isUsingCorrectAlgorithm($hashedValue)
{
return $this->info($hashedValue)['algoName'] === 'bcrypt';
}
$2a$ は unknown を返すため、ここで RuntimeException が投げられる。
照合処理まで到達しない。だからログイン失敗ではなく 500 になる。
password_verify() だけを単体でテストすると通ってしまうため、この落とし穴は事前検証をすり抜けやすい。移植前の確認では password_get_info() も併せて叩いておくとよい。
対処
プレフィックスだけ置換する。
$hash = '$2y$' . substr($oldHash, 4);
SQL ファイルに対して一括で処理する場合。
sed -i 's/\$2a\$/\$2y\$/g' users_*.sql
DB に投入済みなら UPDATE でも対応できる。
UPDATE `users`
SET `password` = CONCAT('$2y$', SUBSTRING(`password`, 5))
WHERE `password` LIKE '$2a$%';
置換後を確認する。
$hash = '$2y$08$ctrIrsdtQ9zWivv2v8stTe6MA098myWziljCIEd0P/BR9In1OS/em';
echo password_get_info($hash)['algoName']; // bcrypt
var_dump(password_verify('akamisoshiru', $hash)); // bool(true)
ハッシュ本体は1文字も変更していない。利用者は従来どおりのパスワードでログインできる。
第二パスワードなど、bcrypt ハッシュを格納している他のカラムがあれば同様に置換が必要。今回の案件では second_password カラムが該当した。置換漏れがないか LIKE '$2a$%' で全カラムを確認しておくとよい。
移植前チェックリスト
同種の移植をやる際に最初に流すクエリ。
SELECT
LEFT(password, 4) AS prefix,
COUNT(*) AS cnt
FROM lib_users
GROUP BY prefix;
結果ごとの判断。
| 結果 | 判断 |
|---|---|
$2a$ のみ |
移植可。$2y$ へ置換すること |
$2y$ $2b$ のみ |
移植可。そのままでよい |
$P$ が含まれる |
その分は移植不可。再設定案内が必要 |
| 空文字が含まれる | 停止アカウントの可能性。ダミー値を入れる |
| 混在 | 形式ごとに分けて処理する |
空のパスワードが見つかった場合、Laravel 側の password は NOT NULL であることが多い。ログイン不可にしたいならハッシュとして成立しない文字列を入れておけばよい。
UPDATE `users` SET `password` = 'banned' WHERE `password` = '';
password_get_info() が unknown を返すため、そもそも検証に到達しない。
まとめ
| 項目 | 結論 |
|---|---|
| パスワードは移植できるか | 先頭が $2 なら可。$P$ なら不可 |
| なぜ別ライブラリで通用するか | bcrypt はアルゴリズムであり、実装は共通の crypt() に帰着する |
| プレフィックスの意味 | アルゴリズム識別子。$2a$ $2y$ はどちらも bcrypt |
| salt とは | ユーザーごとの乱数。ハッシュ文字列に内蔵され、秘密ではない |
| salt の役割 | 同一パスワードの露見と、事前計算表の使い回しを防ぐ |
| コストとは | 計算の反復回数(2 の累乗)。意図的に遅くして総当たりを阻む |
| 移植時の作業 |
password カラムをコピーし、$2a$ を $2y$ に置換するだけ |
| 500 エラーの原因 |
password_get_info() が $2a$ を bcrypt と認識せず Laravel が例外を投げる |
| 検証時の注意 |
password_verify() だけでは通ってしまう。password_get_info() も確認する |
| 移植後の挙動 | ログイン時に Laravel が自動で現行コストへ再ハッシュする |