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

  • 38
    いいね
  • 0
    コメント

はじめに

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

なお、本記事は、Web Pushでブラウザにプッシュ通知を送ってみるのアップデートとなっています。

旧記事との差分

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

Web Push

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

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

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

対応ブラウザ

現時点ではChrome, FirefoxがWeb Pushに対応しています。ただ、Web PushのRFC化に向けた仕様変更が進み、実装が追従している段階で、本記事は今のところChrome 60向けの内容となっています。なお、Firefoxも順次対応が進むはずですので、状況が進展次第、本記事でもフォローします。

Push API: HTMLページ側

Push APIService Workerの仕組みを使います。少し具体化すると、(a) 主にHTMLページ側でプッシュ通知の登録・解除を行い、(b) Service Workerで(当該HTMLページが閉じている間でも)プッシュ通知を受信する、という2段階になっています。

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

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

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

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

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

プッシュ通知の登録
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を試してみるを参照して下さい。

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

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

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

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

本記事ではaes128gcmの暗号化手順を紹介します。aesgcmについてはWeb Pushでブラウザにプッシュ通知を送ってみるを参照して下さい。

以下、具体的な処理の内容を示しますが、Node.js向けにはオープンソースのweb-push-libがありますので、そちらを利用するのが簡単です。

参考程度に、Javaのサンプルコードテストサイトを用意していますので、ご興味のある方はお試しください。

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: " || 0x00 || ブラウザ公開鍵 || サーバ公開鍵
  4. IKM = HMAC_SHA_256(PRK_key, key_info || 0x01)

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

  1. salt = 16バイトの乱数列
  2. PRK = HMAC_SHA_256(salt, IKM)
  3. cek_info = "Content-Encoding: aes128gcm" || 0x00
  4. CEK = HMAC_SHA_256(PRK, cek_info || 0x01)の先頭16バイト
  5. nonce_info = "Content-Encoding: nonce" || 0x00
  6. 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: プッシュ通知の受信有効期限を秒単位で指定

VAPIDでサーバ認証する場合は、さらにCrypto-KeyヘッダとAuthorizationヘッダの指定も必要になります。なお、以前のaesgcmを使う際はメッセージ暗号化のECDH公開鍵とサーバ署名のECDSA公開鍵の両方を一つのフィールドに記入していましたが、aes128gcmではECDSA公開鍵のみとなります。

Push API (Service Worker側)

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

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の親ドキュメントと同じオリジンに限られます。)

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以降が対応しています。なお、今のところChromeのプッシュ通知(FCM)では特にエンドポイントに有効期限が設けられていないため、常にnullとなります。