ファイルの整合性確認やAPI署名の検証で、手元でサッとSHA-256を計算したいことがある。ブラウザには標準で Web Crypto API(crypto.subtle)が載っているので、ライブラリを一切入れずにハッシュ生成ができる。
ぱんだツールズに作ったハッシュ値生成ツールは、テキストから SHA-256 / SHA-1 / SHA-512 を一括計算する。中身は crypto.subtle.digest を呼ぶだけだが、「16進数への変換」「非同期である理由」「MD5が無い」「HTTPS必須」あたりに、知らないと詰まる罠がいくつかある。実装と一緒にまとめておく。
crypto.subtle.digest でハッシュを計算する
コアはこれだけ。TextEncoder で文字列をバイト列にし、crypto.subtle.digest に渡す。
type Algorithm = 'SHA-256' | 'SHA-1' | 'SHA-512'
async function computeHash(text: string, algorithm: Algorithm): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(text)
const hashBuffer = await crypto.subtle.digest(algorithm, data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
}
crypto.subtle.digest が返すのは ArrayBuffer(生のバイト列)。これをそのまま画面に出しても意味不明なので、16進数文字列に変換する。ここに地味な罠がある。
hex 変換の padStart(2, '0') を忘れると壊れる
ArrayBuffer を Uint8Array にして、各バイトを toString(16) で16進数にする。問題は 1桁になるバイトだ。
たとえば値が 10(0x0a)のバイトを (10).toString(16) すると "a" になる。本来は "0a" の2文字でなければいけないのに1文字になってしまい、ハッシュ全体の桁がずれて別物の文字列になる。0 〜 15(0x00〜0x0f)のバイトが入るたびにこれが起きる。
なので必ず padStart(2, '0') で2桁に左ゼロ埋めする。
hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
SHA-256 なら 32バイト → 64文字、SHA-512 なら 64バイト → 128文字、SHA-1 なら 20バイト → 40文字。padStart を入れておけば、どんなバイト値が来ても必ずこの長さの小文字16進数(lowercase hex)になる。他ツールやDBのハッシュ値と突き合わせる前提なら、この形式に揃えるのが無難。
crypto.subtle はなぜ非同期なのか
computeHash が async で、digest を await しているのに気づいたと思う。Web Crypto の subtle 系APIはすべて Promise を返す。同期版は無い。
btoa や TextEncoder は同期なのに、ハッシュ計算だけ非同期なのは違和感があるかもしれない。これは Web Crypto の設計思想で、暗号処理は重くなりうる・ハードウェアアクセラレーションや別スレッド実装の余地を残したい、という理由で最初から非同期APIとして定義されている。実際の SHA 計算は一瞬で終わるが、APIの契約として await が必須。
なので「同期的にハッシュが欲しい」と思っても crypto.subtle.digest では叶わない。同期が要るならサードパーティの純JS実装(js-sha256 等)を使うことになるが、標準APIで済むならそちらが速くて安全。
複数アルゴリズムは Promise.all で並列計算
このツールは SHA-256 / SHA-1 / SHA-512 を一度に出す。3回 await を直列で書くと待ち時間が積み上がるので、Promise.all で並列にする。
const ALGORITHMS: Algorithm[] = ['SHA-256', 'SHA-1', 'SHA-512']
async function handleGenerate() {
const entries = await Promise.all(
ALGORITHMS.map(async (alg) => [alg, await computeHash(input, alg)] as const)
)
setResults(Object.fromEntries(entries) as Record<Algorithm, string>)
}
各アルゴリズムの計算は互いに独立なので、直列にする理由がない。map で [アルゴリズム名, ハッシュ値] のタプル配列を作り、Object.fromEntries で { 'SHA-256': '...', ... } のオブジェクトに畳む。as const でタプル型を保ったまま渡すのがTypeScript的なポイント。
罠1:Web Crypto に MD5 は無い
crypto.subtle.digest が受け付けるアルゴリズムは SHA-1 / SHA-256 / SHA-384 / SHA-512 の4つだけ。'MD5' を渡すとエラーになる。
MD5 は衝突攻撃が現実的で暗号学的に壊れているため、Web Crypto の仕様から意図的に除外されている。「レガシーなシステムとの照合でどうしてもMD5が要る」場合は、標準APIでは作れず、別途ライブラリを入れるしかない。逆に言うと、ブラウザ標準だけで完結させたいなら MD5 は選択肢に入らない。これは設計段階で知っておきたい制約。
このツールが SHA-1 を残しているのも「SHA-1 を新規に推奨するから」ではなく、既存システムとの互換性確認・比較用途のため。新規なら SHA-256 が無難。
罠2:crypto.subtle は secure context(HTTPS)でしか動かない
もう一つハマりやすいのが、crypto.subtle は secure context でしか使えないこと。具体的には HTTPS のページか localhost でのみ動作し、http:// の本番ページや、ファイルを直接開いた file:// では crypto.subtle が undefined になる。
ローカルの localhost:3000 で開発しているときは動くのに、HTTP の検証環境にデプロイした途端「Cannot read properties of undefined」で落ちる、というのが典型的な踏み方。ハッシュ生成をフロントに組み込むなら、配信が HTTPS であることが前提になる。
ブラウザ完結ツールにとってはむしろ好都合で、Web Crypto を使う=テキストがサーバーに一切送られない。パスワードや機密文字列のハッシュを確認したいときでも、入力がネットワークに出ないので安心して使える。
まとめ
ブラウザだけでハッシュ生成するときの要点。
-
crypto.subtle.digest(algorithm, data)でライブラリ不要。入力はTextEncoderでバイト列に - 出力の hex 変換は
padStart(2, '0')必須。1桁バイトでハッシュが壊れる -
crypto.subtle系は すべて非同期(await必須)。同期版は無い - 複数アルゴリズムは独立計算なので
Promise.allで並列 - MD5 は無い(仕様から除外)。SHA-1/256/384/512 のみ
-
secure context(HTTPS / localhost)必須。HTTP本番だと
crypto.subtleが undefined
外部ライブラリゼロ・サーバー送信ゼロでハッシュが計算できるのが Web Crypto の良いところ。padStart と非同期だけ押さえておけば、自前ツールにも数行で組み込める。
ぱんだツールズ では他にもパスワード生成・JWTデコード・テキスト暗号化など、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・入力はサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。