
WebCrypto APIでブラウザ内クレデンシャル暗号化を実装する — AES-256-GCM + PBKDF2の実践
はじめに
社内AIツールの開発で、ユーザーのログイン情報(ユーザー名、パスワード、TOTPシークレット)をブラウザに保存する必要がありました。サーバーサイドで自動ログインを行うため、認証情報をlocalStorageに保持するのですが、平文で保存するのは論外です。
WebCrypto APIを使い、マスターパスワードベースのAES-256-GCM暗号化を実装しました。マスターパスワードはどこにも保存されず、暗号化データのみがlocalStorageに残ります。
前提・環境
- TypeScript / SvelteKit
- WebCrypto API(モダンブラウザで標準搭載)
- localStorage(ブラウザ側ストレージ)
暗号化の全体設計

マスターパスワードは保存しません。 ユーザーがアプリを開くたびに入力し、それを使ってlocalStorageの暗号化データを復号します。
実装
型定義
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";
}
}
暗号化パラメータ
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)
マスターパスワードから暗号鍵を導出します。
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"]に限定しています。
暗号化
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を生成しています。同じパスワードで同じデータを暗号化しても、出力は毎回異なります。
復号
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への保存・読み込み
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(サーバーサイドレンダリング)を行うため、windowやlocalStorageがサーバー側で存在しません。$app/environmentのbrowserフラグでガードしています。
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は毎回ランダム生成する(同じ入力でも異なる暗号文を出力)
- マスターパスワードは保存しない(ユーザーの頭の中だけに存在する)





