WebOS Goodies

WebOS の未来を模索する、ゲームプログラマあがりの Web 開発者のブログ。

WebOS Goodies へようこそ! WebOS はインターネットの未来形。あらゆる Web サイトが繋がり、共有し、協力して創り上げる、ひとつの巨大な情報システムです。そこでは、あらゆる情報がネットワーク上に蓄積され、我々はいつでも、どこからでも、多彩なデバイスを使ってそれらにアクセスできます。 WebOS Goodies は、さまざまな情報提供やツール開発を通して、そんな世界の実現に少しでも貢献するべく活動していきます。
Subscribe       

Closure Library の暗号化モジュールの使い方

だいぶ前になりますが、 Google Apps Script で動くパスワード管理ツールを公開しました。このツールでパスワードの暗号化に使っているのが、 Closure Library の暗号化モジュールである goog.crypt です。これを使えば、 AES 暗号化や各種ハッシュ関数、 HMAC などをブラウザ上の JavaScript で利用できます。

最近は Web Storage や Indexed DB などの利用機会も多くなってきたと思いますが、 goog.crypt を使えばそれらに保存するデータを簡単に暗号化できます。今後はクライアントサイドの暗号化が必須の技術になっていくでしょう。そこで、本日はこの goog.crypt を使った暗号化の実装方法をまとめてみました。

予備知識

goog.crypt の説明に入る前に、暗号化処理の一般的な話を少し書いておきます。下の図は、 AES 暗号を使ってデータを暗号化する流れを簡単にまとめたものです。

核となる AES 暗号化のほかにも、いくつかの処理ブロック(オレンジのボックス)がありますね。 goog.crypt ではこれらの処理を個別にクラス化しており、使う側が適切に組み合わせなければなりません(今のところあまり選択肢はないのですが)。そのためには、それぞれの処理についてある程度の知識が必要となります。

そこで、それぞれの処理について、私の知っている範囲で説明します。ただし、私は暗号については素人なので、情報の正確性については眉唾程度です。あくまで goog.crypt を使うための予備知識というレベルでお考えください。

PBKDF2 (Password-Based Key Derivation Function 2)

PBKDF2 は、ユーザーが入力したパスワードから暗号鍵を生成する、いわゆる鍵導出アルゴリズム (KDA) のひとつです。 KDA はほかにもありますが、 Closure Library に実装されているのは PBKDF2 のみです。

なぜパスワードをそのまま暗号鍵として使わないのかというと、以下の問題があるからです。

  1. パスワード総当たり系の攻撃に弱い
  2. すべてのデータに対して同じ暗号鍵が使用されてしまう

これらの問題を解決(もしくは緩和)するのが PBKDF2 の役割です。まず (1) については、ハッシュ関数を繰り返しかけることで計算量を意図的に増やし、攻撃を困難にします。繰り返し回数はパラメータで自由に指定でき、回数が多いほど攻撃耐性が高くなります。ただし、そのぶん暗号化・復号にかかる時間も増えるので、実際には処理時間の許す範囲で大きな値を指定することになります。ここが JS では辛いところなのですが・・・。

(2) については、「salt」と呼ばれるランダムなバイナリ列をハッシュ関数(正確には HMAC-SHA1)の初期値とすることで、データごとに暗号鍵が変わるようにします。これによって同じ平文でも暗号化結果が変化し、内容の推測がより困難になります。また、事前計算されたテーブルを使って解読するような攻撃を防ぐ目的もあるようです(が、私はいまいち理解できていません)。

ちなみに、 salt はパスワードではないので、とくに秘密にする必要はありません。平文で保存しても大丈夫です。

AES 暗号化

PBKDF2 で算出された暗号鍵を使って、実際にデータを暗号化(もしくは復号)するのがこの部分です。 AES はブロック暗号と呼ばれる種類の暗号アルゴリズムであり、常に固定長(16 バイト)の平文を、同じく 16 バイトのバイナリ列へと暗号化して返します(復号の場合はその逆)。このため、 16 バイトを超えるデータは 16 バイトごとのブロックに分割し、それぞれを個別に暗号化することになります。

ただし、単純に 16 バイトずつ暗号化するだけでは、平文の内容が同じなら暗号化後も同じになってしまい、よろしくありません。この点も考慮して 16 バイト以上のデータをうまく暗号化するのが、次の CBC と呼ばれる処理です。

CBC (Cipher-block chaining)

16 バイト固定長のデータしか扱えない AES (もしくは他のブロック暗号)を繰り返し呼び出して、任意の長さのデータを暗号化・復号する処理が CBC です。具体的には、元データの 16 バイトごとのブロックに対して、以下の処理を順番に適用していきます(たぶん)。

  1. 平文から対象となる 16 バイトのデータを切り出す
  2. 切り出したデータに対して、前のブロックの暗号化後のデータ(先頭ブロックの場合は IV)との XOR をとる
  3. XOR をとったデータを AES に渡して、暗号化する
  4. 暗号化されたデータを全体の暗号化結果の最後に結合する

(2) で IV という言葉が出てきましたが、これはパラメータとして与えるランダムなバイナリ列です。サイズは暗号アルゴリズムのブロック長(AES なら 16 バイト)と同じでなければなりません。

この IV も salt と同様に平文で保存できますが、攻撃者による変更が可能だと危険です。 IV のビットを操作することで、先頭ブロックの復号結果を意図的に書き換えることができてしまうからです。 DB に保存するぶんには大丈夫でしょうが、信頼できない経路で転送する場合などは注意してください。

サンプル

さて、前置きが長くなりましたが、本題である goog.crypt の使い方に入りましょう。サンプルとしてフォームでデータを暗号化・復号できるものを用意したので、まずはそれを掲載しておきます。

上のフォームに平文とパスワードを入力して「暗号化」ボタンをクリックすると、暗号化結果を下のフォームで表示します。さらに「復号」ボタンで復号すれば、「復号結果」に最初と同じ平文が表示されるはずです。意図的にパスワードを変えてやれば、復号結果はたぶん文字化けした意味不明な文字列になります。

サンプルの JavaScript は下のようになっています。 Closure Library の独特の記述を含んでいるので、初めて触れる方はこちらの記事を参照していただくと良いかと思います。

goog.provide('crypt.App');
goog.require('goog.crypt');
goog.require('goog.crypt.pbkdf2');
goog.require('goog.crypt.Aes');
goog.require('goog.crypt.Cbc');
goog.require('goog.dom');
goog.require('goog.dom.forms');
goog.require('goog.events');

/** @constructor */
crypt.App = function() {
  goog.events.listen(goog.dom.getElement('encrypt-form'), 'submit', this.encrypt, false, this);
  goog.events.listen(goog.dom.getElement('decrypt-form'), 'submit', this.decrypt, false, this);
};

crypt.App.randomArray = function(length) {
  var array = [];
  for(var i = 0 ; i < length ; i++) {
    array.push((Math.random() * 256) & 0xff);
  }
  return array;
};

crypt.App.prototype.encrypt = function(e) {
  e.preventDefault();

  // 平文とパスワードを取得
  var plaintext = goog.dom.forms.getValueByName(e.target, 'plaintext');
  var password  = goog.dom.forms.getValueByName(e.target, 'password');

  // 平文とパスワードをバイト配列に変換
  var plaintextBytes = goog.crypt.stringToUtf8ByteArray(plaintext);
  var passwordBytes  = goog.crypt.stringToUtf8ByteArray(password);

  // PBKDF2で暗号鍵を生成
  var salt = crypt.App.randomArray(10);
  var key  = goog.crypt.pbkdf2.deriveKeySha1(passwordBytes, salt, 1000, 128);

  // 平文のバイト数が16の倍数になるように、末尾に0を付加する
  while(plaintextBytes.length % 16 != 0) {
    plaintextBytes.push(0);
  }

  // AESで暗号化
  var iv  = crypt.App.randomArray(16);
  var aes = new goog.crypt.Aes(key);
  var cbc = new goog.crypt.Cbc(aes);
  var encrypted = cbc.encrypt(plaintextBytes, iv);

  // 結果を復号フォームに設定
  var formEl = goog.dom.getElement('decrypt-form');
  formEl.elements['encryptedtext'].value = goog.crypt.byteArrayToHex(encrypted);
  formEl.elements['password'].value      = password
  formEl.elements['salt'].value          = goog.crypt.byteArrayToHex(salt);
  formEl.elements['iv'].value            = goog.crypt.byteArrayToHex(iv);
};

crypt.App.prototype.decrypt = function(e) {
  e.preventDefault();

  var encryptedHex = goog.dom.forms.getValueByName(e.target, 'encryptedtext');
  var password     = goog.dom.forms.getValueByName(e.target, 'password');
  var saltHex      = goog.dom.forms.getValueByName(e.target, 'salt');
  var ivHex        = goog.dom.forms.getValueByName(e.target, 'iv');

  // 暗号文などを16進文字列からバイト配列に変換
  var encrypted = goog.crypt.hexToByteArray(encryptedHex);
  var salt      = goog.crypt.hexToByteArray(saltHex);
  var iv        = goog.crypt.hexToByteArray(ivHex);

  // PBKDF2で暗号鍵を生成
  var passwordBytes = goog.crypt.stringToUtf8ByteArray(password);
  var key = goog.crypt.pbkdf2.deriveKeySha1(passwordBytes, salt, 1000, 128);

  // 復号
  var aes = new goog.crypt.Aes(key);
  var cbc = new goog.crypt.Cbc(aes);
  var plaintext = cbc.decrypt(encrypted, iv);

  // 末尾の0を削除
  var length = plaintext.indexOf(0);
  if(length >= 0) {
    plaintext.length = length;
  }

  // 結果を表示
  goog.dom.setTextContent(
    goog.dom.getElement('decryptedtext'),
    goog.crypt.utf8ByteArrayToString(plaintext));
};

var app = new crypt.App();

他のファイルは以下の場所で公開しています。

http://webos-goodies.googlecode.com/svn/trunk/blog...

暗号化してみる

それでは、上記のサンプルを題材にして、 goog.crypt の使い方を説明していきます。まずは暗号化の処理から見ていきましょう。

平文とパスワードをバイト配列に変換

「暗号化」ボタンがクリックされたら、まずはフォームに入力された平文 (plaintext) とパスワード (password) を取得し、それらをバイト配列に変換しています。

var plaintextBytes = goog.crypt.stringToUtf8ByteArray(plaintext);
var passwordBytes  = goog.crypt.stringToUtf8ByteArray(password);

goog.crypt は暗号化対象としてバイト配列(全要素が 0 〜 255 の整数値となっている配列)を前提としているので、あらかじめデータをその形式に変換しておく必要があるのです。幸い文字列に関しては UTF-8 エンコードのバイト配列を生成する goog.crypt.stringToUtf8ByteArray() が用意されています。日本語などのマルチバイト文字も正しく変換してくれます。

PBKDF2 で暗号鍵を生成

前述の PBKDF2 で暗号鍵を生成するコードが以下です。

var salt = crypt.App.randomArray(10);
var key  = goog.crypt.pbkdf2.deriveKeySha1(passwordBytes, salt, 1000, 128);

salt として 10 バイトのランダムなバイナリを生成し、 goog.crypt.pbkdf2.deriveKeySha1() を呼び出して暗号鍵を生成しています。 deriveKeySha1() の引数は、順にバイト配列に変換したパスワード、 salt、繰り返し回数、暗号鍵のビット長です。暗号鍵のビット長は AES なら 128, 192, 256 のいずれかで、ビット長が長いほど強度が上がりますが、処理は重くなります。ここではサンプルということで、最も軽い 128 にしています。

salt の生成に使っている crypt.App.randomArray() は、単に Math.random() で乱数列を作っているだけです。

crypt.App.randomArray = function(length) {
  var array = [];
  for(var i = 0 ; i < length ; i++) {
    array.push((Math.random() * 256) & 0xff);
  }
  return array;
};

本当はもっと質の良い乱数を使うべきなのでしょうが、ひとまずこれで妥協しています。最近のブラウザなら crypto.getRandamValues() を使うべきかもしれません。

元データの長さをブロック長の倍数に合わせる

前述のとおり AES は 16 バイトごとに暗号化処理を行います。 CBC を使うことで 16 バイトを超えるデータも暗号化できますが、それでもデータの長さは 16 の倍数になっていなければなりません。そこで、データの最後に 0 を付け足して、 16 の倍数になるように調整してやります。

while(plaintextBytes.length % 16 != 0) {
  plaintextBytes.push(0);
}

このくらいライブラリ側でやってほしいものですが、最適なパディング方法はデータの性質によって異なるので、決め打ちするべきではないという考えなのでしょう。実際、上記の単純な処理は 0 を含むバイナリ列には適用できません(復号後にパディングを取り除くことができないため)。

AES で暗号化

ここまでくれば暗号化の処理は簡単です。 PBKDF2 で生成した暗号鍵を引数にして goog.crypt.Aes のオブジェクトを生成し、さらにそれを引数にして goog.crypt.Cbc のオブジェクトを生成します。そして、 encrypt() に暗号化するバイト配列と IV を渡せば、暗号化されたバイト配列が返ります。

var iv  = crypt.App.randomArray(16);
var aes = new goog.crypt.Aes(key);
var cbc = new goog.crypt.Cbc(aes);
var encrypted = cbc.encrypt(plaintextBytes, iv);

IV は salt と同じ方法でランダムなバイト配列を生成しています。ただし、 IV は暗号化アルゴリズムのブロック長と同じでなければならないので、配列の長さを 16 にしています。

暗号化結果を 16 進文字列に変換

暗号化結果はバイナリのバイト配列として返ります。 JSON などでサーバーに送信するならそのままでもかまいませんが、サンプルではフォームで表示するために 16 進の文字列に変換しています。 goog.crypt.byteArrayToHex() というヘルパー関数が用意されているので、それを呼び出すだけです。

var formEl = goog.dom.getElement('decrypt-form');
formEl.elements['encryptedtext'].value = goog.crypt.byteArrayToHex(encrypted);
formEl.elements['password'].value      = password
formEl.elements['salt'].value          = goog.crypt.byteArrayToHex(salt);
formEl.elements['iv'].value            = goog.crypt.byteArrayToHex(iv);

以上のコードで任意の文字列が暗号化できます。 Closure Library は汎用性重視なのでちょっと面倒ですが、自分で使う際はニーズに合わせて使いやすいクラスにまとめてしまうのが良いでしょう。

復号してみる

復号の処理も暗号化の時とほとんど同じです。ただし salt や IV は生成するのではなく、暗号化時に保存しておいたもの(サンプルではフォームに出力している)を使います。 byteArrayToHex() で 16 新文字列にしてある場合は、 goog.crypt.hexToByteArray() でバイト配列に戻してやります。

var encrypted = goog.crypt.hexToByteArray(encryptedHex);
var salt      = goog.crypt.hexToByteArray(saltHex);
var iv        = goog.crypt.hexToByteArray(ivHex);

そして、暗号化時とほぼ同様に PBKDF2 による暗号鍵生成、および復号の処理を行います。 goog.crypt.Cbc の encrypt() の代わりに decrypt() を使うのが唯一の違いです。

var passwordBytes = goog.crypt.stringToUtf8ByteArray(password);
var key = goog.crypt.pbkdf2.deriveKeySha1(passwordBytes, salt, 1000, 128);

var aes = new goog.crypt.Aes(key);
var cbc = new goog.crypt.Cbc(aes);
var plaintext = cbc.decrypt(encrypted, iv);

最後に暗号化時に負荷した 0 パディングを取り除き、必要に応じてバイト配列を文字列などに変換すれば、元データと同じ物が得られるはずです。

// 末尾の0を削除
var length = plaintext.indexOf(0);
if(length >= 0) {
  plaintext.length = length;
}

// 結果を表示
goog.dom.setTextContent(
  goog.dom.getElement('decryptedtext'),
  goog.crypt.utf8ByteArrayToString(plaintext));

以上、本日は Closure Library の暗号化モジュールの使い方をご紹介しました。今後 Web アプリケーションにおけるクライアントサイドの比重が高まっていくにつれて、暗号化はさまざまな場面で必要になってくるでしょう。ぜひご活用ください。

関連記事

この記事にコメントする

Recommendations
Books
「Closure Library」の入門書です。
詳しくはこちらの記事をどうぞ!
Categories
Recent Articles