セキュリティ 2026年10月17日

日本語のパスワードは24文字目までしか効いていなかった。bcryptの72バイト上限を4言語7実装で測ったら、照合でも例外を投げるのはPythonの5.0.0だけだった

XECIN 認証認可文字コード技術検証

先週、社内の検証環境で、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 bcrypt4.3.0黙って先頭72バイトで作る一致する
Python bcrypt5.0.0ValueErrorValueError
Node.js bcryptjs3.0.3黙って切る(truncates で事前に判定できる)一致する
Node.js bcrypt6.0.0黙って切る一致する
Go x/crypto/bcryptv0.58.0エラー(password length exceeds 72 bytes)一致する
Spring Security6.4.3黙って切る一致する
Spring Security7.1.1IllegalArgumentException一致する

並べてみると、流れは「作るときに弾く」へ寄っています。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件を必ず入れるようにしていきたいと思っています。そもそもパスワードを自前で持つかどうかも、新しく作るものでは、マネージドの認証基盤に寄せる選択肢を先に検討するつもりです。運用を考えると、ハッシュの方式を自分たちで追いかけ続けるコストは、思っているより大きいので。