Web Pushのサーバ認証VAPIDを試してみる (旧題: GCMの登録が不要になったChromeのWeb Pushを試してみる)

※ 2018-2-26: *VAPIDの[RFC 8292](https://tools.ietf.org/html/rfc8292)対応ですが、FirefoxとMicrosoft Edgeが対応済みで、Chromeでも少々手を加えれば使えるようです。*

W3CとIETFで標準化が進むWeb Pushですが、Firefoxでは事前の設定を特に必要としないのに対し、ChromeではAndroidアプリと同様にGoogle Cloud Messaging (GCM)の登録とキーやIDの取得が必要となります。従って、Webアプリ側では、Chrome専用のマニフェスト記述が、アプリケーションサーバ側ではGCMサーバの認証用に専用のHTTPヘッダの記述が必要となっています。

これに対し、ようやくプッシュサーバのアプリサーバに対する認証などの仕様について、IETFでの策定が進み、ChromeやFirefoxで実装が進んできています。今回はこの新しいサーバ認証の仕組みを使って、GCM特有の実装が不要になったWeb Pushを試してみます。これで、**ChromeでもFirefoxでも完全に同じ手順でプッシュ通知に対応できるようになります**。

対応ブラウザは、[Chrome 52以降、Chrome for Android 52以降](https://www.chromestatus.com/feature/5748876422152192)、Firefox 48以降、Microsoft Edge (EdgeHTML 17以降)となります。(Chrome 51では、`chrome://flags`でExperimental Web Platform FeaturesをEnabledにすると使えるようになります。)

# 全体の流れ

Web Pushでアプリケーションサーバのプッシュサーバへの登録と認証を行う仕組みとして、IETFでは[Voluntary Application Server Identification for Web Push (VAPID) (RFC 8292)](https://tools.ietf.org/html/rfc8292)が標準化されています。まず、このVAPIDの全体の流れを説明します。

![78faeb04-41e3-a37a-0884-217cd8a5c931.png](https://qiita-image-store.s3.amazonaws.com/0/48556/0c9b0eb9-c967-2e70-9764-da9865a0c17d.png)

1. アプリケーションサーバはECDSAの鍵ペアを生成する。
2. ブラウザ(UA; User Agent)はアプリケーションサーバからECDSA公開鍵を受け取る。
3. ブラウザがプッシュサーバにプッシュ通知の購読を登録する際、アプリケーションサーバのECDSA公開鍵を渡す。
4. アプリケーションサーバがプッシュサーバにプッシュ通知を送信する際、ECDSA秘密鍵を使ってSHA-256 with ECDSAによってJSON Web Token (JWT)形式の署名トークンを生成し、ECDSA公開鍵と合わせてプッシュサーバに渡す。
5. プッシュサーバは、ECDSA公開鍵を使ってアプリケーションサーバから受け取ったJWTを検証し、内容に問題がなければプッシュ通知をブラウザに転送する。

以上の手順のうち、3.-5.は暗号化付きWeb Pushの手順の中で合わせて行う形となります。

なお、VAPIDはVoluntary(自発的)というだけあって、この仕様自体はJWTによる認証を必須にしているわけではありません。また、3.の手順を踏むことで、そのブラウザ(Webアプリ)に対するプッシュ通知に対してJWTによる認証が必須になりますが、*Firefoxでは今のところ3.の手順は必須ではありません*。Chromeの場合、今のところ、従来のFCM/GCM専用拡張かVAPIDによる認証のいずれかを使うことが必須になっています。

# 実際の処理

実際の具体的な処理や実装の仕方については、基本的にはこれまでのPush APIの使い方に則ったものですのでまずは[[改訂版] Web Pushでブラウザにプッシュ通知を送ってみる](https://qiita.com/tomoyukilabs/items/2ae4a0f708a1af75f13e)を参照して下さい。本記事では、サーバの登録や認証に関連する部分にフォーカスして説明します。

なお、今回のVAPIDに対応する以前のバージョンのChrome (≦51)に対応する必要がなければ、マニフェストに記載していた`gcm_sender_id`等が不要になります。

詳細は[サンプルコード](https://github.com/tomoyukilabs/WebPushTest)および[デモ](https://labs.othersight.jp/webpushtest/)をご覧下さい。また、Node.js向けのWeb Pushライブラリ[web-push](https://github.com/web-push-libs/web-push/)がVAPIDに対応しています。

なお、VAPIDでは、SHA-256 with ECDSA (P-256楕円曲線)による署名からJWTを生成する処理を伴います。ECDSAを用いた署名の作り方については、[WebCrypto APIでECDSAの署名と検証を試してみる](http://qiita.com/tomoyukilabs/items/b346a71a920eb7a93501)を参照して下さい。

## ブラウザでのプッシュ通知の購読とサーバ公開鍵の登録

まず、ブラウザのWebアプリでは、アプリケーションサーバからP-256楕円曲線のECDSA公開鍵を予め受け取るようにします。このECDSA公開鍵をArrayBufferもしくはUint8Arrayに格納した上で、プッシュ通知の購読処理を次のようにします。

```js:プッシュ通知の購読要求
let serverPublicKey = /* アプリケーションサーバから入手したものを格納 */;

navigator.serviceWorker.ready.then(reg => {
  return reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: serverPublicKey
  });
}).then(subscription => {
  /* ... */
  /* 購読成功時の処理 */
  /* ... */
});
```

すなわち、PushManager.subscribe()のオプションとして、applicationServerKeyメンバにアプリケーションサーバの公開鍵を渡すようにします。この時、公開鍵が65バイトのバイナリデータではない場合に、購読処理がエラーとなります。なお、Chrome 50以前、Firefox 47以前では、このオプションは無視されます。

## プッシュ通知時のサーバ認証

applicationServerKeyオプション付きでプッシュ通知の購読処理を行った場合、アプリケーションサーバからプッシュサーバにプッシュ通知をHTTP POSTでリクエストする際、サーバ認証の必要なヘッダを追加する必要があります。現時点では、Chrome (FCM)は[draft-ietf-webpush-vapid-01](https://tools.ietf.org/html/draft-ietf-webpush-vapid-01)に、Firefox ([Autopush](https://github.com/mozilla-services/autopush/))とMicrosoft Edge (Windows Push Notification Service (WNS))は[RFC 8292](https://tools.ietf.org/html/rfc8292)およびdraft-ietf-webpush-vapid-01に対応しています。

以下、JWTに記述する内容と、各バージョンでのHTTPリクエストヘッダの内容を順に示します。

### JWTに記述する内容

JWTのヘッダとクレームの内容は次のようにします。

* JWTヘッダ:
    * `typ`の値: `JWT`
    * `alg`の値: `ES256` (SHA-256 with ECDSA (P-256)を表す)
* JWTクレーム:
    * `aud`の値: Webアプリ側で取得したendpointのURL(PushSubscription.endpoint)のorigin
    * `exp`の値: 適当なJWTの有効期限(秒)、但しリクエスト送信時から24時間以内とすること
    * `sub`の値(オプション): Webアプリの連絡先(`mailto:(メールアドレス)`もしくはWebアプリのURL)

```json:JWTヘッダの例
{"typ":"JWT","alg":"ES256"}
```

```json:JWTクレームの例
{"aud":"https://android.googleapis.com","exp":1464269795,"sub":"https://labs.othersight.jp/webpushtest/"}
```

これらのヘッダとクレームをURLセーフBase64でエンコードしたものを`.`で連結して、アプリケーションサーバのECDSA秘密鍵を使ってSHA-256 with ECDSAで署名を作成し、最初の連結文字列にさらに、`.`と、署名をURLセーフBase64でエンコードしたものを連結すると、JWTが生成できます。

### HTTPリクエストヘッダ: RFC 8292 (Chrome(要ハック)/Firefox/Edge)

RFC 8292に対応する場合、アプリケーションサーバがプッシュサーバに送信するリクエストには、HTTPリクエストヘッダとして以下を追加します。

* `Authorization`ヘッダに、`vapid t=(アプリケーションサーバのECDSA公開鍵をURLセーフBase64エンコードしたもの) k=(アプリケーションサーバが署名したJWT)`を記述

```http:VAPID(RFC8292)+aes128gcm
POST /endpoint HTTP/1.1
Host: pushserver.example.com
Content-Encoding: aes128gcm
TTL: 86400
Authorization: vapid t=... k=...
```

```http:VAPID(RFC8292)+aesgcm
POST /endpoint HTTP/1.1
Host: pushserver.example.com
Content-Encoding: aesgcm
Crypto-Key: dh=...
Encryption: salt=...
TTL: 86400
Authorization: vapid t=... k=...
```

すなわち、Web Push暗号化でaes128gcm、VAPIDをRFC 8292準拠とすると、これまでのバージョンのWeb PushやVAPIDで使われていた`Crypto-Key`ヘッダが完全に不要になります。

#### ChromeでのRFC 8292対応

https://github.com/web-push-libs/web-push/issues/278#issuecomment-356783840 によると、RFC 8292のVAPIDをFCMで使うには、

> FCMのエンドポイントURLにおいて`fcm/send`の部分を`wp`に書き換える

という手順を加えるとよいとのことです。恐らく一時的なハックだと思われますので、不要になったのが確認出来次第、この説明を削除します。

### HTTPリクエストヘッダ: draft-ietf-webpush-vapid-01 (Chrome/Firefox/Edge)

draft-ietf-webpush-vapid-01に対応する場合、アプリケーションサーバがプッシュサーバに送信するリクエストには、HTTPリクエストヘッダとして以下を追加します。

* `Crypto-Key`ヘッダに、`p256ecdsa=(アプリケーションサーバのECDSA公開鍵をURLセーフBase64エンコードしたもの)`を追加
* `Authorization`ヘッダに、`WebPush (アプリケーションサーバが署名したJWT)`を記述

```http:VAPID(draft-ietf-webpush-vapid-01)+aes128gcm
POST /endpoint HTTP/1.1
Host: pushserver.example.com
Content-Encoding: aes128gcm
Crypto-Key: p256ecdsa=...
TTL: 86400
Authorization: WebPush ...
```

```http:VAPID(draft-ietf-webpush-vapid-01)+aesgcm
POST /endpoint HTTP/1.1
Host: pushserver.example.com
Content-Encoding: aesgcm
Crypto-Key: dh=...;p256ecdsa=...
Encryption: salt=...
TTL: 86400
Authorization: WebPush ...
```

# ECDSAで署名してJWTを生成する際の注意事項

アプリケーションサーバでJWTを生成する際、ECDSAによる電子署名のフォーマットに注意が必要です。Java 8の`java.security.Signature`等で扱われるECDSA署名は、JWTのシグネチャ部(JWS)とは異なるフォーマットとなっているため、変換が必要になります。

詳細は、[WebCrypto APIでECDSAの署名と検証を試してみる](http://qiita.com/tomoyukilabs/items/b346a71a920eb7a93501)を参照して下さい。