在 Chrome 50 之前,推送消息不得包含任何载荷数据。在 Service Worker 中触发“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=="}}
p256dh
和 auth
这两个值采用 Base64 变体进行编码,我将其称为可在网址中安全使用的 Base64。
如果您想直接获取字节,则可以对订阅使用新的 getKey()
方法,该方法会将参数作为 ArrayBuffer
返回。您需要使用 auth
和 p256dh
这两个参数。
> 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
字节填充。因此,最小填充是两个字节 - 即数字 0 编码为 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 所需的密码是使用 GCM 的 AES128。我们将内容加密密钥用作密钥,将 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 工程师之一(也是参与制定规范的人员之一),他创建了一个验证器。
通过让代码输出加密的每个中间值,您可以将这些值粘贴到验证程序中,并检查自己是否走在正确的道路上。