ライブラリを使用してプッシュ メッセージをトリガーする方法を説明しましたが、これらのライブラリは具体的に何を行っているのでしょうか。
ネットワーク リクエストを行い、そのリクエストが正しい形式であることを確認しています。このネットワーク リクエストを定義する仕様は、ウェブプッシュ プロトコルです。
このセクションでは、サーバーがアプリケーション サーバーキーを使用して自身を識別する方法と、暗号化されたペイロードと関連データが送信される方法について説明します。
これはウェブプッシュの優れた側面ではなく、私は暗号化の専門家ではありませんが、これらのライブラリが内部で何を行っているかを知ることは便利なので、各部分を見ていきましょう。
アプリケーション サーバーキー
ユーザーを登録するときに、applicationServerKey を渡します。この鍵はプッシュ サービスに渡され、ユーザーを登録したアプリケーションがプッシュ メッセージをトリガーするアプリケーションでもあることを確認するために使用されます。
push メッセージをトリガーすると、push サービスがアプリケーションを認証できるようにする一連のヘッダーが送信されます。(これは VAPID 仕様で定義されています)。
これは実際にはどういう意味で、具体的に何が起こるのでしょうか。アプリケーション サーバーの認証手順は次のとおりです。
- アプリケーション サーバーは、アプリケーションの秘密鍵を使用して JSON 情報に署名します。
- この署名付き情報は、POST リクエストのヘッダーとしてプッシュ サービスに送信されます。
- プッシュ サービスは、
pushManager.subscribe()から受信した保存済みの公開鍵を使用して、受信した情報が公開鍵に関連する秘密鍵で署名されていることを確認します。注意: 公開鍵は、subscribe 呼び出しに渡されるapplicationServerKeyです。 - 署名された情報が有効な場合、プッシュ サービスはプッシュ メッセージをユーザーに送信します。
この情報の流れの例を以下に示します。(左下の凡例は、公開鍵と秘密鍵を示しています)。
リクエストのヘッダーに追加される「署名付き情報」は JSON Web Token です。
JSON Web Token
JSON ウェブトークン(JWT)は、受信者が送信者を検証できるように、サードパーティにメッセージを送信する方法です。
サードパーティがメッセージを受信すると、送信者の公開鍵を取得し、それを使用して JWT の署名を検証する必要があります。署名が有効な場合、JWT は一致する秘密鍵で署名されている必要があるため、想定される送信者からのものである必要があります。
jwt.io/ には、署名を行うことができるライブラリが多数あります。可能な場合は、これらを使用することをおすすめします。完全を期すために、署名付き JWT を手動で作成する方法を見てみましょう。
ウェブプッシュと署名付き JWT
署名付き JWT は単なる文字列ですが、3 つの文字列がドットで結合されたものと考えることができます。
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 から連絡先情報を取得できるようにするためです。(このため、web-push ライブラリにはメールアドレスが必要でした)。
JWT Info と同様に、JWT Data は 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]';
また、ウェブプッシュ プロトコルでは、公開アプリケーション サーバーキーを Crypto-Key ヘッダーで、URL セーフの Base64 エンコード文字列として送信し、先頭に p256ecdsa= を付加する必要があると規定されています。
Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]
ペイロードの暗号化
次に、プッシュ メッセージでペイロードを送信する方法を見てみましょう。これにより、ウェブアプリがプッシュ メッセージを受信したときに、受信したデータにアクセスできるようになります。
他のプッシュ サービスを使用したことがある人からよく寄せられる質問は、ウェブプッシュのペイロードを暗号化する必要がある理由です。ネイティブ アプリでは、プッシュ メッセージでデータを書式なしテキストとして送信できます。
ウェブプッシュの優れた点の 1 つは、すべてのプッシュ サービスが同じ API(ウェブプッシュ プロトコル)を使用するため、デベロッパーはプッシュ サービスが誰であるかを気にする必要がないことです。適切な形式でリクエストを送信すれば、プッシュ メッセージが送信されることが期待できます。この方法のデメリットは、デベロッパーが信頼できないプッシュ サービスにメッセージを送信する可能性があることです。ペイロードを暗号化することで、プッシュ サービスは送信されたデータを読み取ることができなくなります。情報を復号できるのはブラウザのみです。これにより、ユーザーのデータが保護されます。
ペイロードの暗号化は、メッセージ暗号化仕様で定義されています。
プッシュ メッセージのペイロードを暗号化する具体的な手順を説明する前に、暗号化プロセスで使用されるいくつかの手法について説明します。(プッシュ暗号化に関する優れた記事を執筆した Mat Scales 氏に深く感謝します)。
ECDH と HKDF
ECDH と HKDF はどちらも暗号化プロセス全体で使用され、情報を暗号化する目的でメリットを提供します。
ECDH: 楕円曲線ディフィーヘルマン鍵交換
Alice と Bob という 2 人のユーザーが情報を共有したいとします。Alice と Bob は、それぞれ独自の公開鍵と秘密鍵を持っています。アリスとボブは、公開鍵を互いに共有します。
ECDH で生成された鍵の有用な特性は、アリスが自分の秘密鍵とボブの公開鍵を使用して秘密値「X」を作成できることです。Bob も同様に、自分の秘密鍵と Alice の公開鍵を使用して、同じ値「X」を個別に作成できます。これにより、「X」が共有シークレットになり、アリスとボブは公開鍵を共有するだけで済みます。これで、Bob と Alice は「X」を使用してメッセージを暗号化および復号できるようになりました。
私の知る限り、ECDH は、共有シークレット「X」を作成するこの「機能」を可能にする曲線のプロパティを定義します。
これは ECDH の概要を説明したものです。詳しくは、ECDH の概要を詳しく説明した動画をご覧ください。
コードの観点から見ると、ほとんどの言語 / プラットフォームには、これらの鍵を簡単に生成できるライブラリが用意されています。
Node では、次のようにします。
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 バイト)以下にする必要があります。
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 は、安全でないマテリアルを安全にする方法です。
これは、ペイロードの暗号化時に使用されます。次に、入力として受け取るものと、それがどのように暗号化されるかを見てみましょう。
入力
ペイロードを含むプッシュ メッセージをユーザーに送信する場合、次の 3 つの入力が必要です。
- ペイロード自体。
PushSubscriptionのauthシークレット。PushSubscriptionのp256dhキー。
auth と p256dh の値が PushSubscription から取得されることはすでに確認しましたが、簡単に復習すると、サブスクリプションを指定する場合、次の値が必要になります。
subscription.toJSON().keys.auth;
subscription.toJSON().keys.p256dh;
subscription.getKey('auth');
subscription.getKey('p256dh');
auth 値はシークレットとして扱い、アプリケーションの外部で共有しないでください。
p256dh 鍵は公開鍵です。これはクライアント公開鍵と呼ばれることもあります。ここでは、p256dh をサブスクリプションの公開鍵と呼びます。サブスクリプションの公開鍵はブラウザによって生成されます。ブラウザは秘密鍵を秘密に保ち、ペイロードの復号に使用します。
auth、p256dh、payload の 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
最初のステップは、サブスクリプションの公開鍵と新しい秘密鍵を使用して共有シークレットを作成することです(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 のバイトを Buffer の末尾に追加します。これは、メッセージを復号するブラウザが、コンテンツ エンコードに多くのバイトを想定し、その後に値 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()]);
これで、暗号化されたペイロードが作成されました。正解です。
あとは、このペイロードをプッシュ サービスに送信する方法を決定するだけです。
暗号化されたペイロードのヘッダーと本文
この暗号化されたペイロードをプッシュ サービスに送信するには、POST リクエストでいくつかの異なるヘッダーを定義する必要があります。
暗号化ヘッダー
「Encryption」ヘッダーには、ペイロードの暗号化に使用されるソルトを含める必要があります。
16 バイトのソルトは、Base64 URL セーフでエンコードし、次のように Encryption ヘッダーに追加する必要があります。
Encryption: salt=[URL Safe Base64 Encoded Salt]
Crypto-Key ヘッダー
「アプリケーション サーバーキー」セクションで、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-Type が application/octet-stream に設定されていることに注意してください。これは、暗号化されたペイロードをバイト ストリームとして送信する必要があるためです。
NodeJS では、次のようにします。
const pushRequest = https.request(httpsOptions, function(pushResponse) {
pushRequest.write(encryptedPayload);
pushRequest.end();
ヘッダーをさらに追加しますか?
JWT / アプリケーション サーバーキーに使用されるヘッダー(プッシュ サービスでアプリケーションを識別する方法)と、暗号化されたペイロードの送信に使用されるヘッダーについて説明しました。
push サービスが送信メッセージの動作を変更するために使用する追加のヘッダーがあります。これらのヘッダーの一部は必須ですが、他のヘッダーは省略可能です。
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 でライブラリがプッシュ メッセージをトリガーする方法をいつでも確認できます。
暗号化されたペイロードと上記のヘッダーを取得したら、PushSubscription で endpoint に POST リクエストを送信するだけです。
では、この POST リクエストに対するレスポンスをどのように処理すればよいでしょうか。
プッシュ サービスからのレスポンス
プッシュ サービスにリクエストを送信したら、レスポンスのステータス コードを確認して、リクエストが成功したかどうかを判断する必要があります。
| ステータス コード | 説明 |
|---|---|
| 201 | 作成されました。push メッセージを送信するリクエストが受信され、承認されました。 |
| 429 | リクエスト数が多すぎます。つまり、アプリケーション サーバーがプッシュ サービスのレート制限に達したことを意味します。プッシュ サービスには、別のリクエストを送信できるようになるまでの時間を示す「Retry-After」ヘッダーを含める必要があります。 |
| 400 | 無効なリクエストです。これは通常、ヘッダーの 1 つが無効であるか、形式が正しくないことを意味します。 |
| 404 | 見つかりませんでした。これは、定期購入の有効期限が切れており、使用できないことを示します。この場合は、`PushSubscription` を削除し、クライアントがユーザーを再度登録するのを待つ必要があります。 |
| 410 | Gone. サブスクリプションは無効になり、アプリケーション サーバーから削除する必要があります。これは、`PushSubscription` で `unsubscribe()` を呼び出すことで再現できます。 |
| 413 | ペイロード サイズが大きすぎます。プッシュ サービスがサポートする必要があるペイロードの最小サイズは 4,096 バイト(4 KB)です。 |
HTTP ステータス コードの詳細については、ウェブプッシュ標準(RFC8030)もご覧ください。
次のステップ
- ウェブプッシュ通知の概要
- プッシュの仕組み
- ユーザーの登録
- 権限の UX
- ウェブプッシュ ライブラリを使用したメッセージの送信
- ウェブプッシュ プロトコル
- push イベントの処理
- 通知の表示
- 通知の動作
- 一般的な通知パターン
- プッシュ通知に関するよくある質問
- 一般的な問題とバグの報告