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

JavaScriptファイル暗号化 実践チュートリアル

AES暗号化、鍵導出、安全なファイル処理をカバーする、JavaScriptを使ってブラウザでファイルを暗号化する実践的なチュートリアルです。

JavaScriptはWeb Crypto APIを使ってサーバー介入なしにブラウザ上でファイルを完全に暗号化できる。標準的なパイプライン:ファイルをArrayBufferとして読み込み、PBKDF2-SHA256(600,000回反復)でパスワードから256ビットAES-GCM鍵を導出し、ランダムな12バイトIVで暗号化し、salt・IV・暗号文をダウンロード可能なBlobにパッケージ化する。すべての主要ブラウザがwindow.crypto.subtleを通じてこれをネイティブにサポートしており、最新のラップトップでは2〜3GBのファイルをサードパーティライブラリなしで10秒以内に処理できる。個人情報保護委員会(PPC)のプライバシーバイデザイン原則に沿った実装だ。

重要なアルゴリズムの選択

AES-CBCでなくAES-GCMを選ぶこと。GCMは一回のパスで128ビットタグで認証付き暗号化を提供し、CBCは別途HMACのステップが必要でほとんどのチュートリアルがこれを間違える。256ビット鍵を選ぶ—AES-NIを持つハードウェアでは128ビットとの性能差は無視できる。パスワードベースの鍵導出にはPBKDF2-SHA256を選ぶ(WebAssembly経由でArgon2idを送れない限り、Argon2idはより強力だが50KBのダウンロード重量を追加する)。

避けるべきもの:ECBモード(根本的に破られている)、手書きのパディング(CBCパディングオラクル攻撃の10年)、MD5またはSHA-1(衝突破られ)、デフォルトでCBCとPKCS7を使うcrypto-jsパッケージ。

メモリへのファイルの読み込み

File APIは3つのバイト取得方法を提供する:

const buf = await file.arrayBuffer();          // ファイル全体
const stream = file.stream();                  // ストリーミング
const text = await file.text();                // UTF-8デコード

500MB以上のファイルではarrayBuffer()がモバイルSafariで失敗することが多い。代わりにストリーミング:

async function* chunks(file, size = 4 * 1024 * 1024) {
  for (let off = 0; off < file.size; off += size) {
    yield new Uint8Array(await file.slice(off, off + size).arrayBuffer());
  }
}

各スライスはディスクから遅延読み込みするため、ピークメモリが制限される。

ユーザーパスワードからの鍵導出

生のパスワードをencryptに渡さないこと。まず鍵を導出する:

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

crypto.getRandomValues(new Uint8Array(16))でファイルごとに新鮮な16バイトのsaltを生成する。ファイル間でsaltを再利用するとPBKDF2の目的が無効になる。OWASPの2026年パスワードストレージチートシートは現在PBKDF2-SHA256に600,000回の反復を推奨しており、中程度のスマートフォンで約500ミリ秒の鍵導出に相当する。

ファイルの暗号化

鍵が手に入ったら、暗号化はバッファあたり1つのsubtle.encrypt呼び出しだ:

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

GCMは(iv, key)ペアを再利用すると壊滅的に失敗する—キーストリームの衝突が両方の平文を漏洩させる。96ビットのランダムIVは1つの鍵で約2^48回の安全な暗号化を提供し、ファイル暗号化には十分だ。

出力のパッケージング

復号器はsalt、IV、暗号文を必要とする。小さなヘッダーで一つのBlobにパックする:

function pack(salt, iv, ciphertext) {
  const magic = new TextEncoder().encode('ENC1');
  return new Blob([magic, salt, iv, new Uint8Array(ciphertext)]);
}

後でアルゴリズムを移行する際に古いファイルを壊さないようヘッダーにバージョンを付ける(ENC1ENC2…)。

ファイルの復号

復号はプロセスを逆にし、パスワードが間違っているかファイルが改ざんされた場合にOperationErrorをスローする:

async function decryptFile(blob, password) {
  const buf = await blob.arrayBuffer();
  const view = new Uint8Array(buf);
  const magic = new TextDecoder().decode(view.slice(0, 4));
  if (magic !== 'ENC1') throw new Error('Unknown format');
  const salt = view.slice(4, 20);
  const iv = view.slice(20, 32);
  const ct = view.slice(32);
  const key = await deriveKey(password, salt);
  const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
  return new Blob([pt]);
}

タグの失敗と構造の失敗を区別せずに「復号できませんでした、パスワードが間違っているか破損したファイルです」という単一のエラーメッセージを表示する。

本番で出荷される一般的なミス

JavaScriptの暗号のコードレビューで3つのパターンが繰り返し見つかる。第一に、利便性のために生の鍵をlocalStorageに保存すること。localStorageは同期で、オリジンスコープで、XSSが読み取れる。代わりに抽出不可のCryptoKeyをIndexedDBに使うこと。第二に、IVやsaltにMath.random()を使うこと。Math.random()は予測可能で、常にcrypto.getRandomValuesを使うこと。第三に、subtle.encryptが定時間であると仮定すること。

HexaTransferはこの正確なパイプライン—600k回反復のPBKDF2、AES-256-GCM、ストリーミングチャンク—を適用しており、サーバーは不透明な暗号文のみを保存する。詳細は https://hexatransfer.com で。無料、アカウント不要、最大10GB。

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

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

ファイルを送信