👓

Web Pushのサーバーサイドの処理〜VAPIDとMessage Encriptionを中心に〜

2022/06/20に公開

はじめに

この記事では、Push APIを用いたWeb Pushにおけるアプリケーションサーバーの処理を概観します。

特に重要と思われる以下2つの点を中心に説明します。

  • VAPID (Voluntary Application Server Identification)
  • Message Encryption

加えて、そのほかにアプリケーションを実装する上で考慮する点をいくつか紹介します。

サーバーサイドの実装ではWeb Push用のライブラリを使うことが多く細かい部分を自分で実装する機会は少ないと思いますが、どのような技術が使われているのか把握しておくことにより、より自信を持って正しく扱えるようになると思います。

対象読者

Push APIを用いたWeb Pushを触ってみたことがあり、大まかの仕組みをイメージできる程度の知識があるのが望ましいです。

「詳しい仕様はよくわからないが、ライブラリを使って動くものを作ってみたことがある」という程度の知識を想定しています。

ただしそのような知識がなくても、「Web Pushのフローにおいて、どのような点が問題となり、どのような技術で対策がされているのか」について、大まかな理解が得られるように心がけました。

また、公開鍵暗号、共通鍵暗号、ECDH(鍵共有)、メッセージ認証コード(MAC)といった暗号技術の基礎的な理解があることを想定しています。暗号技術そのものへは深入りしないため、非専門家向けの入門書、入門記事の類を読んだことがある、程度で構いません。

著者について

JavaのWeb Pushのサーバーサイドライブラリ「zerodep-web-push-java」を個人で開発しています。

サンプルプロジェクトもいくつかあります。実際に動かしたりコードを見ながら本記事を読むとより理解が深まると思います。

Web Pushのフローと問題意識

本題に入る前に、Web Pushのフロー大まかに把握し、Web Pushのフローで使われている仕組みの背後にある問題意識を整理します。
(引用元:Push API - W3C「5.2 Sequence diagram」 青枠のオブジェクトは筆者が挿入)

overview-image

細かくみていくと複雑なフローですが、大雑把に以下の3つのステップに分けて考えると理解しやすいと思います。

  1. 購読(subscribe): ブラウザ[1]が「push service」に対して通知の購読(subscribe)をする。
  2. PushSubscriptionの共有:購読を行うことで得られた情報(「PushSubscription」)をブラウザが「application server」に共有する。
  3. push messageの送信:「PushSubscription」の情報を利用して「application server」が「push service」経由で、ブラウザに「push message」を送信する。

シンプルに図解すると以下のようになります。

overview-simple-image

フローについて補足

ここで、以降の説明で前提となる知識を補足します。

全体の補足

  • 通信は、基本的にHTTPS
  • 「push service」へのアプリケーションの登録は必要とされない

「アプリケーションの登録は必要とされない」とは、例えば以下のような作業は必要ないということです。

  • iOSアプリなどのような、事前のデベロッパー登録やアプリの審査
  • Web APIなどのような開発者アカウントに基づいたアップケーションの登録&アクセスキーの発行

クライアントサイドのブラウザアプリケーションと、サーバーサイドのアプリケーションを開発&デプロイしたら、それだけですぐに利用できるということです。

「1.購読」への補足

ブラウザが「push service」に対して、購読(「subscribe」)を行い、購読をしている旨の情報が「push service」上に生成される訳ですが、この情報をもう少し具体的にイメージしてみます。(以降「購読している旨の情報」を「subscription」と呼びます)

例えば、以下のような状況ではどのようになるのでしょうか。

  • ブラウザ1(Chrome)はSite_A[2]、Site_B、Site_Cの通知を購読している
  • ブラウザ2(Chrome)はSite_A、Site_Cの通知を購読している
  • ブラウザ3(FireFox)はSite_Aの通知を購読している

このような場合、Chrome側の「push servie」には5つのsubscriptionが発生し、FireFox側の「push service」には1つsubscriptionが発生します[3]

よくWebサイトを閲覧している時に出てくるダイアログの「許可」を押すたびに1つ生成されるようなイメージです。

subscriptionは、後述する「PushSubscription」endpointフィールドで一意に識別できるようになっています。

「2. PushSubscriptionの共有」への補足

以下のようなデータをブラウザが「application server」に対して共有します。

{
    "endpoint": "https://fcm.googleapis.com/fcm/send/abcEg3...aiDg",
    "expirationTime": null,
    "keys": {
        "p256dh": "BI...-dv1",
        "auth": "Gda...5d"
    }
}

(Chromeの場合の例。「...」は省略の意味)

endpointは「push service」上のリソースを表すURIで、前述した「subscription」を一意に識別できる情報です。

「3. push messageの送信」への補足

「application server」は、2で得られたendpointに対して「push message」を送信します。すなわぢ「application server」は「subscription」を一意に指定して「push message」を送信します。

そのため「push service」も、どの「subscription」に対して「push message」を送信すればよいのか一意に識別できます。

この「subscriptionを指定して、「push message」を送信する」というイメージは、とりわけVAPIDを理解する上で重要です。

Web Pushのフローにおける問題点

ここで、今まで見てきたWeb Pushのフローにおける問題点について考えてみます。

例えば、以下の2つのような疑問が湧いてこないでしょうか?[4]

  1. 「application server」のなりすましは防がれているのか?
  2. 「push message」の内容(平文)は誰に見えて、誰に見えないのか?

例えば「Site_D」が「Site_A」などになりすまして、「Site_A」に対して購読しているブラウザに対して、「push message」を送ることはできてしまうのでしょうか?(アクセスキーなどの事前に発行された資格情報がある訳ではありません)

通信経路は基本HTTPSで暗号化されているので、上の図における通信経路に全く関係のない第三者には基本的に「push message」の内容は見えません。しかし「push service」についてはどうなのでしょうか?

これらの疑問を以下の図解します。

questions-image

以降での詳細な説明に先んじて、簡単に答えてしまいます。

  1. VAPIDにより「application server」のなりすましが発生するリスクが大きく下げられている。
  2. 「push service」は「push message」の内容(平文)を見ることはできない。また、改ざんもできない。「push message」はブラウザと「application server」の間の共通鍵を用いてAES128GCMで暗号化されているためである。

以降ではこの2つ仕組み(VAPID、暗号化)を中心に、理解を深めていきます。

VAPID (Voluntary Application Server Identification)

一言で言うと、「application server」が「push service」に自分自身の情報を提供する仕組みです。

RFC

Voluntary Application Server Identification (VAPID) for Web Push - RFC8292

仮にVAPIDがなかったら簡単になりすましできるのか?

状況にもよりますが、答えは「No」です。
もし仮にVAPIDがなかった場合でも「PushSubscription」のendpointが第三者に盗み見られたり、推測されたりする可能性がゼロであれば、なりすましは発生しません。

なぜならば、先述のとおり、「push message」送信の際はendpointで「subscription」を一意に識別する必要があり、この識別子を知らない限り、紐づく「subscription」に通知を送信することは不可能だからです。

一方、RFC8292でも、何かしらのリスクは想定されているようで、第三者に「subscription」のURIが知られた場合の影響を減少させることがVAPID意義の1つとされています[5]

VAPIDはなりすまし防止のためだけにあるのか?

これも答えは「No」です。RFC8292では、異常事態に際して「application server」の管理者にコンタクトをとることに使用できるなど、いくつか動機付けが述べられています[6]

さらに、厳密いうと同RFCでは「なりすまし防止が目的である」と直接述べられてるわけではありません(先述の通り、何かしらのリスクは想定されていますが)。

しかしながら、以下の考えにより本記事では「なりすまし防止」という点を強く意識しています。

  • 先ほどまで見てきたようなWeb Pushのフローでは、なりすましのリスクは当然に想定されるものと考えられる
  • 実際に「application server」を設計、実装していく際、このようなリスクがあることを強く意識すると考えられる

VAPIDに着目してWeb Pushのフローを見ていく

(引用元:Push API - W3C「5.2 Sequence diagram」 青枠のオブジェクトは筆者が挿入)

vapid-image

  1. VAPID用キーペアの生成:「application server」はECDSAのキーペアを事前に生成し保持しておく(公開鍵:pub_v、秘密鍵:priv_vとする)。
  2. ブラウザのpub_vの取得:ブラウザは「application server」へのHTTPリクエストなど何らかの方法で、pub_vを取得する。
  3. 購読 with pub_v:ブラウザは購読の際、「push service」にpub_vを送信する。
  4. push message送信 with 署名:「application server」は「push message」送信の際、priv_vで生成した署名を含んだトークンを一緒に送信する(ブラウザの「PushSubscription」は共有されている前提)。
  5. 署名の検証:「push service」はそのトークンを検証する。

1. VAPID用キーペアの生成

最初に「application server」ではECDSAのキーペアを生成する必要があります。
直感的には、公開鍵/秘密鍵はそれぞれ「application server」のユーザーID/パスワード(マスターパスワード)のようなもの、とイメージすると分かりやすいと思います。

VAPID用キーペアの留意点

  • ECDSAのアルゴリズムは決まっており、P-256 curve[7]を使用する
  • このキーペアはVAPID専用のもので、後述するMessage Encryptionで使用されるものとは異なる
  • ブラウザ毎に生成したり、使い捨てたりするものではない。通常ある程度の期間、同一のものを使い続ける。場合によってはサービス開始〜終了まで同じ物を使い続ける

opensslで生成する例

vapidPublicKey.pemvapidPrivateKey.pemがそれぞれ公開鍵、秘密鍵です。

openssl ecparam -genkey -name prime256v1 -noout -out soruceKey.pem
openssl pkcs8 -in soruceKey.pem -topk8 -nocrypt -out vapidPrivateKey.pem
openssl ec -in sourceKey.pem -pubout -conv_form uncompressed -out vapidPublicKey.pem

2. ブラウザのpub_vの取得

ブラウザは、以下のような要領で、上述したVAPID用の公開鍵(pub_v)を取得します。

const serverPublicKey = await fetch('/getPublicKey')
                            .then(response => response.arrayBuffer());

3. 購読 with pub_v

ブラウザが購読する際には、上記で得た「application server」のVAPID用の公開鍵(pub_v)が必要となります。javascriptでは以下のように購読の処理が記述されますが、この際に「push service」に公開鍵(pub_v)が共有されます。

const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: serverPublicKey
});

公開鍵は「subscription」と紐付けられ、「subscription」に対する「push message」が要求された際の検証に利用されます。

4. push message送信 with 署名

「application server」が「push service」に対して「push message」を送信する際は、以下のようにいくつかのパラメータをAuthorizationヘッダーに含めます。

   Authorization: vapid
      t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3
        B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1ha
        Wx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_H
        LGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA,
      k=BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dR
        uU_RCPCfA5aq9ojSwk5Y2EmClBPs

(引用元:RFC8292「2.4. Example」)

t=ey...の値は、署名が含まれたトークン[8]ですが署名だけではなく、以下のような情報も含まれます。

JWT body = { "aud": "https://push.example.net",
                "exp": 1453523768,
                "sub": "mailto:push@example.com" }

(引用元:RFC8292「2.4. Example」)

  • aud:「push service」のオリジン。意味合いとしては、このトークンの受取り手を表現。「audience」の略。
  • exp:このトークンの有効期限。「Expiry」の略。
  • sub:「application server」のコンタクト情報。管理者のメールアドレスなど。「subject」の略。

k=BDE...の値は、VAPID用の公開鍵(pub_v)の情報です。

5. 署名の検証

「application server」から送信されたトークンは、「push service」によって署名が正しいか、有効期限が切れていないかなどが検証されます。もちろん、署名に対応する公開鍵が「subscription」に紐づくものとマッチするかも確認されます[9]
検証が失敗すると「application server」にはエラーが返され、ブラウザに「push message」は送信されません。

つまり、例えば以下のようなことはできないということです。

  • 「Site_D」が自分自身で生成した何らかの秘密鍵により署名を生成し、「Site_A」に紐づく「subscription」に「push message」を送信する。

検証に成功すると「push message」が「user agent」に送信されます。

まとめ

上記までで見てきたように

  • 「subscription」と「application server」の公開鍵を紐づける。
  • ある「subscription」に対する「push message」を「push service」が受けとった際には、その「subscription」に紐づく公開鍵と対になる秘密鍵で署名されているかを検証する。

ことおよび、トークンの有効期限のチェックなどにより、なりすましに対する保護がより強固になっていることがわかると思います。

なお、VAPIDには「application server」に関するコンタクト情報を「push service」に提供するなど、なりすまし防止以外にも目的があるのは先述した通りです。

Message Encryption

「push message」の暗号化と改ざん防止です。以下で説明する方法に従う場合、共通鍵はブラウザと「application server」間のみで共有されるため、「push service」にも「push message」の平文を見たり、改ざんしたりすることはできません[10]

RFC

Message Encryption for Web Push - RFC8291

アルゴリズム

  • AES128GCM。メッセージ認証コード付きの共通鍵暗号。
  • 共通鍵はECDHにより、「application server」と「user agent」で共有される

この共通鍵は「application server」にて「push message」毎に生成されます。先述したVAPID用のキーペアを用いる訳ではないので注意が必要です。

Message Encryptionに着目してWeb Pushのフローを見ていく

(引用元:Push API - W3C「5.2 Sequence diagram」 青枠のオブジェクトは筆者が挿入)

message-encryption-image

  1. ブラウザのECDHキーペアの生成:ブラウザは購読に際して自身のECDHキーペア(公開鍵:ua_pub、秘密鍵:ua_privとする)と、共通鍵生成に使用されるauthentication secret(auth_secret)を生成する。これらはsubscriptionと紐づけて管理される。
  2. ブラウザ公開鍵の共有:ブラウザは、ua_pub、auth_secretを「application server」に共有する。
  3. application serverでのpush message暗号化&送信:「application server」は自身のECDHのキーペア(as_pub、as_priv)と、ua_pub、auth_secretなどを元に共通鍵を生成。「push message」を暗号化、送信する。
  4. ブラウザでのpush message復号化:ブラウザは自身のECDHキーペアと、「push message」に含まれるas_pubなどを元に「push message」を復号化。

1. ブラウザのECDHキーペアの生成

購読に際して、ブラウザは自身のECDHキーペアを生成します。P-256 curve[7:1]を使用します。
また、共通鍵生成で使用される「authentication secret」も生成します。

javascriptでは直接この生成処理を記載することはなく、subscribe時に、「PushSubscription」の一部としてこれらの情報を取得します。

const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: serverPublicKey
});

(subscriptionの中身。「...」は省略の意味)

{
    "endpoint": "https://fcm.googleapis.com/fcm/send/abcEg3...aiDg",
    "expirationTime": null,
    "keys": {
        "p256dh": "BI...-dv1",
        "auth": "Gda...5d"
    }
}

p256dhフィールドがECDH公開鍵(ua_pub)、authフィールドが「authentication secret」です。

2. ブラウザ公開鍵の共有

ブラウザのECDH公開鍵(ua_pub)、auth_secretは「push message」送信に先立って「application server」に共有されます。


await fetch('/subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription),
    headers: {
        'content-type': 'application/json'
    }
}).then(res => {
    ....
});

3. application serverでのpush message暗号化&送信

「application server」のECDHキーペアとブラウザのECDH公開鍵(ua_pub)を用いて共通鍵を作成します。ECDHで生成したシークレットをそのままキーに使用する訳ではなく、いくつかの手順を踏んでキーやナンスを生成します[11]

暗号化には、AES128GCMが用いられます。名前の通り暗号化モードがGCMであるため、暗号化だけではなくメッセージ認証コードの機能も含んでいます[12]

暗号化された「push message」は、以下のようなHTTPリクエストで「push service」に送信されます。

{encrypted push message}はHTTPリクエストのボディで、暗号化されたバイナリデータが格納されます。

   POST /p/JzLQ3raZJfFBR0aqvOMsLrt54w4rJUsV HTTP/1.1
   Host: push.example.net
   TTL: 30
   Content-Length: 136
   Content-Encoding: aes128gcm
   Authorization: vapid
      t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3
        B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1ha
        Wx0bzpwdXNoQGV4YW1wbGUuY29tIn0.i3CYb7t4xfxCDquptFOepC9GAu_H
        LGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA,
      k=BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dR
        uU_RCPCfA5aq9ojSwk5Y2EmClBPs
   { encrypted push message }

(引用元:RFC8292「2.4. Example」)

4. ブラウザでのpush message復号化

「application server」から送信される「push message」(前の手順における{encrypted push message})には、「application server」のECDH公開鍵(as_pub)やsaltの情報も含まれています。

これらの情報を元に、ブラウザは「application server」と同様に共通鍵を生成します。

共通鍵の生成、及び復号化は、ブラウザが裏でやってくれるので、javascriptアプリケーションなどでは通常この処理を意識する必要はありません。

service workerなどで以下の様にメッセージを受け取る時は、復号化済みの平文が得られます。


self.addEventListener('push',
    event => event.waitUntil(handlePushEvent(event))
);

function handlePushEvent(event) {
    if (event.data) {
        let msg = event.data.json();
        ...
    }
}

まとめ

  • ECDHによってブラウザ、「application server」間で共通鍵が共有されている
  • この共通鍵を利用し、AES128GCMで暗号化されているので、ブラウザ、「application server」以外には「push message」の平文を見ることも、改ざんすることもできない

ことがわかりました。

そのほか「application server」の処理で考慮する点

push message送信時のHTTPヘッダー

TTL

RFC: RFC8030「5.2. Push Message Time-To-Live」

「application server」が送信した「push message」が「push service」上で、どのくらいの期間(削除されずに)保持されるか、を示すヘッダーフィールドです。

「push service」上では一定期間「push message」が保持されることが想定されています。

「user agent」側のPCがシャットダウンされているなど、「push message」を即座に送信できない場合でも、「push service」が一定時間「push message」を保持することで、「push message」配信の確実性を上げることができます[13]

以下の様に長さを「秒」で指定します。

POST /example-push/123sbcigeidei HTTP/1.1
...
TTL: 60

必須のヘッダーフィールドです。

どのように設定する値を定めるかですが、通常は、ある程度余裕を持った数分〜数時間などで設定することが多いのではないかと思います[14]

Urgency

RFC: RFC8030「5.3. Push Message Urgency」

緊急度(urgency)に応じて、「user agent」が「push message」を受け取るかどうかの選択ができるようになっており[15]、この選択のための情報を提供するためのヘッダーフィールドです。

以下のようにUrgencyを表す文字列を指定します。選択できる文字列は下の表の4種類[16]です。

POST /example-push/123sbcigeidei HTTP/1.1
...
Urgency: normal
   +----------+-----------------------------+--------------------------+
   | Urgency  | Device State                | Example Application      |
   |          |                             | Scenario                 |
   +----------+-----------------------------+--------------------------+
   | very-low | On power and Wi-Fi          | Advertisements           |
   | low      | On either power or Wi-Fi    | Topic updates            |
   | normal   | On neither power nor Wi-Fi  | Chat or Calendar Message |
   | high     | Low battery                 | Incoming phone call or   |
   |          |                             | time-sensitive alert     |
   +----------+-----------------------------+--------------------------+

(引用元:RFC8030「5.3. Push Message Urgency」)

必須のフィールドではなく、未指定の場合「normal」であると判断されます。

Topic

RFC: RFC8030「5.4. Replacing Push Messages」

「user agent」に対して、冗長に「push message」を送信しないために指定するヘッダフィールドです。「push service」上に「user agent」に未送信かつ同一のTopicの「push message」がある場合、その「push message」が新しいもので置き換えられます。

以下のようなイメージです。

  • 「user agent」は機内モードのため、「push message」が送信できない状態であった。
  • その間に、下の表のような順番で「push message」が発生した。
  • この場合「user agent」が機内モードから復活した際に送信されるのは、「message 4」「message 5」「message 6」である。
message Topic
message 1 topic1
message 2 topic2
message 3 topic3
message 4 topic3
message 5 topic2
message 6 topic1

以下のように任意の文字列を指定します[17]

POST /example-push/123sbcigeidei HTTP/1.1
...
Topic: my-topic-1

このヘッダーフィールドは必須とはされていません。

push message送信のHTTPリクエスト ステータスコード

「push service」からのレスポンスのステータスコードについて、発生する状況と「application server」での扱い方の例を簡単にまとめます。

status code 状況 備考/扱い方の例
201 Created 「push message」が正常に「push service」に送信できたことを表す -
201以外の2xx - 2xxにおいて送信成功の場合「201」以外のステータスコードとなる場合があるのか明確ではない。そのためもし201以外であれば、厳密さを求めるならば想定外のエラーとして対処する[18]
3xx - RFC8030などでは3xxが明示的に要求されるケースは記されていないが、厳密にやるならばステータスコードに応じて扱うか想定外のエラーとする[19]
400 Bad Request ヘッダーフィールドの値などリクエストの内容が不正。たとえばTTLが指定されていない。 「application server」側の実装不備の場合、想定外のエラーとして対処する。
401 Unauthorized vapidの不正 実装不備の可能性もあるが、トークンの期限切れの可能性もある。その場合トークンを生成しなおし再送するという対処でも良い[20]
403 Forbidden vapidの不正 実装不備の可能性もあるが、トークンの期限切れの可能性もある。その場合トークンを生成しなおし再送するという対処でも良い[20:1]
404 Not Found 指定された「subscription」が使用できない 「PushSubscription」の情報をストレージから削除するなど、今後その「subscription」を送信対象としない。
410 Gone 指定された「subscription」が無効 「PushSubscription」の情報をストレージから削除するなど、今後その「subscription」を送信対象としない。
413 Payload Too Large リクエストボディ、すなわち「push message」のサイズが制限[21]を超過している application server」側の実装不備の場合、想定外のエラーとして対処する。
429 Too Many Request 一定期間に大量の「push message」送信リクエストを送りすぎている しばらくしてからリトライ。レスポンスにRetry-Afterヘッダーフィールドがある場合その指定に従う。
上記以外の4xx - 想定外のエラーとして対処する
5xx - しばらくしてからリトライし、何度かやっても5xxとなる場合、想定外のエラーとして対処する

(参考サイト:The Web Push Protocol「Response from push service」)

上の表の内容についていくつか補足します。

  • 「状況」列について、Web Pushに関連するRFCまたは参考サイトで発生条件が明記されている場合は、その内容を記入し、そうでない場合は「-」を記入しています。
  • 「扱い方の例」における「想定外のエラー」について、「監視システムにアラートが記録され、管理者に通知される」などの対処がなされる状況とったイメージです。

Appendix

VAPIDにおけるトークン(JWT、JWSについて)

VAPIDにおいて「application server」が「push message」とともに送信するAuthorizationヘッダーフィールドのt=パラメータの中身は以下のような形式になっています。

BASE64URL(UTF8(JWS Protected Header)) || '.' ||
BASE64URL(JWS Payload) || '.' ||
BASE64URL(JWS Signature)

(引用元:RFC7515「3.1. JWS Compact Serialization Overview」)

これは「JWS Compact Serialization」と呼ばれる形式でシリアライズされたJWT(JSON Web Token)で、HTTPヘッダやURLなどに含めて送信することができます。

VAPIDにおいて、JWS Protected HeaderJWS PayloadJWS Signatureの内容は具体的には以下のようになります。

JWS Protected Header = { "typ": "JWT", "alg": "ES256" }
JWS Payload = { "aud": "https://push.example.net",
                "exp": 1453523768,
                "sub": "mailto:push@example.com" }

(引用元:RFC8292 「2.4. Example」。引用元でJWT headerJWT bodyであった箇所を筆者書き換え)

Signing input = BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)
JWS Signature = Singing InputをVAPID用キーペアの秘密鍵で署名したもの

JWS Protected Headerでは署名に使用されるアルゴリズムをJWA(JSON Web Algorithms)で定義された識別子で指定しています。JWS Payloadには、本文でも紹介したように有効期限(exp)などが含まれています。JWS Signatureは、VAPID用のキーペアにおける秘密鍵で生成された署名です。

このトークンにより、署名および改ざんされていない(改ざんが検知できる)データ(JWS Payload)が送信できます。

リファレンス

脚注
  1. 本記事では、ほとんどの箇所でweb pageservice workeruser agentを厳密に区別せず「ブラウザ」と表記しています。 ↩︎

  2. サイト(Site)=オリジンと考えた場合、厳密にはservice workerはサイド毎に複数存在し得るので、「サイト」毎に「subscription」も複数存在し得ます。説明の煩雑さを避けるため本記事ではこの点には深く立ち入らず、漠然と「Site」と表記しています。 ↩︎

  3. 「push service」はブラウザ毎に異なる場合が多く、たとえばChromeであればhttps://fcm.googleapis.com、FireFoxであればhttps://updates.push.services.mozilla.comなどとなっています(記事執筆時点で、筆者の環境で確認)。送信先の「push service」は、ブラウザの指定に従うため、アプリケーション開発者はどのURLに送信するか意識することは少ないように思います。 ↩︎

  4. もちろん、他にも様々に考慮すべき点があると思いますが、この2つの問題点を特に意識しておくことで、Web Pushの仕様(特にapplication server側の)を理解しやすくなるとの考えから、この2点を大きく取り上げています。 ↩︎

  5. RFC8292「1. Introduction」に「Any application server in possession of a push message subscription URI is able to send messages to the user agent. If use of a subscription could be limited to a single application server, this would reduce the impact of the push message subscription URI being learned by an unauthorized party.」とありますが「the impact」についてこれ以上具体的には述べられていません。もっとも、通常URLに非公開としたいセンシティブな情報を含めることは、アクセスログに記録される可能性が高いことなどから推奨されないことが多く、これ以上説明するまでもないということなのかもしれません。(例:The OAuth 2.0 Authorization Framework: Bearer Token Usage - RFC6750の「2.3. URI Query Parameter」) ↩︎

  6. RFC8292「1.1. Voluntary Identification」参照。 ↩︎

  7. より正確には、NIST「Digital Signature Standard (DSS)」(FIPS 186-4))で定義されている「Curve P-256」。opensslでは「prime256v1」、javaのJCAでは「secp256r1」といった文字列で指定されます。 ↩︎ ↩︎

  8. JWS(JSON Web Signiture)の「JWS Compact Serialization」を用いたJWT(JSON Web Token)です。詳細はAppendixを参照してください。 ↩︎

  9. 詳細:RFC8292「4.2. Using Restricted Subscriptions」 ↩︎

  10. RFC8291には「This document describes how messages sent using this protocol can be secured against inspection, modification, and forgery by a push service」などと書かれています。 ↩︎

  11. このアルゴリズムを記事内にまとめるのは煩雑になるため割愛します。興味のある方はRFC8291の「3.4. Encryption Summary」や実際のソースコードを参照してください。 ↩︎

  12. 参考「Galois/Counter Mode - Wikipedia↩︎

  13. そのほかにもRFC8030「5.2. Push Message Time-To-Live」には「Delaying delivery might also be used to batch communication with the user agent, thereby conserving radio resources.」などの利点が挙げられています。 ↩︎

  14. RFC8030「5.2. Push Message Time-To-Live」には「A push service is not obligated to account for time spent by the application server in sending a push message to the push service, or delays incurred while sending a push message to the user agent. An application server needs to account for transit delays in selecting a TTL header field value.」とあり、送信時に発生する遅延などは「application server」側で考慮する必要があります。 ↩︎

  15. RFC8030「5.3. Push Message Urgency」には「A user agent MAY include the Urgency header field when monitoring for push messages to indicate the lowest urgency of push messages that it is willing to receive.」「Push messages of any urgency are delivered to a user agent that does not include an Urgency header field when monitoring for messages.」とあり、「user agent」側のUrgencyに応じた送信可否の指定は任意のようです。たとえば、私の所持するAndroid端末のFireFoxでは4G接続の場合でも「very-low」の通知が受け取れました。 ↩︎

  16. ブラウザによっては、必ずしも全てのUrgencyがサポートされている訳ではない模様です。記事執筆時点において、Google Chrome、Operaでは「very-low」がサポートされておらず、「very-low」で送信すると「400 invalid urgency header defined. Valid options are: high, normal and low.」などとなりました。 ↩︎

  17. より正確には「the Topic header field MUST be restricted to no more than 32 characters from the URL and a filename-safe Base 64 alphabet」(RFC8030「5.4. Replacing Push Messages」)です。 ↩︎

  18. RFC8030「5.1. Requesting Push Message Receipts」には「When the push service accepts the message for delivery with confirmation, it MUST return a 202 (Accepted) response.」とありますが、「Prefer: respond-async」ヘッダーフィールドを含めたリクエストを試しに送信してみても「201」が返されました(Chrome、FireFoxで確認)。そのため扱い方の例について、201以外の場合は「想定外」としていますが、もし「Prefer: respond-async」が有効に使えるのであれば202は想定通りのレスポンスとして扱えると思います。 ↩︎

  19. 例えば「301 Moved Permanently」の場合、リダイレクト先に対して改めてリクエストを送信する。「304 Not Modified」の場合、合理的に判断がつかないのでエラーとするなど。 ↩︎

  20. RFC8292「4.2. Using Restricted Subscriptions」を読む限り、401と403の厳密な使い分けは想定されていない様です(「A 401 (Unauthorized) status code might be used」など、MUSTではなく「might be」と書かれている)。実際FireFoxでは動作を試してみると、VAPIDスキームのAuthorizationヘッダーが存在しない場合でも、期限切れのトークンを送信した場合でも、「401」が返ってきました(Chromeの場合それぞれ「401」「403」)。 ↩︎ ↩︎

  21. RFC8030「7.2. Push Message Expiration」には「To limit the size of messages, the push service MAY return a 413 (Payload Too Large) status code [RFC7231] in response to requests that include an entity body that is too large. Push services MUST NOT return a 413 status code in responses to an entity body that is 4096 bytes or less in size.」とあり、逆に4096より大きなリクエストボディは送信できない可能性があります。 ↩︎

Discussion