株式会社クリアコード > ククログ

ククログ


Thunderbirdアドオン「CardBook(連絡先)」でローカルに保存されるデータの暗号化に対応しました

CardBookと企業利用

皆さんはCardDAVという仕様をご存じでしょうか? CardDAVはWebDAVのプロトコルを使ってLDIF形式のアドレス帳をやり取りするという物で、これを用いると「読み書き両方を行えて、内容が複数PC間で同期される」という種類のリモートアドレス帳を汎用の物として実現することができます。CardDAVサーバーとして振る舞える製品にはownCloudやDAViCalなどがあり、読み取り専用に設定したリモートアドレス帳を複数人で共有するという事もできますので、企業利用では重宝する場面がありそうです。

このように便利なCardDAVですが、残念ながらThunderbirdは本体の機能としては対応していません。CardDAVベースのリモートアドレス帳を使うにはアドオンをインストールする必要があります。CardBookは、そのようなCardDAV対応のためのアドオンの一例です。

ところで、企業によっては個人情報の取り扱い方について、「顧客や取引先の個人情報をローカルに保存する際は必ず暗号化する」といったプライバシーポリシーを定めている場合があります。前出のCardBookはリモートアドレス帳のデータをIndexedDBを使用してローカルにキャッシュする設計で、この時のデータは暗号化されないため、そのままでは前述のポリシーに抵触するので採用できないという事になります。

そのような背景から、「CardBookでローカルに保存されるデータを暗号化したい」というご相談を頂き、成果を開発元に還元する前提で先行して作業を進めていたのですが、残念ながら受注には至らず、手元には実現可能性の調査のために行った試験的な実装が残るという結果になりました。しかしせっかく実装した物をそのまま放置しておくのも勿体なかったので、CardBookプロジェクトに還元したところ、標準機能の1つとして取り込まれるに至りました。現在リリース済みのCardBook 33.9以降のバージョンでは、設定画面でチェックボックスをONにすればローカルデータの暗号化が有効になるようになっています。

以下、CardBookのローカルデータベースの暗号化を実現するにあたって行った具体的な内容をご紹介します。

IndexedDBに格納するデータの暗号化と復号

幸い、Thunderbirdの基盤であるGeckoには、暗号化のための汎用APIであるWeb Crypto APIが実装されています。あるのなら使わない理由はありませんので、CardBookでもデータの暗号化はWeb Crypto APIによる共通鍵暗号で行う事にしました。何故公開鍵暗号ではなく共通鍵暗号なのかについては別項で詳しく述べていますので、そちらも併せてご覧下さい。

実装は、まず暗号化・復号を行う専用のモジュールを追加した上で、IndexedDBの読み書きを行うモジュールの書き込み用のデータを用意する箇所に暗号化処理を読み込んだデータを検証する箇所に復号処理を仕掛けることで、他のモジュールに影響を与えず透過的に動作するような組み込み方としました。

この時気をつけなくてはならないポイントとして、暗号化をどのタイミングで行うかという点が挙げられます。以下、暗号化を行っている実際の箇所を抜粋しながら説明します。

元々の設計では、IndexedDBへのデータ書き込みは以下の要領で行われていました。

addCard: unction (aDirPrefName, aCard, aMode) {
  var db = cardbookRepository.cardbookDatabase.db;
  // トランザクション開始
  var transaction = db.transaction(["cards"], "readwrite");
  var store = transaction.objectStore("cards");
  var storedCard = aCard;
  // データの書き込み
  var cursorRequest = store.put(storedCard);

  // 以下、成功時・エラー時の処理
}

ここに暗号化処理を組み込むのですが、Web Crypto APIは暗号化したデータがPromiseで返されるため、値を使うには.then()のコールバック関数で受け取るか、awaitで値の解決を待つ必要があります。コールバック関数を使うスタイルで実装するにはこのメソッドの書き方を大幅に変えなくてはなりませんが、asyncキーワードとawaitを使うと、この同期処理の関数を容易に非同期処理に対応させることができます。

addCard: async function (aDirPrefName, aCard, aMode) { // asyncキーワードを追加
  var db = cardbookRepository.cardbookDatabase.db;
  // トランザクション開始
  var transaction = db.transaction(["cards"], "readwrite");
  var store = transaction.objectStore("cards");
  // 暗号化処理を追加
  var storedCard = cardbookIndexedDB.encryptionEnabled ? (await cardbookEncryptor.encryptCard(aCard)) : aCard;
  // データの書き込み
  var cursorRequest = store.put(storedCard);

  // 以下、成功時・エラー時の処理
}

当初はこの例のように、書き込みを行う直前で暗号化を行うようにしていました。しかし実際に動作させてみると、これではIndexedDBでのデータ書き込みに失敗するという結果になりました。何故でしょうか?

実は、IndexedDBでのデータ書き込みはトランザクション開始から書き込みまでを同期的に(同じイベントループ内で)行う必要があります。この例ではトランザクション開始後にawaitを使ってしまっているせいで、store.put(storedCard)が次のイベントループでの実行となってしまい、そのせいで書き込みに失敗してしまうという訳です。

そのため最終的な実装では、以下の例のようにトランザクション開始前に暗号化を終えておくようにしました。

addCard: async function (aDirPrefName, aCard, aMode) {
  // 暗号化
  var storedCard = cardbookIndexedDB.encryptionEnabled ? (await cardbookEncryptor.encryptCard(aCard)) : aCard;
  var db = cardbookRepository.cardbookDatabase.db;
  // トランザクション開始
  var transaction = db.transaction(["cards"], "readwrite");
  var store = transaction.objectStore("cards");
  // データの書き込み
  var cursorRequest = store.put(storedCard);

  // 以下、成功時・エラー時の処理
}

これなら、トランザクション開始から書き込みまでが同期的に行われるため問題ありません。

復号時には、特にこのような注意は必要ありません。また、元々IndexedDBからのデータ読み取りは結果が非同期で返されるので、CardBookのデータ読み込み処理もその前提で設計されていました。そのため、IndexedDBから返ってきたデータを非同期で復号した上で返却するという処理を挟み込んでも、CardBookのデータ読み込み処理全体としてはインターフェースを変えずに済んだのでした。

パスワード入力を求める方式にしなかった理由

CardBookのローカルデータ暗号化では、暗号化・復号に使う共通鍵は、バックグラウンドで自動生成した物を暗黙的に使い、鍵自体をユーザープロファイル内に保存する形としました。

「暗号化されたデータと鍵を同じ場所に置いておくのでは、暗号化の意味が無いじゃないか」と思うでしょうか? 実際、変更をフィードバックした際にもCardBookプロジェクトの開発者の方からも「パスワード入力を求める方式にした方がいいのではないか?」という質問がありました。Web Crypto APIの機能を使うとユーザーが入力したパスワードから秘密鍵を作る事もできる(Web Crypto APIの解説記事の「パスワードを鍵に変換する」の項をご参照下さい)のに、そうしなかったのは何故でしょうか。

ここで一旦、パスワードの安全な運用という事を考えてみましょう。パスワードを自分で記憶しておきその都度入力するという方式は、一見すると安全なように思えます。しかしながら、運用の仕方によっては却って危険になる場合があります。

  • パスワード入力には、肩越しに入力の様子を覗き見るショルダーハックや、キーの入力を監視するキーロガーなどによってパスワードを盗み取られるリスクがあります。パスワード入力の頻度が多ければ多いほど、このリスクは高まります。
  • 人間の記憶容量には限りがあるため、あまり複雑なパスワードを複数覚えるという事はできません。そのために「同じパスワードを何度も使い回す」「推測が容易なパスワードにする」といった事が行われてしまい、そうなると却って危険な状態となります。
  • 定期的なパスワード変更にも、同様の問題があります

これらの理由から、パスワードの入力は「複雑で憶えにくいパスワードを1つだけ覚える」「それをマスターパスワードとして使い、それ以外はパスワードマネージャに憶えさせる」という運用にするのが比較的安全だというのが現在の定説となっています*1

「企業でThunderbirdを使う」というシチュエーションでは、「PCのログオン」「受信メールサーバーの認証」「送信メールサーバーの認証」などでそれぞれパスワードの入力が発生する可能性があります。という事は、ここにさらに「ローカルデータの復号」のためのパスワードが加わるというのは、さすがに実運用を妨げるレベルの煩わしさでしょう。かといって、他の部分ではパスワードを使用していないのにここでだけパスワードの入力を求める、というアンバランスな運用も考えにくいです。そういった事を考慮した結果として、CardBookのローカルデータ暗号化は現在比較的安全とされている運用を想定し、

  • 秘密鍵は、自動生成した物をJWK形式でエクスポートし、「長いパスワード文字列」の一種としてThunderbirdのパスワードマネージャに記憶させる。
  • 秘密鍵の保護が必要な場合は、使用者が任意でThunderbird本体のマスターパスワード機能を有効化する。

というポリシーを採用する事にしたのでした。

まとめ

Thunderbird用アドオンのCardBookに対して行った、ローカルデータの暗号化対応の概要をご紹介しました。

当社では、一般に公開されているFirefox用アドオン・Thunderbird用アドオンをはじめとした様々なフリーソフトウェア・OSSについて機能追加・改造のご依頼を承っております。また、成果をアップストリームに還元しても差し支えがないケースでは、積極的に還元を行うようにしています。自社でフリーソフトウェア・OSSを採用したいが少しだけ要件に合わない、という事でお悩みの場合には、メールフォームからお問い合わせ下さい。

*1 ただし、これはあくまで現時点での話です。技術の進歩や、この分野での研究が進む事などによって、「最もマシ」なやり方は変わっていく可能性があります。

2019-05-28

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

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

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

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

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

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

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

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

これらは相反する概念ではなく直交する概念なので、「一意で、且つランダムである」「一意でもないし、ランダムでもない」「一意だが、ランダムではない」「一意ではないが、ランダムである」という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つの教室の中にいる人達の中で「自分と同じ誕生日の人がいる」確率は低いが、「同じ誕生日の人が二人いる」確率は直感に反してずっと高い、という事実のこと。

タグ: JavaScript
2019-02-08

Webアプリや拡張機能(アドオン)で、Web Crypto APIを使ってローカルに保存されるデータを暗号化する

※注記:本文末尾の「公開鍵暗号ではなく共通鍵暗号を使う理由」の説明について、2019年1月30日午前0時から21時までの間の初出時に内容の誤りがありました。また、2019年1月30日午前0時から2月5日20時頃までの間において、本文中での AES-CTR による暗号化処理が、 nonce を適切に指定していないために脆弱な状態となっていました。お詫びして訂正致します。初出時の内容のみをご覧になっていた方は、お手数ですが訂正後の説明を改めてご参照下さい。

クリアコードで主にMozilla製品のサポート業務に従事している、結城です。

FirefoxやThunderbirdがSSL/TLSで通信する際は、通信内容は自動的に暗号化されます。その一方で、Cookieやローカルストレージ、IndexedDBなどに保存されるデータは、平文のままでユーザーの環境に保存される事が多く、必要な場合はアプリ側で内容を暗号化しなくてはなりません。こういった場面でJavaScriptから使える暗号化・復号のための汎用APIが、Web Crypto APIです。

ただ、Web Crypto APIでデータを暗号化するにはある程度の知識が必要になります。「encrypt() という関数に文字列と鍵を渡したらいい感じに暗号化されて、decrypt() という関数に暗号と鍵を渡したらいい感じに復号される」というような単純な話ではないため、全く前提知識がないと、そう気軽には使えません。

この記事では、Web Crypto APIを用いたローカルデータの暗号化の基本的な手順をご紹介します。

Web Crypto APIでの暗号化の基本

Web Crypto APIでは様々な暗号方式を使えますが、それぞれ適切な用途が決まっています。一般的な「データの暗号化と復号」という場面では、以下のいずれかの暗号方式を使うことになります。

  • 共通鍵暗号
    • AES-CTR
    • AES-CBC
    • AES-GCM
  • 公開鍵暗号
    • RSA-OAEP

AES や RSA というのが具体的な暗号アルゴリズムの名前で、CTR、CBC、GCM というのは暗号モードの名前です。諸々の理由から、アプリ内部でローカルに保存するデータを暗号化するという場面では、AES-CTR AES-GCM が最適と言えるでしょう(詳細は後で述べます)。

JavaScriptで書かれた実装において、暗号化したいデータは普通は文字列や配列、オブジェクトといった形式を取っていることでしょう。しかしながら、Web Crypto APIでこれらのプリミティブな値を直接暗号化する事はできません。Web Crypto APIでは基本的に、データを ArrayBuffer やTyped Arrayと呼ばれる、よりバイナリ形式に近いデータで取り扱います。そのため、これらのデータ型の概念をきちんと理解しておく必要があります。Blob, ArrayBuffer, Uint8Array, DataURI の変換という記事では、これらのデータ型の関係を図を交えて分かりやすく説明してくれていますので、Web Crypto APIを触る前にぜひ読んでおきましょう。

また、Web Crypto APIの多くの機能は非同期処理になっており、処理結果がPromiseとして得られる場合が多いです。Promiseとは、簡単に言えば以下のような物です。

  • Promiseは値の入れ物と言える。
    • 中の値を取り出すには、promise.then(function(value) { ... }) のように、then メソッドにコールバック関数を渡してその中で受け取る必要がある。
    • 中の値をすぐに取り出せるとは限らない。通信先のサーバーから結果が返ってきて初めて then メソッドのコールバック関数が呼ばれる、というような事もある。
  • async function() { ... } という風に async キーワードを付けて定義された関数は、非同期の関数になる。
    • 非同期の関数を呼び出すと、関数の中で return した値は、呼び出した側からは常にPromiseとして受け取る事になる(関数の戻り値が常にPromiseになる)。
    • 非同期の関数の中では、let value = await promise; のように書くと、その場でPromiseの中の値を取り出せる(then メソッドを使わなくてもよくなる)。

以上の事を踏まえ、実際にデータを暗号化してみることにしましょう。

なお、Web Crypto APIの入出力はPromiseになっている事が多いので、以降のサンプルはすべて以下のような非同期関数の中で実行するものとします。

(async function() {

  // ここで実行

})();
鍵の生成

Web Crypto APIでの暗号化・復号では、Web Crypto APIで生成された CryptoKey クラスのインスタンスを鍵に使います。ここでは以下の2パターンの鍵の作り方を紹介します。

  • ユーザーが入力したパスワードを鍵に変換する方法
  • 完全に自動で鍵を生成する方法
パスワードを鍵に変換する

パスワードで保護されたOffice文書やPDFを取り扱う時のように、暗号化や復号の時にユーザーによるパスワードの入力を求める形にしたい場合には、入力されたパスワードを CryptoKey クラスのインスタンスに変換する操作を行います。Webブラウザで開発者用のコンソールを開いて、以下のスクリプトを実行してみると、実際に得られた AES-GCM 用の鍵がコンソールに出力されます。

// パスワードとして使う文字列。(ユーザーの入力を受け付ける)
let password = prompt('パスワードを入力して下さい'); // 例:'開けゴマ'

// 文字列をTyped Arrayに変換する。
let passwordUint8Array = (new TextEncoder()).encode(password);

// パスワードのハッシュ値を計算する。
let digest = await crypto.subtle.digest(
  // ハッシュ値の計算に用いるアルゴリズム。
  { name: 'SHA-256' },
  passwordUint8Array
);

// 生パスワードからのハッシュ値から、salt付きでハッシュ化するための素材を得る
let keyMaterial = await crypto.subtle.importKey(
  'raw',
  digest,
  { name: 'PBKDF2' },
  // 鍵のエクスポートを許可するかどうかの指定。falseでエクスポートを禁止する。
  false,
  // 鍵の用途。ここでは、「鍵の変換に使う」と指定している。
  ['deriveKey']
);

// 乱数でsaltを作成する。
let salt = crypto.getRandomValues(new Uint8Array(16));

// 素材にsaltを付与して、最終的なWeb Crypto API用の鍵に変換する。
let secretKey = await  crypto.subtle.deriveKey(
  {
    name: 'PBKDF2',
    salt,
    iterations: 100000, // ストレッチングの回数。
    hash: 'SHA-256'
  },
  keyMaterial,
  // アルゴリズム。
  { name: 'AES-GCM', length: 256 },
  // 鍵のエクスポートを禁止する。
  false,
  // 鍵の用途は、「データの暗号化と復号に使う」と指定。
  ['encrypt', 'decrypt']
);

console.log(secretKey);
// => CryptoKey { type: "secret", extractable: false, algorithm: {…}, usages: (2) […] }
パスワードから鍵への変換に際して、より安全にするためにsaltとストレッチングを使っている事に注意して下さい*1

この使い方においては、CryptoKey クラスのインスタンスとして得られた鍵はメモリ上に一時的に保持しておくだけでよく、ログアウト時やアプリの終了時にはそのまま破棄する事になります。次回訪問時・ログイン時・アプリ起動時には、再びパスワードの入力を求め、その都度この方法で鍵へと変換します。

なお、saltは次回以降のパスワード→鍵の変換時にも必要になります。保存と復元は以下の要領で行えます。
// saltの保存。
localStorage.setItem('passwordSalt',
  JSON.stringify(Array.from(salt)));

// saltの復元。
let salt = localStorage.getItem('passwordSalt');
salt = Uint8Array.from(JSON.parse(salt));

ところで、上記の例では「平文のパスワード→SHA-256でハッシュ化→AES-GCMの鍵としてインポート」という経路で変換を行っていますが、「パスワードのハッシュ化にsaltは必要じゃないの?」と疑問に思う方もいらっしゃるかと思います。saltが必要かどうかは、ハッシュ化したパスワードを外部や第三者に読み取られる危険性を考慮しないといけないかどうかに依存します。ハッシュ化したパスワードや鍵を一旦ストレージに保存するのであればハッシュ化は必要ですが、この例のようにメモリ上でごく短時間保持されるだけの場合は必要ない、というのが筆者の考えです。ハッシュ化時にsaltを使うという事自体は以下のようにして実現できますが、ストレッチングにそれなりの時間がかかりますので、本当に必要という事が言える場合にのみ使う事をお薦めします。

鍵の自動生成

「何らかの方法で鍵を安全な場所(暗号化したデータとは別の保存先)に保管できる」という前提がある場合には、crypto.subtle.generateKey() を使って鍵を生成し、ユーザーにパスワードの入力を求めずに暗黙的に暗号化・復号を行うという事もできます。この場合、鍵は以下のように生成します。

let secretKey = await crypto.subtle.generateKey(
  // アルゴリズムと鍵長。ここでは最長の256bitを指定している。
  { name: 'AES-GCM', length: 256 },
  // 鍵のエクスポートを許可するかどうか。trueでエクスポートを許可する。
  true,
  // 鍵の用途。
  ['encrypt', 'decrypt']
);
console.log(secretKey);
// => CryptoKey { type: 'secret', extractable: true, algorithm: Object, usages: Array[2] }

生成された鍵は CryptoKey クラスのインスタンスとなっており、そのままでは永続化(保存)できませんが、以下のようにすると、JSON Web Key形式(通常のオブジェクト)として鍵をエクスポートできます。

// JSON Web Key(JWK)形式でのエクスポート。
let exportedSecretKey = await crypto.subtle.exportKey('jwk', secretKey);
console.log(exportedSecretKey);
// => Object { alg: "A256GCM", ext: true, k: "DAvha1Scb8jTqk1KUTQlMRdffegdam0AylWRbQTOOfc", key_ops: (2) […], kty: "oct" }

このようにして得られたJWK形式の鍵は、以下の手順で再び CryptoKey のインスタンスに戻す事ができます。

// JWK形式からのインポート。
let importedSecretKey = await crypto.subtle.importKey(
  'jwk',
  exportedSecretKey,
  // 鍵を生成した際の暗号アルゴリズム。
  { name: 'AES-GCM' },
  // 再エクスポートを許可するかどうかの指定。falseでエクスポートを禁止する。
  false,
  // 鍵の用途。
  ['encrypt', 'decrypt']
);
console.log(importedSecretKey);
// => CryptoKey { type: 'secret', extractable: true, algorithm: Object, usages: Array[2] }

実際には、初回訪問時・初回起動時などのタイミングで生成した鍵をエクスポートして安全な場所に補完しておき、次回訪問時・次回起動時などのタイミングでそれを読み取ってインポートし、メモリ上に鍵を復元する、といった要領で使う事になります。

データの暗号化

鍵の準備ができたらいよいよ本題の暗号化ですが、その前に、「暗号化する対象のデータ」を「暗号化できるデータ形式」に変換するという操作が必要です。

Web Crypto APIの暗号化処理は、データの入出力を ArrayBuffer やTyped Arrayの形式のみで行う仕様になっているため、どんなデータを暗号化するにせよ、何らかの方法でデータをこれらの形式に変換しなくてはなりません。文字列は 前出の例の中で行っていたように、TextEncoder を使って以下のようにTyped Arrayに変換できます。

// データをTyped Arrayに変換。
let inputData = (new TextEncoder()).encode('暗号化したい文字列');
console.log(inputData);
// => Uint8Array(27) [ 230, 154, 151, 229, 143, 183, 229, 140, 150, 227, … ]

オブジェクト形式のデータであれば、この直前に「JSON.stringify() で文字列に変換する」という操作を加えればよいでしょう。

入力データが用意できたら、次は初期ベクトルの準備です。

初期ベクトルとは、AES-CBC および AES-GCM においてデータを同じ鍵で暗号化する際に、暗号文から内容の推測を困難にするために付与する値です*2。パスワードのハッシュ化に用いるsaltのようなもの、と言えば分かりやすいでしょうか。本来は一意な値である必要がありますが、ここでは話を単純にするために、とりあえず乱数を使う事にします。実際に使う時は、きちんとした初期ベクトルを生成する手順の解説も併せて参照して下さい。

// 初期ベクトルとして、8bit * 16桁 = 128bit分の領域を確保し、乱数で埋める。
let iv = crypto.getRandomValues(new Uint8Array(16));

入力データと初期ベクトルが用意できたら、ようやく暗号化です。これは以下の要領で行います。

let encryptedArrayBuffer = await crypto.subtle.encrypt(
  // 暗号アルゴリズムの指定とパラメータ。
  { name: 'AES-GCM',
    iv },
  // 事前に用意しておいた鍵。
  secretKey,
  // ArrayBufferまたはTyped Arrayに変換した入力データ。
  inputData
);
console.log(encryptedArrayBuffer);
// => ArrayBuffer { byteLength: 27 }

暗号化後のデータは、非同期に ArrayBuffer 形式で返されます。ここでは27バイトの長さがあるという事だけが分かっているため、このような表示になっています。

データを暗号化できたら、今度はこれをIndexedDBや localStorage などに格納可能な形式に変換します。例えば文字列への変換であれば、以下の手順で行えます。

let encryptedBytes = Array.from(new Uint8Array(encryptedArrayBuffer), char => String.fromCharCode(char)).join('');
console.log(encryptedBytes);
// => �����`Ù�¥ë�`û-Þm#þ'�¾��[�·�

ここでは ArrayBuffer 形式で27バイトの長さのデータとして得られた物を、8bitずつに切り分けて Unit8Array に変換し、さらにその1バイトずつを文字コードと見なして文字に変換しています。

このような文字列はBinary Stringと呼ばれ、コンソールなどに出力しても文字化けした結果になる事がほとんどのため、データを持ち回る過程で破損してしまわないよう、取り扱いには注意が必要です。安全のためには、以下のようにしてBase64エンコード済みの文字列に変換して、文字化けなどが起こりにくい安全な状態で取り回すのがお薦めです。

let encryptedBase64String = btoa(encryptedBytes);
console.log(encryptedBase64String);
// => YPgdHZgguUeHpt9FcYy2IaZTfbTNswbfn93e

AES-GCMで暗号化したデータ*3の復号には、暗号化時に使用した初期ベクトルが必要となります*4。以下のようにして、暗号化したデータとセットで保存しておきます。

localStorage.setItem('encryptedData',
  JSON.stringify({
    data: encryptedBase64String,
    iv:   Array.from(iv)
  }));
データの復号

次は、暗号化済みのデータから元のデータを取り出してみましょう。

Web Crypto APIの復号処理も、暗号化処理と同様、データの入出力を ArrayBuffer やTyped Arrayの形式のみで行う仕様になっています。先の例と逆の操作を行い、まずは暗号化されたデータを ArrayBuffer またはTyped Array形式に変換します。

let encryptedData = JSON.parse(localStorage.getItem('encryptedData'));
let encryptedBase64String = encryptedData.data;
// 通常のArrayとして保存しておいた初期ベクトルをUint8Arrayに戻す
let iv = Uint8Array.from(encryptedData.iv);

// Base64エンコードされた文字列→Binary String
let encryptedBytes = atob(encryptedBase64String);

// Binary String→Typed Array
let encryptedData = Uint8Array.from(encryptedBytes.split(''), char => char.charCodeAt(0));

データの準備ができたら、いよいよ復号です。これは以下の手順で行います。

let decryptedArrayBuffer = await crypto.subtle.decrypt(
  // 暗号アルゴリズムの指定とパラメータ。暗号化時と同じ内容を指定する。
  { name: 'AES-GCM',
    iv },
  // 暗号化の際に使用した物と同じ鍵。
  secretKey,
  // ArrayBufferまたはTyped Arrayに変換した暗号化済みデータ。
  encryptedData
);
console.log(decryptedArrayBuffer);
// => ArrayBuffer { byteLength: 27 }

復号の時には、暗号化時と同じパラメータ、同じ鍵が必要である点に注意して下さい。何らかのトラブルで鍵や初期ベクトルを喪失してしまうと、元のデータは永遠に取り出せなくなってしまいます

復号されたデータは ArrayBuffer 形式になっています。これを通常の文字列へ変換するには以下のように操作します。

let decryptedString = (new TextDecoder()).decode(new Uint8Array(decryptedArrayBuffer));
console.log(decryptedString);
// => '暗号化したい文字列'

無事に、暗号化する前の平文であった「暗号化したい文字列」という文字列を取り出す事ができました。

疑問

なぜ暗号アルゴリズムに AES-CTR AES-GCM を使うのか?

冒頭で、Web Crypto APIでデータの暗号化と復号に使えるアルゴリズムは4つあると述べましたが、本記事ではその中で何故 AES-CTR AES-GCM を選択しているのかについて、疑問に思う人もいるでしょう。

暗号アルゴリズムには、大別して「共通鍵暗号」と「公開鍵暗号」の2種類があります。共通鍵暗号は暗号化と復号に同じ鍵を使う方式、公開鍵暗号は暗号化と復号で異なる鍵を使う方式です。公開鍵暗号は「暗号化したデータと、それを復号する鍵の両方を、信頼できない通信経路で送り、受信側で復号する」「暗号化と復号を別々の所で(別々の人が)行う」という場面に適しています。逆に言うと、「アプリ内部でローカルに保存するデータを暗号化する」という場面のように、データや鍵が信頼できない通信経路を通る事がないのであれば暗号化も復号も同じ所で行うのであれば、公開鍵暗号(RSA-OAEP)ではなく共通鍵暗号(AES-* 系)で充分に安全だと言えます。

また、AES-* 系の中で AES-CTR を選択する理由は、他の2つが「初期ベクトル」という、鍵とは別の使い捨ての情報を組み合わせて使う(「鍵」と「初期ベクトル」の両方が揃って初めて暗号を復号できる)方式だからです。

AES-CBCAES-GCM は、1回1回の暗号化・復号ごとに初期ベクトルを変えることで安全性を高める前提の方式です。ネットワークを通じてサーバーとクライアントの間でデータをやりとりするという場面では、暗号化して送られたデータはすぐに受け手の側で復号されます。このようなケースでは、同じ暗号データを何度も復号するという事は起こり得ないため、初期ベクトルは使い捨てにできます

一方、アプリケーションのローカルデータを暗号化するという場面では、暗号化して保存されたデータを何度も復号する必要があります。データの復号には暗号化した時と同じ初期ベクトルが必要になるため、必然的に、初期ベクトルは使い捨てにできず、何度も使い回す事になります。本当は怖いAES-GCMの話 - ぼちぼち日記などの解説記事で詳しく述べられていますが、初期ベクトルを使い回した場合これらの暗号アルゴリズムは非常に脆弱な物となり、「情報の漏洩を防ぐ」という目的を達成できなくなってしまいます

以上の理由から、このような場面では初期ベクトルを用いない方式である AES-CTR が最適だと言える訳です。

AES-* 系の中で AES-GCM を選択した理由は、他の2つには以下のようなデメリットがあるからです。

  • AES-CTR: カウンタの取り扱いが初期ベクトルに比べて面倒。
  • AES-CBC: 暗号化の並列処理ができない=暗号化処理に時間がかかる可能性がある。

Webアプリなどでパスワード認証を実装する際には、パスワードをそのまま保存するのはなく、非可逆的に変換した結果であるハッシュ値を保存する事が一般に推奨されています。この時には、ハッシュ値から元のパスワードを推測しにくくするために、パスワードに余計なデータを加えてからハッシュ化するのが一般的です。この追加するデータを「塩をまぶす」ことに例えてsaltと呼びます。

これと同様にAESにおいても、データには必ずsaltのような一意なデータを付与してから暗号化するようになっています。これは仕様上秘密である必要はなく、一意でさえあればいいという事になっています。AES-CBC および AES-GCM では、そのようなsalt相当のデータを「初期ベクトル」と呼んでいます。

一方、AES-CTR ではsaltにあたるデータを「カウンタ」の一部として与えます。カウンタは最大で16byte=128bitの長さを指定できますが、そのうち一部をnonce(一意な数字)、残りを0からカウントアップする文字通りのカウンタとして使う事になっています。例えば128bit中の64bitをnonce、残り64bitをカウントアップ部にする、といった要領です。……という具合に長々説明している事自体に顕れていますが、「一意な値を1つ用意するだけでいい」という初期ベクトルに比べて、カウンタは取り扱いが若干ややこしいという事自体が AES-CTR のデメリットと言えます。

では、AES-CTR 以外なら AES-CBC でも AES-GCM でもどちらでも良いのでは? という話になりそうですが、AES-CBC には処理を並列化できないという原理上の制限がありますAES-CTRAES-GCM にはそのような制限が無く、上手く実装されたソフトウェアであれば、マルチスレッドを活用した並列処理で高速ができます*5

以上の理由から、この3つの選択肢の中では総合的に見て AES-GCM が最もメリットが大きいと筆者は考えています。

なお、本文中の例のように乱数を使って初期ベクトルを生成するのは、本来は望ましい事ではありません。実際の実装にそのまま組み込む前には、よりセキュアなきちんとした初期ベクトルを生成する手順の解説も併せてご参照下さい。

自動生成した鍵はどこに保存すればよいのか?

鍵を自動生成して暗黙的に暗号化・復号を行うという動作をさせたい場合、鍵は暗号化されたデータとは別の安全な場所に置く必要があります。生の鍵を暗号化されたデータと同じ場所に保存してしまっては、暗号化の意味がありません

鍵の置き場所としては、例えば以下のような例が考えられます。

  • ファイルとして保存した物をUSBメモリに書き込み、そのUSBメモリを物理的な鍵として使う。
  • QRコードに変換して表示した物をスマートフォンで撮影し、そのスマートフォンを物理的な鍵として使う。
  • 鍵をICカードに保存し、その都度鍵をICカードリーダー経由で読み取る事として、ICカードそのものを物理的な鍵として使う。
  • パスワードマネージャの中にパスワードの一種として保管し、パスワードマネージャのマスターパスワードで保護する。

こういった方法で鍵を保存しておくやり方と、都度パスワードの入力を求めるやり方のどちらが安全かについては、専門家の間でも意見が分かれているようです。鍵をデバイス上に保存しておくやり方には、当然ながら、デバイスそのものが盗難された場合にデータを保護できなくなるというリスクがあります。一方で、パスワード入力を頻繁に求める方法は、パスワードの入力の機会が増えるため、キーロガーやショルダーハッキングといった攻撃に遭うリスクが増大します。パスワードマネージャとマスターパスワードを使う方法は、両者の弱点を補うものと言えます。

まとめ

以上、Web Crypto APIを使ってJavaScriptでローカルデータを暗号化する手順をご紹介しました。個人情報のようにクリティカルな情報を取り扱うWebアプリや拡張機能を開発する場合に、参考にしてみて下さい。

*1 saltというとハッシュ化した後のパスワードの流出に備えての物のように思えますが、ここでは暗号化されたデータが流出した時の保護を目的としています。ハッシュ化した後のパスワードを保存していない場合、ハッシュ化されたパスワードの流出については考えなくても良い(JavaScriptのコードから機械語への変換のされ方を制御したり、メモリ上の情報を再配置したり、といった低レベルの対策を取れないJavaScriptという言語の特性上、サイドチャネル攻撃で平文のパスワードやハッシュ化したパスワードを読み取られる事を警戒する事はアプリケーション実装者の側では不可能で、JavaScriptの実行エンジンの実装側が責任を持つことになります。また、平文のパスワードが流出しないようXSSの対策は充分に取られていることを前提とします)のですが、saltを使用していないと、攻撃者は生パスワードからのハッシュ値の計算を省略できるため、暗号化済みデータに対する攻撃がより成功しやすくなります。saltを付与すると、攻撃者はそのような最適化を行えなくなり、結果的に、暗号化されたデータのみが流出した時の攻撃に対する耐性が高まります。

*2 `AES-CBC`、`AES-GCM`だけでなく、`AES-CTR` においても暗号化時のパラメータの1つである「カウンタ」の一部としてこれに相当する情報を指定する必要があります。

*3 `AES-CBC` も同様。

*4 よって、実質的には初期ベクトルも「暗号を解く鍵」の一部と言えるかもしれません。

*5 実際にブラウザのWeb Crypto APIの実装がそのように最適化されているかどうかは、また別の話になります。

タグ: JavaScript
2019-01-30

JavaScriptのArrayでuniqする8つの方法(と、その中で最速の方法)

この記事はQiitaとのクロスポストです。

みなさんはuniqというコマンドやメソッドをご存じでしょうか?

LinuxやmacOSのシェルのコマンドとして使えるuniqは、与えられた入力の中で(連続する)同じ値を重複と見なして除外するというコマンドです。例えばこんな風に使います。

$ cat /var/log/apache2/access.log | cut -d ' ' -f 1
192.168.0.12
192.168.0.10
192.168.0.12
192.168.0.12
192.168.0.11
192.168.0.10
192.168.0.11
192.168.0.11
$ cat /var/log/apache2/access.log | cut -d ' ' -f 1 | sort | uniq
192.168.0.10
192.168.0.11
192.168.0.12

プログラミング言語でも似たような機能を持っている物があります。例えばRubyでは、Arrayクラスのuniqメソッドを使うと配列の要素から重複を簡単に取り除くことができます。

[0,3,2,1,4,2,3,1,1,2,3,5,2,3,1,2,3,1,1,3,3,1,2].uniq
# => [0, 3, 2, 1, 4, 5]

ここで標題のJavaScriptの話をすると、JavaScript自体の言語仕様はES6やES2015、ES2017などを経て強化されてきているのですが、残念ながら上記のようなことを一発でやる機能は仕様化されていません。やりたければ、既存の機能を組み合わせて実現するほかありません。

uniqのやり方色々

やり方を紹介した記事は、配列の重複をはじく、もしくは重複を取り出す - Qiitaなど既にいくつも例がありますが、ここでは改めてなるべく多くのパターンを挙げてみる事にします。

Objectのキーを使う方法

古典的なやり方としては、Objectのプロパティ名を使う方法があります。JavaScriptではObjectのインスタンスは一種のハッシュ(連想配列)として機能し、プロパティ名(=ハッシュのキー)に重複はあり得ないため、配列の要素が登場済みかどうかの判定をするのに使うことができます。

function uniq(array) {
  const knownElements = {};
  const uniquedArray = [];
  for (let i = 0, maxi = array.length; i < maxi; i++) {
    if (array[i] in knownElements)
      continue;
    uniquedArray.push(array[i]);
    knownElements[array[i]] = true;
  }
  return uniquedArray;
};

const array = [0,3,2,1,4,2,3,1,1,2,3,5,2,3,1,2,3,1,1,3,3,1,2];
console.log(uniq(array));
// => [0, 3, 2, 1, 4, 5]

for文の箇所を今風の書き方に改めると、以下のようになるでしょうか。

function uniq(array) {
  const knownElements = {};
  const uniquedArray = [];
  for (const elem of array) {
    if (elem in knownElements)
      continue;
    uniquedArray.push(elem);
    knownElements[elem] = true;
  }
  return uniquedArray;
}

ただ、この方法には一つ致命的な欠陥があります。それは、Objectのプロパティ名は必ず文字列として扱われるため、文字列化できない要素や、文字列化した時に区別がつかない要素を含む配列に対しては使えないという点です。JavaScriptではDOMのノードや何らかのクラスのインスタンスなどのオブジェクトを格納した配列を使うことが多いので、これでは使える場面が非常に限定されてしまいます。

Arrayの便利メソッドを使う

ArrayのインスタンスのindexOfメソッドを使うと、任意の形式のオブジェクトについて、配列に含まれているかどうかを容易に識別することができます。これを使うと、先の例は以下のように書き直せます。

function uniq(array) {
  const uniquedArray = [];
  for (const elem of array) {
    if (uniquedArray.indexOf(elem) < 0)
      uniquedArray.push(elem);
  }
  return uniquedArray;
}

ES2016で追加されたArrayincludesメソッドは、渡されたオブジェクトが配列に含まれているかどうかを真偽値で返すという物です。これを使うと、indexOfの戻り値の特性(配列に含まれていなければ-1を返す)を知らない人でも読みやすいコードになります。

function uniq(array) {
  const uniquedArray = [];
  for (const elem of array) {
    if (uniquedArray.includes(elem))
      uniquedArray.push(elem);
  }
  return uniquedArray;
}

ES5.1で追加されたArrayfilterメソッドは、条件に当てはまる要素だけを含んだ配列を生成するという物です。これを組み合わせると、forループを書かずに同様のことができます。

function uniq(array) {
  return array.filter(function(elem, index, self) {
    return self.indexOf(elem) === index;
  });
}

元の配列の中で2回目以降に登場した(同じ要素が既に登場済みの)要素は、indexOfの結果=先頭からその要素を探した時の位置が、index=要素自身の位置と一致しません。そういった要素を除外すれば、各要素が1回ずつしか出現しない配列を取り出せる、という訳です。一時的な配列を作らないで済ませるために、2つ前の例とは異なるindexOfの使い方をしています(先の例では一時的な配列に対するindexOfなのに対し、こちらは元の配列に対するindexOfです)。

アロー関数を使うと、もう少しすっきり書けます。

function uniq(array) {
  return array.filter((elem, index, self) => self.indexOf(elem) === index);
}
Mapを使う

ES2015で追加されたMapは、それまでのObjectを使った擬似的な連想配列(ハッシュ)とは異なり、任意のオブジェクトをキーとして使うことができる本物の連想配列です。これを使うと、先のObjectを使った例をより完全な物にすることができます。

function uniq(array) {
  const knownElements = new Map();
  const uniquedArray = [];
  for (const elem of array) {
    if (knownElements.has(elem))
      continue;
    uniquedArray.push(elem);
    knownElements.set(elem, true);
  }
  return uniquedArray;
}

この例では配列を先に用意していますが、実はその必要はありません。というのも、Mapkeysメソッドを使うとキーだけの集合を得ることができるからです。keysメソッドの戻り値はイテレータなので、ES2015で追加されたArray.fromを使って配列に変換することができます。

function uniq(array) {
  const knownElements = new Map();
  for (const elem of array) {
    knownElements.set(elem, true); // 同じキーに何度も値を設定しても問題ない
  }
  return Array.from(knownElements.keys());
}
Setを使う

Mapに比べるとややマイナーですが、ES2015で追加されたSetという機能もあります。Mapがキーと値のペアを取り扱うのに対して、SetMapでいう所のキーだけ、つまり、一意な値を格納する集合です。要素の重複があり得ないArrayのような物、とも言えます。これを使うと、先の例はこう書き直せます。

function uniq(array) {
  const knownElements = new Set();
  for (const elem of array) {
    knownElements.add(elem); // 同じ値を何度追加しても問題ない
  }
  return Array.from(knownElements);
}

しかし、これはもっと簡潔に書き直すことができます。Setはコンストラクタの引数としてイテレータや配列を受け付けるため、new Set(array)とすれば、渡した配列の要素の中から一意な値だけの集合を得ることができます。それをArray.fromで配列に戻せば、即ち「配列から重複を取り除く」のと同じ事になります。

function uniq(array) {
  return Array.from(new Set(array));
}

2019年1月5日追記:Array.fromではなくES2015のスプレッド構文を使うと、以下のようにも書けます。

function uniq(array) {
  return [...new Set(array)];
}

どの方法が一番おすすめ?

ということで、JavaScriptで配列をuniqする方法を色々と列挙してみましたが、どの方法が一番おすすめと言えるでしょうか。

判断の優先順位

その答えを考える前に、何を以て「良い」と判断するかをまずは明らかにする必要があります。具体的には、以下の各観点に優先順位を付けなくてはなりません。

  • コードの量が短い事
  • 古い実行環境でも使える事
  • 処理対象の配列が巨大でも高速である事
  • 処理対象の配列が多数でも高速である事

というのも、上記の例のそれぞれには一長一短あり、すべての条件を同時に満たす事は困難だからです。

コードの量については、見ての通りです。コードゴルフなどの文脈や、1バイトでもソースの量を減らさなくてはならないようなシビアな状況であれば、多少動作速度が遅くても文字数の短いコードにする必要があるでしょう。そうでない限りは、記述量が多くなっても速度の速いコードを採用する方が良いでしょう。

実行環境(ブラウザやNodeのバージョン)については、想定する環境でサポートされていない機能を使用している方法は、当然ながら採用できません。MapSetを使う方法は、トランスパイラを使う事で古い環境でも動作させる事はできますが、その場合、変換後のコードがどのパターンになるかによって動作速度が変わってきます。

動作速度については、処理対象の配列の要素数と実行回数によって最適な方法が変わってきます。

まず重要な観点として、計算量の問題があります。アルゴリズムごとに計算量には差があり、配列の要素数や実行回数が多い場面では、なるべく計算量の小さいアルゴリズムの方が望ましいです。

また別の観点として、オーバーヘッドの問題があります。JavaScriptでは関数の呼び出しやオブジェクトの作成、値の型の変換など、純粋な計算量とは別の部分で処理に時間がかかる部分があります。実行回数が少なければオーバーヘッドはほとんど無視できますが、多いとほとんどの処理時間がオーバーヘッドによる物という事になる場合もあります。

各アルゴリズムの処理速度の比較

それぞれのアルゴリズムで同じ配列を条件を変えながら処理するベンチマークをFirefox 64上で実行した場合の結果で、それぞれの優劣を見ていきましょう。まずは、要素数10の配列を10万回から50万回まで処理した結果です。グラフの縦軸は処理に要した時間(ミリ秒)で、グラフの線が上にあるほど処理が遅く、下にあるほど処理が速い事を示しています。

サイズの小さい配列のuniqのベンチマーク

このように要素数の少ない(サイズの小さい)配列を処理する場面では、forループやArrayのメソッドだけを使う方法がおすすめという事がグラフから見て取れます。MapSetを使う方法は、このくらいの要素数だとオーバーヘッドが非常に大きいようです。

次は、要素数300の配列を2500回から12500回まで処理した結果です。グラフの縦軸は処理に要した時間(ミリ秒)です。

サイズが中くらいの配列のuniqのベンチマーク

今度は結果が逆転しました。最速なのはSetを使う方法で、最も遅いのはArrayfilterを使う方法という結果になっています。これは、Arrayfilterを使うアルゴリズムが本質的に二重ループである(いわゆる、計算量がO(n^2)のアルゴリズム)ことが原因です。この結果からは、要素数が増えてくるとオーバーヘッドよりも計算量の方が支配的になってくるという事が分かります。

次は、要素数10000の配列を100回から500回まで処理した結果です。グラフの縦軸は処理に要した時間(ミリ秒)です。

サイズが大きい配列のuniqのベンチマーク

もはやオーバーヘッドは完全に無視できるレベルになり、計算量が少ない物が明白に高速という結果になりました。ArrayindexOfincludesを使う方法とfilterを使う方法で、本質的なアルゴリズムそのものは大差ない(どちらも二重ループ)のに、forループとfilterとでパフォーマンスに大きな差が現れているのは何故でしょうか? その答えは後ほど説明しましょう。

今度は指標を変えて、要素数がどの程度になったあたりから計算量とオーバーヘッドの問題が逆転するかを見てみます。以下は、要素数を100から1000まで増やしながら5000回処理した場合の結果です。グラフの縦軸は処理に要した時間(ミリ秒)です。

配列の要素数を増やしながらのuniqのベンチマーク

実行環境の性能による部分が大きいと考えられますが、今回の実験環境においては要素数が300を超えたあたりで計算量の増大がオーバーヘッドを追い抜くという結果になりました。しかしやはりアルゴリズム的に差が無い筈のuniqByIndexOfuniqByIncludesuniqByFilteruniqByFilterArrowの間で大きな差がある……というか、filterを使うと指数関数的に処理時間が増大するのにforループでは線形の増加に留まっているように見えます。

この謎を解くために、次はNightly 66.0a1での結果を見てみましょう。JavaScriptエンジンの最適化が進んでいるためか、同じパラメータだと数字が小さくなりすぎてしまったため、今度はパラメータを「要素数を100から200刻みで1900まで増やしながら10000回処理」に変えています。

配列の要素数を増やしながらのuniqのベンチマーク2

グラフの傾向を見ると、計算量が大きいアルゴリズムであるindexOfincludesを使った物はいずれも指数関数的な増大を見せています。1つ前の結果でuniqByIndexOfuniqByIncludesが線形の増加に見えたのは、

  • Firefox 64時点ではforループは最適化されていたが、filterのループは最適化されていなかった。
  • 最適化の結果、forループを使った物の結果の指数関数的な増大が顕著になり始めるタイミングが右にずれていた(グラフが横に引き延ばされていた)。そのため、グラフの描画範囲では線形の増加に見えていた。
  • Nightly 66ではfilterも最適化されたため、アルゴリズムが本質的に似ている4つの項目のグラフが再び接近するようになった。

という事だったようです。

また、それでもまだfilterを使った方法の方が遅いのは、indexOfincludesを使った方法では検索対象の配列がuniq後の配列(=小さい配列)なのに対し、filterを使う方法ではuniq前の配列(=大きい配列)を対象にしているせいで、その分余計な時間がかかったからだと考えられます。

2019年1月5日追記:new Set()とスプレッド構文の組み合わせを加えて再計測した結果も掲載しておきます。
配列の要素数を増やしながらのuniqのベンチマーク3
検証環境上では、スプレッド構文を使うとArray.fromより若干速いという結果が得られました。

まとめ

以上、JavaScriptでArrayuniqを実現する様々な手法をご紹介しました。

Webのフロントエンドの開発では計算量が問題になるような事はそう多くないのではないかと思われますが、Nodeで大量のリクエストを捌くような場面や、どれだけの量のデータが渡されるか事前に予想できないライブラリ開発のような場面では、なるべく計算量が小さいアルゴリズムを採用する事が望ましいです。

また、1つの事を実現するのに複数のやり方がある場合には、何を優先するかを事前にきちんと明らかにし、数値で比較可能な場合は特に、この例のようにベンチマークを取ってそれぞれのやり方の優劣を比較する事が大事です。

性能測定の結果を踏まえると、処理速度的には、少なくとも今回の実験環境においては

  • SetArray.fromまたはスプレッド構文の組み合わせが最もおすすめ。
  • ただし、「要素数が小さい配列を」「頻繁に(多数)処理する」という前提がある場合には、SetMapを使わない方法の方がおすすめ。

という事が言えそうです。ベンチマークに使用したスクリプトはGistで公開していますので、皆さんもぜひお手元で試してみて下さい。

タグ: JavaScript
2018-12-27

自己流JavaScriptを書いていた人がAngularJSのユニットテストで躓いた点

結城です。

最近、AngularJSを使ったWebアプリ開発のプロジェクトに参加する事になり、とりあえず一通りの事は把握しておかなければと思って公式のチュートリアル(英語)を実践してみたのですが、JavaScriptの経験が浅い人だとハマらなさそうだけれども、中途半端に経験があったせいでドハマり、という場面に遭遇してしまいました。 恥ずかしい話ですが、せっかくなので同じように躓いている人(もしいれば)のために、分かった事や理解のポイントを書き記しておこうと思います。

この記事の対象読者は、以下のような状況にある人です。

  • フレームワークを使わないJavaScript(例えば、jQueryを使ったJavaScript程度)は書いた事がある。
  • 自動テスト(特に、ユニットテスト)は書いた事がある。
  • AngularJSを始めたばかりである。
  • 依存性注入という概念は理解できるが、実際にどう使うかはあまり知らない。

依存性注入って何?

ソフトウェアの設計の話で、依存性注入(dependency injection、DI)という考え方があります。

例えば、Webアプリの開発においてサーバー(が提供するWeb API)との通信を行う場面はよくありますが、JavaScriptの世界で最も低レイヤのやり方として、XMLHttpRequestを使う方法があります。 XMLHttpRequestを使ってWeb APIと通信する事が前提のWebアプリは、「XMLHttpRequestというクラス」であったり「接続先のWeb API」であったりといった要素に依存していると言うことができます。

このようなWebアプリで問題になるのは、自動テストのしにくさです。 WebサーバやAPIサーバを用意して実際にWeb APIを提供するのは骨が折れますし、テストの実行ごとにまっさらな環境を整え直すというのも難しいです。

そこで登場するのが依存性注入、略してDIという考え方です。

例えば上記のようなWebアプリでは、「接続先のWeb APIのURL」を設定で変えられるようにしておけば、本物のWeb APIを用意しなくてもダミーのAPIサーバで動作テストが可能です。

また、XMLHttpRequestを使う処理を共通化してhttp()のような名前のユーティリティ関数として括り出しておき、Web APIとの通信は全てこの関数を使うようにしておけば、

  • 本番環境では、実際にWeb APIとの通信を行う。
  • 動作テストの時は、http()というユーティリティ関数の定義を置き換えて、実際の通信を行わずに、あらかじめ与えられた固定のJSON文字列を、あたかも実際の通信結果がそれであったかのような形で返すようにする。

という具合で、http()の置き換えだけで自動テストを簡単に行えるようになります。

このように、「本体の処理が依存している外部との接続箇所を変更可能にしておいて、本番環境とテストの時のように場面に応じて実装を切り替える」という設計のパターンをDIと言います。 DIという言葉は知らなくても、そういう設計のコードを書いた事があるという人は少なくないでしょう。

AngularJSでのDI

上記チュートリアルのステップ5ではXMLHttpRequestを使った通信と依存性注入の事について触れられています。

AngularJSでは、XMLHttpRequestをそのまま使うのではなく、AngularJSが提供している$httpというモジュール(正確には、このモジュールが内部で使用している、$httpBackendという別のモジュール)の機能を通じてWeb APIと通信する事が強く推奨されています。 同じ$httpという名前のモジュールであっても、実際に機能を使う時にはAngularJSというフレームワークによって

  • 本番運用では、実際のWeb APIと通信する。
  • 自動テストでは、あらかじめ用意しておいたダミーのレスポンスを使う。

という具合に挙動が変化する(自動的に実装が切り替わる)ため、自動テストをしやすくなっています。 「実際に通信する処理の切り替えはどうやってやるのか?」は、「フレームワークのユーザー」であるWebアプリ開発者は気にする必要はなく、単に$httpというモジュールを使うことを宣言するだけでOKです。

その宣言の仕方として、AngularJSではinject(function(モジュール1, モジュール2, ...) { 実行したい処理 })という書き方をすることになっています。 このように書くと、AngularJSはinject()に渡された関数の引数に書かれているモジュール1モジュール2といった名前のモジュールを探してきて、それらを実際に引数として渡して、その関数を実行します。

もっと平たく言うと、inject(function(モジュール1, モジュール2, ...) { 実行したい処理 })という書き方をすれば、「実行したい処理」の中でモジュール1モジュール2を利用できるようになります。 これが、AngularJSでのDIの基本です。

既存のフレームワークを使わずにDI的な事をやろうとすると、「どうやって依存モジュールを渡すのか」や「どうやって実装を切り替えるのか」といった点が悩み所になりますが、AngularJSでは「使いたいモジュールは関数の引数に書いておけばそれでいい。モジュールの実装は、フレームワークがその引数名に対応する物を勝手に探してくる。」という割り切りをすることで、Webアプリ開発者は極力何も考えなくていいようになっているわけです。よく考えられていますね。

AngularJSのユニットテストでのDI

このinject()を使ったDIの仕組みはAngularJS全体で共通して利用できるようになっていて、自動テストも例外ではありません。 例えば、上記チュートリアルのステップ5でも、ユニットテストのbeforeEach()(前処理の定義)を以下のように記述しています。

describe('PhoneListCtrl', function(){
  ...
  beforeEach(inject(function(_$httpBackend_, $rootScope, $controller) {
    $httpBackend = _$httpBackend_;
    $httpBackend.expectGET('phones/phones.json').
        respond([{name: 'Nexus S'}, {name: 'Motorola DROID'}]);

    scope = $rootScope.$new();
    ctrl = $controller('PhoneListCtrl', {$scope: scope});
  }));
...

……という所まで読み進めた時点で、自分は躓いてしまいました。 _$httpBackend_って何なんだ!? と。

実のところ、自分はその時点では「AngularJSでのDI」の全貌をまだきちんとは理解できておらず、かろうじて、最初に述べた「DIの一般的な話」だけを理解できていました。 その時の認識で上記のコード片を見ると、なんとなく、以下のように読めてしまっていました。

  • 本番用の$httpBackendの実装と、テスト時用の$httpBackendの実装を切り替える必要がある。
  • その切り替えは、_$httpBackend_という書き方で行う。前後にアンダースコアが付いた名前で参照すると、テスト時用のダミー実装が渡される。

ところが、どれだけ探してもこのアンダースコア付きの名前での参照についての詳しい話が見つかりません。 そのため、このコードが意味している所を理解できず、チュートリアルの次のステップに進めずにいました。 (最初はとりあえず読み飛ばして次に進んでみたのですが、後でまたこの書き方が出てきたので、頻出する書き方なのであれば理解が怪しいまま進むのは危険だと感じて、そこで止まってしまいました。)

「同じ動作をする、別の書き方」で理解する

理解の糸口となったのは、実際に動くコードが手元にあった事でした。

AngularJSのチュートリアルでは、そのステップで動かす事になっているコードそのもののリポジトリ(各stepがタグになっていて、タグをチェックアウトすればそのstepでのコード全体を見られる)が公開されていて、手元にそれをcloneして実際に動かしながらAngularJSを理解できるようになっています。 そのリポジトリでstep5の内容を見てみると、以下のようになっていました。

describe('PhoneListCtrl', function(){
  ...
  beforeEach(module('phonecatApp'));
  beforeEach(inject(function(_$httpBackend_, $rootScope, $controller) {
    $httpBackend = _$httpBackend_;
    $httpBackend.expectGET('phones/phones.json').
        respond([{name: 'Nexus S'}, {name: 'Motorola DROID'}]);
    ...
  }));

  it('should create "phones" model with 2 phones fetched from xhr', function() {
    ...
    $httpBackend.flush();
    ...
  });
  ...

これを見てまず疑問に思ったのが、「inject()は必ず使わないといけないものじゃなかったのか?」という事でした。 というのも、チュートリアルで出てきたそれまでのコードでは基本的にinject()を使っていたので、使っているコードと使っていないコードが混在しうるというのがまず驚きでした。

次に思ったのは、「これを必ずinject()を使うようにして書く事もできるのだろうか?」という事でした。 それでコードを書き換えながら実行していたところ、以下のように書き変えても結果は変わらないという事が分かりました。

describe('PhoneListCtrl', function(){
  ...
  beforeEach(inject(function($httpBackend, $rootScope, $controller) {
    $httpBackend.expectGET('phones/phones.json').
        respond([{name: 'Nexus S'}, {name: 'Motorola DROID'}]);
    ...
  }));

  it('should create "phones" model with 2 phones fetched from xhr',
     inject(function($httpBackend) {
    ...
    $httpBackend.flush();
    ...
  }));
  ...

また、以下のように書き換えても同様でした。

describe('PhoneListCtrl', function(){
  var backend;
  ...
  beforeEach(inject(function($httpBackend, $rootScope, $controller) {
    backend = $httpBackend;
    backend.expectGET('phones/phones.json').
        respond([{name: 'Nexus S'}, {name: 'Motorola DROID'}]);
    ...
  }));

  it('should create "phones" model with 2 phones fetched from xhr', function() {
    ...
    backend.flush();
    ...
  });
  ...

これを踏まえて最初のコードのコメントを改めて読み直してみた所、ようやく意味が分かりました。

  // The injector ignores leading and trailing underscores here (i.e. _$httpBackend_).
  // This allows us to inject a service but then attach it to a variable
  // with the same name as the service in order to avoid a name conflict.

このコメントでは「injectorはモジュール名の前後に付けられたアンダースコアを無視する」と書かれていますが、自分はこれを深読みしすぎて、実装の切り替えなどの話と絡めて考えてしまっていました。 そうではなく、これは本当に文字通りに、「_$httpBackend_と書いても、$httpBackendと書いたのと同じに扱われる」という意味なのでした。

つまり、こういう事です。 一切の省略をしないでDIを素直に使って自動テストを書くと、

describe('PhoneListCtrl', function(){
  ...
  beforeEach(inject(function($httpBackend, $rootScope, $controller) {
    $httpBackend.expectGET('phones/phones.json').
        respond([{name: 'Nexus S'}, {name: 'Motorola DROID'}]);
    ...
  }));

  it('should create "phones" model with 2 phones fetched from xhr',
     inject(function($httpBackend) {
    ...
    $httpBackend.flush();
    ...
  }));
  ...

という風に、$httpBackendというモジュールをinject()する書き方を何度も何度も書かないといけません。

しかし、テストの数が増えてくると、何度も何度もinject()するのはいかにも冗長です。 そういう時に、フレームワークをあまり使わない・使った事がない人が思いつくのが、beforeEach()で一時的な変数に1回だけ代入するというやり方でしょう。

describe('PhoneListCtrl', function(){
  var backend;
  ...
  beforeEach(inject(function($httpBackend, $rootScope, $controller) {
    backend = $httpBackend;
    backend.expectGET('phones/phones.json').
        respond([{name: 'Nexus S'}, {name: 'Motorola DROID'}]);
    ...
  }));

  it('should create "phones" model with 2 phones fetched from xhr', function() {
    ...
    backend.flush();
    ...
  });
  ...

AngularJSのチュートリアルに含まれているコードに書かれている内容も実質的にはこれと同じなのですが、こちらのやり方には1つ問題があります。 それは、依存しているモジュールをどの名前で参照すればいいのかが分かりにくくなるという事です。 この例であればbackendという変数名を使っていますが、人によってはHTTP Backendの略でhbと名付けるかも知れませんし、あるいはアンダースコアを付けて_$httpBackendとしたり、ダラーを重ねて$$httpBackendとしたりするかも知れません。 1つの開発プロジェクトの中で、その時の気分や担当者によって同じ物をどう書くかの書き方がばらけてしまうと、複数人での共同作業や長期的なメンテナンスの際に、混乱の元になってしまいます。

これと似たような事象が、低レベルのJavaScriptを書く場面でもよくあります。 それは、thisにどのような別名を割り当てるかという場面です。

JavaScriptでは、thisが指す対象は関数の実行時の文脈によって変動するにも関わらず、コールバック関数という形で処理の一部を分けて書く場面が多発します。 そのため、Rubyなどの他の言語の感覚でコードを書くとエラーになる事があります。 例えば以下のようなコードです。

var oldValue = this.currentValue;
setTimeout(function() {
  var newValue = this.currentValue;
  // ↑ここでは this === undefined になっているのでエラーになる
  console.log('delta: ' + (newValue - oldValue));
}, 1000);

Function.prototype.bind()を利用できる状況では、以下のように書けば問題ありません。

var oldValue = this.currentValue;
setTimeout((function() {
  var newValue = this.currentValue;
  // ↑束縛されたthisなので、これは1行目のthisと同じ物。
  console.log('delta: ' + (newValue - oldValue));
}).bind(this) /* ←ここでthisを束縛 */, 1000);

Function.prototype.bind()を使えない場合は、以下のように書くのが一般的です。

var oldValue = this.currentValue;
var self = this; // ←ここで別名を割り当てている
setTimeout(function() {
  var newValue = self.currentValue;
  // ↑別名で参照しているだけで、これは1行目のthisと同じ物。
  console.log('delta: ' + (newValue - oldValue));
}, 1000);

この例ではselfという変数名でthisの内容を参照できるようにしていますが、この時使う変数名はthat_thisなど様々な流儀があります(自分はselfを使う事が多いです)。 そのため、他の人の書いたコードや他のプロジェクトのコードで違う流儀の物を見ると、コードが意味する内容を読み取るのに時間がかかったり、意味を誤解してしまったりする事があります。

AngularJSでは、この発想を逆転することで、このような混乱した状況の発生を予防していると言えます。 つまり、最初から、$httpBackendという名前のモジュールを_$httpBackend_という別名でも参照できるようにしてあるため、$httpBackendという本来のモジュール名を「複数の関数で参照するための一時的な別名」として使えるのです。 この「最初から使える別名」のおかげで、AngularJSでは「一時的な別名をどう決めればいいか?」という事に無駄に悩まなくてもいいようになっているわけです。

まとめ

自分はJavaScriptを使うプロダクトをフレームワークを使わずに開発していた時期が長かったため、上記の「thisの別名問題」のようなやり方がすっかり身に染みついてしまっていました。 そのため、同じ物を敢えて別の名前で参照する(できるようにしてある)という発想が無く、思わぬ所で理解に躓いてしまいました。 自分自身が知らず知らずのうちに固定観念に囚われてしまっていたことを意識させられる出来事でした。

また、「Webアプリケーションフレームワーク」と同じようにメタ的な存在である「テスティングフレームワーク」を開発する中で悩み所になりがちだった問題について、AngularJSでは利用者目線での使い勝手を優先して巧みな解決策を用意している事が分かりました。 自分が今後フレームワークを開発する際にも、「フレームワークの実装のしやすさ」だけに囚われず、フレームワーク利用者の利便性を最大化するための努力を惜しまないように気を付けたい、という思いを新たにした次第です。

オチは特にありません。

タグ: JavaScript
2015-07-10

クリアコードの公開リポジトリ

すでにお気づきの方もいるかもしれませんが、先日から、クリアコードで開発したプログラムが入ったSubversionリポジトリリポジトリの更新状況のRSS)の公開を始めました。

クリアコードでは既存のフリーソフトウェアの開発に参加したり、新しくmilter managerなどのフリーソフトウェアを開発したりしていますが、それらの開発成果の公開場所はケースバイケースとなっています。

  • 既存のフリーソフトウェアの開発に参加する場合は、基本的に、開発成果はアップストリームに還元しています。
  • 新しくフリーソフトウェアを開発する場合は、基本的には関連コミュニティで標準的なホスティングサイトを利用しています。例えば、Ruby関連のソフトウェアであればRubyForge、GNOME関連のソフトウェアであればgnome.orgといった具合です。
  • 関連するソフトウェアがGitHubGoogle Codeを利用している場合は、それらのサイトを利用することもあります。
  • 特に標準的なホスティングサイトが無い場合はSourceForgeを利用しています。

このように、クリアコードの開発成果のソースコードは様々なホスティングサイトのリポジトリにて管理、および公開されています。

まもなくクリアコードは設立から3年が経とうとしていますが、その間、プロジェクトを作るまでもないような小規模なソースコードがいくつかたまってきました。この度、そのようなソースコードをSubversionリポジトリで公開することにしました。

このリポジトリには現在、ページの一部を折りたたむfolding.jsや、このククログを生成するためのtDiary関連のスクリプト(日記のデータをSubversionで管理するIOバックエンド日記を静的なHTMLに変換するスクリプトなどの記事で述べた物)、Thunderbird用の各種アドオンのソースコードが入っています。誰でも自由にチェックアウトできますので、注意事項をご了承の上でどうぞご利用ください。

以下、現在入っているプログラムを簡単に紹介します。

注意事項

  • これらのプログラムはすべて無保証です。
  • プログラムは予告なく追加・削除・変更されることがあります。

JavaScript関連

tDiary関連

ククログだけではなく、milter managerのブログでも使っています。使い方はmilter managerのtdiary.confが参考になると思います。

  • /tdiary/subversionio.rb: tDiaryのSubversionバックエンド
  • /tdiary/gitio.rb: ↑のSubverionバックエンドのgitバージョンです。
  • /tdiary/html-archiver.rb: tDiaryのデータをHTML化するで紹介した物ですが、その後、カテゴリに対応するなどいくつかの点で改良されています。
  • /tdiary/patches/customizable-style-path.rb: スタイルファイルのパスをカスタマイズできるようにします。RDスタイルなど、標準では有効になっていないスタイルを使うときに便利です。本家に提案したのですが、rejectされたのでここに入っています。
  • /tdiary/plugin/classed-category-list.rb: class付きでカテゴリリストを生成します。ククログのトップに並んでいる「タグ: ...」の部分です。
  • /tdiary/plugin/date-to-tag.rb: 日付を本文の下に生成します。今思えば名前が悪いですね。後で変えるかもしれません。
  • /tdiary/plugin/link-subtitle.rb: サブタイトルをその記事のリンクにします。通常は日付がリンクになるのですが、このククログでは日付は本文の下に置いてあるので、代わりにサブタイトルをリンクにしています。
  • /tdiary/plugin/multi-icon.rb: ページアイコンとして、favicon.ico(ICO形式の画像)とfavicon.png(PNG形式の画像)を両方指定できるようにします。
  • /tdiary/plugin/title-navi-label.rb: 「前の日記」「次の日記」リンクのラベルをリンク先の日記のタイトルにします。
  • /tdiary/plugin/zz-permalink-without-section-id.rb: section_footerプラグインが生成するpermalinkから「#pXX」を削除します。ククログでは、1記事を1セクションにして同じ日には複数の記事を書かないという方針で運営しており、セクションIDが必要ないため、このプラグインを作成しました。ファイル名の「zz-」は、このプラグインの読み込み順序を最後の方にさせるためのものです。

Thunderbird関連

Thunderbird Add-ons - クリアコードで公開している、Thunderbirdのバグを回避するパッチや挙動の変更を行う拡張機能です。公開ページにも書いてありますが、これらの拡張機能は無保証です。業務上の必要性からの導入をお考えの場合は、Mozilla Firefox & Mozilla Thunderbird保守・サポートサービスのご利用もご検討ください。

おまけ

リポジトリの更新状況を配信しているRSSは、Subversionに標準添付のcommit-email.rbで生成しています。今回、RSSのタイトルや説明を日本語にしたかったので、Subversionのtrunkに--rss-titleと--rss-descriptionオプションを追加しました。また、--repository-uriで指定されたリポジトリのURIをコミットメールのX-SVN-Repositoryヘッダに設定するようにもしています。

Subversionリポジトリの整形表示にはRepos Styleを利用しています。mod_dav_svnにはSVNIndexXSLTというオプションがあって、それを利用しています。

タグ: Ruby | JavaScript | Mozilla
2009-04-06

ページの内容を折りたたむスクリプトを公開しました

UxUのページの過去のバージョンの詳細情報など、そのまま表示すると長くなってしまう内容を折りたたむめの簡単なスクリプトを書いてみました。せっかくなので、AGPLv3にて公開することにします。

このスクリプトを読み込んだ後で以下のようにすると、XPath式で示された要素が折りたたみ可能となります。複数のインスタンスを生成すれば、異なる種類の折りたたみ項目を作ることもできます。

1
2
3
4
5
new Folding(
  '/descendant::*[@class="items-should-be-folded"]',
  '詳細を表示',
  '詳細を隠す'
);

また、折りたたまれた要素をボタンのクリックで表示すると、URIの末尾にその情報が付け加えられた状態となりますので、その状態のページをブックマークしたり、特定の項目を表示した状態のページへリンクしたりすることもできます。

DOM3 XPathを使用しているため、IE6などのレガシーなWebブラウザ向けにはJavaScript-XPathを併用する必要があることに注意してください。

この手のスクリプトはアニメーション効果が派手な物などすでに色々あると思いますが、シンプルな物が好みな場合や、社内Wikiのようにあまり飾り気が無くても良い場面などに使ってみると良いのではないでしょうか。また、折りたたみの対象となる要素をXPath式で自由に指定できるので、すでにあるページの内容を変更せずに簡単に導入できるという利点もあります。

AGPL

ところで、皆さんはAGPLというライセンスについてはご存じでしょうか? 

AGPL(Affero GPL)はGPLの派生ライセンスの1つで、Webサービス用のプログラムで使われることを想定しています。このライセンスが適用されたプログラムのコードを利用したWebサービスは、そのWebサービスのユーザに対して、サービスを構成するすべてのプログラムのソースコードを公開する義務があります。それ以外の点は通常のGPLと同一です。

誤解している方もおられるかもしれませんが、AGPLやGPLでライセンスされたコードを利用すると、すぐにソースコードを公開する義務があるということはありません。AGPLやGPLは、プログラムのユーザ(AGPLの場合はそのサービスのユーザも含む)に対してはソースコード開示の義務がありますが、それ以外の関係ない人にまでソースコードを公開する義務はありません。例えば社内Wikiに上のスクリプトを組み込んだ場合であれば、社内の人にはソースコードを公開しなくてはいけませんが、社外にまで公開する必要はありません。「よく分からないけどGPLこわい!」と尻込みしてしまわずに、色々なコードを用途に応じて使い分けてみると良いでしょう。

タグ: JavaScript
2009-04-03

タグ:
年・日ごとに見る
2008|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|10|11|12|
2011|01|02|03|04|05|06|07|08|09|10|11|12|
2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|04|05|06|07|08|09|10|11|12|
2018|01|02|03|04|05|06|07|08|09|10|11|12|
2019|01|02|03|04|05|06|07|08|09|10|11|