先週、社内の検証環境で、Python で書いたログイン機能の雛形の依存をまとめて上げました。テストは通り、手で何件かログインしてみて問題なし。ところが検証用アカウントの1件だけ、ログインが 500 になりました。
そのアカウントのパスワードは、日本語25文字のパスフレーズでした。
原因をたどると、パスワードハッシュに使っている bcrypt の「72バイトまでしか見ない」という古い仕様に行き着きます。仕様そのものは有名ですが、日本語だと何文字なのか、ライブラリと版によって扱いがどう違うのかは、手元で測ったことがありませんでした。運用を考えると、ここは「知っている」ではなく「測ってある」状態にしておきたいと考えて、4言語の7つの実装で同じ入力を流しました。
72バイトは、日本語だと24文字
bcrypt はパスワードを文字ではなくバイト列として受け取り、先頭の72バイトだけを鍵の計算に使います。UTF-8 では半角英数が1バイト、ひらがなや常用漢字が3バイト、絵文字が4バイトなので、上限は半角なら72文字、日本語だけなら24文字、絵文字だけなら18個です。
25文字目から先がどう扱われるかを、Python の bcrypt で確かめたのが次のコードです。
import bcrypt
phrase = "あいうえおかきくけこさしすせそたちつてとなにぬね" # 24文字
registered = phrase + "の" # 登録時に入れた25文字
typo = phrase + "は" # 25文字目だけ違う
print(bcrypt.__version__, len(registered), "文字 /", len(registered.encode()), "バイト")
stored = bcrypt.hashpw(registered.encode(), bcrypt.gensalt())
print("25文字目が違っても:", bcrypt.checkpw(typo.encode(), stored))
# 実行結果(bcrypt 4.3.0):
# 4.3.0 25 文字 / 75 バイト
# 25文字目が違っても: True
# 実行結果(bcrypt 5.0.0):
# 5.0.0 25 文字 / 75 バイト
# ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
4.3.0 は、75バイトのパスワードを黙って先頭72バイトで扱います。25文字目が違う入力でも照合は True です。5.0.0 は同じ入力に対して、ハッシュを作る時点で ValueError を投げます。
500 の正体はこれでした。検証用アカウントは 4.3.0 の時代に25文字で登録していて、ハッシュ自体は先頭24文字ぶんで作られています。依存を上げて 5.0.0 になったことで、ログイン時の checkpw に75バイトをそのまま渡した瞬間に例外になっていたんですよね。4.3.0 で作ったハッシュを 5.0.0 の checkpw に渡しても、結果は同じ ValueError でした。
最初の対処は、的を外していた
正直なところ、最初は「登録画面で72バイトを超えたら弾けばいい」と考えて、登録と変更の入力チェックだけを足しました。
これでは何も直りません。困っているのはすでに登録済みのアカウントで、そのログイン処理は相変わらず75バイトを checkpw に渡しているからです。入口を締めても、中にいる人は救えない。直すべきは照合の側でした。
同じ日に、passlib 経由で bcrypt を使う構成も試して、もう1つ想定外に当たりました。passlib 1.7.4 の CryptContext を 5.0.0 と組み合わせると、13バイトの「correct-horse」すらハッシュが作れません。passlib はバックエンドを初期化するときに、折り返しの不具合を調べるため255バイトのテスト用文字列を bcrypt に渡していて、5.0.0 はそこで ValueError を投げます。4.3.0 と組み合わせたときは動くものの、初期化のたびに「(trapped) error reading bcrypt version」と AttributeError のトレースバックがログに出ます。今思えば、この警告は「組み合わせがもう想定されていない」という合図でした。
4言語7実装で、72バイト超の扱いを測った
ほかの言語ではどうなのかも気になったので、Docker の使い捨てコンテナで同じ入力を流しました。登録に使ったのは日本語25文字(75バイト)、照合に使ったのは先頭24文字が同じで25文字目だけ違う文字列です。作る段階で弾く実装では、先頭24文字から作ったハッシュ(旧版が黙って作っていたものと同じ)に対して照合しました。
| 実装 | 版 | 75バイトでハッシュを作る | 25文字目違いで照合 |
|---|---|---|---|
| Python bcrypt | 4.3.0 | 黙って先頭72バイトで作る | 一致する |
| Python bcrypt | 5.0.0 | ValueError | ValueError |
| Node.js bcryptjs | 3.0.3 | 黙って切る(truncates で事前に判定できる) | 一致する |
| Node.js bcrypt | 6.0.0 | 黙って切る | 一致する |
| Go x/crypto/bcrypt | v0.58.0 | エラー(password length exceeds 72 bytes) | 一致する |
| Spring Security | 6.4.3 | 黙って切る | 一致する |
| Spring Security | 7.1.1 | IllegalArgumentException | 一致する |
並べてみると、流れは「作るときに弾く」へ寄っています。Go と Spring Security の新しい版は、ハッシュを作る段階で72バイト超を拒否します。Spring Security の判定はバイト数で、半角73文字だけでなく日本語25文字も弾かれました。一方で、照合のほうはどちらも先頭72バイトで比べて「一致」を返します。照合でも例外を投げたのは、7つのうち Python の bcrypt 5.0.0 だけでした。
ここは好みが分かれるところですが、私は照合で例外にする 5.0.0 の判断は筋が通っていると考えています。黙って切るより、気づける。ただし、旧版で登録した利用者がいるシステムで依存を上げると、その人たちだけログインできなくなる、という形で表に出ます。私たちが踏んだのはまさにそれでした。
Node.js の bcryptjs 3 には、切り捨てが起きるかを先に判定する truncates が用意されています。登録・変更のサーバー側チェックは、これを使うと短く書けます。
import bcrypt from "bcryptjs";
// 登録・変更フォームのサーバー側チェック
export function validateNewPassword(pw) {
if (bcrypt.truncates(pw)) {
const bytes = new TextEncoder().encode(pw).length;
return `パスワードが${bytes}バイトあります。72バイト(日本語なら24文字)までにしてください`;
}
return null;
}
console.log(validateNewPassword("あいうえおかきくけこさしすせそたちつてとなにぬね")); // 24文字
console.log(validateNewPassword("あいうえおかきくけこさしすせそたちつてとなにぬねの")); // 25文字
console.log(validateNewPassword("🔑".repeat(19))); // 絵文字19個
// 実行結果(bcryptjs 3.0.3):
// null
// パスワードが75バイトあります。72バイト(日本語なら24文字)までにしてください
// パスワードが76バイトあります。72バイト(日本語なら24文字)までにしてください
エラーメッセージに「日本語なら24文字」と書いているのは、利用者にバイト数を数えてもらうのは無理があるからです。
前に何かを連結すると、パスワードの取り分が減る
測っているうちに、もう1つ気になったのが「ユーザーIDや社名をパスワードの前に連結してから bcrypt に渡す」書き方です。認証結果のキャッシュのキーや、利用者ごとの値を混ぜたいときに見かけます。72バイトの枠はパスワード専用ではないので、前に置いた値がそのぶん枠を食います。
import bcrypt
def cache_key(user_id: str, company: str, password: str) -> bytes:
# 認証結果キャッシュのキー(よくない例)
return f"{user_id}:{company}:{password}".encode()
for company in ["株式会社サンプル情報システム開発研究所", "株式会社サンプル情報システム開発研究所東"]:
stored = bcrypt.hashpw(cache_key("u-00012345", company, "correct-horse"), bcrypt.gensalt())
left = 72 - len(f"u-00012345:{company}:".encode())
print(len(company), "文字の社名 → パスワードに残るのは", max(left, 0), "バイト")
for guess in ["cor-anything", ""]:
print(" ", repr(guess), bcrypt.checkpw(cache_key("u-00012345", company, guess), stored))
# 実行結果(bcrypt 4.3.0):
# 19 文字の社名 → パスワードに残るのは 3 バイト
# 'cor-anything' True
# '' False
# 20 文字の社名 → パスワードに残るのは 0 バイト
# 'cor-anything' True
# '' True
社名が19文字だと、パスワードとして効いているのは先頭の3バイトだけで、「cor」で始まれば何でも通ります。20文字になると取り分は0バイトで、空のパスワードでも True です。日本語の社名や部署名は、半角の感覚よりずっと早く枠を使い切ります。
5.0.0 ならこのコードは ValueError で止まるので、少なくとも黙って通ることはありません。とはいえ、別の値と混ぜたいなら連結ではなく別の仕組みで持つべきで、bcrypt の入力はパスワードだけにしておくのが安全だと考えています。
ログインの互換を残して、新しいパスワードは入口で止める
最終的に検証環境のコードは、照合と作成を分けてこう直しました。
import bcrypt
BCRYPT_LIMIT = 72 # bcrypt が見るのは先頭72バイトだけ
def check_login(password: str, stored: bytes) -> bool:
raw = password.encode("utf-8")
# 旧版は先頭72バイトで黙ってハッシュを作っていた。照合も同じ位置で切らないと
# bcrypt 5.0.0 では ValueError になり、その利用者だけログインできない
return bcrypt.checkpw(raw[:BCRYPT_LIMIT], stored)
def hash_new_password(password: str) -> bytes:
raw = password.encode("utf-8")
if len(raw) > BCRYPT_LIMIT:
raise ValueError(f"{len(raw)}バイトあります。72バイト(日本語なら24文字)までにしてください")
return bcrypt.hashpw(raw, bcrypt.gensalt(rounds=12))
# 実行結果(bcrypt 5.0.0。ハッシュは 4.3.0 で25文字から作ったもの):
# check_login(25文字の正しいパスワード) → True
# check_login(25文字目だけ違う) → True ← ここが残っている課題
# hash_new_password(25文字) → ValueError: 75バイトあります。72バイト(日本語なら24文字)までにしてください
このコードには改善の余地があります。check_login は旧来のハッシュとの互換のために先頭72バイトで切っているので、25文字目以降は今も確かめていません。実行結果の2行目のとおり、25文字目だけ違う入力でも通ります。旧版で長いパスワードを登録した人が自分でパスワードを変えるまで、この状態が続きます。5.0.0 のエラーメッセージには「必要なら自分で切れ」と書いてありますが、切った結果どこまで守れているかは、呼ぶ側が把握しておく必要があります。きれいにするなら、次回のパスワード変更時に新しい方式へ作り直すか、72バイトの上限がない方式へ移す段取りが要ります。
コスト係数も、ついでに測っておいた
依存を上げたついでに、コスト係数(rounds)ごとの所要時間も同じコンテナで測りました。開発機の Docker で、各3回の中央値です。
| コスト係数 | 1回のハッシュ | ログイン1,000回ぶんのCPU時間 |
|---|---|---|
| 10 | 約42ミリ秒 | 約42秒 |
| 11 | 約83ミリ秒 | 約83秒 |
| 12 | 約172ミリ秒 | 約2.9分 |
| 13 | 約346ミリ秒 | 約5.8分 |
| 14 | 約684ミリ秒 | 約11.4分 |
1つ上げるごとに、きれいに倍になります。4.3.0 と 5.0.0 の差はほぼなく、コスト12で 168 ミリ秒と 172 ミリ秒でした。コスト12と14の差は、ログイン1,000回あたり CPU で約2.9分と約11.4分。攻撃者の手間も同じだけ増える一方で、こちらの応答時間とサーバー代も4倍になる、というのがこの表の読み方だと考えています。私たちは12を基準にして、ログインの応答時間と CPU の予算を見ながら決めることにしました。
おわりに
72バイトの上限は、仕様としては昔から決まっています。変わったのはライブラリの側で、黙って切っていたものが、作るときに弾き、Python では照合でも弾くようになりました。それ自体は良い変化ですが、日本語のパスワードは24文字で上限に届くので、長いパスフレーズを使う人ほど、更新の影響を受けやすいことになります。
今後は、認証まわりの依存を上げるときのテストに「日本語25文字で登録したアカウントのログイン」と「72バイトちょうど」の2件を必ず入れるようにしていきたいと思っています。そもそもパスワードを自前で持つかどうかも、新しく作るものでは、マネージドの認証基盤に寄せる選択肢を先に検討するつもりです。運用を考えると、ハッシュの方式を自分たちで追いかけ続けるコストは、思っているより大きいので。