ブラウザだけでテキスト暗号化する仕組み — Web Crypto API × AES-256-GCM の実装解説
テキストを誰かに安全に送りたい場面は意外と多い。APIキー、パスワード、環境変数、社内の機密メモ。チャットやメールにそのまま貼るのは論外だし、かといって暗号化ツールにテキストを渡すと「このサーバー、本当にデータを保存してないの?」という不安がつきまとう。
その不安を根本から消す方法がある。ブラウザの中だけで暗号化を完結させることだ。
Web Crypto API を使えば、外部ライブラリもサーバー通信も不要で、AES-256-GCM という NIST 標準の暗号をブラウザネイティブに実行できる。この記事では、個人開発の Web ツール「ぱんだツールズ」で実装したテキスト暗号化機能の仕組みを、コード付きで解説する。
Web Crypto API とは
Web Crypto API(window.crypto.subtle)は、ブラウザに標準搭載されている暗号化 API。主要ブラウザすべてで使える。
できることは多い。
- 暗号鍵の生成・導出(PBKDF2, HKDF)
- 共通鍵暗号(AES-CBC, AES-GCM, AES-CTR)
- 公開鍵暗号(RSA, ECDSA, ECDH)
- ハッシュ計算(SHA-256, SHA-384, SHA-512)
- HMAC
今回の暗号化ツールでは、このうち PBKDF2(鍵導出)と AES-256-GCM(暗号化)を組み合わせて使っている。外部の npm パッケージは一切使っていない。crypto-js のようなライブラリを入れる必要がないのがポイントだ。
暗号化の全体フロー
処理の流れを図にするとこうなる。
[平文テキスト] + [パスワード]
│
▼
┌─────────────────────┐
│ Salt(16byte) を生成 │ ← crypto.getRandomValues
│ IV(12byte) を生成 │ ← crypto.getRandomValues
└─────────────────────┘
│
▼
┌─────────────────────┐
│ PBKDF2 で鍵導出 │ ← パスワード + Salt → AES-256 鍵
│ SHA-256 / 10万回反復 │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ AES-256-GCM で暗号化 │ ← 鍵 + IV + 平文 → 暗号文
└─────────────────────┘
│
▼
Salt(16) + IV(12) + Ciphertext を結合
│
▼
Base64 エンコードして出力
最終出力は1本の Base64 文字列。メールやチャットにそのまま貼り付けられる形式だ。
PBKDF2 による鍵導出
パスワードをそのまま暗号鍵にすると危険だ。短いパスワードは鍵空間が狭いし、辞書攻撃にも弱い。PBKDF2(Password-Based Key Derivation Function 2)は、パスワードにソルトを加えたうえでハッシュ計算を大量に繰り返すことで、ブルートフォース攻撃のコストを跳ね上げる。
async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
const enc = new TextEncoder()
// パスワードを「鍵素材」としてインポート
const keyMaterial = await window.crypto.subtle.importKey(
'raw',
enc.encode(password).buffer as ArrayBuffer,
'PBKDF2',
false,
['deriveKey']
)
// PBKDF2 で AES-256 鍵を導出
return window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt.buffer as ArrayBuffer,
iterations: 100000, // 10万回反復
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 }, // 256ビット鍵
false,
['encrypt', 'decrypt']
)
}
ポイントは3つ。
- 反復回数 100,000 回: OWASP の推奨は PBKDF2-SHA-256 で 600,000 回だが、ブラウザでのレスポンスとのバランスを取って 10 万回に設定。攻撃者がGPUで並列計算しても1パスワードあたりの検証に相応の時間がかかる
- Salt はランダム16バイト: 同じパスワードでも毎回異なる鍵が生成される。レインボーテーブル攻撃を無効化する
-
extractable: false: 導出した鍵をexportKeyで取り出せないように設定。JavaScript からの鍵漏洩を防ぐ
AES-256-GCM による暗号化
鍵が手に入ったら、AES-256-GCM で暗号化する。
async function encryptText(plaintext: string, password: string): Promise<string> {
const enc = new TextEncoder()
const salt = window.crypto.getRandomValues(new Uint8Array(16)) // Salt: 16バイト
const iv = window.crypto.getRandomValues(new Uint8Array(12)) // IV: 12バイト
const key = await deriveKey(password, salt)
const ciphertext = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
key,
enc.encode(plaintext).buffer as ArrayBuffer
)
// Salt + IV + Ciphertext を1本のバイナリに結合
const combined = new Uint8Array(salt.length + iv.length + ciphertext.byteLength)
combined.set(salt, 0) // [0..15] Salt
combined.set(iv, salt.length) // [16..27] IV
combined.set(new Uint8Array(ciphertext), salt.length + iv.length) // [28..] Ciphertext
return btoa(Array.from(combined, (b) => String.fromCharCode(b)).join(''))
}
出力される Base64 文字列の中身は、こういうバイナリ構造になっている。
| Salt (16 bytes) | IV (12 bytes) | Ciphertext + Auth Tag (可変長) |
| [0..15] | [16..27] | [28..] |
GCM モードでは暗号文の末尾に 16 バイトの認証タグ(Authentication Tag)が自動的に付加される。この認証タグのおかげで、暗号文が1ビットでも改ざんされると復号時にエラーになる。
IV(Initialization Vector)が12バイトなのは GCM の仕様。GCM では 12 バイトの IV が最も効率的に動作し、NIST SP 800-38D でも推奨されている。crypto.getRandomValues で毎回ランダム生成するため、同じ平文を同じパスワードで暗号化しても、出力は毎回変わる。
復号のフロー
復号は暗号化の逆をたどる。Base64 文字列から Salt・IV・Ciphertext を分離して、同じパスワードで鍵を再導出し、AES-GCM で復号する。
async function decryptText(encryptedBase64: string, password: string): Promise<string> {
// Base64 → バイナリ
const binaryStr = atob(encryptedBase64.trim())
const combined = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) {
combined[i] = binaryStr.charCodeAt(i)
}
// 最低28バイト(Salt 16 + IV 12)がないと不正なデータ
if (combined.length < 28) {
throw new Error('暗号化テキストが短すぎます')
}
// バイナリを3つに分割
const salt = combined.slice(0, 16) // Salt
const iv = combined.slice(16, 28) // IV
const ciphertext = combined.slice(28) // Ciphertext + Auth Tag
// 同じ Salt + パスワードで鍵を再導出
const key = await deriveKey(password, salt)
// AES-GCM で復号(パスワードが間違っていればここで例外)
const decrypted = await window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
key,
ciphertext.buffer as ArrayBuffer
)
return new TextDecoder().decode(decrypted)
}
パスワードが間違っている場合、PBKDF2 が異なる鍵を導出するため decrypt が失敗して例外が投げられる。GCM の認証タグが一致しないので「パスワードが違う」のか「データが壊れている」のかは区別できないが、いずれにしても不正な復号結果が返ることはない。これが認証付き暗号(AEAD)の強みだ。
なぜ外部ライブラリ不要なのか
JavaScript で暗号化というと crypto-js や tweetnacl を入れる発想になりがちだが、Web Crypto API を使えばその必要がない。
- パフォーマンス: Web Crypto API はブラウザのネイティブ実装(C/C++ や Rust)で動くため、JavaScript ライブラリより桁違いに速い
-
バンドルサイズ: 外部ライブラリを入れないのでバンドルサイズが増えない。
crypto-jsは minified で数十 KB ある - セキュリティ: ブラウザベンダーがメンテナンスする暗号実装を使うので、サプライチェーン攻撃のリスクがない。npm パッケージの脆弱性を心配する必要もない
- 標準仕様: W3C の Web Cryptography API 仕様に準拠しており、Chrome / Firefox / Safari / Edge のすべてで動作する
実際、ぱんだツールズの package.json には暗号化関連のライブラリは一切含まれていない。暗号化処理のコードは TextEncryptClient.tsx の約80行だけで完結している。
セキュリティ面の設計
AEAD(認証付き暗号)
AES-GCM は AEAD(Authenticated Encryption with Associated Data)に分類される暗号モードだ。暗号化と同時に認証タグを生成するため、暗号文の改ざんを検出できる。
AES-CBC のような非認証モードだと、暗号文を改ざんしても復号自体は成功してしまう場合がある(Padding Oracle Attack など)。GCM ならそのリスクがない。
ブラウザ完結のプライバシー
このツールはすべての処理を window.crypto.subtle で行う。ネットワークタブを開いても暗号化リクエストは一切飛ばない。入力テキストもパスワードもブラウザの JavaScript ランタイムの中で消費されて終わる。
サーバーサイドに暗号化処理を持つ設計だと、HTTPS で通信が暗号化されていても「サーバー管理者が平文を見れる」リスクが残る。ブラウザ完結なら、そもそもそのリスクが存在しない。
まとめ
Web Crypto API を使えば、外部ライブラリなしでブラウザネイティブの AES-256-GCM 暗号化を実装できる。PBKDF2 でパスワードから安全に鍵を導出し、ランダムな Salt と IV で毎回異なる暗号文を生成する。GCM モードの認証タグにより改ざん検出もカバーされる。
実装に必要なコードは100行にも満たない。ブラウザの標準 API だけでここまでできるなら、暗号化のためだけに外部パッケージを追加する理由はもうないだろう。
Discussion