本記事のアップデートについて
※ Chrome 60以降、Firefox 55以降では、暗号化の手順に変更が生じます。詳細は、[改訂版] Web Pushでブラウザにプッシュ通知を送ってみるを参照してください。
- 2022-05-03: 今後メンテナンスを行わない見通しであることから、Javaで動作するデモの公開を終了いたします。長らくありがとうございました。なお、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)
- アプリケーションサーバ: webサイト側で用意する、上記プッシュサーバにプッシュ通知をリクエストするサーバ
があります。ブラウザはプッシュ通知の購読をプッシュサーバに要求すると、プッシュサーバから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()
によって必要な鍵を取得することが出来ます。
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()
の引数には鍵の種類を指定します:
-
'p256dh'
: 鍵共有プロトコルECDHで利用するブラウザの公開鍵を取得します。ここで、p256dh
という名称は、楕円曲線の種類である、P-256あるいはprime256v1を指します。 -
'auth'
: 仕様修正後に導入されたパラメータで、鍵生成をさらに複雑にするための秘密の乱数(仕様書ではAuthentication Secret)を取得します。ECDHの鍵共有だけではセキュリティ上の懸念が残るため仕様に追加されたもののようです。以下、authと呼びます。
getKey()
の戻り値はArrayBuffer
型ですので、上記の例では、Uint8Array
型に変換してBase64 URLエンコードによって文字列に変換してからアプリケーションサーバに送信しています。
アプリケーションサーバ側でプッシュ通知を送信
アプリケーションサーバ側でプッシュ通知のendpointとブラウザの公開鍵を取得できれば、アプリケーションサーバからプッシュサーバのendpointにHTTP POSTでプッシュ通知の内容を送信することで、ブラウザ側にプッシュ通知が渡され、ブラウザ側で暗号化されたメッセージを解読してService Worker側で処理できるようになります。
結構ややこしい処理になりますので、説明するよりはサンプルコードを示したほうが良いのではと思います。
Node.jsの場合
Web Push Encryptionに対応したNode.js用のサンプルコードがGitHubで公開されていますので、こちらを試しに使ったり、アプリケーションサーバの実装の参考にしたりするとよいでしょう。関連するリンクを次に示します。
- https://github.com/googlechrome/push-encryption-node
- https://github.com/marco-c/web-push
- https://github.com/martinthomson/encrypted-content-encoding
下の2つについては、npmでもインストール可能です。
npm install web-push
npm install encrypted-content-encoding
アプリケーションサーバ側の実装で参考になる箇所は、
- https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js
- https://github.com/marco-c/web-push/blob/master/index.js
- https://github.com/martinthomson/encrypted-content-encoding/blob/master/nodejs/ece.js
辺りになると思います。これらのコードで鍵ペアの生成やメッセージの暗号化を行っています。
Javaの場合(Tomcat, Jetty等)
上記のNode.jsのコードを参考にして、こちらでJavaのコードを書いてみました。最後に更新してから何年も経過しているため現在のJavaで動作するかどうか確認できていませんが、参考になれば幸いです。
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暗号化に対応するにはフラグを有効化する必要がありますので、実用上はあまり意識しなくても良いかもしれません。)
- ブラウザから公開鍵とauthを取得する。
- アプリケーションサーバ用の鍵ペアを生成する。
- ブラウザの公開鍵とアプリケーションサーバの秘密鍵から共有鍵を生成し、そのバイナリデータを
IKM
とする。 - 16バイト(128ビット)の乱数を生成し、これをsaltとする。
- authが無い場合は、
PRK = HKDF_extract(salt, IKM)
を計算し、9.に進む。 -
_PRK = HKDF_extract(auth, IKM)
を計算する。 -
_IKM = HKDF_expand(_PRK, "Content-Encoding: auth" || 0x00, 32)
を計算する。 -
PRK = HKDF_extract(salt, _IKM)
を計算する。 -
context = 0x00 || "P-256" || 0x00 || (ブラウザ公開鍵のバイト長の16ビット表現(=0x0041)) || (ブラウザ公開鍵のバイナリデータ) || (サーバ公開鍵のバイト長の16ビット表現(=0x0041)) || (サーバ公開鍵のバイナリデータ)
を取得する。 -
HKDF_expand(PRK, "Content-Encoding: aesgcm" || context, 16)
を計算し、得られる128ビットのバイナリデータをAES-GCMでの暗号鍵とする。- Chrome 49に対しては、
HKDF_expand(PRK, "Content-Encoding: aesgcm128" || context, 16)
とする。
- Chrome 49に対しては、
-
HKDF_expand(PRK, "Content-Encoding: nonce" || context, 12)
を計算し、得られる96ビットのバイナリデータをAES-GCMでのnonceとする。 - 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バイトはそのまま)。
- endpointで示されるプッシュサーバのURLにHTTP POSTで暗号化したメッセージを送信する。このとき、HTTPリクエストヘッダに次のパラメータを指定する。
-
Crypto-Key:
(Firefox 44/45の場合はEncryption-Key:
)にdh=(Base64 URLエンコードした10.の共有鍵)
を指定 -
Encryption:
に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"
}
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.data
がnull
となります。
上記のサンプルコードでは、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アプリを開くことができるようになる、といった挙動でした。今後のバージョンアップで改善されることを願います。