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


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で取得できます。