先日の Web Crypto API の基本的な使い方の解説(改訂済み)においては、説明を簡単にするために AES-GCM
の初期ベクトルを乱数に基づいて生成しましたが、これはセキュリティの観点からはあまり好ましくありません。本記事では、Web Crypto API で AES-CBC
や AES-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で実装してみる事にしました。以下は、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を使ってローカルデータを暗号化してみようという方に参考にしていただければ幸いです。
Firefox ESR60をWindows 7で使用していると、ウィンドウコントロール(タイトルバーの「最小化」「最大化」「閉じる」のボタン)が動作しなくなる、という現象に見舞われる場合があります。Firefoxの法人サポート業務の中でこの障害についてお問い合わせを頂き、調査した結果、Firefox自体の不具合である事が判明しました。
この問題は既にMozillaに報告済みで、Firefox 67以降のバージョンで修正済みですが、Firefox ESR60では修正されない予定となっています。この記事では、Firefox ESR60をお使いの方向けに暫定的な回避方法をご案内します。
この現象は、FirefoxのUIの設計に由来する物です。
一般的なWindowsアプリケーションは、タイトルバーなどを含めたウィンドウの枠そのものはWindowsに描画や制御を任せて、枠の内側だけでUIを提供します。それに対し、Firefoxのブラウザウィンドウでは、タイトルバー領域に食い込む形でタブを表示させるため、タイトルバーを含むウィンドウの枠まで含めた全体を自前で制御しています。そのため、タイトルバー領域に食い込む形でタブが表示されている場面では、Windowsが標準で提供しているウィンドウコントロールのボタンは実は使われておらず、それを真似る形で置かれた独自のUI要素で代用しています。
通常、これらの代用ボタンは他のUI要素よりも全面に表示されるため、タブなどの下に隠れる事はなく、ユーザーは見たままの位置にあるボタンをクリックできます。しかし、特定の条件下ではこれらの代用ボタンが他のUI要素の下に隠れてしまう形となり、ボタンをクリックできなくなってしまいます。
この問題が起こる条件は、以下の通りです。
window.open()
で開かれ、その際、メニューバーを非表示にするように指定された。Windowsのテーマ設定とFireofxのツールバーの設定を整えた状態で、w3schools.comのwindow.oepn()
の各種指定のサンプルを開き「Try it」ボタンをクリックしてみて下さい。実際に、ボタンが機能しないためウィンドウを閉じられなくなっている*1事を確認できるはずです。
問題が再現した時のFirefoxの内部状態を詳しく調査すると、Classicテーマにおいては、この状況下ではタブバーのz-index
(重ね合わせの優先順位)が2になるのに対し、ウィンドウコントロールのz-index
は常に1になるために、タブバーがウィンドウコントロールの上に表示されてしまっている状態である*2という事が分かりました。
なお、ClassicテーマはWindows 7以前のバージョンでのみ使用できる機能で、Windows 10以降では使えません*3。そのため、この問題はWindows 10以降では再現しません。
原因が分かれば対策は容易です。
最も単純な対策は、ユーザープロファイル内にchrome
という名前でフォルダを作成し、以下の内容のファイルをuserChrome.css
の名前で設置するというものです。
@namescape url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
/* WindowsでClassicテーマが反映されている場合、 */
@media (-moz-windows-classic) {
/* タイトルバーにタブを表示する設定で、フルスクリーン表示でない時は、 */
#main-window[tabsintitlebar]:not([sizemode=fullscreen]) #titlebar-buttonbox {
/* ウィンドウコントロールの重ね合わせ順位をタブバーよりも上にする */
z-index: 3 !important;
}
}
ただ、ユーザープロファイルの位置はFirefoxの実行環境ごとに異なる*4ため、管理者側でこの対応を全クライアントに反映するのは難しいです。クライアント数が多い場合は、Firefoxのインストール先のchrome
やbrowser\chrome
配下に置かれたCSSファイルを自動的に読み込むためのスクリプトをMCD用設定ファイルに組み込むなどの方法をとるのがおすすめです。
原因が判明した時点で、本件はFirefox本体の問題としてbugzilla.mozilla.orgに以下の通り報告しました。
また、調査を行った時期にちょうどFirefoxのタブバー・タイトルバー周りの実装の仕方が変化していたため、最新の開発版でも状況を確認した所、Firefox 65以降ではWindows 7でなくても同様の問題が起こり、しかも今度はそもそもウィンドウコントロールが表示されなくなってしまっているという、より酷い状況でした。そのため、そちらは別の問題として以下の通り報告しました。
Firefox ESR60およびFirefox 64以前での問題(本記事で解説している問題)については、Firefox 65でタブバーの設計が変わったために現象としては再現しなくなった事と、セキュリティに関わる問題ではない事から、修正はされないという決定がなされています。
Firefox 65以降での問題については、既に修正のためのパッチを提供し、Firefox 67以降のバージョンに取り込まれる事が確定しています。Firefox 65に対しては修正はバックポートされず、Firefox 66については特に誰も働きかけなければ修正は反映されないままとなる見込みです。
以上、Firefoxの法人向け有償サポートの中で発覚したFirefoxの不具合について暫定的な回避方法をご案内しました。
当社のフリーソフトウェアサポート事業では、当社が開発した物ではないソフトウェア製品についても、不具合の原因究明、暫定的回避策のご提案、および(将来のバージョンでの修正のための)開発元へのフィードバックなどのサポートを有償にて行っております。Firefoxのようなデスクトップアプリケーションだけでなく、サーバー上で動作する各種ソフトウェアについても、フリーソフトウェアの運用でトラブルが発生していてお困りの企業のシステム管理担当者さまがいらっしゃいましたら、メールフォームよりご相談下さい。
*1 そのため、ウィンドウを閉じるにはCtrl-F4などのキーボードショートカットを使うなどの、ボタンを使わない方法を使う必要があります。
*2 タブバーの右端はあらかじめウィンドウコントロールを重ねて表示するために余白領域が設けられていますが、現象発生時には、この余白領域の上ではなく下にウィンドウコントロールが表示されており、「ボタンは見えているのにその手前に透明な壁があってクリックできない」というような状況が発生しているという状況です。
*3 Classicテーマ風のテーマは存在していますが、Windows 7以前のそれとは異なり、単に配色等をClassicテーマ風にするだけの物です。
*4 安全のため、パスにランダムな文字列が含まれる形になっています。