Cloudflare Workers の PBKDF2 は 100,000 回までしか回せない — テストが全部通ったまま本番だけ落ちた
OpenID Connect プロバイダを Cloudflare Workers + D1 へ移す作業で、ローカルのテストが全部通っているのに dev 環境へデプロイした瞬間に新規登録が 500 を返した。原因は Workers の crypto.subtle が PBKDF2 の反復回数に上限を持っていることで、これはローカルでは絶対に再現しない種類の不具合だった。
本番だけが 500 を返した
パスワードのハッシュには PBKDF2-HMAC-SHA256 を使い、反復回数は OWASP が推奨する 600,000 にしていた。ローカルのテストスイートは全部緑。デプロイして最初のサインアップを叩いたら、これが出た。
NotSupportedError: iteration counts above 100000 are not supported at crypto.subtle.deriveBits POST /signup -> 500
Cloudflare Workers の crypto.subtle は、PBKDF2 の反復回数を 100,000 までしか受け付けない。それを超えると NotSupportedError を投げる。仕様上の制限ではなく、ランタイムが持っている実装上の制限だ。
なぜテストをすり抜けたのか
テストは緑だった。しかもそれは「テストが甘かった」わけではなく、実際にハッシュ生成と検証を通す本物のテストが通っていた。
- テストは Node の WebCrypto に backing された環境で走っていて、反復回数の上限がそもそも存在しなかった。
- 上限は仕様上のものではなく、実行環境が持つ制限。コードを読んでも型を見ても、どこにも現れない。
- パスワードハッシュは「通る/通らない」ではなく「時間がかかる」処理なので、遅い=異常という直感が働きにくい。
つまりこの不具合は、コードの側にも、テストの側にも、手がかりが一切残らない。実際のランタイムに載せて初めて姿を現す。
ランタイム固有の制限は、ローカルの互換レイヤーが「親切に」埋めてしまうことがある。互換レイヤーが厳密であるほど安心だが、厳密でない箇所は本番でしか分からない。
壊れていたのは新規登録だけではない
目に見えていたのはサインアップの 500 だったが、より深刻だったのは検証側だ。旧実装(Go)から移行してきたユーザーのハッシュは、次の形式で保存されている。
pbkdf2$sha256$600000$<salt_b64url>$<hash_b64url>
検証時は、この文字列に書かれた反復回数をそのまま使って再計算し、突き合わせる。つまり移行済みユーザーは全員 600,000 で検証されるため、既存ユーザーのログインも同じ理由で通らない状態だった。サインアップのエラーが先に出たから気づけたが、これは「新機能が動かない」ではなく「既存ユーザーが締め出される」不具合だった。
pure JS の PBKDF2 に差し替える
反復回数を落として仕様を弱めるという選択肢は取らなかった。代わりに、実行環境に依存しない pure JS 実装へ差し替えた。
// これは Cloudflare Workers では動かない
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt, iterations: 600_000 },
key,
256,
);
// pure JS 実装なら通る(出力は WebCrypto / Go と bit 一致)
import { pbkdf2 } from "@noble/hashes/pbkdf2.js";
import { sha256 } from "@noble/hashes/sha2.js";
const bytes = pbkdf2(sha256, encoded, salt, { c: 600_000, dkLen: 32 });PBKDF2-HMAC-SHA256 の出力は決定的なので、pure JS 実装の結果は WebCrypto や Go の実装と bit 単位で一致する。だからハッシュのスキーム、反復回数、保存形式のいずれも変えずに済み、移行済みハッシュの検証互換もそのまま保たれた。差し替えたのは計算の経路だけだ。
次に踏んだ CPU 時間の壁
pure JS の PBKDF2 は、当然ながら速くはない。1 ハッシュあたり実測で約 1.6〜1.9 秒かかる。これが次の問題を呼んだ。無料プランの CPU 時間上限は 10ms で、サインアップが Error 1102 (exceededCpu) を断続的に返すようになった。断続的というのが厄介で、通ることもあるので原因の切り分けに時間がかかる。
有料プランへ切り替え、Worker の設定で CPU 時間の上限を明示的に引き上げて解消した。パスワードハッシュのように「意図的に重い」処理を Workers に載せるなら、CPU 時間は最初に確認しておくべき制約だった。
argon2id に逃げられるかを検討した
1 秒を超えるハッシュ計算を抱えるくらいなら、argon2id へ移行したほうが筋がいいのではないか、と考えて調べた。結論は「今回は見送り」で、理由は2つある。
- Workers は実行時の WASM コンパイルを全面的に禁止している。base64 を埋め込んで実行時にコンパイルする方式のライブラリはそのままでは動かない。静的な .wasm module import と手書きの ABI 配線が必要になる。
- pure JS の argon2id を OWASP 推奨パラメータで動かすと約 1.5 秒で、PBKDF2 とほぼ同速だった。速くならないなら移行する理由がない。
認証のハッシャに素性の確認しきれていない wasm を同梱するリスクと工数が、得られるものに見合わない。この判断はコードから読み取れないので、コメントとして実装ファイルに残した。
再発の経路を塞ぐ
直しただけでは、次に誰かが crypto.subtle へ戻したときに同じことが起きる。塞ぐために3つ入れた。
- Go 実装が生成した反復回数 600000 の golden ハッシュを固定値でテストに埋め、実 600000 のまま検証が通ることを確認する。crypto.subtle が再び混入したらこのテストが落ちる。
- 新規ハッシュ生成の反復回数だけ、テスト環境から下げられるようにする。検証側は保存文字列に書かれた反復回数をそのまま使うので、移行済みハッシュの互換性はこの設定と無関係に保たれる。
- 新しいエンドポイントは、ローカルのテストが緑になっただけでは完了にしない。dev 環境へデプロイしてログを見るまでを1セットにする。
まとめ
テストが緑であることは、そのコードが本番のランタイムで動くことを意味しない。特に暗号処理やタイマー、メモリ制限のようにランタイムが独自の制限を持ちうる領域では、ローカルの互換レイヤーは「動いてしまう」方向に嘘をつく。
エッジランタイムに何かを載せるときは、「仕様上できること」ではなく「その実行環境で実際にできること」を、デプロイして確かめるところまでを最小単位にしたほうがいい。