大容量ファイルのプログレッシブ暗号化:ストリーム暗号化
ストリーミングAPIで大容量ファイルをプログレッシブに暗号化。メモリ不足なしに数GBのファイルをチャンク単位で暗号化処理。
プログレッシブ(ストリーミング)暗号化は、ファイル全体をメモリに読み込まず、チャンク単位で処理します。ブラウザで10GBのファイルをアップロードする場合、この方式が「動くアプリ」と「クラッシュするアプリ」の分かれ道になります。基本パターンは次の通りです。File.stream() でチャンクを読み込み、ユニークなノンスを使って AES-256-GCM で暗号化し、ReadableStream ボディ付きの fetch で暗号文をアップロードストリームに直接送り出し、バッファを解放して次へ進みます。ファイルサイズに関わらず、メモリは4〜16MB に収まります。libsodium の crypto_secretstream_xchacha20poly1305 を使えば、切り詰め検出を含む本格的なストリーミング AEAD セマンティクスも得られます。
バッファ型暗号化が破綻する理由
FileReader.readAsArrayBuffer(file) で10GBのファイルを読もうとすると、ブラウザメモリに10GB分を確保しようとします。32GB の RAM を積んだデスクトップ版 Chrome では動くかもしれませんが、タブあたり400MB の上限がある モバイル Safari ではアップロード完了前にクラッシュします。 Firefox では2GBを超える ArrayBuffer が内部制限に引っかかり RangeError を投げます。
ハードウェアがその確保量をこなせる場合でも、10GB をメモリに保持するとガベージコレクションが阻害され、深刻なページングが発生します。正解は「フルバッファを確保しない」ことです。
ストリーミングパターンの実装
async function streamEncrypt(file, key, uploadURL) {
const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
const reader = file.stream().getReader();
let chunkIndex = 0;
let buffer = new Uint8Array(0);
const uploadStream = new ReadableStream({
async pull(controller) {
while (buffer.length < CHUNK_SIZE) {
const { done, value } = await reader.read();
if (done) {
if (buffer.length > 0) {
await enqueueEncrypted(controller, buffer, chunkIndex++, key);
}
controller.close();
return;
}
const newBuf = new Uint8Array(buffer.length + value.length);
newBuf.set(buffer, 0);
newBuf.set(value, buffer.length);
buffer = newBuf;
}
const chunk = buffer.subarray(0, CHUNK_SIZE);
buffer = buffer.subarray(CHUNK_SIZE);
await enqueueEncrypted(controller, chunk, chunkIndex++, key);
}
});
await fetch(uploadURL, {
method: "POST",
body: uploadStream,
duplex: "half",
headers: { "Content-Type": "application/octet-stream" },
});
}
async function enqueueEncrypted(controller, chunk, index, key) {
const iv = new Uint8Array(12);
new DataView(iv.buffer).setBigUint64(4, BigInt(index));
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, chunk);
controller.enqueue(new Uint8Array(ct));
}
File.stream() はファイル内容の ReadableStream を返し、ReadableStream ボディ付きの fetch はボディ全体をバッファリングせずにアップロードを流します。 Chrome 105以降でストリーミングリクエストボディには duplex: "half" が必須です。ある時点でのメモリ使用量は、ソースチャンク・バッファ残差・暗号化チャンクの合計で、4MB チャンクサイズではピーク12〜16MB 程度です。
ストリームにおけるノンス管理
各チャンクにはユニークなノンスが必要です。主なアプローチは3つあります。
カウンタベース:96ビットのノンスにチャンクインデックスを埋め込みます。上位32ビットをランダムなプレフィックス(同一鍵を複数ファイルで使う際の衝突を防ぐため)、下位64ビットをチャンクインデックスとします。
const noncePrefix = crypto.getRandomValues(new Uint32Array(1));
function makeNonce(chunkIndex) {
const iv = new Uint8Array(12);
new DataView(iv.buffer).setUint32(0, noncePrefix[0]);
new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex));
return iv;
}
チャンクごとのランダム生成:crypto.getRandomValues(new Uint8Array(12)) を使います。ファイル単位の新鮮な鍵なら安全ですが、ノンスを各チャンクの暗号文とともに保存する必要があります。
HKDF で派生:チャンクごとの鍵を HKDF で導出し、固定ノンスを使います。ほとんどのケースでは過剰です。
ファイルごとの新鮮な鍵には、カウンタベースが最もシンプルで、チャンクごとに別途ノンスを保存する必要もありません。
切り詰め攻撃と検出方法
素朴なチャンク AES-GCM には重大な欠陥があります。攻撃者が末尾チャンクを削除しても、残ったチャンクは正常に復号されてしまいます。チャンクを互いに紐づけることで検出できます。
方法1:すべてのチャンクの AAD(追加認証データ)に合計チャンク数を含める。受信側が受け取ったチャンク数と照合します。
const aad = new TextEncoder().encode(JSON.stringify({ totalChunks, fileSize: file.size }));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: aad }, key, chunk
);
方法2:libsodium の crypto_secretstream_xchacha20poly1305 を使います。チャンクを暗号的に連鎖させ、受信側が検証する TAG_FINAL マーカーを発行します。libsodium.js を導入できる環境では、これが最もクリーンな選択肢です。
受信側のストリーミング復号
送信側と対称なパターンを受信側にも実装します。fetch でダウンロードし、レスポンスボディの ReadableStream を読み取りながらチャンク単位で復号します。チャンクサイズ(プラス16バイトの GCM タグ)に達したら復号し、onChunk コールバックへ渡します。
File System Access API によるディスク書き込み
非常に大きなファイルをダウンロードする際、復号結果すべてを Blob に溜めるとストリーミングの意味がなくなります。 File System Access API(Chrome 86以降、 Safari は OPFS 経由で部分対応)を使えば、受信者がローカルファイルを選択してチャンクを直接ディスクに書き込めます。
const handle = await window.showSaveFilePicker({ suggestedName: "decrypted-file" });
const writable = await handle.createWritable();
await streamDecrypt(url, key, async (chunk) => { await writable.write(chunk); });
await writable.close();
チャンクは即座にディスクへ送られるため、メモリは常に一定量に収まり、リアルな進捗表示も可能で、途中キャンセルにも対応できます。 Firefox デスクトップは showSaveFilePicker を未サポートのため、数百MB 未満なら Blob に溜める方法、またはマルチGB の場合は Origin Private File System にフォールバックしてください。
アップロードの再開可能性とエラー回復
Chrome 105以降と Firefox 127以降はストリーミングリクエストボディをサポートしますが、S3互換のマルチパートアップロードもすべてのブラウザで動作し、失敗したパートだけを再送できます。S3 の最小パートサイズは5MB、最大は5GB です。ネットワーク障害が発生しても、各パートは独立しているため最後に成功したチャンクから再開できます。 tus プロトコルもストリームネイティブで再開可能なアップロードをサポートします。
ベンチマーク:10GBファイルの実測値
2024年の MacBook Pro(M3 Max)、高速 SSD 環境での実測値です。File.stream() によるディスク読み込みは2.5GB/s、 Web Crypto 経由の AES-256-GCM は1.7GB/s、組み合わせたパイプライン全体は1.1GB/s(直列チェーンがボトルネック)、ギガビットイーサネット経由のアップロードは115MB/s(ネットワーク律速)、ファイルサイズに関わらずメモリのピークは14MB です。モバイルの数値はデスクトップの30〜50% 程度です。暗号化はボトルネックではなく、ネットワークがボトルネックです。
まとめ:正しいアプローチ
ファイル全体をメモリに確保しないでください。チャンク単位で読み、チャンク単位で暗号化し、チャンク単位でアップロードし、処理済みのチャンクは都度解放します。切り詰め攻撃を防ぐために AAD またはストリーミング AEAD でチャンクを暗号的に紐づけ、進捗バーを必ず実装し、デスクトップだけでなくモバイルでも必ずテストしてください。
hexatransfer.comでお試しください — 無料、登録不要、最大10GB。
エンドツーエンド暗号化で大容量ファイルを安全に送信
エンドツーエンド暗号化で最大10GBのファイルを無料で転送。アカウント不要。ファイルはアップロード前にブラウザで暗号化されるため、他の誰にも読まれません。
ファイルを送信