ウェブプッシュ プロトコル

ライブラリを使用してプッシュ メッセージをトリガーする方法を見てきましたが、これらのライブラリは実際には何をしているのでしょうか?

ネットワーク リクエストを作成し、そのリクエストが正しい形式であることを確認します。このネットワーク リクエストを定義する仕様は、ウェブ プッシュ プロトコルです。

サーバーからプッシュ サービスにプッシュ メッセージを送信する図。

このセクションでは、サーバーがアプリケーション サーバーキーを使用して自身を識別する方法と、暗号化されたペイロードと関連データが送信される方法について説明します。

これはウェブプッシュのきれいな側面ではありません。また、私は暗号化の専門家ではありませんが、これらのライブラリが内部で何をしているのかを知っておくと便利なので、各部分を見ていきましょう。

アプリケーション サーバーキー

ユーザーを登録するときに、applicationServerKey を渡します。このキーはプッシュ サービスに渡され、ユーザーを登録したアプリがプッシュ メッセージをトリガーしているアプリでもあることを確認するために使用されます。

プッシュ メッセージをトリガーすると、プッシュ サービスがアプリケーションを認証できるようにする一連のヘッダーが送信されます。(これは VAPID 仕様で定義されています)。

これは実際にはどういう意味で、具体的に何が起こるのでしょうか?アプリケーション サーバーの認証手順は次のとおりです。

  1. アプリケーション サーバーは、アプリケーションの秘密鍵を使用して JSON 情報に署名します。
  2. この署名付き情報は、POST リクエストのヘッダーとしてプッシュ サービスに送信されます。
  3. プッシュ サービスは、pushManager.subscribe() から受け取って保存した公開鍵を使用して、受け取った情報がその公開鍵に関連する秘密鍵で署名されていることを確認します。注意: 公開鍵は、subscribe 呼び出しに渡される applicationServerKey です。
  4. 署名された情報が有効な場合、プッシュ サービスはプッシュ メッセージをユーザーに送信します。

情報の流れの例を以下に示します。(左下の凡例で公開鍵と秘密鍵を確認してください)。

メッセージ送信時に限定公開アプリケーション サーバーキーが使用される仕組みを示す図。

リクエストのヘッダーに追加される「署名付き情報」は JSON Web Token です。

JSON ウェブトークン

JSON ウェブトークン(略して JWT)は、受信者が送信者を検証できるように、サードパーティにメッセージを送信する方法です。

サードパーティがメッセージを受信した場合は、送信者の公開鍵を取得し、それを使用して JWT の署名を検証する必要があります。署名が有効な場合、JWT は一致する秘密鍵で署名されているため、想定される送信元からのものである必要があります。

jwt.io/ には、署名を行うことができるライブラリが多数あります。可能な場合は、それらのライブラリを使用することをおすすめします。完全を期すため、署名付き JWT を手動で作成する方法を見てみましょう。

ウェブプッシュと署名付き JWT

署名付き JWT は単なる文字列ですが、ドットで結合された 3 つの文字列と考えることができます。

JSON ウェブトークン内の文字列のイラスト。

1 番目と 2 番目の文字列(JWT 情報と JWT データ)は、base64 でエンコードされた JSON の一部であり、公開されているため読み取り可能です。

最初の文字列は JWT 自体に関する情報で、署名の作成に使用されたアルゴリズムを示します。

ウェブプッシュの JWT 情報には、次の情報が含まれている必要があります。

{
  "typ": "JWT",
  "alg": "ES256"
}

2 番目の文字列は JWT データです。JWT の送信者、対象者、有効期間に関する情報を提供します。

ウェブプッシュの場合、データの形式は次のようになります。

{
  "aud": "https://some-push-service.org",
  "exp": "1469618703",
  "sub": "mailto:example@web-push-book.org"
}

aud 値は「オーディエンス」、つまり JWT の対象者です。ウェブプッシュの場合、オーディエンスはプッシュ サービスであるため、プッシュ サービスのオリジンに設定します。

exp 値は JWT の有効期限です。これにより、スヌーパが JWT を傍受した場合に、その JWT を再利用できなくなります。有効期限は秒単位のタイムスタンプで、24 時間を超えてはなりません。

Node.js では、有効期限は次のように設定します。

Math.floor(Date.now() / 1000) + 12 * 60 * 60;

送信アプリとプッシュ サービス間のクロックのずれによる問題を回避するため、24 時間ではなく 12 時間です。

最後に、sub の値は URL または mailto メールアドレスのいずれかである必要があります。これは、プッシュ サービスが送信者に連絡する必要がある場合に、JWT から連絡先情報を見つけられるようにするためです。(そのため、ウェブプッシュ ライブラリにはメールアドレスが必要でした)。

JWT 情報と同様に、JWT データは URL セーフな base64 文字列としてエンコードされます。

3 番目の文字列(署名)は、最初の 2 つの文字列(JWT 情報と JWT データ)を取得し、それらをドット文字で結合して「署名なしトークン」を作成し、それに署名した結果です。

署名プロセスでは、ES256 を使用して「署名なしトークン」を暗号化する必要があります。JWT 仕様によると、ES256 は「P-256 曲線と SHA-256 ハッシュ アルゴリズムを使用する ECDSA」の略です。ウェブ暗号を使用して、次のように署名を作成できます。

// Utility function for UTF-8 encoding a string to an ArrayBuffer.
const utf8Encoder = new TextEncoder('utf-8');

// The unsigned token is the concatenation of the URL-safe base64 encoded
// header and body.
const unsignedToken = .....;

// Sign the |unsignedToken| using ES256 (SHA-256 over ECDSA).
const key = {
  kty: 'EC',
  crv: 'P-256',
  x: window.uint8ArrayToBase64Url(
    applicationServerKeys.publicKey.subarray(1, 33)),
  y: window.uint8ArrayToBase64Url(
    applicationServerKeys.publicKey.subarray(33, 65)),
  d: window.uint8ArrayToBase64Url(applicationServerKeys.privateKey),
};

// Sign the |unsignedToken| with the server's private key to generate
// the signature.
return crypto.subtle.importKey('jwk', key, {
  name: 'ECDSA', namedCurve: 'P-256',
}, true, ['sign'])
.then((key) => {
  return crypto.subtle.sign({
    name: 'ECDSA',
    hash: {
      name: 'SHA-256',
    },
  }, key, utf8Encoder.encode(unsignedToken));
})
.then((signature) => {
  console.log('Signature: ', signature);
});

プッシュ サービスは、公開アプリケーション サーバーキーを使用して JWT を検証し、署名を復号して、復号された文字列が「署名なしトークン」(JWT の最初の 2 つの文字列)と同じであることを確認できます。

署名付き JWT(つまり、3 つの文字列をドットで結合したもの)は、次のように WebPush を先頭に付加した Authorization ヘッダーとしてウェブプッシュ サービスに送信されます。

Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';

Web Push Protocol では、公開アプリケーション サーバー鍵は Crypto-Key ヘッダーで、p256ecdsa= が前に付いた URL セーフの Base64 エンコード文字列として送信する必要があるとも規定されています。

Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]

ペイロードの暗号化

次に、ウェブアプリがプッシュ メッセージを受信したときに、受信したデータにアクセスできるように、プッシュ メッセージでペイロードを送信する方法について説明します。

他のプッシュ サービスを利用したことがあるユーザーからよく寄せられる質問は、ウェブプッシュのペイロードを暗号化する必要があるのはなぜかということです。ネイティブ アプリでは、プッシュ メッセージでデータをプレーン テキストとして送信できます。

ウェブプッシュの利点の 1 つは、すべてのプッシュ サービスが同じ API(ウェブプッシュ プロトコル)を使用しているため、デベロッパーはプッシュ サービスが誰であるかを気にする必要がないことです。正しい形式でリクエストを送信すれば、プッシュ メッセージが送信されることが期待できます。このデメリットは、デベロッパーが信頼できないプッシュ サービスにメッセージを送信する可能性があることです。ペイロードを暗号化することで、プッシュ サービスは送信されたデータを読み取ることができません。情報を復号できるのはブラウザのみです。これにより、ユーザーのデータが保護されます。

ペイロードの暗号化は、メッセージ暗号化仕様で定義されています。

プッシュ メッセージのペイロードを暗号化する具体的な手順を確認する前に、暗号化プロセスで使用されるいくつかの手法について説明します。(プッシュ暗号化に関する優れた記事を執筆した Mat Scales に感謝します)。

ECDH と HKDF

ECDH と HKDF はどちらも暗号化プロセス全体で使用され、情報の暗号化という目的においてメリットがあります。

ECDH: 楕円曲線 Diffie-Hellman 鍵交換

Alice と Bob という 2 人が情報を共有したいとします。アリスとボブはそれぞれ独自の公開鍵と秘密鍵を持っています。アリスとボブは互いに公開鍵を共有します。

ECDH で生成された鍵の有用な特性は、アリスが自分の秘密鍵とボブの公開鍵を使用して秘密値「X」を作成できることです。Bob も同様に、自分の秘密鍵と Alice の公開鍵を使用して、同じ値「X」を独自に作成できます。これにより、X は共有シークレットになり、Alice と Bob は公開鍵のみを共有すればよくなります。これで、Bob と Alice は「X」を使用してメッセージを暗号化し、復号できるようになりました。

私の知る限り、ECDH は、共有シークレット「X」を作成するこの「機能」を可能にする曲線のプロパティを定義します。

これは ECDH の概要です。詳細については、ECDH の概要を説明する動画をご覧ください。

コードに関して言えば、ほとんどの言語 / プラットフォームには、これらの鍵を簡単に生成するためのライブラリが付属しています。

ノードでは、次の操作を行います。

const keyCurve = crypto.createECDH('prime256v1');
keyCurve.generateKeys();

const publicKey = keyCurve.getPublicKey();
const privateKey = keyCurve.getPrivateKey();

HKDF: HMAC ベースの鍵導出関数

Wikipedia には、HKDF の簡潔な説明があります。

HKDF は、弱い鍵マテリアルを暗号的に強力な鍵マテリアルに変換する HMAC ベースの鍵導出関数です。たとえば、Diffie Hellman で交換された共有シークレットを、暗号化、完全性チェック、認証での使用に適した鍵マテリアルに変換するために使用できます。

HKDF は、特に安全ではない入力を受け取り、より安全なものにします。

この暗号化を定義する仕様では、ハッシュ アルゴリズムとして SHA-256 を使用し、ウェブプッシュの HKDF の結果の鍵は 256 ビット(32 バイト)以下にする必要があります。

ノードでは、次のように実装できます。

// Simplified HKDF, returning keys up to 32 bytes long
function hkdf(salt, ikm, info, length) {
  // Extract
  const keyHmac = crypto.createHmac('sha256', salt);
  keyHmac.update(ikm);
  const key = keyHmac.digest();

  // Expand
  const infoHmac = crypto.createHmac('sha256', key);
  infoHmac.update(info);

  // A one byte long buffer containing only 0x01
  const ONE_BUFFER = new Buffer(1).fill(1);
  infoHmac.update(ONE_BUFFER);

  return infoHmac.digest().slice(0, length);
}

このサンプルコードについては、Mat Scale 氏の記事をご覧ください。

これは、ECDHHKDF を大まかにカバーしています。

ECDH は、公開鍵を共有して共有シークレットを生成する安全な方法です。HKDF は、安全でないマテリアルを安全にする方法です。

これは、ペイロードの暗号化中に使用されます。次に、入力として受け取るものと、その暗号化方法について説明します。

入力

ペイロードを含むプッシュ メッセージをユーザーに送信する場合、次の 3 つの入力が必要です。

  1. ペイロード自体。
  2. PushSubscriptionauth シークレット。
  3. PushSubscriptionp256dh キー。

PushSubscription から auth 値と p256dh 値が取得されるのを確認しました。念のため、サブスクリプションが指定された場合に必要な値は次のとおりです。

subscription.toJSON().keys.auth;
subscription.toJSON().keys.p256dh;

subscription.getKey('auth');
subscription.getKey('p256dh');

auth の値はシークレットとして扱い、アプリケーションの外部で共有しないでください。

p256dh キーは公開鍵です。これはクライアント公開鍵と呼ばれることもあります。ここでは、p256dh をサブスクリプション公開鍵と呼びます。サブスクリプション公開鍵はブラウザによって生成されます。ブラウザは秘密鍵を秘密に保ち、ペイロードの復号に使用します。

authp256dhpayload の 3 つの値が入力として必要です。暗号化プロセスの結果は、暗号化されたペイロード、ソルト値、データの暗号化のみに使用される公開鍵になります。

Salt

ソルトは 16 バイトのランダムなデータである必要があります。NodeJS でソルトを作成するには、次の操作を行います。

const salt = crypto.randomBytes(16);

公開鍵 / 秘密鍵

公開鍵と秘密鍵は P-256 楕円曲線を使用して生成する必要があります。Node では次のようにします。

const localKeysCurve = crypto.createECDH('prime256v1');
localKeysCurve.generateKeys();

const localPublicKey = localKeysCurve.getPublicKey();
const localPrivateKey = localKeysCurve.getPrivateKey();

これらのキーを「ローカルキー」と呼びます。これらは暗号化にのみ使用され、アプリケーション サーバーキーとは関係ありません

ペイロード、認証シークレット、サブスクリプション公開鍵を入力として、新しく生成されたソルトとローカル鍵のセットを使用して、実際に暗号化を行う準備が整いました。

共有 Secret

最初の手順は、サブスクリプションの公開鍵と新しい秘密鍵を使用して共有シークレットを作成することです(アリスとボブの ECDH の説明を思い出してください。そのように)。

const sharedSecret = localKeysCurve.computeSecret(
  subscription.keys.p256dh,
  'base64',
);

これは、次のステップで疑似乱数キー(PRK)を計算するために使用されます。

疑似ランダムキー

疑似ランダム鍵(PRK)は、プッシュ サブスクリプションの認証シークレットと、先ほど作成した共有シークレットを組み合わせたものです。

const authEncBuff = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(subscription.keys.auth, sharedSecret, authEncBuff, 32);

Content-Encoding: auth\0 文字列の用途が気になるかもしれません。つまり、明確な目的はありません。ただし、ブラウザは着信したメッセージを復号して、想定されるコンテンツ エンコードを探すことができます。\0 は、値が 0 のバイトをバッファの末尾に追加します。これは、メッセージを復号するブラウザが想定しているものです。ブラウザは、コンテンツ エンコードのバイト数、値が 0 のバイト、暗号化されたデータの順に続くことを想定しています。

疑似ランダムキーは、認証、共有シークレット、エンコード情報を HKDF で実行するだけです(暗号化を強化します)。

コンテキスト

「コンテキスト」は、暗号化ブラウザで後で 2 つの値を計算するために使用されるバイトのセットです。これは、サブスクリプション公開鍵とローカル公開鍵を含むバイト配列です。

const keyLabel = new Buffer('P-256\0', 'utf8');

// Convert subscription public key into a buffer.
const subscriptionPubKey = new Buffer(subscription.keys.p256dh, 'base64');

const subscriptionPubKeyLength = new Uint8Array(2);
subscriptionPubKeyLength[0] = 0;
subscriptionPubKeyLength[1] = subscriptionPubKey.length;

const localPublicKeyLength = new Uint8Array(2);
subscriptionPubKeyLength[0] = 0;
subscriptionPubKeyLength[1] = localPublicKey.length;

const contextBuffer = Buffer.concat([
  keyLabel,
  subscriptionPubKeyLength.buffer,
  subscriptionPubKey,
  localPublicKeyLength.buffer,
  localPublicKey,
]);

最終的なコンテキスト バッファは、ラベル、サブスクリプション公開鍵のバイト数、鍵自体、ローカル公開鍵のバイト数、鍵自体です。

このコンテキスト値を使用して、ノンスとコンテンツ暗号鍵(CEK)の作成に使用できます。

コンテンツ暗号鍵とノンス

ノンスは、1 回しか使用されないため、リプレイ攻撃を防ぐ値です。

コンテンツ暗号鍵(CEK)は、最終的にペイロードの暗号化に使用される鍵です。

まず、nonce と CEK のデータバイトを作成する必要があります。これは、コンテンツ エンコード文字列の後に、先ほど計算したコンテキスト バッファが続くものです。

const nonceEncBuffer = new Buffer('Content-Encoding: nonce\0', 'utf8');
const nonceInfo = Buffer.concat([nonceEncBuffer, contextBuffer]);

const cekEncBuffer = new Buffer('Content-Encoding: aesgcm\0');
const cekInfo = Buffer.concat([cekEncBuffer, contextBuffer]);

この情報は、ソルトと PRK を nonceInfo および cekInfo と組み合わせて HKDF を介して実行されます。

// The nonce should be 12 bytes long
const nonce = hkdf(salt, prk, nonceInfo, 12);

// The CEK should be 16 bytes long
const contentEncryptionKey = hkdf(salt, prk, cekInfo, 16);

これにより、ノンスとコンテンツ暗号鍵が取得されます。

暗号化を実行する

コンテンツ暗号鍵を取得したので、ペイロードを暗号化できます。

コンテンツ暗号鍵を鍵として使用し、nonce を初期化ベクトルとして使用して、AES128 暗号を作成します。

Node では、次のように行います。

const cipher = crypto.createCipheriv(
  'id-aes128-GCM',
  contentEncryptionKey,
  nonce,
);

ペイロードを暗号化する前に、ペイロードの先頭に追加するパディングの量を定義する必要があります。パディングを追加する理由は、ペイロード サイズに基づいてメッセージの「タイプ」を盗聴者が特定できるリスクを防ぐためです。

追加のパディングの長さを示すために、2 バイトのパディングを追加する必要があります。

たとえば、パディングを追加しなかった場合、値が 0 の 2 バイト(パディングが存在しないことを示す)があり、この 2 バイトの後にペイロードが読み取られます。5 バイトのパディングを追加した場合、最初の 2 バイトの値は 5 になります。コンシューマーはさらに 5 バイトを読み取ってから、ペイロードの読み取りを開始します。

const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeros, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);

次に、パディングとペイロードをこの暗号で実行します。

const result = cipher.update(Buffer.concat(padding, payload));
cipher.final();

// Append the auth tag to the result -
// https://nodejs.org/api/crypto.html#crypto_cipher_getauthtag
const encryptedPayload = Buffer.concat([result, cipher.getAuthTag()]);

これで、暗号化されたペイロードができました。正解です。

残りの作業は、このペイロードをプッシュ サービスに送信する方法を決定することです。

暗号化されたペイロード ヘッダーと本文

この暗号化されたペイロードをプッシュ サービスに送信するには、POST リクエストでいくつかの異なるヘッダーを定義する必要があります。

暗号化ヘッダー

「Encryption」ヘッダーには、ペイロードの暗号化に使用されるソルトを含める必要があります。

16 バイトのソルトは、Base64 の URL セーフな形式でエンコードし、次のように Encryption ヘッダーに追加する必要があります。

Encryption: salt=[URL Safe Base64 Encoded Salt]

Crypto-Key ヘッダー

[Application Server Keys] セクションで Crypto-Key ヘッダーが使用され、公開アプリケーション サーバー鍵が含まれていることがわかりました。

このヘッダーは、ペイロードの暗号化に使用されるローカル公開鍵を共有するためにも使用されます。

結果のヘッダーは次のようになります。

Crypto-Key: dh=[URL Safe Base64 Encoded Local Public Key String]; p256ecdsa=[URL Safe Base64 Encoded Public Application Server Key]

コンテンツ タイプ、長さ、エンコード ヘッダー

Content-Length ヘッダーは、暗号化されたペイロードのバイト数です。「Content-Type」ヘッダーと「Content-Encoding」ヘッダーは固定値です。以下に示します。

Content-Length: [Number of Bytes in Encrypted Payload]
Content-Type: 'application/octet-stream'
Content-Encoding: 'aesgcm'

これらのヘッダーを設定したら、リクエストの本文として暗号化されたペイロードを送信する必要があります。Content-Typeapplication/octet-stream に設定されていることに注意してください。これは、暗号化されたペイロードをバイト ストリームとして送信する必要があるためです。

NodeJS では、次のように記述します。

const pushRequest = https.request(httpsOptions, function(pushResponse) {
pushRequest.write(encryptedPayload);
pushRequest.end();

ヘッダーを追加しますか?

JWT / アプリケーション サーバーキーに使用されるヘッダー(プッシュ サービスでアプリケーションを識別する方法など)と、暗号化されたペイロードを送信するために使用されるヘッダーについて説明しました。

プッシュ サービスが送信メッセージの動作を変更するために使用する追加のヘッダーがあります。これらのヘッダーの一部は必須ですが、他のヘッダーは省略可能です。

TTL ヘッダー

必須

TTL(または有効期間)は、プッシュ メッセージが配信される前にプッシュ サービスで保持される秒数を指定する整数です。TTL の有効期限が切れると、メッセージはプッシュ サービス キューから削除され、配信されなくなります。

TTL: [Time to live in seconds]

TTL を 0 に設定すると、プッシュ サービスはメッセージの配信をすぐに試みますが、デバイスに到達できない場合、メッセージはプッシュ サービス キューからすぐに削除されます。

技術的には、プッシュ サービスは必要に応じてプッシュ メッセージの TTL を減らすことができます。プッシュ サービスからのレスポンスの TTL ヘッダーを調べると、この現象が発生したかどうかを確認できます。

トピック

任意

トピックは、一致するトピック名がある場合に、保留中のメッセージを新しいメッセージに置き換えるために使用できる文字列です。

これは、デバイスがオフラインの間に複数のメッセージが送信され、デバイスがオンになったときにユーザーに最新のメッセージのみを表示したい場合に便利です。

緊急度

任意

緊急度は、メッセージがユーザーにとってどれほど重要であるかをプッシュ サービスに示します。これは、バッテリー残量が少ないときに重要なメッセージのみで起動することで、ユーザーのデバイスのバッテリー駆動時間を節約するためにプッシュ サービスで使用できます。

ヘッダー値は次のように定義されます。デフォルト値は normal です。

Urgency: [very-low | low | normal | high]

すべてを 1 か所に

この仕組みについてご不明な点がある場合は、web-push-libs org でライブラリがプッシュ メッセージをトリガーする方法をご確認ください。

暗号化されたペイロードと上記のヘッダーを取得したら、PushSubscriptionendpoint に POST リクエストを行うだけです。

では、この POST リクエストに対するレスポンスをどのように処理すればよいのでしょうか?

プッシュ サービスからのレスポンス

プッシュ サービスにリクエストを送信したら、レスポンスのステータス コードを確認する必要があります。これにより、リクエストが成功したかどうかを判断できます。

ステータス コード 説明
201 作成済み。プッシュ メッセージを送信するリクエストが受信され、承認されました。
429 リクエスト数が多すぎます。これは、アプリケーション サーバーがプッシュ サービスでレート制限に達したことを意味します。プッシュ サービスには、次のリクエストを送信できるまでの時間を示す「Retry-After」ヘッダーを含める必要があります。
400 無効なリクエストです。これは通常、ヘッダーのいずれかが無効であるか、形式が正しくないことを意味します。
404 見つかりませんでした。これは、定期購入が期限切れで、使用できないことを示します。この場合は、`PushSubscription` を削除して、クライアントがユーザーを再登録するのを待ちます。
410 削除されました。定期購入は無効になったため、アプリケーション サーバーから削除する必要があります。これは、`PushSubscription` で `unsubscribe()` を呼び出すことで再現できます。
413 ペイロード サイズが大きすぎます。プッシュ サービスがサポートする必要がある最小サイズのペイロードは 4,096 バイト(4 KB)です。

HTTP ステータス コードの詳細については、ウェブプッシュ標準(RFC8030)もご覧ください。

次のステップ

Codelab