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

[Web Cryptography (WebCrypto) API](http://www.w3.org/TR/WebCryptoAPI/)を使うと、JavaScriptで暗号化や復号、署名やその検証等の処理を実行することができるようになります。特に、[CryptoJS](https://code.google.com/p/crypto-js/)でも扱われていないRSA等も扱えるのが便利かもしれません。

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

今回は、[GoogleのOpenID Connect認証](https://developers.google.com/identity/protocols/OpenIDConnect)で得られるJWTを例にして試してみます。

# JSON Web Token (JWT)

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

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

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

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

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

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

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

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

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

# まず、鍵を取得

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

簡単のため、今回はJWTの内容を予め変数に入れておくことにします。
まず、GoogleのWebサイトよりJWKを取得し、JWTのヘッダで指定されている`kid`の鍵を選択します。

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