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

Mat Scales

Chrome 50 より前は、プッシュ メッセージにペイロード データを含めることはできませんでした。Service Worker で 'push' イベントが呼び出されたときにわかっていたのは、サーバーが何かを返そうとしていることだけで、それが何であるかは把握していません。その後、サーバーにフォローアップ リクエストを行い、表示する通知の詳細を取得する必要がありましたが、ネットワークが不安定な場合は失敗する可能性があります。

Chrome 50(およびデスクトップ版 Firefox の最新バージョン)では、プッシュとともに任意のデータを送信できるため、クライアントが余分なリクエストを送信する必要がなくなりました。ただし、大きな力には大きな責任が伴うため、すべてのペイロード データを暗号化する必要があります。

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

ここで、HTTPS で保証できるのは、プッシュ サービス プロバイダに送信される途中でメッセージが傍受されないことです。攻撃者はペイロードを受け取ると、サードパーティに再送信したり、悪意を持って別のものに変更したりなど、自由に操作できます。これを防ぐため、暗号化を使用して、転送中のペイロードをプッシュ サービスが読み取ったり改ざんしたりできないようにします。

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

すでにペイロードなしでプッシュ通知を実装している場合、クライアント側で行う必要があるのはわずか 2 つの変更だけです。

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

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

2 つの値 p256dhauth は、Base64 のバリアントでエンコードされます。このバリアントを URL セーフ 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 イベントが発生したときの新しい data プロパティです。受信したデータを解析するためのさまざまな同期メソッド(.text().json().arrayBuffer().blob() など)があります。

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

サーバーサイドに対する変更

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

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

ライブラリの使用を強くおすすめしますが、これは新機能であり、多くの一般的な言語がまだライブラリを持っていません。これを自分で実装する必要がある場合は、以下の詳細をご確認ください。

ここでは Node 風の 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);

HKDF

そろそろ別の余談に移りましょう。暗号鍵として使用したい秘密データがあるが、暗号的に十分に安全でない場合があります。HMAC ベースの鍵導出関数(HKDF)を使用すると、セキュリティの低いシークレットをセキュリティの高いシークレットに変換できます。

この仕組みにより、任意の数のビットのシークレットを取得して、使用するハッシュ アルゴリズムで生成されたハッシュと同じく、最大 255 倍の任意のサイズのシークレットを生成できるようになります。プッシュの場合、仕様では 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 ではないことに気が付くかもしれません。

次に、最終的なコンテンツ暗号鍵と、暗号に渡されるノンスを作成します。これらは、メッセージの送信元をさらに検証するために、楕円曲線、情報の送信者、受信者に固有の情報を含む、仕様で情報として参照される、それぞれに単純なデータ構造を作成することで作成されます。次に、PRK、ソルト、情報を使用して 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)として使用します。

この例ではデータは文字列ですが、任意のバイナリ データに置き換えることができます。送信できるペイロードのサイズは、1 回の投稿あたり最大 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 としてエンコードされます。

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

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

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

デバッグ / 検証ツール

この機能を実装した Chrome エンジニアの 1 人(また、仕様作成にも携わった)である Peter Beverloo が、検証ツールを作成しました。

コードで暗号化の中間値をそれぞれ出力すると、それらを検証ツールに貼り付けて、正しい方向に進んでいることを確認できます。