4
1

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 の実装解説

4
Posted at

テキストを誰かに安全に送りたい場面は意外と多い。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-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 だけでここまでできるなら、暗号化のためだけに外部パッケージを追加する理由はもうないだろう。

ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理などの開発者向けツールを公開中。すべて無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

4
1
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
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?