[改訂版] Web Pushでブラウザにプッシュ通知を送ってみる

## はじめに

ブラウザやプッシュ通知サービスによらず、標準化された手順でブラウザにプッシュ通知が可能になるWeb Pushに関連する技術仕様のRFC化が完了し、ブラウザとプッシュサービスへの実装もかなり整ってきています。そこで、Web Pushの使い方を改めて整理してみます。

なお、本記事は、[Web Pushでブラウザにプッシュ通知を送ってみる](http://qiita.com/tomoyukilabs/items/217915676603fda73b0a)のアップデートとなっています。

### [旧記事](http://qiita.com/tomoyukilabs/items/217915676603fda73b0a)との差分

* `PushManager.supportedContentEncodings`がサポートされ、ブラウザが対応するContent-Encodingの種類を確認できるようになりました。Chrome 60以降、Microsoft Edge (EdgeHTML 17)が対応しています。
* IETF仕様の変更により、`aes128gcm`というContent-Encodingが規定されました。Chrome 60以降、Firefox 55以降、Microsoft Edge (EdgeHTML 17以降)が対応しています。
    * 暗号化の手順に一部変更があります。(128ビットAES/GCMのAEADを用いること自体に変更はありません。)
    * `Crypto-Key`ヘッダおよび`Encryption`ヘッダによる鍵等の指定が廃止され、HTTPリクエストのボディ部の先頭にヘッダとして指定するように変更されています。
    * プッシュ通知で送信できる最大メッセージ長が3992バイトに変更されました。
* `PushSubscription#expirationTime`によって、プッシュ通知のエンドポイントの有効期限を確認できるようになりました。Chrome 60以降、Microsoft Edge (EdgeHTML 17以降)が対応しています。

## Web Push

Web Pushは、ブラウザの種類によらず、同じ[JavaScript API](https://w3c.github.io/push-api/)を用いてブラウザにプッシュ通知を設定し、受信できるようにする仕組みです。ブラウザAPIはW3C、プロトコルとデータフォーマット等はIETFで標準化が進められています。

プロトコルとデータフォーマット関連の技術仕様は次の3種類に分かれています。

* Web Pushプロトコル本体 ([RFC 8030](https://tools.ietf.org/html/rfc8030))
* Web Pushメッセージ暗号化 ([RFC 8291](https://tools.ietf.org/html/rfc8291))
    * 暗号化本体はHTTPの暗号化Content-Encoding ([RFC 8188](https://tools.ietf.org/html/rfc8188))を参照
* プッシュサーバによるアプリケーションサーバの認証(VAPID) ([RFC 8292](https://tools.ietf.org/html/rfc8292))

特に、**メッセージ付きプッシュ通知を送信する場合は、メッセージの暗号化が必須になる**ことに注意が必要です。

プッシュサーバによっては、上記のVAPIDでアプリケーションサーバの認証が必要になる場合があります。VAPIDの手順については別記事[Web Pushのサーバ認証VAPIDを試してみる](https://qiita.com/tomoyukilabs/items/9346eb44b5a48b294762)を参照して下さい。

### 対応ブラウザ

現時点ではChrome, Firefox, Microsoft EdgeがWeb Pushに対応しています。

## Push API: HTMLページ側

[Push API](https://w3c.github.io/push-api/)は[Service Worker](https://w3c.github.io/ServiceWorker/v1/)の仕組みを使います。少し具体化すると、(a) 主にHTMLページ側でプッシュ通知の登録・解除を行い、(b) Service Workerで(当該HTMLページが閉じている間でも)プッシュ通知を受信する、という2段階になっています。

HTMLページ側の処理から順番に述べます。まず、Service Workerをインストール(register)します。Service Workerが有効化されると、プッシュ通知の登録(subscribe)が可能になります。
Service Worker側のスクリプトは後述。

```js:ServiceWorker
navigator.serviceWorker.register('serviceworker.js').then(() => {
  ...
});
```

続いて、プッシュ通知の受信・通知表示の許可をユーザに求めます。

```js:パーミッション
Notification.requestPermission(state => {
  if (state === 'granted') {
     ...
  }
});
```

プッシュ通知の登録は次のような手順になります。

```js:プッシュ通知の登録
function encodeBase64URL(buffer) {
  return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)))
           .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

navigator.serviceWorker.ready.then(registration => {
  return registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: serverPublicKey // VAPIDで使用するサーバ公開鍵
  }).then(subscription => {
    const endpoint = subscription.endpoint; // エンドポイントのURL
    const publicKey = encodeBase64URL(subscription.getKey('p256dh')); // クライアント公開鍵
    const authSecret = encodeBase64URL(subscription.getKey('auth')); // auth_secret
    let contentEncoding; // プッシュ通知の送信時に指定するContent-Encoding
    // Chrome 50以降、Firefox 48以降のみを想定
    if ('supportedContentEncodings' in PushManager) {
      contentEncoding =
        PushManager.supportedContentEncodings.includes('aes128gcm') ? 'aes128gcm' : 'aesgcm';
    }
    else
      contentEncoding = 'aesgcm';

    ... // 以上4つのパラメータをアプリケーションサーバに送信
  });
})
```

`registration.pushManager.subscribe`の引数で指定するパラメータのうち、`applicationServerKey`メンバは、サーバ認証の際に必要になるパラメータで、Chrome (FCM)では必須、Firefoxではオプションとなります。詳細は[GCMの登録が不要になったChromeのWeb Pushを試してみる](http://qiita.com/tomoyukilabs/items/9346eb44b5a48b294762)を参照して下さい。

HTML側では、アプリケーションサーバに4つのパラメータ(エンドポイントURL、クライアント公開鍵、auth_secret、Content-Encodingの値)を送信することで、アプリケーションサーバからプッシュサーバにプッシュ通知のメッセージを送信して、ブラウザに配信されるようにすることができます。

## アプリケーションサーバ側の処理

アプリケーションサーバは、ブラウザから受け取ったエンドポイントのURLにHTTP POSTリクエストを送信することによって、プッシュサーバ経由でブラウザにプッシュ通知を送信することが出来ます。エンドポイントのURLには、プッシュサーバのホスト名やプッシュ通知の配信先のウェブアプリケーション等が識別できるパスが含まれています。

プッシュ通知にメッセージを加えるにはメッセージの暗号化が必要になりますが、暗号化の詳細な方式をHTTPリクエストの`Content-Encoding`ヘッダで指定し、その内容は[RFC 8188](https://tools.ietf.org/html/rfc8188)で標準化されています。Content-Encodingは、Web Push暗号化の標準化が進む過程で何度か変更されており、Chrome 50-59およびFirefox 48-では`aesgcm`ですが、RFC 8188に対応したChrome 60以降では`aes128gcm`に対応しています。

本記事では`aes128gcm`の暗号化手順を紹介します。`aesgcm`については[Web Pushでブラウザにプッシュ通知を送ってみる](http://qiita.com/tomoyukilabs/items/217915676603fda73b0a)を参照して下さい。

以下、具体的な処理の内容を示します。なお、Node.js向けオープンソースライブラリの[web-push](https://github.com/web-push-libs/web-push/)等がRFC 8291準拠の`aes128gcm`に対応しています。

参考程度に、[Javaのサンプルコード](https://github.com/tomoyukilabs/WebPushTest)と[テストサイト](https://labs.othersight.jp/webpushtest/)を用意していますので、ご興味のある方はお試しください。

### ECDHで暗号鍵を生成

**ECDH (Elliptic Curve Diffie-Hellman)**は、楕円曲線を応用した鍵共有プロトコルで、2つの鍵ペアA, Bがある時に、互いに*公開鍵だけ共有*して、

* Aの公開鍵 + Bの秘密鍵
* Aの秘密鍵 + Bの公開鍵

のどちらの組合せでも同じ暗号鍵を生成できる、という特徴があります。このとき、AES等の共有鍵暗号方式であれば同じ鍵で暗号化と復号の両方を行うことになるため、ECDHを用いれば、暗号鍵自体を直接通信することなく、互いに同じ暗号鍵を共有して暗号化と復号が互いに可能になります。

Web Pushでは、ブラウザでクライアント鍵ペア、アプリケーションサーバでサーバ鍵ペアを生成したときに、

* アプリケーションサーバは、ブラウザ公開鍵とサーバ秘密鍵で暗号鍵を生成してメッセージを暗号化し、
* ブラウザは、ブラウザ秘密鍵とサーバ公開鍵で暗号鍵を生成してメッセージを復号する

といった手順で、暗号化したメッセージをプッシュ通知で送信します。

アプリケーションサーバで必要となるのは、

* ブラウザの公開鍵 + アプリケーションサーバで生成した鍵ペアの秘密鍵 (暗号鍵の生成)
* アプリケーションサーバで生成した鍵ペアの公開鍵 (プッシュ通知に添付)

となります。なお、上記の例ではブラウザ公開鍵とauth_secretはBase64 URLエンコードしていますので、サーバ側の処理ではバイトデータに戻す(デコードする)必要があります。

### 暗号鍵の生成

`aes128gcm`における暗号鍵の生成手順は次の通りです。(`||`はバイト列の結合)

1. 共有鍵 = ECDH(ブラウザ公開鍵, サーバ秘密鍵)
2. PRK_key = HMAC_SHA_256(auth_secret, 共有鍵)
3. key_info = "WebPush: info" || 0x00 || ブラウザ公開鍵 || サーバ公開鍵
4. IKM = HMAC_SHA_256(PRK_key, key_info || 0x01)

さらに暗号鍵の推測を困難にするため、乱数のsaltとの組合せで、CEK (Content Encryption Key)とnonceを生成します。

5. salt = 16バイトの乱数列
6. PRK = HMAC_SHA_256(salt, IKM)
7. cek_info = "Content-Encoding: aes128gcm" || 0x00
8. CEK = HMAC_SHA_256(PRK, cek_info || 0x01)の先頭16バイト
9. nonce_info = "Content-Encoding: nonce" || 0x00
10. nonce = HMAC_SHA_256(PRK, nonce_info || 0x01)の先頭12バイト

### メッセージ暗号化

まず、ヘッダを生成します。具体的には、下記のようなフォーマットとなります。(()内はバイト数)

```
上記5.のsalt (16) || レコードサイズ (4) || 0x41 (1) || サーバ公開鍵 (65)
```

レコードサイズはヘッダに続く暗号化メッセージの区切りの単位長ですが、通常は4096とします。なお、*Web Pushではヘッダ等を含めて4096バイトを超えるメッセージはプッシュサーバ側で無視しても構わないこととなっています*ので、実際には上記ヘッダ(86バイト)と、2バイトのパディング、この後の暗号化で発生する16バイトの拡張情報を除いた、**3992バイトがメッセージ自体の最大長となります**のでご注意下さい。

ヘッダの後には、暗号化したメッセージが続きます。暗号化はAEAD (Authenticated Encryption with Associated Data)を用いたAES/GCMで行います。以下に手順を示しますが、レコードサイズを4096としているため1個のレコードのみで済みますので、手順は仕様書よりも単純になっています。

1. カウンタの値を0とする。
2. IV (Initialization Vector)の値を、nonceの下位6バイトについてカウンタの値とのXORを取って置き換えたものとする(上位6バイトはそのまま)。
3. 暗号鍵CEKとIVを使って、`メッセージ本体 || 0x02 || 0x00`を暗号化する。

### プッシュ通知のPOST

ヘッダと暗号化したメッセージをメッセージボディとして、エンドポイントのURLに対してHTTPリクエストを送信すると、プッシュサーバ経由でブラウザにプッシュ通知を送信できます。このとき、次のようなヘッダをHTTPリクエストに追加する必要があります。

* `Content-Encoding`: 本記事の手順の場合は`aes128gcm`を指定
* `TTL`: プッシュ通知の受信有効期限を秒単位で指定

## Push API (Service Worker側)

まず、Service Workerがプッシュ通知を受信すると、`push`イベントが発生します。このときに、通知をブラウザが動作する端末上で表示させるよう、コードを作成します。上記の`subscription.subscribe`で指定した**`userVisibleOnly`オプションで`true`が指定されていると、必ずユーザに見える形で通知を表示しなければなりません**。仮に通知を表示する部分を実装しなかったとしても、ブラウザが強制的にデフォルトの通知を表示しますので、ご注意ください。なお、`userVisibleOnly`に`false`を指定することは、そのままではできなくなっています。(対応する方法については別途改めて記事にします。)

```js:serviceworker.js
self.addEventListener('push', event => {
  return event.waitUntil(
    self.registration.showNotification(
      '(プッシュ通知に表示するタイトル)',
      {
        icon: '(アイコンのURL(パスのみでOK))',
        body: '(プッシュ通知に表示する説明テキスト)',
        tag: '(識別用の適当なタグ("tag", "notification", 等)',
        vibrate: [200, 100, 200]
      }
    )
  );
}, false);
```

表示された通知をユーザがクリックすると、Service Workerで`notificationclick`イベントが発生します。ここで、`self.clients`を操作して、表示中のウィンドウにフォーカスしたり、新しくウィンドウを開いてページを表示させたりすることが可能です。(なお、対象はService Workerの親ドキュメントと同じオリジンに限られます。)

```js:serviceworker.js
self.addEventListener('notificationclick', event => {
  event.notification.close();

  return event.waitUntil(
    clients.matchAll({ type: 'window' }).then(cls => {
      let p = location.pathname.split('/');
      p.pop();
      p = location.protocol + '//' + location.hostname + (location.port ? ':'+location.port : '') + p.join('/') + '/';
      for (let i = 0 ; i < cls.length ; i++) {
        let client = cls[i];
        if(((client.url == p) || (client.url == p + 'index.html')) && ('focus' in client))
          return client.focus();
      }
      if (clients.openWindow)
        return clients.openWindow('./');
    })
  );
}, false);
```

## その他

* Push APIの新しい仕様として、プッシュ通知のエンドポイントの有効期限を`subscription.expirationTime`で取得できるようになりました。Chrome 60以降、Microsoft Edgeが対応しています。
  * 今のところChromeのプッシュ通知(FCM)では特にエンドポイントに有効期限が設けられていないため、常に`null`となります。
  * Microsoft Edgeでは有効期限が30日間となるようです。