ウェブの push ペイロード暗号化

マットスケール

Chrome 50 より前のバージョンでは、push メッセージにペイロード データを含めることができませんでした。Service Worker で 「push」イベントが発生したとき、サーバーが何かを伝えようとしていることしかわからず、それが何であるかはわかりません。その後、サーバーにフォローアップ リクエストを行い、表示する通知の詳細を取得する必要がありましたが、ネットワーク状態が悪いと失敗する可能性があります。

Chrome 50(およびパソコン版 Firefox の現行バージョン)では、プッシュ時に任意のデータを送信できるため、クライアントは余分なリクエストを行うのを回避できます。ただし、優れた機能には大きな責任が伴うため、すべてのペイロード データを暗号化する必要があります。

ペイロードの暗号化は、ウェブプッシュのセキュリティ ストーリーにおける重要な部分です。HTTPS はサーバーを信頼しているため、ブラウザと独自のサーバー間の通信時にセキュリティを確保できます。ただし、実際にペイロードを配信するために使用される push プロバイダはブラウザが選択するので、アプリ デベロッパーはこれを制御できません。

ここで、HTTPS は、プッシュ サービス プロバイダへの転送中のメッセージを誰も盗聴できないことのみを保証できます。受信したユーザーは、ペイロードを第三者に再送信したり、悪意を持って別のものに変更したりするなど、自由に自由に操作できます。これを防ぐために、Google では暗号化を使用して、push サービスが転送中のペイロードの読み取りや改ざんを実行できないようにしています。

クライアントサイドの変更

すでにペイロードなしでプッシュ通知を実装している場合は、クライアント側で 2 つの小さな変更を行うだけで済みます。

これは、定期購入情報をバックエンド サーバーに送信するときに、いくつかの追加情報を収集する必要があることです。PushSubscription オブジェクトで JSON.stringify() をすでに使用してシリアル化し、サーバーに送信している場合は、何も変更する必要はありません。これで、サブスクリプションのキー プロパティに追加のデータが追加されます。

> JSON.stringify(subscription)
{"endpoint":"https://android.googleapis.com/gcm/send/f1LsxkKphfQ:APA91bFUx7ja4BK4JVrNgVjpg1cs9lGSGI6IMNL4mQ3Xe6mDGxvt_C_gItKYJI9CAx5i_Ss6cmDxdWZoLyhS2RJhkcv7LeE6hkiOsK6oBzbyifvKCdUYU7ADIRBiYNxIVpLIYeZ8kq_A",
"keys":{"p256dh":"BLc4xRzKlKORKWlbdgFaBrrPK3ydWAHo4M0gs0i1oEKgPpWC5cW8OCzVrOQRv-1npXRWk8udnW3oYhIO4475rds=",
"auth":"5I2Bu2oKdyy9CwL8QVF0NQ=="}}

p256dhauth の 2 つの値は、Base64(URL-Safe Base64 という)のバリアントでエンコードされます。

正確なバイト数を取得したい場合は、パラメータを ArrayBuffer として返すサブスクリプションの新しい getKey() メソッドを使用します。必要な 2 つのパラメータは authp256dh です。

> new Uint8Array(subscription.getKey('auth'));
[228, 141, 129, ...] (16 bytes)

> new Uint8Array(subscription.getKey('p256dh'));
[4, 183, 56, ...] (65 bytes)

2 つ目は、push イベントが発生したときの新しいデータ プロパティです。.text().json().arrayBuffer().blob() など、受信データを解析するためのさまざまな同期メソッドを備えています。

self.addEventListener('push', function(event) {
  if (event.data) {
    console.log(event.data.json());
  }
});

サーバーサイドの変更

サーバー側では、状況が少し異なります。基本的なプロセスでは、クライアントから取得した暗号鍵情報を使用してペイロードを暗号化し、それを HTTP ヘッダーを追加して、POST リクエストの本文としてサブスクリプションのエンドポイントに送信します。

細部は比較的複雑であるため、暗号化に関連するすべてのものと同様に、独自のライブラリを作成するよりも、積極的に開発されたライブラリを使用することをおすすめします。Chrome チームが Node.js 用のライブラリを公開しており、さらに多くの言語とプラットフォームを近日公開予定です。これは暗号化とウェブ push プロトコルの両方を処理するため、Node.js サーバーからの push メッセージの送信が webpush.sendWebPush(message, subscription) と同じくらい簡単です。

ライブラリの使用を強くおすすめしますが、これは新機能であり、まだライブラリのない一般的な言語が数多く存在します。ご自身で実装する必要がある場合は、以下をご覧ください。

ここではノードフレーバーの JavaScript を使用したアルゴリズムを説明しますが、どの言語でも基本的な原理は同じです。

入力

メッセージを暗号化するには、まずクライアントから受け取ったサブスクリプション オブジェクトから 2 つのものを取得する必要があります。クライアントで JSON.stringify() を使用してサーバーに送信した場合、クライアントの公開鍵は keys.p256dh フィールドに格納され、共有認証シークレットは keys.auth フィールドに格納されます。これらはどちらも、前述のように URL 用 Base64 でエンコードされます。クライアントの公開鍵のバイナリ形式は、圧縮されていない P-256 楕円曲線点です。

const clientPublicKey = new Buffer(subscription.keys.p256dh, 'base64');
const clientAuthSecret = new Buffer(subscription.keys.auth, 'base64');

公開鍵によってメッセージを暗号化できるため、クライアントの秘密鍵を使用してのみメッセージを復号できます。

公開鍵は通常、公開鍵とみなされます。そのため、信頼できるサーバーによってメッセージが送信されたことをクライアントが認証できるようにするために、認証シークレットも使用します。当然のことながら、これは秘密にし、メッセージを送信するアプリケーション サーバーとのみ共有し、パスワードのように扱う必要があります。

また、新しいデータを生成する必要もあります。16 バイトの暗号で保護されたランダム ソルトと、楕円曲線鍵の公開/非公開ペアが必要です。プッシュ暗号化仕様で使用される特定の曲線は、P-256(prime256v1)と呼ばれます。セキュリティを最大限に高めるには、メッセージを暗号化するたびに鍵ペアをゼロから生成する必要があります。また、ソルトを再利用しないでください。

ECDH

ここでは、楕円曲線暗号の優れた特性について説明します。自身の秘密鍵と他人の公開鍵を組み合わせて値を抽出する比較的簡単なプロセスがあります。では、どうすればよいでしょうか。相手側が秘密鍵とお客様の公開鍵を取得した場合、取得値はまったく同じになります。

これは、楕円曲線 Diffie-Hellman(ECDH)鍵交換プロトコルの基礎です。これにより、公開鍵を交換するだけであっても、双方が同じ共有シークレットを持つことができます。この共有シークレットを実際の暗号鍵の基礎として使用します。

const crypto = require('crypto');

const salt = crypto.randomBytes(16);

// Node has ECDH built-in to the standard crypto library. For some languages
// you may need to use a third-party library.
const serverECDH = crypto.createECDH('prime256v1');
const serverPublicKey = serverECDH.generateKeys();
const sharedSecret = serverECDH.computeSecret(clientPublicKey);

香港ドル

そろそろまたまたの時間だ。暗号鍵として使用したいシークレット データがあるものの、暗号的に十分に安全ではないとします。HMAC ベースの鍵導出関数(HKDF)を使用して、安全性の低いシークレットを高セキュリティのシークレットに変換できます。

この方法の効果として、任意のビット数のシークレットを取得し、使用するハッシュ アルゴリズムによって生成されたハッシュの最大 255 倍の任意のサイズの別のシークレットを生成できます。push の場合、この仕様では SHA-256 の使用が規定されており、そのハッシュ長は 32 バイト(256 ビット)です。

実際のところ、最大サイズで 32 バイトの鍵のみを生成すればよいことがわかります。つまり、より大きな出力サイズを処理できない簡素化されたアルゴリズム バージョンを使用できます。

Node バージョンのコードを以下に示しますが、実際の動作については RFC 5869 をご覧ください。

HKDF への入力は、ソルト、初期の鍵マテリアル(ikm)、現在のユースケースに固有のオプションの構造化データ(info)、目的の出力キーの長さ(バイト単位)です。

// Simplified HKDF, returning keys up to 32 bytes long
function hkdf(salt, ikm, info, length) {
  if (length > 32) {
    throw new Error('Cannot return keys of more than 32 bytes, ${length} requested');
  }

  // 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);
}

暗号化パラメータの導出

ここでは、HKDF を使用して、取得したデータを実際の暗号化のパラメータに変換します。

まず、HKDF を使用して、クライアント認証シークレットと共有シークレットを、より長く暗号的に安全なシークレットに結合します。これは仕様上、疑似ランダム鍵(PRK)と呼ばれます。ここではこれを使用します。ただし、暗号学の純粋な人物にとっては、これは厳密には PRK ではないことに注意するかもしれません。

次に、最終的なコンテンツ暗号鍵と、暗号に渡されるノンスを作成します。これらは、メッセージのソースをさらに検証するために、楕円曲線、情報の送信者、受信者に固有の情報を含む、仕様では情報と呼ばれる、それぞれにシンプルなデータ構造を作成することによって作成されています。次に、PRK、ソルト、info とともに HKDF を使用して、正しいサイズのキーとノンスを取得します。

コンテンツ暗号化の情報タイプは「aesgcm」です。これはプッシュ暗号化に使用される暗号の名前です。

const authInfo = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(clientAuthSecret, sharedSecret, authInfo, 32);

function createInfo(type, clientPublicKey, serverPublicKey) {
  const len = type.length;

  // The start index for each element within the buffer is:
  // value               | length | start    |
  // -----------------------------------------
  // 'Content-Encoding: '| 18     | 0        |
  // type                | len    | 18       |
  // nul byte            | 1      | 18 + len |
  // 'P-256'             | 5      | 19 + len |
  // nul byte            | 1      | 24 + len |
  // client key length   | 2      | 25 + len |
  // client key          | 65     | 27 + len |
  // server key length   | 2      | 92 + len |
  // server key          | 65     | 94 + len |
  // For the purposes of push encryption the length of the keys will
  // always be 65 bytes.
  const info = new Buffer(18 + len + 1 + 5 + 1 + 2 + 65 + 2 + 65);

  // The string 'Content-Encoding: ', as utf-8
  info.write('Content-Encoding: ');
  // The 'type' of the record, a utf-8 string
  info.write(type, 18);
  // A single null-byte
  info.write('\0', 18 + len);
  // The string 'P-256', declaring the elliptic curve being used
  info.write('P-256', 19 + len);
  // A single null-byte
  info.write('\0', 24 + len);
  // The length of the client's public key as a 16-bit integer
  info.writeUInt16BE(clientPublicKey.length, 25 + len);
  // Now the actual client public key
  clientPublicKey.copy(info, 27 + len);
  // Length of our public key
  info.writeUInt16BE(serverPublicKey.length, 92 + len);
  // The key itself
  serverPublicKey.copy(info, 94 + len);

  return info;
}

// Derive the Content Encryption Key
const contentEncryptionKeyInfo = createInfo('aesgcm', clientPublicKey, serverPublicKey);
const contentEncryptionKey = hkdf(salt, prk, contentEncryptionKeyInfo, 16);

// Derive the Nonce
const nonceInfo = createInfo('nonce', clientPublicKey, serverPublicKey);
const nonce = hkdf(salt, prk, nonceInfo, 12);

パディング

別のことはさておき、ばかばかしく不自然な例で考える時間です。上司が使用しているサーバーでは、数分ごとに会社の株価情報を含むプッシュ メッセージを送信します。この場合の書式なしメッセージは、常にセント単位の 32 ビット整数になります。彼女はまた、ケータリング スタッフとひそかな取引をしています。実際に配達の 5 分前に「休憩室でドーナツ」のひもを送ってもらい、彼らが到着したときに「偶然」その場にいて、一番良いものをピックアップできます。

ウェブプッシュで使用される暗号は、暗号化されていない入力よりも正確に 16 バイト長い暗号化された値を作成します。「休憩室のドーナツ」は 32 ビットの株価より長いため、覗き見している従業員は誰でも、メッセージを復号することなく、データの長さからドーナツが到着したことを知ることができます。

このため、ウェブプッシュ プロトコルでは、データの先頭にパディングを追加できます。これの使用方法はアプリケーションによって異なりますが、上記の例では、すべてのメッセージがちょうど 32 バイトになるようにパディングできるため、長さだけに基づいてメッセージを区別することはできません。

パディング値は 16 ビットのビッグ エンディアン整数で、パディングの長さと、その後ろにパディングの NUL バイト数を指定したものです。したがって、パディングの最小は 2 バイトであり、16 ビットにエンコードされた数字のゼロです。

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

プッシュ メッセージがクライアントに到達すると、ブラウザは自動的にパディングを除去できるため、クライアント コードはパディングされていないメッセージのみを受け取ります。

暗号化

これで、暗号化に関するすべての作業が終わりました。ウェブプッシュに必要な暗号は、GCM を使用した AES128 です。コンテンツ暗号鍵を鍵として使用し、ノンスを初期化ベクトル(IV)として使用します。

この例では、データは文字列ですが、どのようなバイナリデータでもかまいません。ペイロードは最大 4,078 バイトから最大 4,096 バイトで、暗号化情報には 16 バイト、パディング用に少なくとも 2 バイトを使用できます。

// Create a buffer from our data, in this case a UTF-8 encoded string
const plaintext = new Buffer('Push notification payload!', 'utf8');
const cipher = crypto.createCipheriv('id-aes128-GCM', contentEncryptionKey,
nonce);

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

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

ウェブプッシュ

これでこれで暗号化されたペイロードは、ユーザーのサブスクリプションで指定されたエンドポイントに比較的単純な HTTP POST リクエストを行うだけで済みます。

3 つのヘッダーを設定する必要があります。

Encryption: salt=<SALT>
Crypto-Key: dh=<PUBLICKEY>
Content-Encoding: aesgcm

<SALT><PUBLICKEY> は、暗号化で使用されるソルトとサーバーの公開鍵で、URL 用 Base64 としてエンコードされます。

ウェブプッシュ プロトコルを使用する場合、POST の本文は、暗号化されたメッセージの生のバイトのみになります。ただし、Chrome と Firebase Cloud Messaging がプロトコルをサポートするまでは、次のように既存の JSON ペイロードにデータを簡単に含めることができます。

{
    "registration_ids": [ "…" ],
    "raw_data": "BIXzEKOFquzVlr/1tS1bhmobZ…"
}

rawData プロパティの値は、暗号化されたメッセージの Base64 エンコード表現である必要があります。

デバッグ / 検証ツール

この機能を実装した Chrome エンジニアの 1 人である Peter Beverloo は、検証ツールを作成しました

暗号化の各中間値を出力するようにコードを作成することで、それらを検証ツールに貼り付けて、正しいトラックであることを確認できます。