背景
ハッシュが必要になったとき、Web 標準である crypto.subtle.digest('SHA-256', ...) を真っ先に思い浮かべる人は多いはずです。標準 API であり、ブラウザーにも Node.js にも実装されていて、暗号学的にも安全です。
しかし Edge ではどうでしょうか。ここでいう Edge は、Cloudflare Workers、Fastly Compute、Vercel Edge、Deno Deploy、その他の V8 isolate や WebAssembly ベースの環境を指します。この環境でハッシュを使う場面では、Web Crypto API が常に扱いやすいとは限りません。「標準だからそのまま使えばよい」と考えると、移植性・パフォーマンス・要件の 3 つの観点で噛み合わないことがあります。
この記事では、Edge で Web Crypto API 以外の選択肢を検討するときの見方と、JavaScript 実装のハッシュ(FNV-1a / xxhash / Murmur3 など)の使い分けを整理します。
Web Crypto API を使うときに整理したい 3 つの点
非同期 API であること
crypto.subtle.digest は Promise<ArrayBuffer> を返します。
async function sha256Hex(s: string): Promise<string> {
const data = new TextEncoder().encode(s);
const buffer = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
呼び出し側もすべて async 化する必要があります。1 回のリクエストで数百から数千のハッシュ計算が必要な処理を考えます。URL 群の正規化キー、ログ集約用のキー、トークンごとの認証鍵などです。ここでは Promise の生成と解決のコストや、await が連なることで生じる待ち時間が、無視しにくいオーバーヘッドになることがあります。
JavaScript 実装のハッシュは同期で書けます。
function fnv1aHex(s: string): string {
let hash = 2166136261;
for (let i = 0; i < s.length; i++) {
hash ^= s.charCodeAt(i);
hash = Math.imul(hash, 16777619) >>> 0;
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
数100 回の呼び出しを for ループの中で素直に回せます。
2. Edge ごとに対応状況が揃わない
crypto.subtle は WHATWG / W3C の Web Crypto API 仕様に基づきますが、Edge ごとに対応状況は揃いません。ここは 2 段に分けて考えると整理しやすくなります。
まず、Cloudflare Workers、Fastly Compute、Vercel Edge、Deno Deploy のような主要な Edge 製品でも、アルゴリズム対応や細かな挙動には差があります。代表的な確認ポイントは次のとおりです。
- 対応アルゴリズムのサブセット。SHA-1 / SHA-256 / SHA-512 は広く対応されます。HMAC、AES-GCM、RSA-OAEP のようにアルゴリズムが増えると、ランタイムによって挙動が違ったり、未実装の組み合わせが出てきたりします。
-
crypto.randomUUIDのバージョン差。Workers / Compute / 一部 Lambda 系で利用可否の違う時期がありました。
さらに、制約の強い埋め込み環境や isolate の制約モードのような環境まで含めると、getRandomValues はあっても subtle の一部が揃わない、ということもあります。
そのため、「標準仕様にあるからどこでも同じように使える」とは限りません。プロバイダ A で動いていたコードが、プロバイダ B ではそのまま使いにくい ことがあります。
JavaScript 実装のハッシュは、Math.imul と String.charCodeAt だけで書けるものなら、かなり広い範囲で動かしやすいのが利点です。
要件に対する機能の過剰さ
Web Crypto API を使う本来の動機は「暗号学的な衝突耐性」と「秘密の保持」です。
- 認証トークンの署名検証(HMAC)
- コンテンツの完全性チェック(SHA-256 ハッシュ値)
- 暗号化通信(AES-GCM)
しかし、Edge でハッシュを使う場面の多くは、もっと軽い用途です。
- ログ集約用のグルーピングキー
- A/B テストのバケット決定
- レート制限のバケット決定
- 内部ログのトレース ID
これらは 「攻撃者が衝突を意図的に作る経路がない」 か、「衝突しても被害が小さい」 ケースです。こうした要件で SHA-256 を使うと、計算コストや API の複雑さに対して見返りが小さくなります。
JavaScript 実装ハッシュの選択肢
FNV-1a (32-bit / 64-bit)
シンプルさと速度のバランスがよく、32bit / 64bit / 128bit 版があります。
function fnv1a32(s: string): number {
let hash = 2166136261; // FNV offset basis
for (let i = 0; i < s.length; i++) {
hash ^= s.charCodeAt(i);
hash = Math.imul(hash, 16777619) >>> 0;
}
return hash >>> 0;
}
特性は次のとおりです。
- 32bit で 4 バイト分のハッシュ空間です。要素数が増えると衝突確率が無視しにくくなるので、要件に応じて連鎖します(後述します)。
- 実装が極めて短く、10 行未満で書けます。
- 速度も十分で、1MB の入力を数十 ms 程度で処理できます。
Murmur3
FNV-1a より分散が良く、暗号用途以外の汎用ハッシュとして広く使われています。
特性は次のとおりです。
- 32bit / 64bit / 128bit があります。
- データの分布が偏っている入力でも、比較的バランスよくハッシュされます。
- 実装は FNV-1a より複雑で、数十行になることが多いです。
xxhash
C 実装ではほぼ最速で、JS 実装でも高速です。
特性は次のとおりです。
- 32bit / 64bit があります。
- 大きい入力では性能差が出やすい一方、短い入力では FNV-1a と大差ないことが多いです。
- 実装の複雑さは中程度です。
CityHash / FarmHash
Google 製で、データセンタ向けに設計されたハッシュです。JS 実装は少なく、Edge 用途では Murmur3 / xxhash のほうが現実的な選択肢です。
32bit ハッシュ空間が足りないとき — 連鎖で広げる
FNV-1a 32bit は 4,294,967,296 通り(2³²)しかありません。同一スコープ内に同じハッシュが出る可能性は、要素数が数千になると Birthday paradox により無視しにくくなります。
n² / (2 × 2³²) ≒ 衝突確率(オーダー)です。
- n = 1,000 → 1.16 × 10⁻⁴ (約 1 万分の 1)
- n = 10,000 → 1.16 × 10⁻² (約 100 分の 1)
- n = 100,000 → 1.16 (ほぼ確実に衝突)
短い識別子が 1 スコープに数千件以上出る用途では、32bit はやや危険です。
そこで使えるのが 複数回の連鎖 です。
export function generateFingerprint(s: string): string {
return [
fnv1a32(s),
fnv1a32(s + '\x01'),
fnv1a32(s + '\x02'),
fnv1a32(s + '\x03'),
fnv1a32(s + '\x04'),
fnv1a32(s + '\x05'),
fnv1a32(s + '\x06'),
fnv1a32(s + '\x07'),
]
.map((h) => h.toString(16).padStart(8, '0'))
.join('');
}
入力に異なるサフィックス(\x01 〜 \x07)を付けて 8 回独立に呼び、合計 256bit のハッシュ空間にします。サフィックスを変えることで、各 32bit ハッシュ間の独立性が、厳密ではないものの実用上は確保しやすくなります。
256bit 空間なら、n = 10⁹ でも衝突確率はかなり小さく抑えられます。実装は依然として JavaScript だけで書けて、同期で動かせます。
いつ Web Crypto API を使うべきか
JavaScript 実装のハッシュが扱いやすい場面は多いのですが、Web Crypto API を選んだほうがよい場面もあります。
攻撃者が衝突を作れる経路の有無
ユーザー入力をハッシュした値がアクセス制御の鍵になる、ハッシュ衝突を見つけたらシステムが破綻する、といった場面では暗号学的な衝突耐性が必要です。標準の Web Crypto API の SubtleCrypto.digest() を使うなら SHA-256 / SHA-384 / SHA-512、あるいは同等の標準アルゴリズム実装を使います。BLAKE2 / BLAKE3 を使うなら、Web Crypto API ではなく専用実装や WASM ライブラリの採用を検討します。
例は次のとおりです。
- パスワードハッシュ(さらに salt + iteration が必要)
- HMAC 署名検証
- コンテンツ整合性チェック(CDN が配信するファイルが改ざんされていないか)
既存システムとの標準アルゴリズムの一致
外部 API が「SHA-256 で計算した値を渡せ」と要求している場合は、少なくとも SHA-256 と互換な実装 が必要です。Web Crypto API は有力な選択肢ですが、一択ではありません。JavaScript 実装や WASM 実装でも、同じ出力が得られるなら互換性要件は満たせます。
3. 入力サイズが大きい
数 MB〜数十 MB の入力をハッシュする場合、JavaScript 実装では遅さが問題になることがあります。Web Crypto API は内部で WASM や native 実装を使うので、入力が大きいほど速度差が広がりやすくなります。
ただし「Edge で数十 MB のハッシュを計算する」場面が本当に適切かは、一度立ち止まって考える必要があります。Edge では小さく短い処理を前提にした設計のほうが扱いやすく、大きい計算はバックエンド側に寄せることが一般的です。
ベンチマークで見るべき指標
JavaScript 実装と Web Crypto API で迷ったら、実際に扱う入力でベンチを取ります。
import { describe, bench } from 'vitest';
describe('hash benchmark', () => {
const small = 'a'.repeat(64); // 短い識別子程度
const medium = 'a'.repeat(1024); // URL 群の集約
const large = 'a'.repeat(1024 * 1024); // 1MB
bench('FNV-1a (small)', () => fnv1a32(small));
bench('SHA-256 (small)', async () => await sha256Hex(small));
bench('FNV-1a (medium)', () => fnv1a32(medium));
bench('SHA-256 (medium)', async () => await sha256Hex(medium));
bench('FNV-1a (large)', () => fnv1a32(large));
bench('SHA-256 (large)', async () => await sha256Hex(large));
});
経験的には、次のような傾向があります。
| 入力サイズ | FNV-1a | SHA-256 (Web Crypto API) |
|---|---|---|
| 64B | 1〜10x 速い | - |
| 1KB | 同等 | - |
| 1MB | 2〜5x 遅い | - |
短い入力では JavaScript 実装が有利になりやすく、大きい入力では Web Crypto API が有利になりやすい、というのが大まかな傾向です。
ただし、ベンチで見るべきは ピーク時の latency ではなく 総コスト です。
- 同期 vs 非同期:
awaitが並ぶことでイベントループの解放・再開コストが乗る。 - メモリアロケーション:Web Crypto API は ArrayBuffer を返すので、文字列化のコストが乗る。
- GC 圧力:
Promiseを大量に生成すると GC が忙しくなる。
これらは小さなベンチマークでは見えにくいので、本番ワークロード相当の処理を回して観察するのが堅実です。
JavaScript 実装ハッシュで留意する点
1. Math.imul を使う
JavaScript の Number は 53bit 浮動小数点なので、32bit 同士の乗算は精度を失います。
// ✗ Bad
hash = (hash * 16777619) >>> 0;
// 乗算結果が 2^53 を超えると精度落ち
// ✓ Good
hash = Math.imul(hash, 16777619) >>> 0;
// 32bit 整数乗算が確実に行われる
Math.imul は ES2015 で追加された組込み関数です。Edge なら確実に使えます。
2. UTF-16 サロゲートペアの扱い
String.charCodeAt(i) は UTF-16 のコードユニットを返します。絵文字(👋 など)は 2 ユニットのサロゲートペアで表されるので、charCodeAt でループすると 2 回別のコードを読みます。
const emoji = '👋';
emoji.length; // 2
emoji.charCodeAt(0); // 0xD83D(high surrogate)
emoji.charCodeAt(1); // 0xDC4B(low surrogate)
ハッシュ用途では、サロゲートペアを 2 ユニットとして扱っても 決定的な結果が出れば問題ありません(同じ文字列からは常に同じハッシュが出ます)。ただし、書き手と読み手で文字列の前処理(NFC / NFD 正規化)が違うと、見た目が同じでも別ハッシュになります。
入力文字列を String.prototype.normalize('NFC') で正規化してからハッシュすると、見た目が同じものは同じハッシュになります。
function fnv1aNormalized(s: string): number {
return fnv1a32(s.normalize('NFC'));
}
3. 大きい入力でのスタック・GC
JavaScript 実装で MB 単位の入力をハッシュすると、ループのオーバーヘッドが目立ってきます。長い文字列を処理するなら、Uint8Array に変換してバイト単位で回すほうが速くなることもあります。
function fnv1a32Bytes(bytes: Uint8Array): number {
let hash = 2166136261;
for (let i = 0; i < bytes.length; i++) {
hash ^= bytes[i];
hash = Math.imul(hash, 16777619) >>> 0;
}
return hash >>> 0;
}
TextEncoder().encode(s) で UTF-8 バイト列にしてからハッシュすれば、文字エンコーディングの曖昧さもなくなります。
まとめ
- Web Crypto API は標準ですが、どの場面でも最適とは限りません。Edge では、非同期 API であること、対応状況に差があること、要件に対して機能が大きすぎること、の 3 点を確認する必要があります。
- JavaScript 実装のハッシュ(FNV-1a / Murmur3 / xxhash) は同期で書けて移植しやすく、要件によっては十分な選択肢になります。
- 32bit ハッシュ空間は短い です。同一スコープに数千件以上のキーが出るなら、連鎖して 256bit にします。
- Web Crypto API が向く場面は、暗号学的な衝突耐性を要するとき / 既存システムとの互換性を要するとき / 入力が大きいとき です。
- JavaScript 実装では
Math.imulを必ず使います。UTF-16 サロゲートペア と NFC 正規化 にも注意が必要です。 - ベンチは小さなベンチマークではなく 本番ワークロード相当 で取ります。
Promise連鎖の総コストを見ます。
「標準 API を使う」は多くの場面で妥当ですが、Edge で軽量なハッシュが必要な場面では、別の選択肢のほうが要件に合う ことがあります。Web Crypto API を使うかどうかは、API の好みではなく、要件との相性で決めるのが自然です。