在 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=="}}
p256dh
和 auth
这两个值是在 Base64 的变体中进行编码的,我将称为“网址安全 Base64”。
如果您希望正确处理字节数,可以对以 ArrayBuffer
形式返回参数的订阅使用新的 getKey()
方法。您需要两个参数: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)
一样简单。
虽然我们强烈推荐使用库,但这是一项新功能,而且许多热门语言还没有任何库。如果您确实需要自行实现该 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 推送所需的加密方法是使用 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 推送协议时,POST 的正文就是加密消息的原始字节。不过,在 Chrome 和 Firebase Cloud Messaging 支持该协议之前,您可以轻松地将数据添加到现有 JSON 载荷中,如下所示。
{
"registration_ids": [ "…" ],
"raw_data": "BIXzEKOFquzVlr/1tS1bhmobZ…"
}
rawData
属性的值必须是加密消息的 base64 编码表示形式。
调试 / 验证程序
实现该功能的 Chrome 工程师 Peter Beverloo(也是参与规范的人员之一)创建了一个验证程序。
通过让代码输出加密的每个中间值,您可以将其粘贴到验证程序中,并检查是否正确。