Skip to content
ブログに戻る
セキュリティ

bcrypt「password cannot be longer than 72 bytes」が短いパスワードでも出る理由

14 バイトのパスワードでも 72 バイト超過エラーが出る。原因は passlib の 255 バイトプローブだ。無料のオンライン bcrypt ツール付き。

12 分で読める

bcrypt の「72 バイト超過」エラー — 短いパスワードでも出る理由

まったく別の 2 つの問題が同じメッセージを吐く。そして、パスワードと関係があるのはそのうち片方だけだ。

パスワードが本当に bcrypt の 72 バイト上限を超えているなら、bcrypt は先頭 72 バイトだけを読み、残りを捨てる。先頭 72 バイトが共通する 82 バイトのパスワードを 2 つ用意し、固定 salt でハッシュ化してみた。どちらも $2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S になり、bcrypt.compareSync(p2, hash(p1))true を返した。2 つ目のパスワードで 1 つ目のアカウントにログインできる、ということだ。

一方、どう見ても短いパスワードなのにこれが出るなら話は別だ。

password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])

(「パスワードは 72 バイトを超えられない。必要なら手動で切り詰めること」の意)

このとき、メッセージは原因について誤っている。passlib 1.7.4 と bcrypt 5.0.0 の組み合わせでは、14 バイトのパスワードでもこれが発生する。

犯人は passlib の内部にある、固定長 255 バイトの自己診断プローブだ。これはバックエンドの初期化時に一度だけ走る。つまり、入力したパスワードがハッシュ化処理に届く前の話である。bcrypt 5.0.0 がそのプローブを拒否し、例外がそのまま外へ抜け、誰も入力していないパスワードについての苦情を読まされることになる。

先に一つ片付けておく。このエラーの検索結果を埋め尽くしている __about__ モンキーパッチでは直らない。クリーンなプロセスで、import passlib より前にパッチを当てた状態で再実行したが、ValueError はそのまま返ってきた。

まず 30 秒で切り分ける

パスワードの状態エラーが出るタイミング根本原因進む先
72 バイトより長いhash を呼んだとき本当に長すぎる。bcrypt 5.0 は例外を投げ、bcrypt 4.x は黙って切り詰めるセクション 2 と 3
72 バイト未満、passlib 利用プロセス内の最初の呼び出し時passlib の 255 バイトプローブ。あなたのパスワードとは無関係セクション 4
中国語・日本語・絵文字を含む短く見えるが実は長い文字数はバイト数ではないセクション 3
依存パッケージ更新後に失敗し始めたデプロイ後bcrypt 5.0 の破壊的変更セクション 4 と 5

2 行目に該当するなら、先へ飛ばしてよい。次の 2 セクションには役立つ内容が何もないし、対処法もまったく別だ。

bcrypt の 72 バイト上限がパスワードに何をするか

なぜ bcrypt は 72 で止まるのか

bcrypt は Blowfish を土台にしており、パスワードを Blowfish の鍵としてそのまま流し込む。Blowfish は鍵を 18 個のサブキーからなる P 配列へ展開し、各サブキーの幅は 32 ビットだ。つまり 18 × 4 = 72 バイト分の鍵材料であり、展開ループは 18 スロットを埋め終えると鍵の先頭へ折り返す。

したがって、この天井は構造的なものだ。実装の手抜きでもなければ、誰かが上げ忘れた設定可能なバッファでもない。仕様に準拠した bcrypt 実装なら、どのプラットフォームでも同じ上限を持つ。Python でも Node でも Go でも Java でも PHP でも、出てくる数字は同じ 72 だ。

別々のパスワード、同じハッシュ

bcrypt の切り詰めは、長さの制限が不便だという話では済まない。認証の強度に直接効いてくる。

bcryptjs 3.0.3 と固定 salt $2a$10$abcdefghijklmnopqrstuv を使い、それぞれ 82 バイトのパスワードを 2 つハッシュ化した。

パスワードバイト数
p1"A"×72 + "XXXXXXXXXX"82
p2"A"×72 + "ZZZZZZZZZZ"82

どちらも同じダイジェストになった。

$2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S

異なる 2 つのパスワードが同じハッシュを生む。結果は true。そこから導かれる帰結がこれだ。

bcrypt.compareSync(p2, hash(p1))  // true

長いパスフレーズの先頭 72 バイトを知っている攻撃者は、その後ろに何をくっつけても認証を通せる。境界より先のバイトは、利用者がどれだけ慎重に選んだとしても、保存されたハッシュの強度にきっかり何も寄与しない。手元にあるハッシュを候補パスワードと突き合わせたいがスクリプトを書くほどでもない、という場合は、ブラウザ内で bcrypt ハッシュを生成・検証することで同じ挙動を自分の目で確かめられる。

境界は実際どこに落ちるのか

共通の接頭辞を保ったまま、その直後の 1 バイトだけを変えて、切れ目を 1 バイトずつ絞り込んだ。

一致する接頭辞バイト数N+1 バイト目が異なる位置ハッシュは同じか?
7071false
7172false
7273true
7374true

72 バイト目はまだ効く。効かなくなる最初のバイトが 73 バイト目だ。徐々に薄れることも部分的に混ざることもない。手元のライブラリでも同じ手順で確かめられる。

文字数はバイト数ではない

bcrypt が数えるのは UTF-8 のバイト数であり、利用者が打ち込むのは文字だ。ASCII ではたまたま両者の数値が一致する。だから英語圏の外へサービスを出した瞬間に、チームはこれで噛まれる。

文字種1 文字あたりのバイト数72 バイトは何文字か
ASCII ラテン文字A172 文字
中国語の漢字324 文字
日本語の仮名324 文字
絵文字🔒418 文字
キリル文字я236 文字
ドイツ語のウムラウトü236 文字

両端とも確認済みだ。中国語のパスワードでは 24 文字目より後の差異が無視され(true)、絵文字のパスワードでは 18 文字目より後の差異が無視される(true)。

25 文字の中国語パスフレーズは、入力欄で見るぶんには十分な長さに見える。だが、すでに線を越えている。絵文字を 20 個選んだ利用者は 2 文字ぶん上限を超過しており、そのことは永遠に知らされない。

自分のコードでバイト長を測る

文字数に対して書かれた長さチェックは、実際の値がすでに長すぎるのに通ってしまう。測るならバイトだ。

# Python
len(pw.encode("utf-8"))
// Node.js
Buffer.byteLength(pw, "utf8")
// Go
len([]byte(pw))

Buffer のないブラウザ環境では new TextEncoder().encode(pw).length が同じ数値を返す。このチェックをハッシュ化呼び出しの手前に置き、まっとうなバリデーションメッセージを返そう。午前 3 時にライブラリへ判断を委ねるよりずっとよい。ついでに最小長ポリシーを見直すのなら、パスワード強度が実際にどう測られるかで、長さのルールが何を買ってくれて何を買ってくれないのかを扱っている。

passlib の 255 バイトプローブが短いパスワードを落とす

ここからが、多くの人を検索エンジンへ送り込んでいるケースだ。パスワードは 14 文字なのに、ライブラリは 72 バイトを超えていると言い張る。

再現する

Python 3.14.5、bcrypt 5.0.0、passlib 1.7.4 の環境で、たった 3 行。

from passlib.hash import bcrypt
bcrypt.hash("short-password")   # 14 バイト
# ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])

入力は 14 バイト、出てくるのは 72 バイトについての苦情。passlib の bcrypt エラーは実在するが、そこに書かれた数字はまったく別のものを指している。

呼び出しスタックの全体

これは推測ではない。passlib 1.7.4 を追跡して得た、実際に走る処理である。

  1. 最初の呼び出しがバックエンド初期化を起動する。_calc_checksum_stub_requires_backend()set_backend()
  2. _load_backend_mixinbcrypt.__about__.__version__ を読む。属性が存在しないため AttributeError が送出される。passlib はこれを飲み込み、(trapped) error reading bcrypt version と表示する。
  3. 初期化は _finalize_backend_mixinpasslib/handlers/bcrypt.py:421)へ進み、そこが detect_wrap_bug(IDENT_2A) を呼ぶ。
  4. detect_wrap_bug(同じファイルの :378)が固定長 255 バイトのプローブを検証する。
  5. bcrypt 5.0.0 は 72 バイトを超えるものすべてに ValueError を投げるので、プローブが自分自身で爆発する。
  6. 例外は呼び出し箇所まで伝播する。渡した入力とは一切関係のない、72 バイトについてのメッセージが表示される。

この一連の流れはプロセスごとに一度、最初の hash または verify で起きる。だからこの障害はこれほど確実に再現し、渡した値にまったく左右されない。

プローブの中身

secret = (b"0123456789" * 26)[:255]

この定数は、Openwall が 2012 年に公表した BSD の bcrypt におけるラップアラウンドのバグに由来する。長い鍵が折り返して、より弱いハッシュへ潰れてしまう不具合だ。passlib は起動時に、いま読み込んだバックエンドがその欠陥を抱えていないかを検査し、抱えているバックエンドを信用しない。

正確に呼ぼう。detect_wrap_bug は passlib のバグではない。10 年以上有効であり続けたテストベクタを使って、書かれたとおりの仕事をしている防御的なコードだ。変わったのは、bcrypt 5.0.0 が 255 バイトの入力をハッシュ化対象ではなくエラーとして扱うようになった点であり、それが通っていた自己診断を回避不能な失敗へ変えた。2 つのライブラリの衝突については、pyca/bcrypt の issue #1082 の議論が扱っている。

なぜ __about__ パッチでは直らないのか

このエラーを検索すると、bcrypt が __about__ を削除したせいであり、それを復元すれば passlib は直る、と繰り返し教えられる。どちらの主張も誤りだ。それを示す測定結果がこれである。

バージョンhasattr(bcrypt, "__about__")trapped 警告を表示するpasslib は動くか
bcrypt 5.0.0Falseはいいいえ(ValueError)
bcrypt 4.3.0Falseはいはい

bcrypt 4.3.0 にも __about__ は存在しない。同じ (trapped) error reading bcrypt version の行を表示する。それでも passlib は文句ひとつ言わず動く。つまり、属性が欠けていることは動くか壊れるかの分かれ目ではない。分かれ目は 5.0.0 における ValueError の挙動変更のほうだ。

したがって、あの人気のパッチは原理的に効きようがないし、実際に効かない。

import bcrypt, types
bcrypt.__about__ = types.SimpleNamespace(__version__=bcrypt.__version__)  # passlib を import する前に実行
from passlib.hash import bcrypt as pl
pl.hash("short-password")
# 依然として ValueError: password cannot be longer than 72 bytes, ...

これはクリーンなプロセスで、しかも import passlib より前にパッチを当てた状態で実行した。失敗を import 順のせいにされないためである。それでも失敗する。このパッチが達成するのは、無害な警告を黙らせることだけだ。手順 4 の 255 バイトプローブは独立した段階であり、そもそも __about__ を参照していない。どちらにせよ爆発する。

bcrypt 5.0 が実際に変えたもの

bcrypt 5.0 の破壊的変更は、たった 1 行の挙動でありながら被害範囲が広い。

入力bcrypt 4.3.0bcrypt 5.0.0
72 バイトOKOK
73 バイトOK(黙って切り詰め)ValueError
100 バイトOK(黙って切り詰め)ValueError
255 バイトOK(黙って切り詰め)ValueError

4.x 列の「切り詰め」は比喩ではない。4.3.0 では、同じ接頭辞から作った hash(73 バイト)hash(100 バイト) が一致する。結果は true だ。

つまり、ここでは bcrypt 5.0 のほうが正しいライブラリである。与えられた入力を尊重できないなら、鍵材料を黙って捨てるより処理を拒むほうがましだ。とはいえ、それでアップグレードが痛くなくなるわけではない。何年も黙ってバイトを失っていたコードが例外を投げ始め、そのコードパスが passlib の裏にあるなら、あなたの入力が関与する前に例外が飛ぶ。

この表からは 2 つの帰結が出てきて、それぞれ別のチームに着地する。bcrypt を直接呼んでいるなら、アップグレードの影響は目に見える。登録やログインで例外が上がり、その発生位置は自分が所有するコードで、スタックトレースは自分のハッシュ化呼び出しを指す。手前にバイト長チェックを足せば午後いっぱいで片が付く。

passlib を経由しているなら、アップグレードの影響は見えないまま全面的になる。障害は長いパスワードを使う利用者の数に比例しない。そもそも利用者の入力に依存していないからだ。パスワード処理まわりを何も変えていないコードベースで、最初の呼び出し以降、プロセス内のすべての hash とすべての verify が失敗する。これがバグ報告ではなくデプロイ障害として表面化する理由であり、エラー文が人々をまったく見当違いの場所へ探しに行かせる理由でもある。

直す

コードを変えられる場合

passlib をやめて bcrypt を直接呼ぶ。passlib の最後のリリースは 1.7.4 で、プロジェクトは長らく静かなままだ。bcrypt しか要らないプロジェクトにとって、この層が買ってくれるものはごくわずかである。

import bcrypt

password = "correct horse battery staple".encode("utf-8")
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))

bcrypt.checkpw(password, hashed)  # True

hashpwcheckpw もバイト列を受け取るので、境界でエンコードし、残りのコードは str のまま動かせばよい。バックエンド検出もなく、自己診断プローブもなく、渡してもいないパスワードについて報告してくる障害モードもない。出来上がったハッシュを目で確認したい、あるいはアプリが生成したハッシュを検証したいなら、bcrypt 生成・検証ツールが完全にブラウザ内で動く。HTTP Basic 認証で bcrypt を使っているサーバーも、ファイル形式こそ違え同じ構造的制約を抱えている。それについては .htpasswd の作り方ガイドが順を追って説明している。

今日すぐにはコードを変えられない場合

5 未満に固定する。

bcrypt<5

bcrypt 4.3.0 と passlib 1.7.4 の組み合わせは動くことを確認済みだ。ただし、いま何を買ったのかははっきりさせておきたい。これは止血であって修理ではない。長いパスワードに対して黙ってバイトを捨てる挙動のバージョンに留まるということで、5.0 が手を入れたのはまさにその一点だ。この固定には期限を切り、移行を計画しておくこと。

利用者が本当に長いパスフレーズを打ち込む場合

パスワードをまず SHA-256 で一度ハッシュ化し、そのダイジェストを base64 エンコードしてから bcrypt に渡す。

import base64, hashlib, bcrypt

def prehash(password: str) -> bytes:
    return base64.b64encode(hashlib.sha256(password.encode("utf-8")).digest())

hashed = bcrypt.hashpw(prehash(pw), bcrypt.gensalt(rounds=12))
bcrypt.checkpw(prehash(pw), hashed)

出力は入力長にかかわらず常に 44 バイトで、72 には余裕で収まる。そして、切り詰めが壊した性質を取り戻す。冒頭のセクションに出てきた 82 バイトのパスワード 2 つをこれに通すと、checkpw(prehash(p2), hash(prehash(p1)))False になる。衝突は消えている。

base64 の一手は実際に仕事をしているので、省いてはいけない。生の SHA-256 ダイジェストは任意のバイナリであり、NUL バイトを含みうる。bcrypt 実装は NUL の扱いが一貫していない。base64 なら、NUL を含まない固定長の ASCII 文字列が得られる。登録時とログイン時には同じ関数を通す。さもなければ既存のハッシュがすべて検証に通らなくなる。

やってはいけないこと

__about__ モンキーパッチは効かない。測定はセクション 4 にある。チームの誰かが今まさに貼り付けようとしているなら、あの 4 行が半日を救ってくれる。

自分で pw[:72] と切り詰めるのは、何もしないより悪い。騒がしい失敗を静かな失敗へ戻したうえ、セクション 2 の衝突を自分のコードの中に再現する。bcrypt 5.0 が根絶するためにリリースされた、まさにその挙動を手作りすることになる。しかもライブラリ版と違い、あなたの実装は誰にも警告しない。長いパスワードを機能させたいなら事前ハッシュを使うこと。そうでないなら、バイト長を検証して明確なメッセージとともに拒否すること。

すでにデータベースに入っているハッシュはどうするか

どの行が影響を受けるか

影響を受けるのは、72 バイトを超えるパスワードで登録した所有者のアカウントだけだ。多くのコンシューマー向け製品ではごく少数であり、ASCII のみの環境ならたいていパスフレーズ愛好家に限られる。中国語・日本語・絵文字を打ち込む利用者がいる製品なら、セクション 3 が当てはまり、影響範囲はバイトを見ない監査が示唆するよりずっと大きくなりうる。

これらの行をハッシュから特定することはできない。bcrypt のダイジェストは固定幅で、入力がどれだけ長かったかの記録を一切持たない。登録時にパスワード長をログしていたなら、そのログだけが唯一の棚卸し資料になる。ほとんどのチームはログしておらず、事後に再構成することもできない。だから、一覧が手に入る前提では計画できない。分からないまま進める前提で組む。

一括での再計算はできない

再ハッシュ化するための平文がない。ハッシュを保存することの意義がまさにそこにあるからだ。したがって移行は遅延型にするしかない。所有者が次に認証へ成功したとき、平文が一瞬メモリ上にあるあいだに、そのアカウントを 1 つずつ更新する。

def login(user, password: str) -> bool:
    if not verify_legacy(password, user.password_hash):
        return False
    if needs_rehash(user.password_hash):
        user.password_hash = hash_new_scheme(password)
        save(user)
    return True

まず旧方式で検証し、そのあとで再ハッシュ化すること。この 2 手順を逆にすると、パスワードが正しいと確認する前に保存済みハッシュを書き換えてしまう。needs_rehash を推測ではなくフィールド比較にできるよう、各ハッシュの横に方式の識別子を保存しておこう。そして、二度とログインしない休眠アカウントの長い尾は必ず残る。それらは強制ではなく、パスワードリセットの機会に処理する。

全面移行に踏み切る価値があるとき

遅延再ハッシュの経路をすでに書いているのなら、その下にあるアルゴリズムを差し替える機会としては、これ以上に安い瞬間は二度と来ない。72 バイトの天井は Argon2id には存在しない。Argon2id と bcrypt の詳細比較が、乗り換えが元を取る場面と、bcrypt に留まるのが正しい判断となる場面を扱っている。パラメータを突き合わせる参照先としては OWASP Password Storage Cheat Sheet がある。

このエラーだけを理由に移行を始めてはいけない。パスワードが 72 バイトに余裕で収まっているなら bcrypt は依然として妥当な選択であり、今回の問題はセクション 6 ですでに解決している。

FAQ

パスワードが短いのに、なぜ bcrypt は 72 バイトより長いと言うのか

メッセージが指しているのが、あなたのパスワードではなく passlib の内部プローブだからだ。最初の呼び出しで、passlib は固定長 255 バイトのテスト文字列を使って detect_wrap_bug を実行する。bcrypt 5.0.0 は 72 バイトを超えるものすべてに ValueError を投げるため、プローブが失敗し、エラーが呼び出し箇所に現れる。14 バイトのパスワードでも発生する。

bcrypt は本当に 72 バイトより後をすべて無視するのか

bcrypt は 72 バイトより後を完全に無視する。先頭 72 バイトが共通する 82 バイトのパスワード 2 つは、同一のハッシュ $2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S を生成し、互いのハッシュに対して検証が通る。境界は厳密だ。72 バイト目の差異はハッシュを変え、73 バイト目の差異は変えない。

72 バイト上限はセキュリティ上の問題か

bcrypt の 72 バイト上限は、長いパスフレーズにとっては問題だ。先頭 72 バイトを知っている者は任意のバイトを付け足して認証を通せるので、上限より先のバイトは何も足していない。72 バイト未満のパスワードにとってはまったく何も変わらない。長い入力を完全に効かせる必要があるなら、SHA-256 による事前ハッシュがこの露出を取り除く。

72 バイトは何文字か

72 バイトは ASCII なら 72 文字だが、エンコーディング次第だ。キリル文字やウムラウト付き文字なら 36 文字、中国語の漢字なら 24 文字、日本語の仮名なら 24 文字、絵文字なら 18 文字。bcrypt は文字ではなく UTF-8 のバイト数を数えるので、Python なら len(pw.encode("utf-8"))、Node なら Buffer.byteLength(pw, "utf8") で測ること。

__about__ にパッチを当てれば passlib のエラーは直るか

__about__ にパッチを当てても passlib のエラーは直らない。クリーンなプロセスで import passlib より前にパッチを当てたが、ValueError は変わらず発生した。bcrypt 4.3.0 にも __about__ はなく、それでも passlib は問題なく動く。属性の欠如が原因ではないことの証明である。このパッチが消すのは (trapped) error reading bcrypt version という警告だけだ。

bcrypt を 5.0 未満へダウングレードすべきか

応急処置としてなら、bcrypt を 5.0 未満へ下げてよい。bcrypt 4.3.0 と passlib 1.7.4 の組み合わせは動く。ただし 4.x は 72 バイトより後を黙って切り詰める。それこそ 5.0 がリリースされて止めようとした挙動なので、この固定は一時的なものと割り切り、bcrypt を直接呼ぶ形へ移りたい。

自分でパスワードを 72 バイトに切り詰めてはいけないのか

自分で 72 バイトに切り詰めるのはやめておくこと。pw[:72] は前述の衝突を自分のコードの中に、静かに、ライブラリの警告に引っかかることもなく再現する。長い入力を区別したままにするなら SHA-256 と base64 で事前ハッシュするか、そうでなければ手前でバイト長を検証し、明確なエラーメッセージとともに拒否すること。

これを直す前にハッシュ化済みのパスワードはどうなるか

すでに保存済みの bcrypt ハッシュは検証を通り続ける。verify 側の経路も hash 側と同じように切り詰めるからだ。弱められているのは 72 バイト超のパスワードで登録されたアカウントだけであり、平文なしにそれらを再計算することはできない。次回のログイン成功時に遅延再ハッシュし、休眠アカウントはパスワードリセットの機会に処理すること。

タグ: bcrypt password-hashing passlib python debugging security