Edited at

WebCrypto APIでECDH鍵交換を用いた暗号化を使ってみる


はじめに

ChromeやFirefox、Opera、Edgeなどのブラウザでは、WebCrypto APIを使ってブラウザで鍵の生成や暗号化と復号、署名と検証ができるようになっていますが、今回はECDHを使った鍵共有(およびAES-GCMで暗号化)を題材として、WebCrypto APIの使い方の一例を試してみます。


ECDHでは鍵ペアの生成と鍵共有を行います

ECDHを使って暗号化と復号を行うには、一般的には次のような手順となります(楕円曲線ディフィー・ヘルマン鍵共有)。


  1. 暗号化する側Aと、暗号化されたデータを復号する側Bの両方で、鍵ペア(秘密鍵・公開鍵)を生成

  2. AとBが互いに相手の公開鍵を共有

  3. Aは「Aの秘密鍵」と「Bの公開鍵」を使って共有鍵を生成し、この共有鍵でデータの暗号化を実行して、Bに送信

  4. Bは「Bの秘密鍵」と「Aの公開鍵」を使って共有鍵を生成し、この共有鍵で暗号化されたデータの復号を実行

ここで、3.と4.の手順でAとBが生成する共有鍵が同じものになるのが要点となります。なお、ECDH自体はあくまでも暗号化に用いる鍵を直接送らずに公開鍵だけを交換して鍵の共有を行うプロトコルに過ぎないため、実際の暗号化手順ではAES等の暗号化方式を組み合わせることとなります。

今回は、AES-GCMを暗号化方式として組み合わせてみます。この場合、AとBは公開鍵以外に、AES-GCMでの暗号化に用いる12オクテット(96ビット)の初期化ベクトル(IV; Initialization Vector)もその都度共有する必要があります。


ECDHの公開鍵にはいくつかのフォーマットがあります

WebCrypto APIでは多くのメソッド等でJWK (JSON Web Key)を公開鍵のインポート・エクスポートに用います。一方、Node.jsやJava等、他の実行環境では、単一のバイナリデータで公開鍵を格納する場合が多かったりします。

JWKではECDHの公開鍵は例えば次のように表現されます。


JWKの一例(ECDH公開鍵)

{

"crv": "P-256",
"ext": true,
"key_ops": [],
"kty": "EC",
"x": "UEW7pVoAdOHelJ8vRzUnKKepzcGLEWv4-W-0n2EXBpg",
"y": "S8r1_JCnBLICGpffhoAn0FDtdItmz8vYKIAp069QZmU"
}

実際の公開鍵本体は上記のxyで、それぞれ256ビット(32オクテット)のバイナリデータをBase64 URLエンコードしたものとなっています。

一方、Node.jsのcryptoモジュールやJava 8等では、主にRFC 5480に準じたフォーマットでECDH公開鍵を扱います。合計で65オクテットとなります。なお、Web Pushでブラウザにプッシュ通知を送ってみるで触れているW3C Push APIのPushSubscription.getKey('p256dh')で取得できるECDH公開鍵も、このRFC 5480準拠のフォーマットとなっています。


RFC5480のECDH公開鍵フォーマット(非圧縮)

0x04(1オクテット) || 上記のx(32オクテット) || 上記のy(32オクテット)


(注: ||はバイナリデータの結合を表します)

今回はWebCrypto同士で暗号化と復号を行う例を示しますが、異なる実行環境と互いに暗号文の受け渡しを行う場合は、鍵のフォーマットを適宜変換する必要がある点に注意が必要です。


鍵ペアを生成し、公開鍵をエクスポート(暗号化する側・復号する側で共通)

WebCryptoで鍵ペアを生成し、公開鍵を相手に渡せる状態にする手順は次のような感じになります。


WebCryptoで鍵ペアを生成

let keyPair, publicKeyJWK;

crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256'}, false, ['deriveKey', 'deriveBits'])
.then(key => { // 鍵ペアを生成
keyPair = key;
return crypto.subtle.exportKey('jwk', keyPair.publicKey);
}).then(jwk => { // 公開鍵をJWKとしてエクスポート
publicKeyJWK = jwk;
});


generateKey()の第1引数は、生成する鍵の種類を指定します。ここではP-256楕円曲線のECDHを指定しています。

第2引数は秘密鍵をエクスポートできるようにするかどうかを指定するもので、falseの場合はexportKey()でエクスポートすることができなくなります(公開鍵はエクスポート可能です)。

第3引数は、ここで生成する鍵の用途(usage)を指定します。今回はECDH鍵交換で用いる鍵ペアを生成しますので、鍵を生成するderiveKeyderiveBitsをusageとして指定します。


相手の公開鍵をインポート(暗号化する側・復号する側で共通)

WebCryptoで相手の公開鍵をインポートするには次のようにします。なお、第5引数の[]ではusageを指定しますが、今回の例では特に指定は不要です。


WebCryptoで公開鍵(RFC5480)をインポート

// JavaScriptのatob(), btoa()ではURL-safe Base64が扱えないため変換が必要

function decodeBase64URL(data) {
if(typeof data !== 'string')
return null;
let decoded = atob(data.replace(/\-/g, '+').replace(/_/g, '/'));
let buffer = new Uint8Array(decoded.length);
for(let i = 0 ; i < data.length ; i++)
buffer[i] = decoded.charCodeAt(i);
return buffer;
}

function encodeBase64URL(data) {
if(!(data instanceof Uint8Array))
return null;
let output = '';
for(let i = 0 ; i < data.length ; i++)
output += String.fromCharCode(data[i]);
return btoa(data.replace(/\+/g, '-').replace(/\//g, '_')).replace(/=+$/, '');
}

let remotePublicKey,
rawKey = decodeBase64URL('(Base64 URLエンコードされた公開鍵)');

crypto.subtle.importKey(
'raw',
{
cry: 'P-256',
ext: true,
key: 'EC',
x: encodeBase64URL(rawKey.slice( 1, 33)),
y: encodeBase64URL(rawKey.slice(33, 65))
},
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[]
).then(key => {
remotePublicKey = key;
});



WebCryptoでJWK公開鍵をインポート

let remotePublicKey, jwk = JSON.parse('(文字列シリアライズされたJWK)');

crypto.subtle.importKey(
'jwk',
jwk,
{ name: 'ECDH', namedCurve: 'P-256'},
false,
[]
).then(key => {
remotePublicKey = key;
});



鍵交換で暗号化に利用する共通鍵を生成し、暗号化(暗号化する側)

念のためおさらいしますと、ECDHで暗号化と復号に用いる共通鍵を生成(鍵交換)するには、手元の鍵ペアの秘密鍵と、相手から受け取った公開鍵を用いて、共通鍵を生成する、という手順を踏みます。これを実際にWebCryptoで実行するには、例えば次のようにします。


WebCryptoで鍵交換と暗号化

let iv = crypto.getRandomValues(new Uint8Array(12)),

input = /* 暗号化するデータのArrayBufferもしくはUint8Array 等*/,
result;

crypto.subtle.deriveKey( // 鍵共有による共有鍵生成
{ name: 'ECDH', namedCurve: 'P-256', public: remotePublicKey },
keyPair.privateKey,
{ name: 'AES-GCM', length: 128 },
false,
['encrypt', 'decrypt']
).then(key => { // AES-GCMによる暗号化
return crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, tagLength: 128 },
key,
input
);
}).then(data => {
// このresult(iv, data)を相手に渡す
result = {
iv: encodeBase64URL(new Uint8Array(iv)),
data: encodeBase64URL(new Uint8Array(data))
};
});


deriveKey()では、第1引数でアルゴリズムを指定する際にpublicメンバで相手の公開鍵を指定し、自分の秘密鍵を第2引数に指定します。第4引数ではgenerateKey()等と同様に生成した鍵のエクスポートを許可するかどうかを指定します。第5引数ではusageを指定しますが、この場合は暗号化や復号に使う鍵となりますのでencryptdecryptを指定します。

第3引数では生成する鍵の種類とビット長を指定しますが、このビット長はnameメンバで指定する暗号化アルゴリズムによって指定可能な値が決められます。AES-GCMの場合は128または256となります1。なお、実際のECDHではP-256の場合に鍵共有で生成される共有鍵の長さは256ビット(32オクテット)ですが、WebCryptoでより短い長さ(上記の例では128ビット)を指定している場合は先頭から指定された長さが抜き出されて鍵として出力されます。

encrypt()では、第1引数で暗号化アルゴリズムと関連パラメータ、第2引数で鍵、第3引数で暗号化する平文データ(ArrayBufferもしくはArrayBufferView(Uint8Array等))を指定します。AES-GCMではIVとタグ長(暗号文の先頭に付与されるヘッダの長さ)を指定します。IVは予め12オクテット(96ビット)の乱数の配列を生成し、復号する側にも共有します。タグ長(tagLength)には通常は128を指定すればよいでしょう。

なお、上記の例ではderiveKey()で生成した共有鍵を直接暗号化に用いていますが、実際には解読をさらに困難にするためにHKDFのように共有鍵とHMAC、salt(乱数)から鍵を生成したり、平文の先頭にパディングを足したりするなどの対策を行うのが一般的な使い方となります。このような目的で、共有鍵をオブジェクトではなくバイナリデータ(ArrayBuffer)として取得したい場合は、deriveKey()で生成された鍵をexportKey()でエクスポートしてもよいですが、deriveBits()を用いて直接ArrayBufferとして取得することもできます。

簡単なサンプルになりますが、共有鍵のSHA-256ハッシュ値を取得してその先頭16オクテットを暗号化に用いる共有鍵とする例は次のようになります。


deriveBitsの例

crypto.subtle.deriveBits(  // 鍵共有による共有鍵生成

{ name: 'ECDH', namedCurve: 'P-256', public: remotePublicKey },
keyPair.privateKey,
128
).then(bits => { // 共有鍵のSHA-256ハッシュ値を生成
return crypto.subtle.digest(
{ name: 'SHA-256' },
bits
);
}).then(digest => { // ハッシュ値の先頭16オクテットから128ビットAES-GCMの鍵を生成
return crypto.subtle.importKey(
'raw',
digest.slice(0, 16),
{ name: 'AES-GCM', length: 128 },
false,
['encrypt', 'decrypt']
);
}).then(key => { // AES-GCMによる暗号化
return crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, tagLength: 128 },
key,
input
);
});


鍵交換で復号に用いる共通鍵を生成し、復号(復号する側)

復号する側で暗号文を復号する手順は、暗号化の手順とほぼ同様で、encrypt()の代わりにdecrypt()を使います。鍵共有の結果にさらにハッシュ等の処理を加えて最終的な共有鍵を得る場合についても、暗号化する側と同様にderiveBits()等を暗号化する側と同じ手順で適用します。


WebCryptoで鍵交換と暗号文の復号

crypto.subtle.deriveKey( // 鍵共有による共有鍵生成

{ name: 'ECDH', namedCurve: 'P-256', public: remotePublicKey },
keyPair.privateKey,
{ name: 'AES-GCM', length: 128 },
false,
['encrypt', 'decrypt']
).then(key => { // AES-GCMによる復号
return crypto.subtle.decrypt(
{ name: 'AES-GCM', result.iv, tagLength: 128 },
key,
result.data
);
}).then(data => {
// このdataが暗号化のサンプルコードのinputと同じものになるはず
});





  1. W3CのWebCryptoの仕様によると192ビットのAES-GCMもサポートされることになっていますが、例えばChromeではサポートされていないようです。