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

  • 295
    いいね
  • 19
    コメント

※ 2016/11/1: IETFより新しい仕様ドラフトdraft-ietf-webpush-encryption-06が公開されました。 WebPushの暗号化手順に変更が加えられた模様ですので、ブラウザ及びプッシュサーバの対応状況が確認出来次第、追って本記事もアップデートします。

※ 2016/5/27: Web Push暗号化にサーバ認証を組み合わせるVAPIDの使い方を別記事にまとめました。Chrome 52以降では、FCM/GCM専用のコードを書くことなくWeb Pushが利用できるようになります。
※ 2016/2/26: Web Pushのサンプルページを用意しました。参考までにコードをGitHubに置きました。

先に「ChromeでW3C Push APIを使ってみた」や「Firefox (Developer Edition)でW3C Push APIを使ってみる」で書きましたように、ブラウザでのプッシュ通知の対応が徐々に形になってきています。

ChromeやFirefoxでプッシュ通知を通知だけではなくデータ(ペイロード)付きで送れるようにするには、ペイロードの暗号化(Web Push Encryption)が必要となりますが、Chrome 50以降、Firefox for Desktop 46以降、Firefox for Android 48以降で対応していますので、本記事ではその方法について説明します。(ちなみに、Microsoft EdgeでもPush APIの実装が進んでいるようです。)

今回は暗号化が絡みますので、ちょっと難しい内容になりますが、ご了承下さい。

Web Push

Chromeでは当初バージョン42でGoogle Cloud Messaging (GCM)を利用したプッシュ通知がサポートされましたが、現在はIETFでブラウザ間で共通のプッシュ通知プロトコルとしてWebPushプロトコル(RFC 8030)が標準化されており、ChromeとFirefoxの両方がWebPushに対応しています。WebPushはHTTP/2の新機能であるサーバプッシュ等を活用したことが特徴となっていますが、これは主にブラウザとプッシュ通知サービス側の仕組みであり、開発者は特にそれを意識せず、従来のHTTPのノウハウがあれば使えるようになっています。

全体の構成要素としては、

  • プッシュサーバ
  • ブラウザ(UA: User Agent)
  • アプリケーションサーバ

があります。ブラウザはプッシュ通知の購読をプッシュサーバに要求すると、プッシュサーバからUAに、アプリケーションサーバからUAにプッシュ通知を送る際のプッシュサーバ側のURLが送られます。W3C Push APIではこのURLをendpointと呼んでいます。

ちなみに、現時点(2016/3/1)ではFirefoxはオープンソースのプッシュ通知サーバautopushをプッシュサーバとして利用しており、HTTP/2ではなくWebSocketでautopushサーバと接続しています。(Firefox for Androidでも同様のプッシュサーバを利用していますが、プッシュ通知自体は現時点ではGCMを間接的に利用しているようです。)

メッセージの暗号化

Web Pushでは、さらに、中間者攻撃などのリスクを軽減してプライバシーを高めるために、アプリケーションサーバとUAの2者だけがメッセージを解読できるようにするために、鍵共有プロトコルを用いたメッセージの暗号化を用います[draft-ietf-webpush-encryption-02]。ここで、鍵共有にはECDH (Elliptic Curve Diffie-Hellman)、暗号化は128ビットAES-GCM (Galois/Counter Mode)を利用することになっています。

まず、鍵共有の仕組みについて簡単に説明しますと、

  • メッセージを送る側も受ける側も、まず公開鍵と秘密鍵の鍵ペアを作成する。
  • 「送る側の公開鍵と受ける側の秘密鍵」「受ける側の公開鍵と送る側の秘密鍵」のどちらの組み合わせでも同じ共有鍵を生成できる。

というものになります。すなわち、送る側と受ける側の両方が秘密鍵を外部に漏らさない限りは暗号の解読が困難になり、それでいて共有鍵を直接送らずに同じ鍵を共有できる、というわけです。そこで、Web Pushでは、

  • UAが鍵ペアを生成したら、その公開鍵だけをアプリケーションサーバに渡す。
  • アプリケーションサーバも鍵ペアを生成し、その秘密鍵と、UAから受け取った公開鍵で、共有鍵を生成し、後述するsaltと併せて暗号化に使う。
  • アプリケーションサーバは自身の公開鍵とsalt、暗号文をプッシュサーバ経由でUAに渡す。
  • UAはアプリケーションサーバの公開鍵と自身の秘密鍵で共有鍵を生成し、saltを組み合わせてアプリケーションサーバと同じ手順で暗号文を解読して平文に戻す。

という手順を取ります。また、AES-GCMで実際に用いる鍵とIV(Initialization Vector; 暗号化に使う初期化ベクタ)は、鍵共有から生成できる共有鍵や、saltと呼ばれる乱数、等から、HMAC等の処理を施して生成するため、より一層解読を困難にできるよう工夫されています。

プッシュ通知の具体的な手順

では、実際のWebアプリとWebサーバの両方における実装の仕方を大まかに紹介します。

Webアプリ側でendpointと公開鍵を取得

Webアプリ側の実装については、まずはChromeでW3C Push APIを使ってみたで示しているような手順で、pushManager.subscribe(options)pushManager.getSubscription()によって、subscriptionを取得します。

ここで、Web Pushに対応しているブラウザの場合は、subscription.endpointでプッシュサーバのendpointに加えて、subscription.getKey()によって必要な鍵を取得することが出来ます。

アプリケーションサーバにendpointと公開鍵を登録する例
navigator.serviceWorker.ready.then(function(registration) {
  registration.pushManager.getSubscription().then(function(subscription){
    fetch(appServerURL, {
      credentials: 'include',
      method: 'POST',
      headers: { 'Content-Type': 'application/json; charset=UTF-8' },
      body: JSON.stringify({
        endpoint: subscription.endpoint,
        key: btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('p256dh'))))
               .replace(/\+/g, '-').replace(/\//g, '_'),
        auth: btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('auth'))))
               .replace(/\+/g, '-').replace(/\//g, '_')
    });
  });
});

subscription.getKey()の引数には鍵の種類を指定します:

getKey()の戻り値はArrayBuffer型ですので、上記の例では、Uint8Array型に変換してBase64 URLエンコードによって文字列に変換してからアプリケーションサーバに送信しています。

アプリケーションサーバ側でプッシュ通知を送信

アプリケーションサーバ側でプッシュ通知のendpointとブラウザの公開鍵を取得できれば、アプリケーションサーバからプッシュサーバのendpointにHTTP POSTでプッシュ通知の内容を送信することで、ブラウザ側にプッシュ通知が渡され、ブラウザ側で暗号化されたメッセージを解読してService Worker側で処理できるようになります。

結構ややこしい処理になりますので、説明するよりはサンプルコードを示したほうが良いのではと思います。

Node.jsの場合

Web Push Encryptionに対応したNode.js用のサンプルコードがGitHubで公開されていますので、こちらを試しに使ったり、アプリケーションサーバの実装の参考にしたりするとよいでしょう。関連するリンクを次に示します。

下の2つについては、npmでもインストール可能です。

npm install web-push
npm install encrypted-content-encoding

アプリケーションサーバ側の実装で参考になる箇所は、

辺りになると思います。これらのコードで鍵ペアの生成やメッセージの暗号化を行っています。

Javaの場合(Tomcat, Jetty等)

上記のNode.jsのコードを参考にして、こちらでJavaのコードを書いてみました。サンプルページも用意しておりますので、ChromeあるいはFirefoxでお試し下さい。

Javaでの実装を試してみて注意が必要だった点は次の通りです。

  • Oracle Java 8だけでは楕円曲線の指定ができないようなので、Bouncy Castleの暗号ライブラリを利用する必要があります。この時、Security.addProvider(new BouncyCastleProvider());でプロバイダを追加し、その後の各メソッドでBCを暗号プロバイダとして指定する必要があります。
  • 上記のNode.jsでも同じですが、楕円曲線の名前としてはprime256v1を指定します。
  • アプリケーションサーバの公開鍵localPublicKeyをプッシュ通知に付与する際には、localPublicKey.getEncoded()ではなく、localPublicKey.getQ().getEncoded(false)を送るようにします。ブラウザの公開鍵が65バイトなので、同じ65バイトのデータになっていれば正しい公開鍵と考えられます。

具体的には何の計算をしているのか

ざっとですが、実際のアルゴリズムを書き下します。以下、||は、バイナリデータの連結を表します。

上記のライブラリを活用する場合は下記の詳細なアルゴリズムは参照不要ですが、ご自身で暗号化等を実装する場合等に参考になれば幸いです。

HMAC-based Key Derivation Function (HKDF) [RFC 5869]

Web Push EncryptionではHKDFによって鍵やNonceと呼ばれる暗号化パラメータを生成しますので、HKDFの手順を簡単に説明します。具体的にはextractとexpandの2つの関数で構成されます。

  • HKDF_extract(salt, IKM): 乱数saltを鍵(salt)として、HMAC SHA-256によってメッセージIKM(Input Keying Material)をハッシュする。その結果はPRK (Pseudo-Random Key)と呼び、32バイトのバイナリデータとなる。
  • HKDF_expand(PRK, info, L): infoは何らかの文字列とし、PRKを鍵(salt)として、info || 0x01をHMAC SHA-256によってハッシュし、そのうち先頭のLバイトを取り出す(Lが32バイト以内の場合)。

Web Push Encryption

Web Push Encryptionの手順は次の通りです。なお、authがない場合において下記手順9.のcontextを空データとすると、Firefox 44/45が対応する仕様と同じになります。

(注: auth付き暗号化に対応しているブラウザのうち、Chrome 49に限りdraft-ietf-httpbis-encryption-encoding-00に準拠しているため要注意ですが、そもそもChrome 49でWeb Push暗号化に対応するにはフラグを有効化する必要がありますので、実用上はあまり意識しなくても良いかもしれません。)

  1. ブラウザから公開鍵とauthを取得する。
  2. アプリケーションサーバ用の鍵ペアを生成する。
  3. ブラウザの公開鍵とアプリケーションサーバの秘密鍵から共有鍵を生成し、そのバイナリデータをIKMとする。
  4. 16バイト(128ビット)の乱数を生成し、これをsaltとする。
  5. authが無い場合は、PRK = HKDF_extract(salt, IKM)を計算し、9.に進む。
  6. _PRK = HKDF_extract(auth, IKM)を計算する。
  7. _IKM = HKDF_expand(_PRK, "Content-Encoding: auth" || 0x00, 32)を計算する。
  8. PRK = HKDF_extract(salt, _IKM)を計算する。
  9. context = 0x00 || "P-256" || 0x00 || (ブラウザ公開鍵のバイト長の16ビット表現(=0x0041)) || (ブラウザ公開鍵のバイナリデータ) || (サーバ公開鍵のバイト長の16ビット表現(=0x0041)) || (サーバ公開鍵のバイナリデータ)を取得する。
  10. HKDF_expand(PRK, "Content-Encoding: aesgcm" || context, 16)を計算し、得られる128ビットのバイナリデータをAES-GCMでの暗号鍵とする。
    • Chrome 49に対しては、HKDF_expand(PRK, "Content-Encoding: aesgcm128" || context, 16)とする。
  11. HKDF_expand(PRK, "Content-Encoding: nonce" || context, 12)を計算し、得られる96ビットのバイナリデータをAES-GCMでのnonceとする。
  12. 10.の暗号鍵と11.のnonceを使って128ビットAES-GCMでメッセージを暗号化する。
    • 4078バイト単位(Chrome 50+)でメッセージを区切って暗号化する。(Chrome 49の場合は4079バイト単位。)
    • 区切られたブロックの先頭には0x0000の2バイト(Chrome 49の場合は0x00の1バイト)を付けて、実際には4080バイト単位で暗号化する。暗号化されたペイロードは16バイトのヘッダ込みで4096バイト単位となる。
    • 暗号鍵は11.で生成したものを利用する。
    • 先頭ブロックを0としてカウンタの値をブロック単位で1ずつ増加させる。
    • AES暗号化で使うIVの値は、12.のnonceの下位6バイトについてカウンタの値とのXORを取って置き換えたものとする(上位6バイトはそのまま)。
  13. endpointで示されるプッシュサーバのURLにHTTP POSTで暗号化したメッセージを送信する。このとき、HTTPリクエストヘッダに次のパラメータを指定する。
    • Crypto-Key:(Firefox 44/45の場合はEncryption-Key:)にkeyid=p256dh;dh=(Base64 URLエンコードした10.の共有鍵)を指定
    • Encryption:keyid=p256dh;salt=(Base64 URLエンコードした4.のsalt)を指定
    • Content-Encoding:aesgcm(Chrome 49及びFirefox 44/45の場合はaesgcm128)を指定
    • Authorization:key=(GCMのserver key)を指定(Chromeのみ; 詳細はChromeでW3C Push APIを使ってみたを参照)
    • TTL:にプッシュ通知の受信有効期限を秒単位で指定

なお、Chrome 51以前のブラウザに対してWeb Pushでプッシュ通知を送信する場合は、endpointとしてPush APIから取得できるGCMサーバのURLを次のように書き換える必要があります。(参考)

https://android.googleapis.com/gcm/send/[registration_id]
    ↓
https://gcm-http.googleapis.com/gcm/[registration_id]

なお、Chrome 52以降で、GCMのserver keyをmanifestに記述する方法ではなく、VAPIDでサーバ認証を行っている場合は、endpointのURLはFirebase Cloud Messaging (FCM)のものとなります(上記のような書き換えが不要になります)。

Service Workerでプッシュ通知を受信

アプリケーションサーバ側で正しくブラウザとの鍵共有とメッセージの暗号化ができていれば、プッシュサーバ経由でブラウザにメッセージをプッシュ通知し、ブラウザ側で暗号を解読して通知を表示することが出来ます。

アプリケーションサーバから送信したプッシュ通知のメッセージの例(平文)
{
  "title": "Push Message",
  "body": "プッシュ通知にメッセージを付けて送ることが出来ましたよ",
  "tag": "pushMessage201512080001"
}
ServiceWorkerでの受信例
self.addEventListener('push', function(evt) {
  if(evt.data) {
    var data = evt.data.json();
    evt.waitUntil(
      self.registration.showNotification(
        data.title,
        {
          icon: '(アイコンのURL(パスのみでOK))',
          body: data.body,
          tag: data.tag
        }
      )
    );
  }
}, false);

pushイベントのリスナ関数ではプッシュ通知受信時にPushEventオブジェクトを受け取りますが、Web Pushに対応している場合はevt.dataにメッセージ(ペイロード)の内容が格納されます。ペイロードが空であれば、evt.datanullとなります。

上記のサンプルコードでは、Fetch APIに似た感じで、evt.data.text()で文字列、evt.data.json()でJSONオブジェクト、evt.data.arrayBuffer()でArrayBuffer、evt.data.blob()でBlobとしてメッセージを受け取ることが可能です。(Fetch APIと違い、Promiseではなくメソッドの戻り値として直接受け取ります。)

その他

  • Firefoxについて、こちらの環境(OS X El Capitan)で試してみた限りでは、ウィンドウが全て閉じられていると、通知をクリックしてもウィンドウが開かず、中身がなくても良いので何かウィンドウを開いていれば、通知クリックでそのWebアプリを開くことができるようになる、といった挙動でした。今後のバージョンアップで改善されることを願います。