Web 推送载荷加密

地垫秤

在 Chrome 50 之前,推送消息不能包含任何载荷数据。当 'push' 事件在 Service Worker 中触发时,您知道的只是服务器尝试向您传达某些信息,但不知道它可能是什么。然后,您必须向服务器发出后续请求,并获取要显示的通知的详细信息(在网络状况不佳时可能会失败)。

现在,在 Chrome 50(以及当前版本的桌面版 Firefox)中,您可以在推送的同时发送一些任意数据,以便客户端避免发出额外的请求。但是,能力越强需要承担的责任就越大,因此所有载荷数据都必须加密。

载荷加密是 Web 推送安全事件的重要组成部分。HTTPS 能确保您在浏览器与您自己的服务器之间通信时安全无虞,因为您信任该服务器。不过,浏览器会选择实际传送载荷的推送提供程序,因此作为应用开发者,您无法控制该载荷。

在这里,HTTPS 只能保证在向推送服务提供商传输消息的过程中,任何人都无法窥探其中的内容。收到该载荷后,他们可以随意执行操作,包括将载荷重新传输到第三方,或将其恶意更改为其他载荷。为防止出现这种情况,我们使用加密来确保推送服务无法读取或篡改传输中的载荷。

客户端更改

如果您已经实现无载荷的推送通知,只需在客户端上进行两项细微更改。

首先,在将订阅信息发送到后端服务器时,您需要收集一些额外的信息。如果您已对 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=="}}

p256dhauth 这两个值是在 Base64 的变体中进行编码的,我将称为“网址安全 Base64”

如果您希望正确处理字节数,可以对以 ArrayBuffer 形式返回参数的订阅使用新的 getKey() 方法。您需要两个参数:authp256dh

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

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

第二个更改是在 push 事件触发时添加新的 data 属性。它具有用于解析已接收数据的各种同步方法,例如 .text().json().arrayBuffer().blob()

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

服务器端更改

在服务器端,情况则发生了很大变化。基本流程是使用从客户端获得的加密密钥信息来加密载荷,然后将其作为 POST 请求的正文发送到订阅中的端点,并添加一些额外的 HTTP 标头。

这些细节相对复杂,与任何加密相关的库一样,最好使用积极开发的库,而不是自行开发库。Chrome 团队发布了适用于 Node.js 的,很快就会支持更多语言和平台。这可同时处理加密和 Web 推送协议,因此从 Node.js 服务器发送推送消息就像使用 webpush.sendWebPush(message, subscription) 一样简单。

虽然我们强烈推荐使用库,但这是一项新功能,而且许多热门语言还没有任何库。如果您确实需要自行实现该 API,请查看以下详细信息。

我将使用基于 Node 的 JavaScript 来说明这些算法,但其基本原理对于所有语言都应该相同。

输入内容

为了加密消息,我们首先需要从从客户端接收的订阅对象获取两项内容。如果您在客户端上使用 JSON.stringify() 并将其传输到服务器,则客户端的公钥会存储在 keys.p256dh 字段中,而共享的身份验证密钥会存储在 keys.auth 字段中。如上所述,这两者都将采用在网址中安全使用的 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) 将安全性较低的密钥转换为安全性较高的密文。

它的工作原理之一是,它允许您获取任意数量的位的 Secret,并再生成另一个大小为 255 倍的 Secret,其大小与您使用的任何哈希算法生成的哈希一样。对于推送,该规范要求我们使用 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。

现在,我们创建了最终的内容加密密钥和一个将传递给加密算法的 Nonce。这些消息通过为每条消息创建简单的数据结构(规范中称为信息),其中包含特定于椭圆曲线、发送者和接收者的信息,以便进一步验证消息的来源。然后,我们将 HKDF 与 PRK、盐和信息结合使用,得出正确大小的键和 Nonce。

内容加密的信息类型为“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 分钟给她送上“客厅的甜甜圈”,这样她就能“碰巧”在他们到达时到场并拿到最好的一串。

Web 推送使用的加密方式创建的加密值正好比未加密输入长 16 个字节。由于“休息室中的甜甜圈”比 32 位股票价格更长,因此任何窥探员工只要不解密消息就能知道甜甜圈何时到达,只需根据数据长度即可。

因此,网络推送协议允许您在数据开头添加内边距。具体使用方法取决于您的应用,但在上述示例中,您可以将所有消息填充为正好 32 个字节,从而无法仅根据长度区分消息。

填充值是一个 16 位大端字节序整数,指定填充长度,后跟填充的 NUL 字节数。因此最小填充为 2 个字节,即编码为 16 位的数字 0。

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

当您的推送消息到达客户端时,浏览器将能够自动去除所有内边距,因此客户端代码只会接收未填充的消息。

加密

现在,我们终于完成了加密所需的全部工作。Web 推送所需的加密方法是使用 GCMAES128。我们将内容加密密钥用作密钥,使用 Nonce 作为初始化矢量 (IV)。

在本例中,我们的数据是一个字符串,但它可以是任何二进制数据。每个帖子可以发送的载荷大小上限为 4078 字节 - 4096 字节,加密信息为 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()]);

Web 推送

好了!现在您已加密了载荷,您只需要向用户订阅指定的端点发出相对简单的 HTTP POST 请求即可。

您需要设置三个标头。

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

<SALT><PUBLICKEY> 是用于加密的盐和服务器公钥,以可在网址中安全使用的 Base64 中编码。

使用 Web 推送协议时,POST 的正文就是加密消息的原始字节。不过,在 Chrome 和 Firebase Cloud Messaging 支持该协议之前,您可以轻松地将数据添加到现有 JSON 载荷中,如下所示。

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

rawData 属性的值必须是加密消息的 base64 编码表示形式。

调试 / 验证程序

实现该功能的 Chrome 工程师 Peter Beverloo(也是参与规范的人员之一)创建了一个验证程序

通过让代码输出加密的每个中间值,您可以将其粘贴到验证程序中,并检查是否正确。