🔑

ブラウザだけでテキスト暗号化する仕組み — Web Crypto API × AES-256-GCM の実装解説

に公開

テキストを誰かに安全に送りたい場面は意外と多い。APIキー、パスワード、環境変数、社内の機密メモ。チャットやメールにそのまま貼るのは論外だし、かといって暗号化ツールにテキストを渡すと「このサーバー、本当にデータを保存してないの?」という不安がつきまとう。

その不安を根本から消す方法がある。ブラウザの中だけで暗号化を完結させることだ。

Web Crypto API を使えば、外部ライブラリもサーバー通信も不要で、AES-256-GCM という NIST 標準の暗号をブラウザネイティブに実行できる。この記事では、個人開発の Web ツール「ぱんだツールズ」で実装したテキスト暗号化機能の仕組みを、コード付きで解説する。

https://sakutto-panda.com/tools/text-encrypt

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-jstweetnacl を入れる発想になりがちだが、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 だけでここまでできるなら、暗号化のためだけに外部パッケージを追加する理由はもうないだろう。

https://sakutto-panda.com/tools/text-encrypt

Discussion