WebCrypto APIでブラウザ内クレデンシャル暗号化を実装する — AES-256-GCM + PBKDF2の実践

WebCrypto APIでブラウザ内クレデンシャル暗号化を実装する — AES-256-GCM + PBKDF2の実践

WebCrypto APIを使い、マスターパスワードベースのAES-256-GCM暗号化をブラウザ内で実装しました。PBKDF2による鍵導出、暗号化・復号の実装、信頼モデルの考察までを解説します。
2026.07.28

はじめに

社内AIツールの開発で、ユーザーのログイン情報(ユーザー名、パスワード、TOTPシークレット)をブラウザに保存する必要がありました。サーバーサイドで自動ログインを行うため、認証情報をlocalStorageに保持するのですが、平文で保存するのは論外です。

WebCrypto APIを使い、マスターパスワードベースのAES-256-GCM暗号化を実装しました。マスターパスワードはどこにも保存されず、暗号化データのみがlocalStorageに残ります。

前提・環境

  • TypeScript / SvelteKit
  • WebCrypto API(モダンブラウザで標準搭載)
  • localStorage(ブラウザ側ストレージ)

暗号化の全体設計

webcrypto-aes-gcm-credential-encryption-browser-flow

マスターパスワードは保存しません。 ユーザーがアプリを開くたびに入力し、それを使ってlocalStorageの暗号化データを復号します。

実装

型定義

credentialStorage.ts
interface StoredCredentials {
  username: string;
  password: string;
}

interface EncryptedCredentials {
  encrypted: string; // Base64
  salt: string;      // Base64
  iv: string;        // Base64
}

class CredentialStorageError extends Error {
  constructor(message: string, public code: string) {
    super(message);
    this.name = "CredentialStorageError";
  }
}

暗号化パラメータ

credentialStorage.ts
const STORAGE_KEY = "my-app-credentials";
const PBKDF2_ITERATIONS = 100000;
const SALT_LENGTH = 16;
const IV_LENGTH = 12;
  • PBKDF2_ITERATIONS: 100,000回 — パスワードから鍵を導出するときの反復回数です。ブルートフォース攻撃を遅くするために高い値を設定します。OWASPの推奨は600,000回(SHA-256の場合)ですが、ブラウザでの応答速度とのバランスで100,000回にしています
  • SALT_LENGTH: 16バイト — 同じパスワードでも異なる鍵が生成されるようにするランダム値
  • IV_LENGTH: 12バイト — AES-GCMの推奨IV長。各暗号化操作で新しいIVを生成します

鍵の導出(PBKDF2)

マスターパスワードから暗号鍵を導出します。

credentialStorage.ts
async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
  // パスワードを「鍵素材」としてインポート
  const passwordKey = await window.crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(password),
    { name: "PBKDF2" },
    false,       // エクスポート不可
    ["deriveKey"] // この鍵素材は「鍵導出」にのみ使用
  );

  // PBKDF2で暗号鍵を導出
  return await window.crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: salt.buffer as ArrayBuffer,
      iterations: PBKDF2_ITERATIONS,
      hash: "SHA-256"
    },
    passwordKey,
    { name: "AES-GCM", length: 256 }, // 出力: AES-256用の鍵
    false,              // エクスポート不可
    ["encrypt", "decrypt"] // この鍵で暗号化と復号を許可
  );
}

WebCrypto APIの特徴として、鍵の用途を制限できます。["deriveKey"]と指定すると、その鍵素材は鍵導出にしか使えません。導出された鍵も["encrypt", "decrypt"]に限定しています。

暗号化

credentialStorage.ts
export async function encryptCredentials(
  credentials: StoredCredentials,
  masterPassword: string
): Promise<EncryptedCredentials> {
  // ランダムなsaltとIVを生成
  const salt = generateRandomBytes(SALT_LENGTH);
  const iv = generateRandomBytes(IV_LENGTH);

  // マスターパスワードから暗号鍵を導出
  const key = await deriveKey(masterPassword, salt);

  // クレデンシャルをJSON → バイト列に変換
  const credentialsJson = JSON.stringify(credentials);
  const credentialsBytes = new TextEncoder().encode(credentialsJson);

  // AES-256-GCMで暗号化
  const encryptedBuffer = await window.crypto.subtle.encrypt(
    { name: "AES-GCM", iv: iv.buffer as ArrayBuffer },
    key,
    credentialsBytes
  );

  // Base64でエンコードして返す
  return {
    encrypted: arrayBufferToBase64(encryptedBuffer),
    salt: arrayBufferToBase64(salt.buffer as ArrayBuffer),
    iv: arrayBufferToBase64(iv.buffer as ArrayBuffer)
  };
}

毎回新しいsaltとIVを生成しています。同じパスワードで同じデータを暗号化しても、出力は毎回異なります。

復号

credentialStorage.ts
export async function decryptCredentials(
  encryptedData: EncryptedCredentials,
  masterPassword: string
): Promise<StoredCredentials> {
  try {
    const salt = new Uint8Array(base64ToArrayBuffer(encryptedData.salt));
    const iv = new Uint8Array(base64ToArrayBuffer(encryptedData.iv));
    const encryptedBuffer = base64ToArrayBuffer(encryptedData.encrypted);

    // 同じパスワードとsaltから同じ鍵を導出
    const key = await deriveKey(masterPassword, salt);

    // 復号
    const decryptedBuffer = await window.crypto.subtle.decrypt(
      { name: "AES-GCM", iv: iv.buffer },
      key,
      encryptedBuffer
    );

    const credentialsJson = new TextDecoder().decode(decryptedBuffer);
    return JSON.parse(credentialsJson) as StoredCredentials;
  } catch (error) {
    if (error instanceof CredentialStorageError) throw error;
    throw new CredentialStorageError(
      "Failed to decrypt credentials - invalid password or corrupted data",
      "DECRYPTION_FAILED"
    );
  }
}

AES-GCMの重要な特性として、パスワードが間違っている場合、復号自体が失敗します。GCMには認証タグが含まれており、データの完全性を検証します。不正な鍵での復号は例外をスローするため、「復号できたが壊れたデータが出てくる」という事態は起きません。

localStorageへの保存・読み込み

credentialStorage.ts
export async function saveCredentials(
  credentials: StoredCredentials,
  masterPassword: string
): Promise<void> {
  const encryptedData = await encryptCredentials(credentials, masterPassword);
  window.localStorage.setItem(STORAGE_KEY, JSON.stringify(encryptedData));
}

export async function loadCredentials(
  masterPassword: string
): Promise<StoredCredentials | null> {
  const storedData = window.localStorage.getItem(STORAGE_KEY);
  if (!storedData) return null;

  const encryptedData: EncryptedCredentials = JSON.parse(storedData);
  return await decryptCredentials(encryptedData, masterPassword);
}

localStorageに保存されるのは { encrypted, salt, iv } の3つのBase64文字列だけです。

SvelteKit環境での注意:SSR対応

SvelteKitはSSR(サーバーサイドレンダリング)を行うため、windowlocalStorageがサーバー側で存在しません。$app/environmentbrowserフラグでガードしています。

credentialStorage.ts
import { browser } from "$app/environment";

function generateRandomBytes(length: number): Uint8Array {
  if (!browser || !window.crypto || !window.crypto.getRandomValues) {
    throw new CredentialStorageError("Crypto API not available", "CRYPTO_UNAVAILABLE");
  }
  return window.crypto.getRandomValues(new Uint8Array(length));
}

すべての関数の先頭でbrowserチェックを行い、サーバーサイドで呼ばれた場合はエラーをスローします。

なぜAES-GCMなのか

モード 暗号化 改ざん検知 IV管理
AES-CBC × ブロックサイズ(16B)
AES-GCM 12B推奨

AES-GCMは暗号化と認証(AEAD)を同時に行います。CBCモードでは別途HMACなどで改ざん検知を実装する必要がありますが、GCMではこれが組み込まれています。

このアプローチの信頼モデル

何を防げて、何を防げないかを明確にしておきます。

防げるもの:

  • localStorageのデータを直接閲覧されても、クレデンシャルは読めない
  • 弱いマスターパスワードでもPBKDF2の反復処理で辞書攻撃を遅延させる
  • 暗号文の改ざん(GCMの認証タグで検知)

防げないもの:

  • XSS攻撃(JavaScriptが実行されればWebCrypto APIも呼べる)
  • マスターパスワードが漏洩した場合
  • デバイス自体が盗まれた場合のオフライン攻撃(時間をかければ解読可能)

ブラウザ内暗号化は「ベストエフォート」の防御です。XSS対策(CSP、入力サニタイズ)を併用することが前提です。

まとめ

WebCrypto APIを使うことで、外部ライブラリなしでブラウザ内の暗号化を実装できます。

実装のポイント:

  • PBKDF2でパスワードから鍵を導出する(パスワードを直接暗号鍵にしない)
  • AES-GCMで暗号化と認証を同時に行う(CBCより安全かつシンプル)
  • salt・IVは毎回ランダム生成する(同じ入力でも異なる暗号文を出力)
  • マスターパスワードは保存しない(ユーザーの頭の中だけに存在する)

この記事をシェアする

関連記事