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

ライブラリを使用してプッシュ メッセージをトリガーする方法について説明しましたが、これらのライブラリは具体的に何をしているのでしょうか。

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

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

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

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

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

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

push メッセージのトリガー時には push サービスがアプリケーションを認証できるようにします。(これは VAPID 仕様で定義されています)。

具体的にはどういうことでしょうか。また、どのようなことが起きるのでしょうか。アプリケーション サーバー認証の手順は次のとおりです。

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

この情報フローの例を以下に示します。(左下の凡例で公開鍵と秘密鍵を確認できます)。

アプリケーション サーバーの秘密鍵が Google から送信される
メッセージ

署名された情報JSON Web Token です。

JSON Web Token

JSON Web Token(JWT)は、受信者が送信者を検証できるように、第三者にメッセージを送信する方法です。

サードパーティは、メールを受信したときに JWT の署名を検証できます。署名が有効な場合、JWT は一致する秘密鍵で署名されているため、想定される送信者からのものです。

https://jwt.io/ には、 署名はあなたに代わって行います できます。完全性を期すために、署名付き JWT を手動で作成する方法を見てみましょう。

ウェブ push と署名付き JWT

署名付き JWT は単なる文字列ですが、3 つの文字列を結合したものと考えることができます。 表示されます。

JSON Web Token 内の文字列のイラスト

最初の文字列と 2 番目の文字列(JWT 情報と JWT データ)は、base64 でエンコードされた JSON の一部であり、一般公開されています。

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

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

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

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

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

{
  "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 時間です 送信アプリケーションと push サービスの間のクロック相違の問題

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

JWT Info と同様に、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);
});

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

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

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

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

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

ペイロードの暗号化

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

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

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

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

push メッセージ ペイロードを暗号化する具体的な手順を説明する前に、 暗号化に使用するいくつかの手法と、 プロセスです(Matt Scales はプッシュに関する優れた記事を執筆しているので、 encryption.)

ECDH と HKDF

ECDH と HKDF はどちらも暗号化プロセス全体で使用されるため、暗号化には 目的としています。

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

アリスとボブという 2 人が情報を共有したいと考えているとします。 Alice と Bob の両方に独自の公開鍵と秘密鍵があります。アリスとボブは公開鍵を共有します。

ECDH で生成された鍵の有用な特性は、Alice は ECDH を使用して 使用して、シークレット値「X」を作成します。ボブはできる アリスの秘密鍵とアリスの公開鍵を 独立した同じ値「X」を作成します。これで「X」が共有シークレット その際、Alice と Bob は公開鍵を共有するだけで済みました。これで、Bob と Alice は「X」を使用して、相互間のメッセージを暗号化および復号できるようになりました。

ECDH は、私の知る限りでは、この「特徴」を可能にする曲線の特性を定義している シークレット「X」を作成します。

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

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

ノードでは、次のようにします。

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

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

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

ウィキペディアには、HKDF についての簡潔な説明があります。

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

基本的に、HKDF は特に安全ではない入力を受け取り、安全性を高めます。

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

Node では、次のように実装できます。

// 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 の記事をご覧ください。

ECDH を大まかにカバーしています。 および HKDF

ECDH は、公開鍵を共有して共有シークレットを生成する安全な方法です。HKDF は 保護します。

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

入力

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

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

authp256dh の値は PushSubscription から取得されますが、サブスクリプションの場合、次の値が必要になります。

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

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

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

p256dh キーは公開鍵であり、クライアント公開鍵とも呼ばれます。現在地 サブスクリプションの公開鍵として p256dh を使用します。サブスクリプション公開鍵はブラウザによって生成されます。ブラウザは秘密鍵を秘密にし、それを使って鍵を復号します 記述できます。

これらの 3 つの値、authp256dhpayload は入力として必要であり、 暗号化プロセスには暗号化されたペイロード、ソルト値、公開鍵を データの暗号化を行います。

ソルト

ソルトは 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

まず、サブスクリプションの公開鍵と新しいサブネットを使用して、 (Alice と Bob の ECDH の説明を思い出してください。。

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

これは、次のステップで擬似ランダムキー(PRK)を計算するために使用されます。

疑似ランダム鍵

疑似ランダム鍵(PRK)は、push サブスクリプションの認証と 先ほど作成した共有シークレットが含まれます。

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)は、最終的にペイロードを暗号化するために使用される鍵です。

まず、ノンスと 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);

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

暗号化を実行する

コンテンツ暗号鍵が作成されたので、ペイロードを暗号化できます。

コンテンツ暗号鍵を鍵として使用し、ノンスを初期化ベクトルとして 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()]);

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

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

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

この暗号化されたペイロードを push サービスに送信するには、いくつかの POST リクエスト内の異なるヘッダーを使用します

暗号化ヘッダー

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

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-Encoding」をヘッダーは固定値です。 以下にその例を示します。

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

これらのヘッダーを設定して、暗号化されたペイロードを本文として送信する必要があります。 表示されます。Content-Type が次のように設定されています。 application/octet-stream。これは、暗号化されたペイロードをバイトのストリームとして送信する必要があるためです。

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

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

ヘッダーを追加する

JWT / アプリケーション サーバーキーに使用されるヘッダー( push サービスを使用するアプリケーションなど)で暗号化されるほか、暗号化されたデータを送信して、 記述できます。

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

TTL ヘッダー

必須

TTL(有効期間)は、プッシュ メッセージがプッシュ サービスに保存されてから配信されるまでの秒数を指定する整数です。TTL の期限が切れると、メッセージは push サービスキューに配置され、配信されません。

TTL: [Time to live in seconds]

TTL をゼロに設定すると、push サービスは すぐにメッセージが表示されますが、デバイスにアクセスできない場合は、 すぐに push サービス キューから削除されます。

push サービスは技術的には、push メッセージの TTL を 必要があります。プッシュ サービスからのレスポンスの TTL ヘッダーを確認することで、この状態かどうかを判断できます。

トピック

任意

トピックは、保留中のメッセージを 新しいメッセージが返されます。

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

緊急度

任意

緊急度は、プッシュ サービスにメッセージの重要度を通知します。プッシュ サービスは、バッテリー残量が少ない場合にのみ重要なメッセージに対してデバイスを起動することで、デバイスのバッテリーを節約できます。

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

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

すべてが 1 か所に

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

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

では、この POST リクエストのレスポンスをどうすればよいでしょうか。

push サービスからのレスポンス

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

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

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

次のステップ

Codelab