ククログ

株式会社クリアコード > ククログ > Web Crypto API で AES-CBC や AES-GCM の初期ベクトルをより安全に生成する

Web Crypto API で AES-CBC や AES-GCM の初期ベクトルをより安全に生成する

先日の Web Crypto API の基本的な使い方の解説(改訂済み)においては、説明を簡単にするために AES-GCM の初期ベクトルを乱数に基づいて生成しましたが、これはセキュリティの観点からはあまり好ましくありません。本記事では、Web Crypto API で AES-CBCAES-GCM を用いて暗号化する場合の、より安全な初期ベクトルの生成方法について解説します。

望ましい初期ベクトルとは?

前の記事でも述べていますが、初期ベクトルについて簡単におさらいします。

共通鍵暗号のアルゴリズムである AES にはいくつかの「暗号モード」があり、中でも CBC や GCM といったモードでは、暗号化に際して初期ベクトルというパラメータが必要となっています。これは、データを暗号化する際に添加する無関係のデータのことで、それによって暗号文から元のデータを予測しにくくするという意味があります1。他の暗号モードの CTR でも、カウンタの nonce 部分がこれと同様の役割を果たします。

暗号の仕様上は、初期ベクトルやnonce2一意である事が求められています。そのため、「ランダムなだけの値」を初期ベクトルに使うと困った事になります。

ここで一旦整理してますが、値が一意であるということと値がランダムであるという事は本質的に全く別の事です。

  • 値が一意である=値が重複しない事が保証されている。

  • 値がランダム(乱雑)である=値が予想できない、または極めて予想しにくい事が保証されている。

これらは相反する概念ではなく直交する概念なので、「一意で、且つランダムである」「一意でもないし、ランダムでもない」「一意だが、ランダムではない」「一意ではないが、ランダムである」という4つの組み合わせが理論上あり得ます。「ランダムな値」というと、直感的には「一意で、且つランダムである」という事を指していそうに思えますが、実際には「一意ではないが、ランダムである」という値もその範囲に入ってきます。

これを踏まえると、一意である事が求められる初期ベクトルに、ランダムであるというだけの「乱数」を使うのは、本来は間違いであるという事が言えます。前の記事の例では crypto.getRandomValues() を用いましたが、これもあくまで暗号論的に強度の高い疑似乱数3であって、一意な値であることが保証されているわけではありません。確率は低いですが、生成された値が過去の値と偶然一致してしまうという可能性はあります。

実際、本当は怖いAES-GCMの話 - ぼちぼち日記という記事の中では、疑似乱数で初期ベクトルを大量に生成すると誕生日のパラドックス4によって初期ベクトルが衝突するという事が述べられています。

その一方で、仕様によれば、初期ベクトルは「一意である」という事は求められているものの、「予測不能である」という事は求められていません。極端な話、重複さえしなければ、「単調増加するカウンタ」という極めて予測しやすい物であっても何ら問題ないという事です。実際、仕様の中でもカウンタが妥当な実装の例として挙げられているほどです。

一意な値というとUUIDがまず思い浮かびますが、UUIDは生成方法が妥当でないと値が衝突する可能性が(低いですが)あります。言語のライブラリによってはUUIDの生成が乱数ベースとなっていて、このような実装では、UUIDという名前なのに一意な結果を得られる事は残念ながら保証されていないという事になってしまいます。

しかし、UUIDが信用できない場合でも、単調増加型のカウンタなら確実に一意な結果を得られます。

同一の鍵での暗号化は、仕様では2の32乗回以上はしてはならないことになっています。そのため、カウンタの長さは32bitあれば事足りるということになります。

幸い、TypedArrayにはUint32Arrayという型があり、これを使うと1桁で32bitまでの数字を表す事ができます。また、JavaScriptの数値は64bit浮動小数点として実装されており、52bitまでの範囲であれば正確さが保証されているため、単純に以下の要領でカウンタとして利用できます。

// Uint32Arrayで一桁だけのカウンタを作成
let counter = new Uint32Array(1);

// カウントを足す
counter[0]++;

JavaScriptで任意の桁数のカウンタを実装する

ということで、実際にそれをJavaScriptで実装してみる事にしました。以下は、Typed Array や Array をカウンタとしてインクリメントする関数の実装例です。

function incrementCount(counter) {
  // 桁ごとの計算
  const increment = column => {
    // 左端(最上位)の桁からの繰り上がりは無いので、桁あふれした事のみ返す
    if (column < 0)
      return true;
    // 指定された桁の値を1増やす
    const result = ++counter[column];
    // 最大値を超えていないのであれば、そこで終了
    if (result <= 255)
      return false;
    // 最大値を超えてしまった場合、その桁の値を0にリセット
    counter[column] = +0;
    // その後、繰り上がって1つ左(上位)の桁の値を1増やす(再帰)
    return increment(column - 1);
  };
  // 右端(最下位)の桁を1増やし、左端(最上位)の桁があふれたかどうかを判定
  const overflow = increment(counter.length - 1);
  // 左端(最上位)の桁が溢れた場合、全体を0にリセットする
  if (overflow)
    counter.fill(0);
  return counter;
}

この関数は、Uint8Arrayを任意の桁数のカウンタとして使います。1つの桁あたり8bitなので、255になったら桁が繰り上がるという要領です。実際に、4桁のカウンタ(=32bit)を使って動作を見てみましょう。

const counter = new Uint8Array(4);
console.log(counter);
// => Uint8Array(16) [ 0, 0, 0, 0 ]

カウンタは、最初はすべての桁が0で埋められています。カウントを進めると、最下位=右端の桁の値が増えていきます。

incrementCount(counter);
console.log(counter);
// => Uint8Array(16) [ 0, 0, 0, 1 ]

incrementCount(counter);
console.log(counter);
// => Uint8Array(16) [ 0, 0, 0, 2 ]

これを繰り返すと、いずれ最下位の桁が最大値に達します。

incrementCount(counter);
console.log(counter);
// => Uint8Array(16) [ 0, 0, 0, 255 ]

ここでさらにカウントを進めると、繰り上がりが発生して次の桁の値が増え、最下位の桁の値が0に戻ります。

incrementCount(counter);
console.log(counter);
// => Uint8Array(16) [ 0, 0, 1, 0 ]

カウントを進めると、また最下位の桁の値が増えていきます。

incrementCount(counter);
console.log(counter);
// => Uint8Array(16) [ 0, 0, 1, 1 ]

という事で、確かにカウンタとして動作している事を確認できました。

固定部とカウンタから初期ベクトルを組み立てる形で AES-GCM を使う

GCMの仕様では初期ベクトルの望ましい作り方の例がいくつか挙げられており、その中には、初期ベクトルを「前半の固定部」と「後半の変動部」に分けるやり方があります。

この文書では、固定部分は「デバイスの種類」「暗号化対象のコンテキスト」などを表すために使えると書かれています。よって、固定部として「暗号化を行うアプリのインスタンス」ごとに生成した値を使い、変動部に先のカウンタの値を使えば、GCMの仕様を満たす暗号化可能だと言えます。

そこで、前の記事に記載した AES-GCM による暗号化の例を元にして、カウンタを併用して初期ベクトルを組み立てる例を実装してみる事にします。

まず暗号の鍵ですが、これは話を簡単にするため、指定したパスフレーズ(パスワード)の文字列からその都度生成する事にします。鍵を自動生成ストレージに保管したり自動生成したりする場合については前の記事をご参照下さい。

async function getKey(passphrase, salt = null) {
  passphrase = (new TextEncoder()).encode(passphrase);
  let digest = await crypto.subtle.digest({ name: 'SHA-256' }, passphrase);
  let keyMaterial = await crypto.subtle.importKey('raw', digest, { name: 'PBKDF2' }, false, ['deriveKey']);
  if (!salt)
    salt = crypto.getRandomValues(new Uint8Array(16));
  let key = await crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt,
      iterations: 100000,
      hash: 'SHA-256'
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
  return [key, salt];
}

次は初期ベクトルの組み立てです。固定部の生成は以下の要領です。ここでは、最終的に128bitの長さの初期ベクトルにする前提で、そのうちカウンタに使う32bitを除いた残り96bitを固定部の長さとしています。

function getFixedField() {
  // 作成済みの値を取得する。
  let value = localStorage.getItem('96bitIVFixedField');
  // あれば、それを返す。
  if (value)
    return Uint8Array.from(JSON.parse(value));

  // 無ければ、新しい値(長さ96bit)を作成して保存する。
  // 96bitをUint8Arrayで表すため、96 / 8 = 12が桁数となる。
  value = crypto.getRandomValues(new Uint8Array(12));
  localStorage.setItem('96bitIVFixedField', JSON.stringify(Array.from(value)));
  return value;
}

作成済みの値があればそれを使い、無ければ新たに生成する、という形にすると、固定部の値が各実行インスタンスの識別子を表す事になります。先に述べた通り、乱数から得られた値は一意な値であることが保証されないので、本来は望ましくないのですが、個々の初期ベクトルを毎回乱数で生成するよりは衝突の確率が低いという事で、ここでは乱数を使う事にしました。

初期ベクトルの変動部は、前述の実装によるカウントアップ処理を使った単純なカウンタ前述の説明の通り32bitのカウンタにします。

function getInvocationField() {
  // 前回の値を取得。
  let counter = localStorage.getItem('32bitLastCounter');
  if (counter) // あればそれを使う。
    counter = Uint32Array.from(JSON.parse(counter));
  else // 無ければ新しいカウンタ(長さ32bit)を生成する。
    counter = new Uint32Array(1); 

  counter[0]++; // 値を1増やす。

  // 結果を保存する。
  localStorage.setItem('32bitLastCounter', JSON.stringify(Array.from(counter)));
}

ここでも、作成済みの値があればそれを使い、無ければ新たに生成する、という形にしており、これによって値が各実行インスタンスごとに固有のカウンタとなります。同じ実行インスタンスにおいて動作する限りは、カウンタの値は一意です。

こうして用意できる固定部と変動部の値は、それぞれ長さが96bitと32bitあります。これらを単純に連結すれば、128bitの長さの初期ベクトルを生成する事ができます。

let fixedPart      = getFixedField();
let invocationPart = getInvocationField();

// 固定部と形式を揃えるため、Uint8Arrayに変換する。
invocationPart = new Uint8Array(invocationPart.buffer);

// 2つのTyped Arrayの各桁をスプレッド構文で並べて、
// 新しい配列を生成
let concated = [...fixedPart, ...invocationPart];
// その配列をTyped Arrayに変換
let iv = Uint8Array.from(concated);

暗号化の際は、このようにして毎回新しい初期ベクトルを生成するようにします。

async function encrypt(input, passphrase) {
  let [key, salt]    = await getKey(passphrase);
  // 初期ベクトルを生成する。
  let fixedPart      = getFixedField();
  let invocationPart = getInvocationField();
  let iv = Uint8Array.from([...fixedPart, ...new Uint8Array(invocationPart.buffer)]);
  let encryptedData = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    (new TextEncoder()).encode(JSON.stringify(input))
  );
  encryptedData = Array.from(new Uint8Array(encryptedData), char => String.fromCharCode(char)).join('');
  return JSON.stringify([
    btoa(encryptedData),
    // 暗号化されたデータには、必ず初期ベクトルの
    // 変動部とパスワードのsaltを添付して返す。
    invocationPart[0],
    Array.from(salt)
  ]);
}

関数の戻り値に初期ベクトルの変動部が添付されているという点がポイントです。AES-GCM においては、初期ベクトルは秘密である必要はありません。一方、初期ベクトルは暗号化されたデータの復号時に必要となります。以上の理由から、初期ベクトルは原則として、暗号化されたデータに添付してワンセットで取り扱う事になります。(鍵をパスワードから生成する場合、パスワードのsaltも同様に扱う必要があります。この例では、saltも初期ベクトルと併せてデータに添付しています。)

復号処理では、添付された初期ベクトルの変動部を取り出して固定部と組み合わせる事で、完全な初期ベクトルを復元する、という操作を行います。

// 暗号化されたデータに添付された初期ベクトルの変動部とsaltを得る。
let [encryptedData, invocationPart, salt] = JSON.parse(encryptedResult);
// 固定部を得る。
let fixedPart = getFixedField();
// 変動部をUint32Arrayに戻す。
let invocationPartTypedArray = new Uint32Array(1);
invocationPartTypedArray[0] = invocationPart;
// 変動部をUint8Arrayに変換する。
invocationPart = new Uint8Array(invocationPartTypedArray.buffer);
// 2つのTyped Arrayを連結して、完全な初期ベクトルを得る。
let iv = Uint8Array.from([...fixedPart, ...invocationPart]);

実際の復号処理は以下のようになります。

async function decrypt(encryptedResult, passphrase) {
  // 復号処理は、初期ベクトルが添付されたデータのみを取り扱うものとする。
  let [encryptedData, invocationPart, salt] = JSON.parse(encryptedResult);
  let [key, _] = await getKey(passphrase, Uint8Array.from(salt));
  let invocationPartTypedArray = new Uint32Array(1);
  invocationPartTypedArray[0] = invocationPart;
  // 初期ベクトルを復元する。
  let iv = Uint8Array.from([...getFixedField(), ...(new Uint8Array(invocationPartTypedArray.buffer))]);
  encryptedData = atob(encryptedData);
  encryptedData = Uint8Array.from(encryptedData.split(''), char => char.charCodeAt(0));
  let decryptedData = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv },
    key,
    encryptedData
  );
  decryptedData = (new TextDecoder()).decode(new Uint8Array(decryptedData));
  return JSON.parse(decryptedData);
}

ところで、ここでなぜ初期ベクトル全体ではなく変動部だけを添付しているかを疑問に思う人もいるのではないでしょうか。前の記事の例のように初期ベクトル全体を添付しておけば、上記のような初期ベクトルの復元処理は不要になるはずです。

初期ベクトルの変動部だけを添付する理由は2つあります。1つ目は、初期ベクトルの固定部は毎回共通のため、そのまま添付すると冗長だからで、これについては特に説明の必要はないでしょう。

2つ目の理由は、より安全性を高めるためです。このように暗号化されたデータ単体では初期ベクトルの全体が揃わないようにしておくと、もし万が一暗号化されたデータを知られたとしても、復元に必要な情報が揃わないため、攻撃はより困難になります。AES-GCM において初期ベクトルは秘密である必要はありませんが、一部だけでも秘密にすればより堅牢な保護が可能になる、という事です。

以上のコードをまとめた物をGistに置いてあります。以下の要領で実行すると、暗号化・復号を行える事、および、暗号化の度にカウンタの値が増加して一意な初期ベクトルを得られている事を確認できます。

(async () => {
  let passphrase = '開けゴマ';


  // 単純な文字列の暗号化と復号

  let encrypted = await encrypt('王様の耳はロバの耳', passphrase);
  console.log(encrypted);
  // => '["KoYoXAWjY1lAheEZrHYwAkbOf4e/kr8wgbVEPwNEjTawg3HTLmvvuXOqNn+R",[1],[122,107,206,161,208,200,58,46,97,139,37,201,101,28,223,203]]'

  let decrypted = await decrypt(encrypted, passphrase);
  console.log(decrypted);
  // => '王様の耳はロバの耳'


  // 複雑なデータの暗号化と復号

  encrypted = await encrypt({ a: 0, b: 1, c: true, d: 'foobar' }, passphrase);
  console.log(encrypted);
  // => '["bkvTkQNfTfnP7uUirivktij4iy66pSbiBDYJ3uNChkIlDJPBdkJ4Tqbe98a+QSujoHME",[2],[107,20,195,6,38,82,99,190,182,19,152,93,139,186,235,69]]'

  decrypted = await decrypt(encrypted, passphrase);
  console.log(decrypted);
  // => Object { a: 0, b: 1, c: true, d: "foobar" }
})();

まとめ

以上、AES-GCM の仕様で求められる性質を満たす形で Web Crypto API を用いて安全な暗号化を行う手順を解説しました。

この記事で実装したサンプルは、前の記事の例よりも、妥当且つ堅牢な物となっています。Web Crypto APIを使ってローカルデータを暗号化してみようという方に参考にしていただければ幸いです。

  1. パスワード認証を実装する際の、パスワードをハッシュ化して保存する時に用いる「salt」と似た役割を果たす物と言えます。

  2. そもそも「nonce」という言葉自体が「number once」つまり一度きりの数字という意味で、一意であるという性質を内包している事になります。

  3. Math.random() で生成した結果よりもさらに結果の予想がしにくいという事。

  4. 1つの教室の中にいる人達の中で「自分と同じ誕生日の人がいる」確率は低いが、「同じ誕生日の人が二人いる」確率は直感に反してずっと高い、という事実のこと。