暗号化パフォーマンス最適化:ブラウザで高速暗号処理
大容量ファイル転送のためのブラウザ暗号化パフォーマンスを最適化。ストリーミング暗号化・Web Workers・チャンク処理技術。
ブラウザで5GBのファイルを暗号化しながら UI を止めないためには、特定の技術の組み合わせが必要です。ハードウェア加速 AES-256-GCM のための Web Crypto API(AES-NI で1〜2GB/s)、メモリ消費を抑える1〜4MBブロックのチャンク処理、メインスレッドのレスポンスを維持する Web Workers 、 FileReader.readAsArrayBuffer() の代わりの File.stream() によるストリーミング読み込み、そしてチャンクを並列暗号化するための適切な nonce 管理です。純粋な JavaScript 暗号ライブラリは Web Crypto より10〜20倍遅く、ブラウザがネイティブで公開していないプリミティブのためだけに予約すべきです。
まず計測から始める
最適化の前に計測します。2024年の MacBook Air M2 の Chrome 120 で、1GBのバッファを AES-256-GCM( Web Crypto )でタイトなループで暗号化すると約1.7GB/sです。同じ処理が中位クラスの Android 端末( Pixel 7 )では約600MB/s。ソフトウェアのみの AES を使う2015年製 Intel ノートパソコンでは約250MB/sです。
これが示すのは Web Crypto AES-GCM は大半の暗号化ファイル転送フローでボトルネックにはならないということです。ボトルネックはファイル読み込み、 ArrayBuffer 間の JavaScript マーシャリング、またはネットワークアップロードです。そちらを先に最適化します。
Web Crypto を使い、 JavaScript ライブラリを避ける
AES-GCM 、 AES-CBC 、 AES-CTR 、 PBKDF2 、 HMAC 、 RSA 、 ECDH 、 ECDSA 、 SHA-256/384/512 については、ブラウザのネイティブ Web Crypto API がハードウェア加速を使用します(x86の AES-NI 、モバイルの ARMv8 crypto extensions )。 @noble/ciphers や pure-JS の crypto-js などの JavaScript ライブラリはこれらの命令にアクセスできません。
AES-256-GCM の1MB入力に対する典型的な速度比は次の通りです。
- Web Crypto(ハードウェア加速):1〜2GB/s
- libsodium.js WASM:400〜800MB/s
@noble/ciphers純粋JS:100〜200MB/scrypto-js純粋JS:30〜80MB/s
5GBのファイルでは Web Crypto と純粋JS の差は約3秒対約50秒になります。 Web Crypto がサポートするものは必ず Web Crypto を使います。ネイティブ API が提供しない ChaCha20-Poly1305 、 Argon2id 、古いブラウザでの X25519 には WASM ライブラリ( libsodium.js 、 argon2-browser )を使います。
大容量ファイルのチャンク処理
500MB超のファイルは大半のデバイスで単一の ArrayBuffer に収まりません。1〜4MBのピースに分割して各チャンクを暗号化します。
const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
async function encryptLargeFile(file, key) {
const chunks = [];
let chunkIndex = 0;
for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
const chunk = await file.slice(offset, offset + CHUNK_SIZE).arrayBuffer();
const iv = new Uint8Array(12);
new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv }, key, chunk
);
chunks.push({ iv, ct: new Uint8Array(ct) });
}
return chunks;
}
なぜ4MBチャンクなのかというと、64KBなどの小さいチャンクは Web Crypto API のコール単位のオーバーヘッドが支配的になります。64MBなどの大きいチャンクは L2/L3 キャッシュに収まらずメモリプレッシャーが悪化します。1〜4MBがデスクトップとモバイルの両方でスイートスポットになる傾向があります。
Web Workers でUIのレスポンスを維持
メインスレッドでの暗号化はレンダリングと入力をブロックします。500ms超の処理はすべて Web Worker にオフロードします。
// worker.js
self.onmessage = async (e) => {
const { chunk, key, iv } = e.data;
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv }, key, chunk
);
self.postMessage(ciphertext, [ciphertext]);
};
ArrayBuffer は postMessage の第2引数で転送します。これにより所有権が移転(ゼロコピー)しクローンが発生しません。転送なしでは4MBチャンクのクローンに約20msのオーバーヘッドが生じます。 Web Crypto API は Workers 内で利用可能で、実際の暗号化はそこで実行されメインスレッドと同等のパフォーマンスが得られます。
チャンクの並列暗号化
チャンク単位の AES-GCM 暗号化は nonce が衝突しない限り独立しています。チャンクインデックスから決定論的に nonce を導出すれば複数の Worker にわたってチャンクを並列暗号化できます。ただし、典型的なハードウェアでは約4つの Worker 以降は逓減収益が生じます。 Web Crypto が非常に高速なためボトルネックがファイル読み込みとスレッド間メッセージパッシングに移行するからです。
Readable Streams によるストリーミング
本当に大きいファイル(20GB以上)では、すべてのチャンクを一度にメモリに読み込まないようにします。 File.stream() を使用します。
const reader = file.stream().getReader();
const writer = uploadStream.getWriter();
let chunkIndex = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const iv = new Uint8Array(12);
new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv }, key, value
);
await writer.write(new Uint8Array(ct));
}
await writer.close();
これにより、メモリ使用量は常に1チャンク分に抑えられます。ファイルサイズに関わらず10MB以下のメモリ使用量を維持できます。
アップロードの並行処理
暗号化とアップロードは直列ではなく並列で実行します。チャンクNのアップロード中にチャンクN+1を暗号化するパイプラインを構築します。 TCP スロースタートと TLS ハンドシェイクのオーバーヘッドにより初期アップロードは遅いため、4〜8の同時アップロードを維持してパイプをフルに保ちます( Chrome / Firefox のオリジン当たり6接続制限に注意)。
WASM で不足するプリミティブを補完
Argon2id 鍵導出や ChaCha20-Poly1305 は Web Crypto にネイティブサポートがありません。 WASM ライブラリが補完します。
- libsodium.js:Argon2id 、 XChaCha20-Poly1305 、
crypto_secretstreamを提供 - argon2-browser:Argon2 のみ、バンドルが小さい
@noble/hashes:純粋JS Argon2(低速)、バンドルが tiny
WASM 版はほとんどの暗号ワークロードでネイティブ速度の60〜80%を達成します。ファイル転送では Argon2id の導出に1〜2秒かかることは許容範囲ですが、純粋JSの5秒は許容できません。 WASM は初期ページレンダリングをブロックしないよう動的にロードします。
進捗レポートの実装
大きな暗号化処理には進捗フィードバックが必要です。そうしないとユーザーはアプリが固まったと思います。暗号化済みバイト数をカウントし進捗イベントをポストします。 UI 更新は requestAnimationFrame またはタイムスタンプチェックで約10Hzに throttle します。それ以上の頻度では人間が見えない再描画にサイクルを無駄遣いします。
メモリ上限とGCプレッシャー
ArrayBuffer は参照がなくなるまで生き続けます。20個の4MBの暗号化チャンクを保持すると約80MBのメモリが固定されます。 iOS Safari のメモリ制限(タブあたり200〜400MB)がある環境ではこれが重要です。参照はすぐに解放し、暗号化チャンクの完全な配列をメモリに保持せずアップロード先にストリームして随時参照を解放します。
実際の目標値
ブラウザタブで1GBの暗号化ファイル転送の場合:暗号化時間1〜3秒(ハードウェア加速)、300Mbps接続で約30秒のアップロード、合計約35秒(ほぼネットワークバウンド)、適切なストリーミングでピークメモリ50MB未満、UI は終始レスポンシブ(メインスレッドは50ms以上ブロックされない)。 HexaTransfer の10GBキャップがブラウザ内で実現可能なのは、 Web Crypto と chunked streaming が全体のパスを効率的に保つからです。
hexatransfer.comでお試しください — 無料、登録不要、最大10GB。
エンドツーエンド暗号化で大容量ファイルを安全に送信
エンドツーエンド暗号化で最大10GBのファイルを無料で転送。アカウント不要。ファイルはアップロード前にブラウザで暗号化されるため、他の誰にも読まれません。
ファイルを送信