Web 推送载荷加密

Mat Scales

在 Chrome 50 之前,推送消息不得包含任何载荷数据。当您的服务工件中触发 “push”事件时,您只知道服务器正在尝试告诉您一些信息,但不知道具体是什么信息。然后,您必须向服务器发出后续请求,并获取要显示的通知的详细信息,这在网络状况不佳时可能会失败。

现在,在 Chrome 50(以及桌面版 Firefox 的当前版本)中,您可以随推送消息一起发送一些任意数据,以便客户端避免发出额外的请求。不过,能力越大,责任也就越大,因此所有载荷数据都必须进行加密。

载荷加密是 Web Push 安全性的重要组成部分。 由于您信任自己的服务器,因此在浏览器与您自己的服务器之间进行通信时,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

如果您想直接获取字节,则可以对订阅使用新的 getKey() 方法,该方法会将参数作为 ArrayBuffer 返回。您需要使用 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) 一样简单。

虽然我们强烈建议使用库,但这项功能是新推出的,许多主流语言还没有任何库。如果您确实需要自行实现此功能,请参阅以下详细信息。

我将使用 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');

借助公钥,我们可以加密消息,使其只能使用客户端的私钥解密。

公钥通常被视为公共密钥,因此为了让客户端能够对消息进行身份验证,确认消息是由受信任的服务器发送的,我们还使用身份验证密钥。毫无疑问,此 ID 应保持机密,仅与您希望向其发送消息的应用服务器共享,并像密码一样对待。

我们还需要生成一些新数据。我们需要一个 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 位,您就可以使用任意位数的密钥,并生成最多 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。

现在,我们创建最终的内容加密密钥和将传递给加密算法的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 Push 使用的密码会创建比未加密的输入长 16 个字节的加密值。由于“休息室有甜甜圈”的长度超过 32 位股票价格,因此任何窥探的员工都能够通过数据长度,而无需解密消息,就知道甜甜圈何时送达。

因此,Web Push 协议允许您在数据开头添加填充。具体使用方式取决于您的应用,但在上面的示例中,您可以将所有消息填充为恰好 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);

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

加密

现在,我们终于拥有了进行加密所需的所有内容。Web Push 所需的密码是使用 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 Push 协议时,POST 正文只是加密消息的原始字节。不过,在 Chrome 和 Firebase Cloud Messaging 支持该协议之前,您可以轻松地将数据添加到现有 JSON 载荷中,如下所示。

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

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

调试程序 / 验证程序

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

通过让代码输出加密的每个中间值,您可以将这些值粘贴到验证程序中,并检查自己是否走在正确的道路上。