コンテンツへスキップ
HexaTransfer
ブログへ戻る
技術詳解

Web Crypto API:ファイル暗号化の完全チュートリアル

AES-GCM、RSA-OAEP、鍵管理まで、JavaScriptアプリケーションでブラウザのファイル暗号化を実現するWeb Crypto APIの活用法を解説します。

Web Crypto APIは外部ライブラリなしにwindow.crypto.subtleのネイティブメソッドを使ってブラウザでファイルを直接暗号化できる。個人情報保護委員会(PPC)が推奨するゼロ知識設計を実現するには、パスワードからPBKDF2(210,000回反復、SHA-256)で鍵を導出し、96ビットIVと128ビット認証タグを使ったAES-GCMでファイルバイト列を暗号化する。公開鍵ワークフローでは対称鍵のラッピングにRSA-OAEP(4096ビット鍵)を使う。このAPIはHTTPS上のすべての主要ブラウザで利用可能で、JavaScriptでなくネイティブ暗号バックエンド上で動作する。

SubtleCryptoが純粋JSライブラリに勝る理由

window.crypto.subtleはブラウザの監査済みネイティブ暗号バックエンド(ChromiumではBoringSSL、SafariではCommonCrypto)を呼び出す。CryptoJSやsjclなどの純粋JSオプションに比べ、SubtleCryptoはAES-GCMで30〜80倍高速で、JavaScriptインタープリターのタイミングサイドチャネルを回避し、ユーザーにゼロバイトを送信する。トレードオフはPromiseベースのAPIでArrayBufferCryptoKeyオブジェクトのみを操作するため、Uint8ArrayBlobReadableStream間の変換に多くの時間を費やすことになる。100MB以上のファイルサイズでは、生の暗号速度よりもこの配管工事の方が重要になる。

PBKDF2によるパスワードからの鍵導出

パスワードをAES鍵として直接使用してはならない。代わりにパスワードを素材としてインポートし、256ビット鍵を導出する:

async function deriveKey(password, salt) {
  const enc = new TextEncoder();
  const material = await crypto.subtle.importKey(
    'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']
  );
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: 210000, hash: 'SHA-256' },
    material,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

OWASPの2026年ガイダンスはSHA-256で少なくとも600,000回の反復を求めているが、210,000回は低リスクコンテキストでは許容される。crypto.getRandomValuesでファイルごとに新鮮な16バイトのsaltを生成し、暗号文と一緒に保存すること。Argon2idはより強力だがSubtleCryptoではまだ公開されていない。

AES-GCMによるファイル暗号化

AES-GCMは一回のパスで機密性と真正性を提供する。重要なルールは(key, IV)ペアを再利用しないこと:

async function encryptFile(file, key) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const plaintext = await file.arrayBuffer();
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv, tagLength: 128 },
    key,
    plaintext
  );
  return { iv, ciphertext };
}

2GBのファイルではfile.arrayBuffer()がバッファ全体を割り当て、モバイルSafariでクラッシュすることがある。ファイルを4MBのチャンクに分割し、カウンターとランダムプレフィックスを結合して派生させたユニークなIVで各チャンクを暗号化し、バージョンバイトとsaltを先頭に付ける。

TransformStreamによる大容量ファイルのストリーミング

メモリの肥大化を避けるには、暗号化をTransformStreamでラップしてファイルをパイプスルーする:

const chunkSize = 4 * 1024 * 1024;
const encryptor = new TransformStream({
  async transform(chunk, controller) {
    const iv = nextIV(counter++);
    const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, chunk);
    controller.enqueue(new Uint8Array([...iv, ...new Uint8Array(ct)]));
  }
});
await file.stream()
  .pipeThrough(sliceByChunks(chunkSize))
  .pipeThrough(encryptor)
  .pipeTo(uploadSink);

file.stream()はディスクから遅延読み込みするReadableStream<Uint8Array>を返す。10GBのアップロードでもピークメモリは20MB以下に収まる。

RSA-OAEPによる対称鍵のラッピング

特定の受信者とファイルを共有する場合、RSA鍵ペアを一度生成して公開鍵を公開する:

const keypair = await crypto.subtle.generateKey(
  { name: 'RSA-OAEP', modulusLength: 4096,
    publicExponent: new Uint8Array([1,0,1]), hash: 'SHA-256' },
  true, ['wrapKey', 'unwrapKey']
);
const wrapped = await crypto.subtle.wrapKey(
  'raw', fileKey, keypair.publicKey,
  { name: 'RSA-OAEP' }
);

4096ビットRSA鍵はNIST SP 800-57によれば2030年まで約150ビットのセキュリティを提供する。前方秘匿性やポスト量子耐性が必要な場合は、RSA-OAEPとP-384上のECDHを組み合わせるか、WebCryptoワーキンググループがML-KEM(Kyber)を実装した際に移行する。

IndexedDBへの鍵の安全な保存

CryptoKeyオブジェクトはデフォルトで抽出不可であり、JavaScriptに生のバイト列を公開せずにIndexedDBに永続化できる:

const db = await openDB('keystore', 1);
await db.put('keys', keypair.privateKey, 'user-signing-key');

ブラウザは構造化クローンアルゴリズムを使って鍵をシリアライズし、実際のバイト列は暗号バックエンドに保持する。侵害されたスクリプトは保存された鍵でencryptdecryptを呼び出せるが、素材を読み出すことはできない。これはlocalStorageにbase64鍵を詰め込むのに比べて意味のあるセキュリティ強化だ。

APIが投げるエラーの処理

SubtleCryptoは認証済み復号の失敗時にOperationErrorをスローする。これは通常、暗号文が改ざんされた、IVが間違っている、またはユーザーが誤ったパスワードを入力したことを意味する。DataErrorは入力バッファの長さが間違っている場合、NotSupportedErrorはアルゴリズムが実装されていない場合、InvalidAccessErrorは鍵が正しい使用フラグなしにインポートされた場合にスローされる。復号は常にtry/catchでラップし、「ファイルを復号できませんでした」という中立的なメッセージを表示し、タグと構造のどちらが失敗したかを漏らさないこと。

HexaTransferはこのAES-GCM + PBKDF2パイプラインをすべてのアップロードに使用し、Workerで鍵を導出し、サーバーが平文を一切見ないままストレージに暗号文をストリーミングする。詳細は https://hexatransfer.com で。無料、アカウント不要、最大10GB。

エンドツーエンド暗号化で大容量ファイルを安全に送信

エンドツーエンド暗号化で最大10GBのファイルを無料で転送。アカウント不要。ファイルはアップロード前にブラウザで暗号化されるため、他の誰にも読まれません。

ファイルを送信