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

クライアントサイド暗号化チュートリアル:ゼロから構築

Webアプリにクライアントサイド暗号化を実装するステップバイステップチュートリアル。ユーザーのデバイスを離れる前にブラウザでファイルを暗号化。

ブラウザでのクライアントサイドファイル暗号化は Web Crypto API を使った約80行の JavaScript で実現できます。パターンは次の通りです。ブラウザで AES-256-GCM 鍵を生成し、ランダムな96ビットナンスでファイルを暗号化し、HTTPS/TLS 1.3 で暗号文をアップロードし、フラグメント識別子(#key=...)に鍵を埋め込んだ URL を共有します。ブラウザはフラグメントをサーバーに送信しません。受信者は同じフラグメントを使ってブラウザ内で復号します。このチュートリアルでは動作する実装を解説します。大きなファイルのチャンキング・PBKDF2 による600,000回イテレーションのパスワード導出鍵・最初の試みを失敗させる落とし穴を含みます。

1行で見るアーキテクチャ

[送信者ブラウザ]              [サーバー]              [受信者ブラウザ]
ファイル読込 → AES鍵生成     POST を受信            暗号文をGET
AES-256-GCMで暗号化          暗号文ブロブを保存      URLフラグメントから鍵をパース
暗号文をPOST                 鍵なし・平文なし         ブラウザ内で復号
#key=... 付きURLを構築       ダウンロードURLを返す   ディスクにファイルを保存

サーバーは単純なブロブストアです。暗号文のみを参照し、復号できません。復号鍵は URL フラグメントに存在し、ブラウザはフラグメントを HTTP リクエスト行に送信しません。これがすべてのゼロ知識ファイル転送サービスの基盤です。HexaTransfer もこの仕組みで動作します。

ステップ1:対称鍵を生成する

async function generateKey() {
  return await crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    true, // URL にエクスポートするために抽出可能
    ["encrypt", "decrypt"]
  );
}

extractable: true フラグは鍵を URL フラグメントにシリアライズするために必要です。鍵をメモリにのみ保持するフロー(例:貼り付けして送信するツール)を構築する場合は false に設定してください。

ステップ2:ファイルを ArrayBuffer として読み込む

async function readFile(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = () => reject(reader.error);
    reader.readAsArrayBuffer(file);
  });
}

これはファイル全体をメモリに読み込みます。500 MB 以下のファイルには問題ありません。それ以上の場合は後のストリーミングセクションに進んでください。

ステップ3:バッファを暗号化する

async function encryptFile(key, plaintext) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    plaintext
  );
  // 受信者が抽出できるよう IV を暗号文の前に付加
  const combined = new Uint8Array(iv.length + ciphertext.byteLength);
  combined.set(iv, 0);
  combined.set(new Uint8Array(ciphertext), iv.length);
  return combined.buffer;
}

ナンス(IV)は NIST SP 800-38D に従い96ビット(12バイト)です。秘密ではありませんが、鍵ごとに一意でなければなりません。ファイルごとに新しい鍵を生成するためランダムナンスは安全です。IV を暗号文の前に付加するのは一般的な慣習で、受信者は復号前に分離します。

ステップ4:暗号文をアップロードする

async function uploadCiphertext(ciphertext) {
  const response = await fetch("/api/upload", {
    method: "POST",
    body: ciphertext,
    headers: { "Content-Type": "application/octet-stream" },
  });
  const { fileId } = await response.json();
  return fileId;
}

サーバーはバイナリブロブを受け取り、ID を割り当て、保存して返します。ヘッダーにファイル名は含まれず、クエリパラメータに鍵も含まれません。明日サーバーのハードドライブが盗まれても、攻撃者が見るのはランダムなバイトだけです。

ステップ5:フラグメントに鍵を含む共有 URL を構築する

async function buildShareURL(fileId, key) {
  const rawKey = await crypto.subtle.exportKey("raw", key);
  const keyBase64 = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  return `${location.origin}/f/${fileId}#${keyBase64}`;
}

base64url エンコーディング(+- に、/_ に置換)で URL エスケープの問題を回避します。= パディングは見た目のためにストリップします。

フラグメント(#...)がここで重要です。受信者が URL を読み込むとき、ブラウザはフラグメントをクライアントサイドに保持します。/f/{fileId} への HTTP GET はリクエスト行に #keyBase64 を含まないため、サーバーは鍵を学習しません。フラグメントを含む URL でブラウザの開発ツールを開いてネットワークタブを見れば自分で確認できます。

ステップ6:受信者サイドの復号

async function downloadAndDecrypt() {
  const fileId = location.pathname.split("/").pop();
  const keyBase64 = location.hash.slice(1);
  const rawKey = Uint8Array.from(
    atob(keyBase64.replace(/-/g, "+").replace(/_/g, "/")),
    c => c.charCodeAt(0)
  );
  const key = await crypto.subtle.importKey(
    "raw", rawKey, "AES-GCM", false, ["decrypt"]
  );
  const response = await fetch(`/api/download/${fileId}`);
  const combined = new Uint8Array(await response.arrayBuffer());
  const iv = combined.slice(0, 12);
  const ciphertext = combined.slice(12);
  const plaintext = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv }, key, ciphertext
  );
  const blob = new Blob([plaintext]);
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "downloaded-file";
  a.click();
}

GCM の認証タグは decrypt() 中に確認されます。暗号文が改ざんされた場合、呼び出しは OperationError をスローします。クリーンな失敗モードです。

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

ユーザーがランダムな鍵ではなくパスワードを提供する場合は PBKDF2 で AES 鍵を導出します:

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

PBKDF2-SHA-256 の600,000回のイテレーションは OWASP の2023年ベースライン。ソルトは16ランダムバイトで暗号文と一緒に保存しなければなりません(秘密ではなく、ただし一意でなければならない)。新しいコードでは argon2-browser のようなライブラリ経由の Argon2id を検討してください。GPU 攻撃への耐性が PBKDF2 より大幅に優れています。

大きなファイルのストリーミング

500 MB 以上のファイルはチャンクに分けてください。File.stream() で読み取り、各チャンクを暗号化し、順次アップロードします:

async function encryptStream(file, key) {
  const reader = file.stream().getReader();
  const chunks = [];
  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
    );
    chunks.push({ iv, ct });
  }
  return chunks;
}

ナンスをチャンクインデックスから導出することで状態を追跡せずに一意性を保証します。受信者サイドでの再組み立ては順番にチャンクを復号して連結します。

真のストリーミング AEAD には libsodium の crypto_secretstream_xchacha20poly1305(libsodium.js 経由)がよりクリーンで切り捨て攻撃を検出します。Web Crypto には2026年時点で同等のプリミティブがありません。

テストと落とし穴

避けるべき一般的なミス:

  • 鍵やナンスに Math.random() を使用する: 常に crypto.getRandomValues() を使用してください。
  • 同じ鍵でナンスを再利用する: GCM のセキュリティを破ります。ファイルごとのランダム鍵でこれは安全です。チャンクフローではチャンクごとに一意のナンスが必要です。
  • HTTPS を確認しない: crypto.subtle はセキュアでないオリジンでは undefined です。開発中は localhost または自己署名証明書でテストしてください。
  • localStorage に鍵を保存する: オリジン上の XSS はそれを読み取れます。代わりに URL フラグメントパターンまたは非抽出可能な鍵を使用してください。
  • IV を暗号文に含めるのを忘れる: 有用なエラーなしに復号が失敗します。常に前に付加するかシリアライズして一緒に保存してください。
  • フラグメントの誤処理: URL(フラグメント付き)を誤ってサードパーティのサービスに投稿しないこと。フラグメントが機密の場合はエンドツーエンドチャンネルのみで共有してください。

サーバーサイドの責任

クライアントサイド暗号化アーキテクチャでのサーバーの仕事は小さいです。POST を受け入れ、ブロブを保存し、ID を返し、ブロブの GET を提供し、有効期限で削除する。暗号処理は不要。ストレージを超えてサーバーがすべきこと:

  • ファイルサイズの制限を適用する(悪用を防ぐ)
  • アップロードとダウンロードのレート制限
  • 短い保持期間を設定する(デフォルト7日は HexaTransfer と同様の合理的な値)
  • 必要なものだけログに記録する(アップロードタイムスタンプのみ、プライバシーファーストなら IP はなし)
  • HSTS 付き TLS 1.3 で配信する
  • API がドメインからのみ呼ばれる場合はオリジンを制限する CORS ヘッダー

まとめ

最小限の動作するアプリは1つの HTML ファイルとサーバー側の50行の Express バックエンドに収まります。クライアントに依存関係なし(Web Crypto はネイティブ)、サーバーに Express と multer のみ。暗号化は AES-256-GCM プリミティブと同程度に強力です。秘密のアルゴリズムは存在せず、プリミティブを正しく使用するだけです。

最も難しいのはエッジケースです。大きなファイル・パスワードから鍵へのフロー・復号失敗時の受信者 UX・期限切れリンクの適切な処理。コア暗号処理は直接的です。

hexatransfer.comでお試しください — 無料、登録不要、最大10GB。

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

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

ファイルを送信