まだ重たいCMSをお使いですか?
毎秒 723リクエスト を捌く超高速CMS「adiary

2017/04/22(土)Web Pushの実装まとめ(Chrome/Firefox/Android共通)

WebPushを実装したまとめ。ネット上に「古い仕様に基づく情報」や「ブラウザ固有の情報」が散見してて非常にわかりくかったので、必要な情報をすべてまとめました。

WebPushとは

ブラウザに対して、スマートフォンのようなPush通知を送る仕組みです*1。2015/04/14に登場した「Chrome 42」に初めて登場した機能で、その後紆余曲折を経て現在に至ります。

簡単に流れを説明します。

  1. WebPushに対応したサイトにブラウザでアクセスします。
  2. ユーザーが、サイトに対して通知の許可を出します。
  3. サイトは、JavaScriptによって serviceWorker からPush通知に必要な情報を取得し、サイトに保存します。
  4. サイトは、通知をしたいときに、保存しておいた情報を元に対象ブラウザの通知を管理するサイト*2にPOSTします。
  5. ブラウザに対して通知が発行されます。
    • PCならば、ブラウザ起動時なら即時に、起動時でなければ次回起動時に通知が表示されます。
    • スマートフォンならば、アプリの通知と同じように通知が飛びます。

つまりWebサイトから、個別のユーザーに対して自由に通知が送れるようになるわけです。TwitterをChromeなどで使っている人は、「Web通知」でDMやリプライの通知を受け取った人も多いかと思います。

*1 : 対応ブラウザは Chrome, Firefox などのモダンブラウザですが、最新のEdgeも対応するとかしないとか?

*2 : https://fcm.googleapis.com や https://updates.push.services.mozilla.com

WebPushを理解するのに必要な前提知識

WebPushを理解するためには「DH鍵交換と公開鍵暗号の基礎知識」が必要になります。資料のリンクを貼っておきます。検索してみても良い資料が少ないですね……。気が向いたら自分で執筆しようかな。

楕円曲線暗号(ECC)とは

またWebPushでは公開鍵暗号の中でも楕円曲線暗号や楕円曲線DH鍵交換を使用します。楕円曲線とは、

\begin{equation} y^2=x^3+ax+b \qquad \textrm{※ただし}a,b\textrm{は定数} \label{ecc} \end{equation}

という曲線のことで、この曲線の上に点を取って、「郡」と呼ばれる「整数の足し算、引き算、掛け算」*3を作ることが出来ます。

DH鍵交換や公開鍵暗号というのは、いずれも「足し算、引き算、掛け算」を使用して作られていますので、「足し算、引き算、掛け算」ができる世界ならば(多くは)同じように構成することができます。要は解読しにくければいいのですが、楕円曲線暗号は従来のRSA暗号などで使われる通常の数字の世界よりもはるかに解読しにくいことが知られています。

解読しにくいということは、扱う数字の桁数が少なくても十分強力な暗号が作れるということであり、桁数の少なさも影響し、RSAよりも計算が楽*4だったりもして最近よく使われます。

*3 : 厳密には整数とは違うのですが

*4 : RSAは桁数が多いことの他に間違えなく素数を選ぶという作業が結構大変だったりします。そもそも、ある値の範囲の素数を瞬時に判定できたら素因数分解は今よりずっと楽になりますのでRSAの安全性が下がるという自己矛盾です。

ServiceWorker APIについて

WebPushのブラウザ側の操作には、JavaScriptのServiceWorker APIを使用します。

この ServiceWorker API は https 環境もしくは localhost 接続でのみ有効になるのですが、開発やテスト時に https を用意するのは結構面倒ですので、http 接続で誤魔化す方法を説明しておきます。

  • Firefox : about:config から開発用の設定する。F12のデベロッパーウィンドウ表示時のみ有効になります。

    devtools.serviceWorkers.testing.enabled = true

  • Chrome : 起動時のオプションでドメインを指定する。この際、新しいユーザープロファイルも必ず指定する(新しいプロファイルでないとこのオプションは有効になりません)。

    --user-data-dir=/test/only/profile/dir

    --unsafely-treat-insecure-origin-as-secure=http://example.com

WebPushを実装するための手順

WebPushで使用する暗号は prime256v1 と呼ばれる種類の楕円曲線暗号です。楕円曲線は無数に種類がありますが*5、その中から「暗号として使える」とされているものに名前が付いています。また同じパラメーターにも、複数の名前が付いていて「prime256v1」のだけでも以下の通りたくさんあります。

P-256, prime256v1, nistp256, secp256r1

すべて同じ楕円曲線暗号(暗号パラメーター)を示します。WebPushで使用する暗号はすべてこの楕円曲線になります。

以下が今現在正しく動作する手順です。

  • "\xYY"は文字コード0xYYの文字を示します。
  • 数値はすべてビッグエンディアン表記です。

HKDF

途中に登場する HKDF() は HMAC-SHA256を使用した以下の関数になります。(RFC5869

HMAC_SHA256(key, ikm) {
	prf = key を鍵とした ikm のハッシュ
	return prf;
}
/* IKM = Input Keying Material, PRK = Pseudo-Random Key(32byte) */

HKDF(salt, ikm, info, len) {
	prk = HMAC_SHA256(salt, ikm);
	msg = HMAC_SHA256(prk, "$info\x01");
	return "<msgの先頭 len byte>";
}

登録

  1. Webアプリで、公開鍵spubと秘密鍵sprvのペアを生成する。
  2. serviceWorker を使い、アプリの公開鍵spubおよびpush通知受信用スクリプトを登録する。
  3. ブラウザの通知先URL(endpoint)、公開鍵cpub、authトークンをそれぞれ取得し、Webアプリに保存する。

通知メッセージの暗号化

  1. 送信したいメッセージをJSON形式で生成する。メッセージは4078byte以下とする。*6
  2. メッセージ暗号化用の、公開鍵mpubと秘密鍵mpubのペアを生成する(※mpub=spub, mprv=sprv でも構わない模様)*7
  3. メッセージ用秘密鍵mprvとクライアント公開鍵cpubを使い、共有鍵ikmを計算する。*8
  4. 16byteのランダム文字列saltを生成する
  5. 鍵情報文字列context = "P-256\x00$lc$cpub$ls$spub"を生成する。$lc$cpubの、$ls$spub長さの2byte表記であり、共に65byteなので実用上"\x00\x41"になる。よってcontextは事実上140byte固定になる。*9
  6. PRK = HKDF(auth, secret, "Content-Encoding: auth\x00", 32) を計算する。
  7. メッセージ暗号化共有鍵を計算する。
    aeskey = HKDF($salt, $prk, "Content-Encoding: aesgcm\x00$context", 16)
  8. メッセージ暗号化用nonceを計算する。
    nonce = HKDF($salt, $prk, "Content-Encoding: aesgcm\x00$context", 16)
  9. aeskey と nonce を使い 128ビットAES-GCM でメッセージを暗号化する。

VAPIDの生成と署名

この処理により、ブラウザに登録したspubの秘密鍵sprvを所有していることを証明します。

  1. JWTヘッダを生成する。jwt = '{"typ":"JWT","alg":"ES256"}'で固定。*10
  2. claimと呼ばれるJSONを生成する。
    {
    	"aud": "https://push.services.mozilla.com",
    	"exp": 1458679343,
    	"sub": "mailto:webpush-admin@example.com"
    }
    
    この時各値は以下の通り。
    • aud : endpoint とされる通知先URLのホスト名部まで
    • exp : UTCによる有効期限。最長でも現在 +86400秒(24時間)まで
    • sub : アプリの連絡先(mailto:で始まるメールアドレスか、https:で始まるURLを1つ*11
  3. JWTヘッダをBase64 URL safe Encodeしたものを jwth、claimをBase64 URL safe Encodeしたものを jwtc とする。
  4. jwthとjwtcを"."で連結したもの(jwth + "." + jwtc)を、秘密鍵sprvを使ってSHA256ハッシュ署名し、得られた署名をsigとする。
    • ここで得られる署名は通常70byte(もしくは71か72byte)のASN1 DER formatと呼ばれる物になりますが、実際に署名として添付するのはこの中に含まれる32byteの2つの値(整数)のみです。
  5. JavaScriptによる実装のように関数を使って、もしくはASN1 DER formatを直接分解して「2つの32byteの値sig_x/sig_y」を取り出す(補足参照)。
  6. jwt_sig = "\x04$sig_x$sig_y" と署名を連結する。"\x04"はバイナリ値であることを示すマークである。
  7. jwt_sigをBase64 URL safe Encodeしてjwt_sとする。
  8. それぞれ"."で連結して「jwt = jwth + "." + jwtc + "." + jwt_s」とする。

ヘッダの生成

あとはendpointに向けて、暗号化したメッセージをPOSTするだけですが、ヘッダを適切に設定しなければなりません。

TTL: 86400
Content-Encoding: aesgcm
Crypto-Key: keyid=p256dh;dh=base64urlsafe(mpub);p256ecdsa=base64urlsafe(spub)
Encryption: keyid=p256dh;salt=base64urlsafe(salt)
Authorization: WebPush jwt;
  • base64()となっているところは、URL safe Base64エンコードした文字列を記述します。
  • TTLは通知の有効期限(秒)です。無いとエラーになります。JWTヘッダのexpと違い特に最大値の規定はないようです。
  • Authorizationの「WebPush」は古い仕様では「Bearer」になっています。今のところどっちでも良いようです。
  • Authorizationのjwtは既に述べたとおり「JWTヘッダ」「claim」「jwt_s」をそれぞれURL safe Base64エンコードして"."で連結したものです。

実際の例はデモにリンクしていますので、そこで確認してください。

*5 : (\ref{ecc})式のa,bおよび初期値s(底)

*6 : 4078byte以上を送る場合はメッセージ暗号化の部分がやや複雑になりますが、実用上4078byteで困らないので省略します。

*7 : spub/sprvと同じものを使いまわさず、都度生成するほうが暗号強度は高いかもしれないけども……。spub/sprvと同じにしないまでも、予め生成しておいても良い。

*8 : ECDH=楕円曲線DH鍵交換

*9 : 他の解説での context は "P-256\x00" を含まず135byteになります。わかりやすさを優先し改変してあります。注意してください。

*10 : "ES256"は暗号化方式P-256 = prime256v1 を示します。

*11 : 最新のドラフトを読む限り http:// のURLは許可されていません。

補足情報

URL safe Base64(base64url encode)について

WebPushで使用するBase64エンコードはRFC7515で規定されているもので、通常のBase64のうち「"+"を"-"に」「"/"を"_"に」置き換え更に末尾の"="を除去したものです。

検索するとたった2文字しかない置き換えを間違えている日本語情報が出てきますのでお気をつけ下さい(そしてお察しください……

ASN1 DER formatについて

以下のフォーマットです。

+00h	30h	SEQUENCE
+01h	--	SEQUENCE Length
+02h	02h	Tag
+03h	x	X Length
+04h	--	X
x+4	02h	Tag
x+5	y	Y Length
x+6	--	Y

P-256に限って言えば、全体が70byteでXもYも32byteですから、通常は以下のようになります。

00h	30h	SEQUENCE
01h	70	SEQUENCE Length
02h	02h	Tag
03h	32	X: 32byte
04h-23h	--	X
24h	02h	Tag
25h	32	Y: 32byte
26h-45h	--	Y

決め打ちしても問題ありません……と言いたいところですが、XやYの先頭に"\x00"が付いていて33byteになることがあるようです。使用するライブラリによるかと思いますが、そういう場合決め打ちはできませんし、XやYの先頭のヌル文字("\x00")を除去して32byteのみ取り出す必要があります。

WebPush実装に必要なライブラリ

Base64は普通あると思うので、それ以外に次の機能が必要になります。

  • 楕円曲線暗号ライブラリ
    • ECC P-256の秘密鍵と公開鍵の生成
    • ECC P-256を使ったECDH(楕円曲線DH鍵交換)
    • ECC P-256による文章署名(文章のSHA256ハッシュに対する署名)
  • HMAC-SHA256計算ライブラリ
  • AES-GCM計算ライブラリ(共有鍵暗号)

JavaScript側の実装

JavaScript側の実装はそんなに難しくありません。サンプルを読めば簡単に理解できるかと思いますが、一応解説しておきます。

ServiceWorker API

WebPushの実現にはServiceWorker APIを使用します。

通常JavaScriptの実行は、サイトを訪れているときだけ明示的に実行されますが、いついかなる時もサイトを閉じた瞬間にすべての実行状態は失われます。そうではなく、サイトを訪れた際に「ServiceWorker」にJavaScriptファイルを登録します。

そのJavaScriptファイル内では、イベントを登録し、登録したイベントが発生した時の処理を記述します。受け取るイベントは、WebPushの通知などですが、ブラウザに入力されたURLを加工するなんてこともできるようです。

ServiceWorkerの登録

サイトを開いた時に実行されるJavaScriptで、登録処理を行います。

ServiceWorkerとなるスクリプトの位置(パス)は重要です。ServiceWorkerには、そのスクリプトのあるディレクトリ内およびそれより下位にしかアクセス権限がありません。このパスのことをスコープと言います。詳しくは後述します。

var spub_bin = "<アプリ公開鍵spubのバイナリ>";
var serviceWorkerScript = "push.js";

if (!navigator.serviceWorker) return;

// 通知の許可を求めるダイアログを表示します。
Notification.requestPermission( function(permission) {
	if (permission !== 'granted') return;	// 「許可」以外は処理しない

	// ServiceWorkerスクリプトをブラウザに登録
	navigator.serviceWorker.register(serviceWorkerScript).then( function(registration) {
		regist_push(registration);
	}).catch(function(error) {
		alert(error);
	});
});

function regist_push(registration) {
	// 登録してあるPush通知の情報を取得
	registration.pushManager.getSubscription().then(function(subscription){
		if (!subscription) {	// 登録されていない
			var spub = Uint8Array.from(spub_bin.split(""), c => c.charCodeAt(0));
			// ブラウザにpush通知を受け取るよう登録
			registration.pushManager.subscribe({
				userVisibleOnly: true,
				applicationServerKey: spub
			}).then(setSubscription);
			return;
		}
		setSubscription(subscription);
	});
}

function setSubscription(subscription) {
	var cpub = arybuf2bin( subscription.getKey('p256dh') );	// ブラウザ公開鍵
	var auth = arybuf2bin( subscription.getKey('auth')   ); // 
	var endpoint = subscription.endpoint;
	/* cpub, auth, endpointをWebアプリに登録する */
}

細かい所は文末にあるデモの実際のソースを見て下さい。

ServiceWorkerスクリプト(登録されるスクリプト)

今度は登録される側のスクリプトです。

// push通知が来た時に発生するイベント
self.addEventListener('push', function(evt) {
	if (!evt.data) return;
	var data = evt.data.json();
	evt.waitUntil(
		self.registration.showNotification(data.title, data);
	);
}, false);

// 通知を表示した「notification」をクリックした時に発生するイベント
self.addEventListener('notificationclick', function(evt) {
  evt.waitUntil(
	var data = evt.notification.data || {};
	if (data.url) clients.openWindow(data.url);
  );
}, false);

showNotification()の詳細はマニュアルに譲りますが、重要なのは以下の項目です。

  • data.tag : 通知を一意に識別するたのタグ。*12
  • data.body : メッセージの本体。
  • data.icon : 通知と共に表示するアイコン画像のパス(又はURL)。
  • data.data : ユーザーが自由に利用できる値。通知をクリックした時に発生する「notificationclick」イベント等から参照します。

URLとスクリプトのキャッシュの扱いは少し注意が必要です。

例えば、登録時のURL(ファイル位置)が http://example.com/js/push.js だとします。

このときこのスクリプトはファイルがあった位置(スコープ)で実行されることになります。つまり、location.hrefには http://example.com/js/push.js が入り、img/img.png というパスを指定すれば、それはすなわち http://example.com/js/img/img.png というファイルを指定したことになります。

またこのスクリプトファイルキャッシュされ、前回のキャッシュから24時間経過したときでないと更新されません*13。しかし24時間後に必ず更新されるわけではありません

明示的に更新させたいときは、update()を発行する必要があります。

navigator.serviceWorker.getRegistration(serviceWorkerScript).then(function(registration) {
		return registration.update();
});

また、ServiceWorker内では使えるAPIが限られています。当然DOMは触れませんし、例えばalert()とか無理です(console.log()等は使えます)。

この影響もあり、ServiceWorker Scriptは結構デバッグしにくいのが難点です。

ServiceWorkerスクリプトのスコープと権限

下記のようにスクリプト内で、ブラウザで開いているウィンドウ(タブ)を触ることができるのですが、この時触れるウィンドウはスコープ以下のみです。スコープ対象外のウィンドウはclistとして得ることができません。

clients.matchAll({ type: 'window' }).then(function(clist) {
	var url = "開きたいURL等";
	for(var i=0; i<clist.length; i++) {
		var c = clist[i];
		if (c.url == url) return c.focus();
	}
	clients.openWindow(url);
});

また、ServiceWorkerスクリプトにはスコープ内のURLを動的に書き換えたり、そのURLに対して動的にコンテンツを設定することができますが、ここでもスコープが重要になってきます。

与えられたURLを単純に開くだけならば、スコープは気にしなくても大丈夫です。

*12 : 同じタグで何度通知を送信しても1つしか表示されないようです(おそらくブラウザの実装による)。

*13 : Webサーバの設定にもよる。詳細は検索してね。

VAPID/JWTヘッダ署名の謎

※この項目は実装とは関係のないお話です。

このJWTヘッダと署名、アプリ公開鍵のブラウザへの登録は後から追加された仕様なのですが、アプリ公開鍵としてメッセージ暗号化鍵の公開鍵(mpub)ではなく、JWTヘッダ署名用の公開鍵(spub)を登録させたのが謎です。

というのも、JWTヘッダ(とclaim)には、TTLと送り先URLのドメイン部のみしか含まれてないので、もしその2つを傍受することが出来たなら中身の「TTL有効期限」まで(多くの場合1日)有効な使いまわしできるJWTヘッダと署名を得ることが出来ます。

使い回しできないデータを署名しなければ意味などほとんど無いのですが、これ何の目的で設計されたものなんでしょうか? というわけで、VAPIDのドラフトの冒頭を読んでみました。プッシュ通知を送ってきた人をプッシュ通知を管理するサーバ側で識別できることに意味があるらしい。

ついでに署名は、endpoint URLの秘密を軽減するってなってるけども、全体の構成を考えるとセキュリティの向上には役に立ってないような……。そのためかどうか知りませんが、FirefoxではVAPIDはオプションで(今のところ)使用しなくても通知を送信できます。

DOS攻撃の危険性

WebPushはその仕組み上、ブラウザから知らされたURLを信用してPush通知を送信します。

つまりPush通知先サイトとして、嘘のURLを大量に教えることでDOS攻撃をさせることができます。ここで危険なのは、WebサービスそのものがDOS攻撃をしてしまうことです。

攻撃方法1

例えば、example.com に攻撃をしたい場合。endpointとして https://example.com/XXXXX のようなURLをランダムに生成します。このURLを WebPush 対応サイトに大量に投げ込みます。1000サイトにそれぞれ1万URLを登録します。

1サイト平均5回ぐらい更新通知が届くと仮定すれば、最初の登録作業以外は何もしなくても1日当たり5000万POSTの無駄な負荷をかけることができます。

攻撃方法2

get.example.com にCSRF脆弱性があり、Queryを無条件に処理をすることでコメントを投稿できるなどの欠陥があるとします。

  • 攻撃URL https://get.example.com/?msg=CSRF+text+message

このURLを通知先として、WebPushサービスを提供しているサイトに登録します。するとサイトは、正しく通知を送っているつもりにも関わらず、https://get.example.com に対する CSRF脆弱性攻撃に加担してしまいます。

対応策

具体的な対応策としては「通知サービスURL以外には通知を送らない」ぐらいしか思いつきません。新しいブラウザや通知先が増える度に対応URLをホワイトリストに追加する必要があり汎用性は最悪ですが、仕様が変わらない限り他の方法を思いつきません。

  • https://fcm.googleapis.com
  • https://updates.push.services.mozilla.com

WebPushは通知先URLが正しいかどうか検証する手段*14を用意するべきだと思うのですが……。

*14 : 同一ホスト名の /webpush などの特定URLをGETしたときに、WebPush通知サーバであることを示すヘッダを返す等。

まとめ

これで、WebPushを実装するための(開発言語に依存しない)情報は網羅されていると思いますが、漏れや誤りなどがありましたらコメント等で指摘して頂ければ幸いです。

WebPush/serviceWorker関連はまだまだ策定中の規格ですので、この情報が古くなってしまうこともあるかと思いますが、可能な限り追従して更新する予定です。

元々自前のCMSアプリ(このサイトもそれです)に実装するために調べて確認していたのですが、3日で終わらせるつもりが1週間以上かかってしまいました。網羅的にまとめられた正しい情報がネット上になかったのが時間を食った主要因です。あとスタイリッシュなJS実装は読みにくい(苦笑)

それにしても、Push通知面白いですね。スマホのブラウザで登録すると、スマホ内でブラウザを起動していなくても普通のアプリPush通知と同じように届くので夢が広がります。Webサイトだけでスマホアプリを実現することも可能になるわけですから。

あと、どうでもいいことですが、「||」をバイナリデータ連結とする謎の文化はこれどこから出たんでしょう。以前から見かけますが、個人的に「||」は「または(OR)」にしか見えないので違和感が多くて……。"\x00"が文字終端なのは主にC言語のお話であって、アルゴリズム解説で文字列連結とバイナリ連結を区別すること自体が謎……。

そんなこんなで、WebPush対応のCMS adiaryをよろしくお願いします。その他、プログラム開発の依頼など連絡ください。

参考文献とデモ

デモ

Mozillaのデモは実装する上で大変参考になりました。ソース等も参考になります。

OK キャンセル 確認 その他