Edited at

WebCrypto APIでJSON Web Tokenの検証を試してみる

More than 1 year has passed since last update.

Web Cryptography (WebCrypto) APIを使うと、JavaScriptで暗号化や復号、署名やその検証等の処理を実行することができるようになります。特に、CryptoJSでも扱われていないRSA等も扱えるのが便利かもしれません。

Chrome 37以降、Firefox 34以降で利用できるようですので、まず試してみました。なお、Safari 8でもwebkitプレフィクス付きで使えるようですが(window.crypto.webkitSubtle)、仕様が異なるのかこちらでは動作確認が取れなかったため、今回はChromeとFirefoxのみに限定することにします。

今回は、GoogleのOpenID Connect認証で得られるJWTを例にして試してみます。


JSON Web Token (JWT)

例えば、GoogleアカウントでOpenID Connectを使った認証を自分のWebサイトに実装すると、ログイン成功時にリダイレクトURIに遷移する際、JWTを格納したパラメータid_tokenがURLクエリパラメータの一つとして付与されます。

具体的には、次のような内容になります:

[ヘッダ].[クレーム].[シグネチャ]

[ヘッダ]は、署名に使われた鍵の識別子をJSONで記述したものをBase64 URLエンコードしたものとなります。Base64 URLデコードして得られるJSON文字列の内容は、例えば次のようになります:

{"alg":"RS256","kid":"7158d85c857f368bf3a448b22720a3a55b073447"}

[クレーム]は、トークンで検証するIDプロバイダ名やユーザ名等の情報をJSONで記述したものをBase64 URLエンコードしたものになります。

ここで、[ヘッダ]kidで示されるIDプロバイダ(ここではGoogle)の秘密鍵を用いて、ペイロード[ヘッダ].[クレーム]に署名して得られるシグネチャをBase64 URLエンコードしたものが、上記の[シグネチャ]となります。

Google OpenID Connectの場合、署名を検証するために必要となる公開鍵(JWK; JSON Web Key)がWebサイト上で公開されています。一定時間ごとに更新されるため、署名の検証時にはその都度JWKの内容をWebサイトから取得したほうがよいでしょう。

(参考): http://christina04.hatenablog.com/entry/2015/01/27/131259


まず、鍵を取得

それでは、順を追ってJWTの検証を試してみます。

簡単のため、今回はJWTの内容を予め変数に入れておくことにします。

まず、GoogleのWebサイトよりJWKを取得し、JWTのヘッダで指定されているkidの鍵を選択します。


sample.js

var token = '[JWTの内容]';

function base64UrlDecode(t) {
return atob(t.replace(/\-/g, '+').replace(/_/g, '/'));
}

function getJSON(e) { return e.json(); }

function getJWK(e) {
var key;
var k = JSON.parse(base64UrlDecode(token.split('.')[0]));
for(var i of e.keys) {
if(i.kid == k.kid) {
key = i;
// Base64のpaddingが残っていると、Chromeではcrypto.subtle.importKeyでエラー
key.n = key.n.replace(/=+$/, '');
}
}
if(key)
getCryptoKey(key);
}

fetch('https://www.googleapis.com/oauth2/v2/certs').then(getJSON).then(getJWK);


次に、得られたJWKをインポートして、WebCryptoで利用するCryptoKeyオブジェクトを取得します。


sample.js

function getCryptoKey(key) {

crypto.subtle.importKey('jwk', key, {
name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' }
}, true, ['verify']).then(verifyJWT);
}

上記の例では、関数verifyJWT()の引数として、CryptoKeyオブジェクトとして格納されたGoogleのRSA公開鍵が渡されることになります。

crypto.subtle.importKey()の引数ですが、第1引数はインポート元の鍵のデータフォーマットを指定します。JWKであれば、'jwk'を指定し、第2引数でJWKのJSONオブジェクトをそのまま渡せばよいことになります。

第3引数で、暗号化や署名の方式を指定しますが、JWTのヘッダでRS256が指定されているため、WebCryptoの場合は、署名の方式として'RSASSA-PKCS1-v1_5'(長いですが)、ハッシュの方式として'SHA-256'を指定します。

第4引数は、鍵をエクスポートできるようにしてよいかどうかを指定します。

第5引数では、鍵の用途を一つないし複数、配列で指定します。暗号化なら'encrypt'、復号なら'decrypt'、署名なら'sign'、署名の検証なら'verify'、などが指定可能です。


得られた鍵で署名を検証

これまでの手順で得られた公開鍵を用いて、JWTの内容が真正なものかどうかを検証します。


sample.js

function toBufferSource(t) {

var r = new Uint8Array(t.length);
for(var i = 0 ; i < s.length ; i++)
r[i] = s.charCodeAt(i);
return r;
}

function verifyJWT(cryptoKey) {
var t = token.split('.');
var signature = toBufferSource(base64UrlDecode(t[2]));
var payload = toBufferSource(t[0] + '.' + t[1]);
crypto.subtle.verify({
name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' }
}, cryptoKey, signature, payload).then(function(result) {
console.log(result ? '検証成功' : '検証失敗');
});
}


crypto.subtle.verify()の引数ですが、第1引数はcrypto.subtle.importKey()の第3引数と同じです。第2引数には検証に用いる公開鍵、すなわちcrypto.subtle.importKey()で取得したCryptoKeyオブジェクトを渡します。

さらに、第3引数には署名をBase64 URLデコードしてUint8Arrayに格納したもの、第4引数には検証したいJWTデータ([ヘッダ].[クレーム])をUint8Arrayに格納したものを渡すと、Promiseで検証結果をbooleanで取得できます。