本文へ移動
myowchick

SubtleCryptoにMD5は無い — 穴は定数ではなくパディング

crypto.subtle.digest('MD5', bytes)は動かない。NotSupportedError: Unrecognized algorithm nameが返ってくる。配布元が出しているチェックサムとダウンロードしたファイルを突き合わせる、というたぶん一番ありふれた用途が、ブラウザ標準のハッシュから外れている。ハッシュ計算ツールでここに詰まって、RFC 1321を依存なしで書いた。書いてみたら、間違えそうな場所は思っていたのと違うところにあった。

SubtleCryptoが受け付けるもの

Node 24のWebCryptoに片っ端から投げた結果。

指定結果
MD5NotSupportedError
md5NotSupportedError
SHA-120バイト
SHA-25632バイト
SHA-38448バイト
SHA-51264バイト
SHA3-25632バイト(実験的機能の警告つき)

Web Cryptography APIが並べているのはSHA-1からSHA-512までで、MD5は最初から入っていない。あとから足されたSHA-3のほうが先に通るのがおかしみがある。壊れたハッシュを標準に載せない判断は分かるけれど、SHA-1は残っているので強度だけの線引きでもないと思う。

Node側のnode:cryptoにはmd5がある(getHashes().includes('md5')がtrue)。ブラウザで動かしたいので、これは使えない。

定数の表は写さなくていい

MD5は64ステップあって、各ステップで32ビットの定数T[i]を足す。RFC 1321の3.4には64行の表が載っていて、実装解説はたいていこれを転記する。でも表の出どころは式で、RFCにもそう書いてある。

/** RFC 1321 3.4 の定数 T[i] = floor(abs(sin(i)) * 2^32)。表を写さず sin から作る */
const MD5_SINE = Uint32Array.from({ length: 64 }, (_, index) =>
  Math.floor(Math.abs(Math.sin(index + 1)) * 2 ** 32),
)

index + 1にしているのは、RFCのT[i]がi = 1から始まるから。これで64個そろって、先頭は0xd76aa478、末尾は0xeb86d391になる。表の値と合う。

引っかかるのは浮動小数のほうだ。Math.sinはECMAScriptで実装依存の近似が許されていて、最終桁まで同じ値が返る保証は無い。floorを取る以上、abs(sin(i+1)) * 2^32が整数の境目に近い項があると結果が変わりうる。64個ぶん、小数部が0か1にどれだけ近いかを測った。

項目値
整数境界に一番近い項i = 30、小数部0.0154
2番目i = 4、小数部0.0187
3番目i = 12、小数部0.0277
2^32付近のdoubleの刻み(ulp)9.5e-7

一番際どい項でも境界まで0.0154あって、doubleの刻みのおよそ1万6千倍。Math.sinが最終桁で数ulpずれる程度ならfloorの結果は動かない。

そして表を写さないと、写経ミスが構造的に消える。試しにT[i]のどれか1つだけ最下位ビットを反転させてmd5("abc")を計算すると、64通り全部が既知値と食い違った。floorをroundに書き間違えた場合も25個ずれて、やはり"abc"1本で落ちる。定数の誤りは、どんな入力でも落ちる種類の誤りだ。

この対比があとで効いてくる。

バグるのはパディング

MD5は入力を512ビット(64バイト)のブロックに切って回す。末尾には必ず0x80を1バイト置き、0で埋めて、最後の8バイトに入力のビット長をリトルエンディアンで書く。0x80と長さで9バイト要るので、最後のブロックに9バイトぶんの空きが無ければブロックが1つ増える。

// 末尾に 0x80 と 8 バイトの長さが入る。56 バイト以上余ると、そのぶんブロックが 1 つ増える
const blocks = Math.floor(data.length / 64) + (data.length % 64 < 56 ? 1 : 2)

< 56は「余りが55バイト以下なら同じブロックに収まる」という意味。Math.ceil((data.length + 9) / 64)と書いても同じ値になる(0から256まで全長で一致するのを確認した)。

厄介なのは、ここを1つずらしても、ほとんどの入力では結果が変わらないこと。ブロック数の式を書き換えて、正しい式と食い違う長さを0から256まで数えた。

書き方食い違う長さ(0..256)
n % 64 < 56(正)
n % 64 <= 5656, 120, 184, 248
n % 64 < 5555, 119, 183, 247
Math.ceil((n + 9) / 64)無し

257通りのうち4通り。0から255の一様乱数で長さを引いて1万回試したら、当たったのは150回、1.50%だった。入力を適当に増やしても、まず踏まない。

RFC付属のテストベクタは境界を通らない

RFC 1321の付録A.5には7本のテストベクタが載っている。<= 56にずらした実装に流してみた。

入力長さずれを検出
""0しない
"a"1しない
"abc"3しない
"message digest"14しない
"abcdefghijklmnopqrstuvwxyz"26しない
"ABCDEFG...0123456789"62しない
"1234567890"を8回80しない

7本中0本。長さが0, 1, 3, 14, 26, 62, 80で、% 64が55や56になるものが1つも無い。RFCのテストスイートを全部緑にしても、パディングのoff-by-oneはそのまま残る。

差が出るのは境界の長さだけ。

長さ正しい実装<= 56版
55ef1772b6...同じ
563b0c8ac7...95972952...
57652b906d...同じ
64014842d4...同じ

なのでテストには55/56/64を名指しで固定した。

// 55 バイトは末尾のブロックに長さが収まり、56 バイトからはブロックが 1 つ増える。
// 期待値は Node の crypto.createHash('md5').update('a'.repeat(n)).digest('hex') で取った
const boundaries: [number, string][] = [
  [55, 'ef1772b6dff9a122358552954ad0df65'],
  [56, '3b0c8ac703f828b04c6c197006d17218'],
  [64, '014842d480b571495a4a0363793f7367'],
]

ここは最初、長さだけを見る検査を書いていた。digestが16バイトある、16進数にすると32文字ある、という形。レビューで指摘されて実ダイジェストに直した。長さの検査はdeadbeefを4回繰り返した文字列でも通るし、定数を1ビット壊した実装も全部通る。何も検査していない。

前の節の裏返しになっている。定数のミスは"abc"1本で落ちるのに、パディングのミスは狙った長さを置かないと一生落ちない。テストベクタの本数を増やしても効かなくて、境界の長さを名指しするしかない。

古い結果が後から届く

ツール側はライブ入力で、打つたびに計算し直す。crypto.subtle.digestはPromiseを返すので、投げた順に返るとは限らない。64MBのSHA-512を投げてから1バイトのSHA-512を投げると、解決の順はこうなった。

解決した順: 後から投げた 1 バイト → 先に投げた 64MB
クリーンアップ無しで最後に画面へ残るのは: 64MBの結果

つまり、古い入力の結果が新しい結果を上書きする。素直にawaitしてsetStateすると必ずこの形になる。

計算のほうはeffectの中にあるので、cleanupで捨てられる。

let cancelled = false
void Promise.all(/* ... */).then((next) => {
  if (!cancelled) setDigests(next)
})

// 入力を打ち換えている途中の結果が、後から届いて新しい結果を上書きしないようにする
return () => {
  cancelled = true
}

ファイルの読み込みは同じ手が使えない。<input type="file">のonChangeから始まるので、effectの依存が変わるわけではなく、cleanupの掛かる先が無い。こっちはuseRefの連番を回して、返ってきたときに自分の番号がまだ最新かを見る。

readCount.current += 1
const token = readCount.current

picked.arrayBuffer().then((buffer) => {
  if (readCount.current !== token) return
  setFileBytes(new Uint8Array(buffer))
})

どちらも「古い非同期結果を捨てる」という同じ目的で、置き場所が違うから道具が違う。

上限をどこに引くか

crypto.subtle.digestは一発物で、node:cryptoのcreateHash().update()にあたる逐次APIが無い。ファイルは丸ごとメモリに載せてから渡すしかない。自前MD5はそのうえ、パディングのために入力と同じ大きさの複製をもう1本作る。64MBのUint8Arrayを1回流してprocess.memoryUsage().arrayBuffersの増分を見たら、ちょうど64.0MB増えた。

速度も10倍近く違う。Node 24で3回ずつ測った中央値。

入力自前MD5SubtleCrypto SHA-256
1MB22ms2ms
10MB214ms20ms
100MB2158ms202ms

差はそれ自体より、どこで回るかのほうが痛い。100MBを流しているあいだ10msのタイマーがどれだけ空くかを測った。

走らせたものタイマーの最大間隔
何もしない11ms
自前MD5(100MB)2214ms
SubtleCrypto SHA-256(100MB)83ms

SubtleCryptoは呼び出し元のスレッドの外で回るので、100MBでも80ms程度しか止まらない。自前MD5はJavaScriptなので、2.2秒まるごとメインスレッドを占有する。ブラウザなら画面が固まっている時間だ。

/**
 * 読み込むファイルの上限。MD5 は入力と同じ大きさの複製をメインスレッドで作るので、
 * これより大きいものを渡すと画面が固まるか確保に失敗する。
 */
const MAX_FILE_BYTES = 100 * 1024 * 1024

100MBという数字に理屈があるわけではなくて、2秒強の固まりと2倍のメモリを許容する上限をそのへんに置いた、というだけ。WebWorkerに逃がせば固まりのほうは消えるけれど、今は入れていない。

写経ミスを心配して定数の表を見比べていたが、そこはsinから作れば構造的に消える場所だった。実際に間違えるのはパディングのほうで、しかも257通りの長さのうち4通りでしか落ちない。RFC付属のテストベクタ7本は、その4通りを1つも踏まない。

テストに何を置くかは、どこが間違えやすいかではなく、間違えたときにどの入力で落ちるかで決まる。長さの検査が何も検査していなかったのも、同じ話だと思う。

← 一覧へ戻る