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

2018/08/24(金)[RFC]WebPush実装まとめ(Chrome/Firefox/Android/Edge)

Web Pushを実装したまとめ。動作確認済のデモソース付。

  • 2018/08/24 公開されたRFCに準拠し、内容をアップデートしました。
    • 旧仕様のaesgcmから、新仕様のaes128gcmに説明を変更しました。
  • 2017/04/22 初公開

WebPushとは

ブラウザに対して、スマートフォンのようなPush通知を送る仕組みです。

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

この仕組みを使うと、Webサイトからユーザーに対して自由に通知が送れるようになります。Twitterをブラウザで使っている人は、このWebpushでDMやリプライの通知を受け取った人も多いかと思います。

*1 : 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}

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

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

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

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

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

ServiceWorker APIについて

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

この ServiceWorker API は https 環境もしくは localhost 接続でのみ有効になるのですが、開発やテスト時に https を用意するのはやや面倒ですので*4、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

*4 : Let's Encryptのおかげで今はそこまで大変でもなくなりました。

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. 送信したいメッセージmsgをJSON形式で生成する。メッセージは3992byte以下とする。
  2. メッセージ暗号化用の、公開鍵mpubと秘密鍵mpubのペアを生成する
    • サーバ公開鍵と同一「mpub=spub, mprv=sprv」で構いません。*6
  3. メッセージ用秘密鍵mprvとクライアント公開鍵cpubを使い、共有鍵secretを計算する。*7
  4. 16byteのランダム文字列saltを生成する。
  5. authトークンと共有鍵secretから、鍵prkを生成する("+"は文字連結とします)
    prk = HKDF(auth, secret, "WebPush: info\x00" + cpub + spub, 32)
    
  6. prkを使い暗号化鍵cekとnonceを計算します。
    cek   = HKDF(salt, prk, "Content-Encoding: aes128gcm\x00", 16)
    nonce = HKDF(salt, prk, "Content-Encoding: nonce\x00",     12)
    
  7. msgの後ろに "\x02\x00" を結合します。
    msg   = msg + "\x02\x00"
    
  8. 128bit AES-GCMでmsgを暗号化します。この際、鍵をcek、初期ベクトルをnonceとします。
    gcm  = AES-GCM(cek, nonce)
    body = gcm(msg) . gcm_done()	# gcm_done is authentication TAG
    
  9. bodyに暗号化に関するヘッダを付加します。
    body = salt + "\x00\x00\x10\x00" + "\x41" + mpub + body
    
    "0x00001000"はAES-GCMの符号化単位で、4096を4byteのネットワークバイトオーダー(big eddian)で指定します。0x41はメッセージ公開鍵mpubの長さ*8です。
  10. こうして得られた body をpost時に使用します。

VAPIDの生成と署名

プッシュ通知サーバに対し、自分自身(Web Application)が登録されたアプリケーションであることを証明するためVAPIDと呼ばれる処理を行い、ヘッダを付加します。

この情報はプッシュ通知サーバがWeb Application認証するために必要で、このヘッダが正しければサーバは送られたPOSTに対して「201 Created」を返します。

プッシュ通知サーバは、ブラウザに登録したspubの秘密鍵sprvを所有しているかどうかを確認するだけですので、例え送られたメッセージが正しくなくても201を返します。メッセージデータに誤りがあると、201が返ってきているのに通知が来ないという現象が起こります。

  1. JWT headerを生成します。
    {
    	"typ":"JWT",
    	"alg":"ES256"
    }
    
  2. JWT infoを生成します。
    {
    	"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つ
  3. Base64 url safe Encodeを使用し、JWT(JSON Web Token)dataを生成する。
    jwt_data = base64urlsafe(jwt_header) + "." + base64urlsafe(jwt_info)
    
  4. jwt_dataを、サーバ秘密鍵sprvを使ってSHA256ハッシュ署名し、得られた署名をsigとする。
    sign = SHA256_sign(jwt_data, sprv) 
    
    • ここで得られる署名は通常70byte(もしくは71か72byte)のASN1 DER formatと呼ばれる物になりますが、実際に署名として添付するのはこの中に含まれる32byteの2つの値(整数)を連結したバイナリフォーマットです(補足参照)。
  5. jwt_data と sign を "." 連結して、jwtヘッダを得る。
    jwt = jwt_data + "." + base64urlsafe(sign)
    

    これは以下と全く同じである。

    jwt = base64urlsafe(jwt_header) + "." + base64urlsafe(jwt_info) + "." + base64urlsafe(sign)
    

余談ですが、このJWTヘッダに含まれる可変情報はTTLぐらいですので、もしJWTヘッダを傍受することが出来たなら「TTL有効期限」まで使いまわしできるJWTヘッダと署名を得ることが出来ます。それで良いの?という気もしますが、無いよりマシということでしょうか。*9

ヘッダの生成とデータ送信

ここまで準備ができたら、endpointに向けて暗号化したメッセージをPOSTします。その際、適切にヘッダを設定してあげます。

TTL: 86400
Content-Encoding: aes128gcm
Crypt-key: p256ecdsa=base64urlsafe(spub)
Encryption: salt=base64urlsafe(salt)
Authorization: WebPush jwt;
  • TTLは通知の有効期限(秒)です。無いとエラーになります。0だとEdgeのプッシュサーバではエラーになります。JWTヘッダのexpと違い特に最大値の規定はないようです。
  • salt, jwt はすでに計算した値を使用してください。spubはサーバ公開鍵です。

POST時は Content-Length ヘッダをつけたほうが良いでしょう(つけなくても大丈夫なようです)。

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

*6 : mpub/mprvを都度生成するのが一番強度は高いと思いますが、予め生成しておいても良いし、サーバ鍵と同一でも構いません。ただ、Edgeかつ暗号化方式が古いaesgcmの場合にのみ、サーバ鍵と同一(mpub=spub)だと通知に失敗するので注意。

*7 : ECDH=楕円曲線DH鍵交換と呼ばれる手順です。クライアント側は送られてきたメッセージ公開鍵mpubとクライアント秘密鍵cprvから同じ共有鍵secretを計算します。

*8 : 65byte。mpubが文字列として存在するなら、その長さを取ったほうが確実

*9 : VAPID/JWTが存在しないと、プッシュ通知サーバはEndpointのみで認証することになってしまう。送信データの正当性はcprvを持つクライアント(ブラウザ等)に送るまで分からないので。

補足

URL safe Base64(base64url encode)について

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

ASN1 DER formatについて

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

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

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

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

決め打ちしても問題ありません……と言いたいところですが、RやSの先頭に"\x00"が付いていて33byteになることがあるようです。RやSの後ろから32byteを取り出す必要があります。

バイナリフォーマットは単純に鍵のみを「R + S」で連結した 64byte のデータです。

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」はサイトを訪れていない時も、そのサイトのスクリプトを実行するための仕組みです。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) {
		// 永続Cookieを許可してない場合、登録に失敗する
		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 : 通知を一意に識別するたのタグ。*10
  • 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時間経過しないと更新されません*11。しかし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を単純に開くだけならば、スコープは気にしなくても大丈夫なようです。

スコープは、すでに述べたとおり登録するスクリプトファイルのパス以下でしか有効になりません。serviceWorker.registerのオプションでスコープをさらに限定することはできますが、スクリプトのパスより上位を指定することはできません(エラーになります)。

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

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

DOS/CSRF攻撃の危険性

WebPushはその仕組み上、ブラウザから知らされたURLを信用してPush通知を送信します。つまりPush通知先サイトとして、嘘のURLを大量に教えることでDOS攻撃やCSRF攻撃を実行できます。ここで危険なのは、Webサービスそのものが攻撃をしてしまうことです。

攻撃方法1

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

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

※通知を送信する際に「201 Created」が返ってくるかどうかで、この攻撃はある程度緩和することができます(それでも1度は無駄に通知を送ることになります)。

攻撃方法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/
  • https://updates.push.services.mozilla.com:443/ ※テスト時のみ?
  • https://FOO.notify.windows.com/w/ (FOO is db3, sg2p, ..etc)

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

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

まとめ

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

デモのソースには旧仕様のaesgcmも残っています。aesgcmの古い実装ではEdgeで動作しなかったのですが、問題なく動作するように修正してあります*13。RFCとしてaes128gcmが公開されaesgcmはいずれ消える仕様のようです。

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

*13 : EdgeのPushサーバがまともなエラーメッセージを返さず、どの段階の仕様(ドラフト)に準拠しているのかすら謎であるため、とても苦労しました……。

参考文献

デモと実装例

Safariについて

WebPushの機能は持っているようですが、通知する側がAppleに登録しないと使えないようです。