ブラウザのWeb Crypto APIでAES暗号を試してみた

ATOM開発チーム所属、にゃーんと鳴くバックエンドエンジニアのにゃんと申します。今日はブラウザのWeb Crypto APIでAES暗号を扱ってみようというテーマでブログを書かせていただきます。

AESとは?

AESは共通鍵暗号の一つ、共通鍵暗号とは暗号化と復号で同じ鍵を使う暗号です。今では共通鍵暗号といえばAESというぐらい広く使われています。思いつくだけでも以下の用途で使用されています。

  • SSL/TLS通信の本文の暗号化*1
  • SSHの暗号化
  • BitLockerやFileVaultなどのディスク暗号化
  • 暗号化ZIP
  • Wi-Fi(WPA2/WPA3)の暗号化

Web Crypto APIとは

Web Crypto APIとは、ブラウザ上で暗号を利用するためのJavaScriptインターフェースで、暗号化、復号、署名、検証、鍵生成、鍵交換、鍵導出などの機能を提供します。AESだけでなく、RSAなどの公開鍵暗号やSHA256などのダイジェスト関数も提供されています。

なお、Web Crypto APIは暗号とセキュアシステム構築の専門家に向けて用意されたAPIです。付け焼き刃の知識で使用すればかえってアプリケーションを危険に晒すことになりかねません。暗号に関する深い知識を持たないのであれば、このAPIを何かに使おうとは考えない方がいいでしょう。この記事はJSに暗号APIあるんだへぇー、最近のブラウザ何でもできるんやなwくらいのノリで読んでいただけると幸いです。

Web Crypto APIでAES暗号を使ってみる

プログラマならぐだぐだ説明されるよりサンプルコードを見たほうが早いでしょう。まずは暗号化、復号に使用する暗号鍵の導出のコードです。

/* AESの鍵を導出する
 * @param {ArrayBuffer|TypedArray} password
 * @param {ArrayBuffer|TypedArray} salt
 * @returns {CryptoKey} AES-256-GCMの鍵
 */
async function deriveKey(password, salt) {
  const passwordKey = await window.crypto.subtle.importKey(
    "raw",
    password,
    "PBKDF2",
    false,
    ["deriveKey"]
  );

  return await window.crypto.subtle.deriveKey(
     {
       name: "PBKDF2",
       salt: salt,
       iterations: 2000,
       hash: "SHA-256",
     },
     passwordKey,
     {
       name: "AES-GCM",
       length: 256,
     },
     true,
     ["encrypt", "decrypt"]
   );
}

AES暗号では128, 192, 256ビットの長さの鍵を使用できます。パスワードは大抵それらとは長さが異なりますし、そもそも人間の考えたパスワードなど極めて脆弱なのでそのまま暗号鍵としては使用することはできません。そこでPBKDF2やbcrypy、Argon2といった鍵導出関数を用いてsaltを加えて多数の反復処理を行うことで総当たり攻撃に対する強度を高めた暗号鍵として相応しい適切なフォーマットのデータに変換します。

次に暗号化のサンプルコード。

/* パスワードを用いて文字列を暗号化する
 * @param {string} message 暗号化するメッセージ
 * @param {string} password パスワード
 * @returns {{cipher: ArrayBuffer iv: Uint8Array, salt: Uint8Array}} 暗号化データ
 */
async function encrypt(message, password) {
  // パスワードをTypedArrayに
  const pwd = new TextEncoder().encode(password);

  // 鍵導出用のsaltを生成
  const salt = window.crypto.getRandomValues(new Uint8Array(16));

  // パスワードとsaltから鍵を導出する
  const key = await deriveKey(pwd, salt);

  // 初期化ベクトルを生成
  const iv = window.crypto.getRandomValues(new Uint8Array(12));

  // 暗号化を実行
  const cipher = await window.crypto.subtle.encrypt(
    {
      name: "AES-GCM",
      iv: iv,
      tagLength: 128,  // GCMモードの改ざんチェック用データの長さ
      additionalData: new TextEncoder().encode('test') // GCMモードのAAD(追加認証データ)、無くても良い
    },
    key,
    new TextEncoder().encode(message),
  );

  // IV, salt, 暗号文を出力とする
  return {
    iv,
    salt,
    cipher
  }
}

ここでは暗号利用モードにGCMを選択しました。

暗号利用モードとは暗号鍵より長いデータを処理する方式です。CBC、CTRなど様々な方式がありますが、とりあえずECBモードは安全ではないので使ってはいけない、CCMモードやGCMモードは復号と同時に改ざんチェックも可能なイカしたやつ(認証付暗号化方式 AEAD と呼ばれます)ということだけ覚えておけば十分でしょう。サンプルコード中のtagLengthがGCMでの改ざんチェック用のデータ長を指定するパラメータです。コロナ禍でオンライン会議が普及し始めた頃zoomに脆弱性が発見されてニュースになりましたが、ECBモードの使用がその脆弱性の1つで、のちにGCMモードに修正されました。

IV(初期化ベクトル)は同一の鍵・同一の平文に対して異なる暗号文が出力されるようにするためのデータです。同一の暗号文が出力されると暗号解読の手掛かりになってしまうため、それを避けるために使用されます。IVを秘匿する必要はありませんが、毎回ランダムな値を使用し同じものを使うのは避けましょう。

AAD(追加認証データ)はGCMモードのオプション機能です。これは暗号強度を高めるものではなく復号しても良いのかチェックするために使用します。例えばユーザのデータを暗号化して保存しているwebアプリでは他ユーザのデータが見れてしまうインシデントが発生するかもしれまん。そのような問題の対策として追加認証データが使えます。暗号化・復号にログインユーザのIDをAADとするようプログラミングします。暗号化時と復号時のユーザIDが一致していなければ復号が失敗し、事故を防ぐことができるでしょう。

最後に復号のコードです

/* 暗号文、salt、IVとパスワードで復号する
 * @param {{cipher: ArrayBuffer iv: Uint8Array, salt: Uint8Array}} encrypted 暗号化データ
 * @param {string} password パスワード
 * @returns {string} 復号されたメッセージ
 */
async function decrypt({iv, salt, cipher}, password) {
  // パスワードをTypedArrayに
  const pwd = new TextEncoder().encode(password);

  // パスワードとsaltから鍵を導出する
  const key = await deriveKey(pwd, salt);

  // 復号する
  const buffer = await window.crypto.subtle.decrypt(
    {
      name: "AES-GCM",
      iv: iv,
      tagLength: 128,
      additionalData: new TextEncoder().encode('test')
    },
    key,
    cipher,
  );

  return new TextDecoder().decode(buffer);
}

雑感

正直なところインターフェースの洗練されてない感が否めません。鍵の導出がimportKey, deriveKeyと二段階になっており、両者に同じ設定値を渡す必要があって煩雑です。おそらくパスワード以外による鍵導出や共通鍵の生成などでインターフェースの共通化を試みて複雑になったものと思われます。専用の型が出てくるので型によるサポートの弱いJSではちょっと面倒臭いと思います。その辺はTypeScriptを使用することでカバーできるでしょう。また、エラーメッセージがあまり親切ではなく今回のサンプルコードでさえデバッグしにくさを感じました。まぁchromeのエラーメッセージが不親切なのはいつもの事ですし、だんだん改善されていくことでしょう。

*1:TLS v1.3で使用できる共通鍵暗号はAESとchacha20の2つしかありません