コンテンツへスキップ
HexaTransfer
ブログへ戻る
暗号化とセキュリティ

大容量ファイルのプログレッシブ暗号化:ストリーム暗号化

ストリーミング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のファイルを無料で転送。アカウント不要。ファイルはアップロード前にブラウザで暗号化されるため、他の誰にも読まれません。

ファイルを送信