0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Web Crypto API(AES-256-GCM)でブラウザだけでファイルを暗号化する【Next.js】

0
Posted at

はじめに

ファイルの暗号化というと、サーバーサイドで OpenSSL を動かすイメージがあります。しかし Web Crypto API(ブラウザ標準)を使えば、サーバーへの送信なしにAES-256-GCMでファイルを暗号化・復号できます。

  • インストール不要(ブラウザ標準API)
  • PBKDF2 でパスワードからAES-256キーを導出
  • AES-GCM(認証付き暗号)で暗号化・改ざん検出
  • ファイルはサーバーに一切送信されない

実際に動くツール → PDFパスワード設定ツール - ToolBoxJP


Web Crypto APIとは

window.crypto.subtle から使えるブラウザ標準の暗号化API。IE以外のすべてのモダンブラウザで対応しており、外部ライブラリ不要です。

// インストール不要で使える
const key = await crypto.subtle.generateKey(...);
const encrypted = await crypto.subtle.encrypt(...);

暗号化の設計

今回の実装では以下の構成を採用します。

パスワード(string)
    ↓ PBKDF2(120,000イテレーション、SHA-256)
AES-256-GCM キー(256bit)
    ↓
暗号化(IV: 12バイト、ランダム生成)
    ↓
出力バイナリ: MAGIC(8) + SALT(16) + IV(12) + 暗号文(N)

各パラメータの役割:

  • MAGIC: ファイルフォーマット識別子(間違ったファイルを復号しようとしたときのエラー判定)
  • SALT: PBKDF2のソルト(同じパスワードでも毎回異なるキーを生成)
  • IV: AES-GCMの初期ベクター(同じキー+同じIVで暗号化しないためランダム生成)

実装

1. パスワードからAES-256キーを導出(PBKDF2)

const ENC = new TextEncoder();

async function deriveKey(
  password: string,
  salt: Uint8Array,
  usage: KeyUsage[]  // ["encrypt"] or ["decrypt"]
): Promise<CryptoKey> {
  // パスワード文字列をCryptoKeyとしてインポート
  const rawKey = await crypto.subtle.importKey(
    "raw",
    ENC.encode(password),
    "PBKDF2",
    false,
    ["deriveKey"]
  );

  // PBKDF2でAES-256キーを導出
  return crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: salt,
      iterations: 120000,  // NIST推奨: 600,000+(速度とのトレードオフ)
      hash: "SHA-256",
    },
    rawKey,
    { name: "AES-GCM", length: 256 },
    false,       // extractable: false(キーを外部に取り出せないようにする)
    usage
  );
}

イテレーション数について:
NIST SP 800-132 では 600,000以上を推奨していますが、ブラウザでの処理時間(数百ms〜数秒)とのトレードオフです。今回は120,000を採用していますが、セキュリティ要件に応じて増やせます。

2. ファイルの暗号化

const MAGIC = "TBPDF002";  // フォーマット識別子

async function encryptFile(
  fileBytes: Uint8Array,
  password: string
): Promise<Uint8Array> {
  // ランダムなSALTとIVを生成
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const iv = crypto.getRandomValues(new Uint8Array(12));  // AES-GCMは96bit推奨

  const key = await deriveKey(password, salt, ["encrypt"]);

  // AES-GCM暗号化
  const cipher = new Uint8Array(
    await crypto.subtle.encrypt(
      { name: "AES-GCM", iv },
      key,
      fileBytes
    )
  );

  // バイナリレイアウト: MAGIC(8) + SALT(16) + IV(12) + 暗号文(N)
  const out = new Uint8Array(8 + 16 + 12 + cipher.length);
  out.set(new TextEncoder().encode(MAGIC), 0);  // 0〜7
  out.set(salt, 8);                              // 8〜23
  out.set(iv, 24);                               // 24〜35
  out.set(cipher, 36);                           // 36〜
  return out;
}

3. ファイルの復号

async function decryptFile(
  encBytes: Uint8Array,
  password: string
): Promise<Uint8Array> {
  // MAGICの検証(間違ったファイルの早期検出)
  const magic = new TextDecoder().decode(encBytes.slice(0, 8));
  if (magic !== MAGIC) {
    throw new Error("INVALID_FORMAT");  // このツールで暗号化していないファイル
  }

  // バイナリからSALT・IV・暗号文を取り出す
  const salt = encBytes.slice(8, 24);
  const iv = encBytes.slice(24, 36);
  const cipher = encBytes.slice(36);

  const key = await deriveKey(password, salt, ["decrypt"]);

  try {
    const plain = await crypto.subtle.decrypt(
      { name: "AES-GCM", iv },
      key,
      cipher
    );
    return new Uint8Array(plain);
  } catch {
    // AES-GCMは認証付き暗号なので、パスワード誤りや改ざんは例外で検出できる
    throw new Error("WRONG_PASSWORD");
  }
}

AES-GCMの強みはここ:
AES-GCMは「認証付き暗号(AEAD)」なので、復号時にデータの改ざんも検出します。パスワードが間違っていても、データが改ざんされていても、decrypt は必ず例外をスローします。

4. Reactコンポーネントでの使い方

"use client";
import { useState } from "react";

export function PdfEncryptor() {
  const [file, setFile] = useState<File | null>(null);
  const [password, setPassword] = useState("");
  const [status, setStatus] = useState<"idle" | "processing" | "done" | "error">("idle");

  const handleEncrypt = async () => {
    if (!file || !password) return;
    setStatus("processing");

    try {
      const bytes = new Uint8Array(await file.arrayBuffer());
      const encrypted = await encryptFile(bytes, password);

      // Blobとしてダウンロード
      const blob = new Blob([encrypted], { type: "application/octet-stream" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = file.name.replace(/\.pdf$/i, "") + "_protected.pdf";
      a.click();
      URL.revokeObjectURL(url);

      setStatus("done");
    } catch {
      setStatus("error");
    }
  };

  const handleDecrypt = async () => {
    if (!file || !password) return;
    setStatus("processing");

    try {
      const bytes = new Uint8Array(await file.arrayBuffer());
      const decrypted = await decryptFile(bytes, password);

      const blob = new Blob([decrypted], { type: "application/pdf" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = "decrypted.pdf";
      a.click();
      URL.revokeObjectURL(url);

      setStatus("done");
    } catch (e: unknown) {
      const msg = e instanceof Error ? e.message : "";
      if (msg === "INVALID_FORMAT") {
        alert("このツールで暗号化したファイルではありません");
      } else if (msg === "WRONG_PASSWORD") {
        alert("パスワードが間違っています");
      }
      setStatus("error");
    }
  };

  return (
    <div>
      <input type="file" accept=".pdf" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="パスワード" />
      <button onClick={handleEncrypt} disabled={status === "processing"}>暗号化</button>
      <button onClick={handleDecrypt} disabled={status === "processing"}>復号</button>
    </div>
  );
}

パスワード強度チェック

function getPasswordStrength(pw: string): "weak" | "medium" | "strong" | "very-strong" {
  let score = 0;
  if (pw.length >= 8) score++;
  if (pw.length >= 12) score++;
  if (/[A-Z]/.test(pw) && /[a-z]/.test(pw)) score++;  // 大文字小文字混在
  if (/[0-9]/.test(pw) && /[^A-Za-z0-9]/.test(pw)) score++;  // 数字+記号

  const labels = ["weak", "medium", "strong", "very-strong"] as const;
  return labels[Math.min(score, 3)];
}

制限事項

Adobe Acrobatでは開けない

Web Crypto APIで実装した暗号化は、PDFの標準暗号化仕様(ISO 32000)に準拠していません。Adobe AcrobatなどのPDFビューアで直接開くことはできないため、このツールで復号した後にPDFとして利用する必要があります。

標準PDF暗号化(Adobe互換)はブラウザAPIでは実装困難です(RC4/AES-256のカスタムフォーマットが必要なため)。


セキュリティまとめ

項目 実装
暗号化アルゴリズム AES-256-GCM(AEAD)
キー導出 PBKDF2 + SHA-256(120,000イテレーション)
ソルト 16バイト(ランダム生成)
IV 12バイト(ランダム生成)
改ざん検出 AES-GCMの認証タグで自動検出
ファイル送信 なし(ブラウザ完結)

まとめ

Web Crypto APIはインストール不要でAES-256-GCMが使えるブラウザ標準APIです。

パスワード → PBKDF2 → AES-256キー → AES-GCM暗号化 → Blobダウンロード

サーバーレス・インストール不要でファイルを暗号化できるため、社内文書や個人情報を含むPDFの保護ユースケースに適しています。

実際のデモは ToolBoxJP のPDFパスワード設定ツール で確認できます。


参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?