我们已经了解了如何使用库来触发推送消息,但是这些库究竟在做什么呢?
嗯,它们正在发出网络请求,同时确保这些请求的格式正确。定义此网络请求的规范是 Web Push 协议。
本节概述了服务器如何使用应用服务器密钥标识自身,以及如何发送加密的有效负载和关联数据。
这不是 Web Push 漂亮的一面,我也不是加密专家,但让我们看一下每个部分,因为了解这些库在后台做什么很有用。
应用服务器密钥
当我们订阅用户时,我们传入一个 applicationServerKey
。此密钥将传递给推送服务,并用于检查订阅用户的应用是否也是触发推送消息的应用。
当我们触发推送消息时,我们会发送一组标头,这些标头允许推送服务验证应用身份。(这由 VAPID 规范定义。)
这一切实际上意味着什么?到底发生了什么?以下是应用服务器身份验证采取的步骤
- 应用服务器使用其私有应用密钥对一些 JSON 信息进行签名。
- 此签名信息作为标头在 POST 请求中发送到推送服务。
- 推送服务使用从
pushManager.subscribe()
收到的存储公钥,检查收到的信息是否由与公钥相关的私钥签名。请记住:公钥是传递到订阅调用的applicationServerKey
。 - 如果签名信息有效,则推送服务会将推送消息发送给用户。
以下是此信息流的示例。(请注意左下角的图例,指示公钥和私钥。)
添加到请求标头中的“签名信息”是 JSON Web 令牌。
JSON Web 令牌
JSON Web 令牌(或简称 JWT)是一种向第三方发送消息的方式,以便接收者可以验证发送者。
当第三方收到消息时,他们需要获取发送者的公钥,并使用它来验证 JWT 的签名。如果签名有效,则 JWT 必须已使用匹配的私钥签名,因此必须来自预期的发送者。
https://jwt.node.org.cn/ 上有大量库可以为您执行签名,我建议您尽可能使用这些库。为了完整起见,让我们看看如何手动创建签名 JWT。
Web Push 和签名 JWT
签名 JWT 只是一个字符串,但可以将其视为由点连接的三个字符串。
第一个和第二个字符串(JWT 信息和 JWT 数据)是经过 base64 编码的 JSON 片段,这意味着它是公开可读的。
第一个字符串是关于 JWT 本身的信息,指示用于创建签名的算法。
Web Push 的 JWT 信息必须包含以下信息
{
"typ": "JWT",
"alg": "ES256"
}
第二个字符串是 JWT 数据。这提供了有关 JWT 的发送者、JWT 的预期接收者以及 JWT 有效期长短的信息。
对于 Web Push,数据将具有以下格式
{
"aud": "https://some-push-service.org",
"exp": "1469618703",
"sub": "mailto:example@web-push-book.org"
}
aud
值是“受众”,即 JWT 的接收者。对于 Web Push,受众是推送服务,因此我们将其设置为推送服务的来源。
exp
值是 JWT 的过期时间,这可以防止窥探者在拦截 JWT 时重新使用它。过期时间是时间戳(以秒为单位),并且必须不超过 24 小时。
在 Node.js 中,过期时间使用以下方式设置
Math.floor(Date.now() / 1000) + 12 * 60 * 60;
它是 12 小时而不是 24 小时,以避免发送应用和推送服务之间的时钟差异造成任何问题。
最后,sub
值需要是 URL 或 mailto
电子邮件地址。这样,如果推送服务需要联系发送者,它可以从 JWT 中找到联系信息。(这就是 web-push 库需要电子邮件地址的原因)。
与 JWT 信息一样,JWT 数据也编码为 URL 安全的 base64 字符串。
第三个字符串(签名)是通过获取前两个字符串(JWT 信息和 JWT 数据),用点字符将它们连接起来(我们将其称为“未签名令牌”),并对其进行签名而得到的。
签名过程需要使用 ES256 加密“未签名令牌”。根据 JWT 规范,ES256 是“使用 P-256 曲线和 SHA-256 哈希算法的 ECDSA”的缩写。使用 Web Crypto,您可以像这样创建签名
// Utility function for UTF-8 encoding a string to an ArrayBuffer.
const utf8Encoder = new TextEncoder('utf-8');
// The unsigned token is the concatenation of the URL-safe base64 encoded
// header and body.
const unsignedToken = .....;
// Sign the |unsignedToken| using ES256 (SHA-256 over ECDSA).
const key = {
kty: 'EC',
crv: 'P-256',
x: window.uint8ArrayToBase64Url(
applicationServerKeys.publicKey.subarray(1, 33)),
y: window.uint8ArrayToBase64Url(
applicationServerKeys.publicKey.subarray(33, 65)),
d: window.uint8ArrayToBase64Url(applicationServerKeys.privateKey),
};
// Sign the |unsignedToken| with the server's private key to generate
// the signature.
return crypto.subtle.importKey('jwk', key, {
name: 'ECDSA', namedCurve: 'P-256',
}, true, ['sign'])
.then((key) => {
return crypto.subtle.sign({
name: 'ECDSA',
hash: {
name: 'SHA-256',
},
}, key, utf8Encoder.encode(unsignedToken));
})
.then((signature) => {
console.log('Signature: ', signature);
});
推送服务可以使用公共应用服务器密钥来解密签名,并确保解密的字符串与“未签名令牌”(即 JWT 中的前两个字符串)相同,从而验证 JWT。
签名 JWT(即由点连接的所有三个字符串)作为 Authorization
标头发送到 Web Push 服务,并预先添加 WebPush
,如下所示
Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';
Web Push 协议还规定,公共应用服务器密钥必须作为 URL 安全的 base64 编码字符串在 Crypto-Key
标头中发送,并预先添加 p256ecdsa=
。
Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]
有效负载加密
接下来,让我们看看如何使用推送消息发送有效负载,以便当我们的 Web 应用收到推送消息时,它可以访问它接收的数据。
任何使用过其他推送服务的人都会提出的一个常见问题是,为什么 Web Push 有效负载需要加密?对于原生应用,推送消息可以将数据作为纯文本发送。
Web Push 的部分优点在于,由于所有推送服务都使用相同的 API(Web Push 协议),因此开发者不必关心推送服务是谁。我们可以以正确的格式发出请求,并期望发送推送消息。这样做的一个缺点是,开发者可能会向不可信的推送服务发送消息。通过加密有效负载,推送服务无法读取发送的数据。只有浏览器可以解密信息。这可以保护用户的数据。
有效负载的加密在 消息加密规范中定义。
在我们查看加密推送消息有效负载的具体步骤之前,我们应该介绍一些将在加密过程中使用的技术。(非常感谢 Mat Scales 在推送加密方面的出色文章。)
ECDH 和 HKDF
ECDH 和 HKDF 都贯穿整个加密过程,并为加密信息提供了优势。
ECDH:椭圆曲线 Diffie-Hellman 密钥交换
假设有两个人想要共享信息,Alice 和 Bob。Alice 和 Bob 都有自己的公钥和私钥。Alice 和 Bob 互相共享他们的公钥。
使用 ECDH 生成的密钥的有用特性是,Alice 可以使用她的私钥和 Bob 的公钥来创建秘密值“X”。Bob 也可以做同样的事情,使用他的私钥和 Alice 的公钥来独立创建相同的值“X”。这使得“X”成为共享机密,而 Alice 和 Bob 只需要共享他们的公钥。现在,Bob 和 Alice 可以使用“X”来加密和解密他们之间的消息。
据我所知,ECDH 定义了曲线的属性,这些属性允许这种“特性”来生成共享机密“X”。
这是对 ECDH 的高级解释,如果您想了解更多信息,我建议您观看此视频。
在代码方面;大多数语言/平台都带有库,可以轻松生成这些密钥。
在 Node 中,我们将执行以下操作
const keyCurve = crypto.createECDH('prime256v1');
keyCurve.generateKeys();
const publicKey = keyCurve.getPublicKey();
const privateKey = keyCurve.getPrivateKey();
HKDF:基于 HMAC 的密钥派生函数
Wikipedia 对 HKDF 进行了简洁的描述
HKDF 是一种基于 HMAC 的密钥派生函数,可将任何弱密钥材料转换为密码学上强大的密钥材料。例如,它可用于将 Diffie Hellman 交换的共享机密转换为适用于加密、完整性检查或身份验证的密钥材料。
本质上,HKDF 会获取不特别安全的输入,并使其更安全。
定义此加密的规范要求使用 SHA-256 作为我们的哈希算法,并且 Web Push 中 HKDF 的结果密钥不应超过 256 位(32 字节)。
在 Node 中,这可以像这样实现
// Simplified HKDF, returning keys up to 32 bytes long
function hkdf(salt, ikm, info, length) {
// 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);
}
ECDH 是一种安全共享公钥和生成共享机密的方法。HKDF 是一种获取不安全材料并使其安全的方法。
这将在我们加密有效负载期间使用。接下来,让我们看看我们采用什么作为输入以及如何加密。
输入
当我们想要向用户发送带有有效负载的推送消息时,我们需要三个输入
- 有效负载本身。
- 来自
PushSubscription
的auth
密钥。 - 来自
PushSubscription
的p256dh
密钥。
我们已经看到了如何从 PushSubscription
中检索 auth
和 p256dh
值,但为了快速提醒,给定订阅,我们将需要这些值
subscription.toJSON().keys.auth;
subscription.toJSON().keys.p256dh;
subscription.getKey('auth');
subscription.getKey('p256dh');
auth
值应被视为机密,不得在您的应用外部共享。
p256dh
密钥是一个公钥,有时也称为客户端公钥。在这里,我们将 p256dh
称为订阅公钥。订阅公钥由浏览器生成。浏览器将对私钥保密,并使用它来解密有效负载。
这三个值 auth
、p256dh
和 payload
是必需的输入,加密过程的结果将是加密的有效负载、一个盐值和一个仅用于加密数据的公钥。
盐
盐需要是 16 字节的随机数据。在 NodeJS 中,我们将执行以下操作来创建盐
const salt = crypto.randomBytes(16);
公钥/私钥
公钥和私钥应使用 P-256 椭圆曲线生成,我们将在 Node 中像这样执行此操作
const localKeysCurve = crypto.createECDH('prime256v1');
localKeysCurve.generateKeys();
const localPublicKey = localKeysCurve.getPublicKey();
const localPrivateKey = localKeysCurve.getPrivateKey();
我们将这些密钥称为“本地密钥”。它们仅用于加密,与应用服务器密钥无关。
有了有效负载、auth 密钥和订阅公钥作为输入,以及新生成的盐和一组本地密钥,我们就可以真正开始进行加密了。
共享机密
第一步是使用订阅公钥和我们新的私钥创建共享机密(还记得 Alice 和 Bob 的 ECDH 解释吗?就像那样)。
const sharedSecret = localKeysCurve.computeSecret(
subscription.keys.p256dh,
'base64',
);
这在下一步中用于计算伪随机密钥 (PRK)。
伪随机密钥
伪随机密钥 (PRK) 是推送订阅的 auth 密钥和我们刚刚创建的共享机密的组合。
const authEncBuff = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(subscription.keys.auth, sharedSecret, authEncBuff, 32);
您可能想知道 Content-Encoding: auth\0
字符串是做什么用的。简而言之,它没有明确的目的,尽管浏览器可以解密传入的消息并查找预期的内容编码。\0
向缓冲区的末尾添加一个值为 0 的字节。这是浏览器解密消息时所期望的,它们会期望内容编码占用这么多字节,然后是一个值为 0 的字节,然后是加密数据。
我们的伪随机密钥只是通过 HKDF 运行 auth、共享机密和一段编码信息(即,使其在密码学上更强大)。
上下文
“上下文”是一组字节,用于在加密浏览器中稍后计算两个值。它本质上是一个字节数组,包含订阅公钥和本地公钥。
const keyLabel = new Buffer('P-256\0', 'utf8');
// Convert subscription public key into a buffer.
const subscriptionPubKey = new Buffer(subscription.keys.p256dh, 'base64');
const subscriptionPubKeyLength = new Uint8Array(2);
subscriptionPubKeyLength[0] = 0;
subscriptionPubKeyLength[1] = subscriptionPubKey.length;
const localPublicKeyLength = new Uint8Array(2);
subscriptionPubKeyLength[0] = 0;
subscriptionPubKeyLength[1] = localPublicKey.length;
const contextBuffer = Buffer.concat([
keyLabel,
subscriptionPubKeyLength.buffer,
subscriptionPubKey,
localPublicKeyLength.buffer,
localPublicKey,
]);
最终的上下文缓冲区是一个标签、订阅公钥中的字节数(后跟密钥本身)、然后是本地公钥中的字节数(后跟密钥本身)。
有了这个上下文值,我们可以使用它来创建 nonce 和内容加密密钥 (CEK)。
内容加密密钥和 nonce
Nonce 是一个防止重放攻击的值,因为它应该只使用一次。
内容加密密钥 (CEK) 是最终将用于加密我们有效负载的密钥。
首先,我们需要为 nonce 和 CEK 创建数据字节,这只是一个内容编码字符串,后跟我们刚刚计算的上下文缓冲区
const nonceEncBuffer = new Buffer('Content-Encoding: nonce\0', 'utf8');
const nonceInfo = Buffer.concat([nonceEncBuffer, contextBuffer]);
const cekEncBuffer = new Buffer('Content-Encoding: aesgcm\0');
const cekInfo = Buffer.concat([cekEncBuffer, contextBuffer]);
此信息通过 HKDF 运行,将盐和 PRK 与 nonceInfo 和 cekInfo 结合起来
// The nonce should be 12 bytes long
const nonce = hkdf(salt, prk, nonceInfo, 12);
// The CEK should be 16 bytes long
const contentEncryptionKey = hkdf(salt, prk, cekInfo, 16);
这为我们提供了 nonce 和内容加密密钥。
执行加密
现在我们有了内容加密密钥,我们可以加密有效负载了。
我们使用内容加密密钥作为密钥创建一个 AES128 密码,而 nonce 是初始化向量。
在 Node 中,这是这样完成的
const cipher = crypto.createCipheriv(
'id-aes128-GCM',
contentEncryptionKey,
nonce,
);
在我们加密有效负载之前,我们需要定义我们希望添加到有效负载前面的填充量。我们想要添加填充的原因是,它可以防止窃听者能够根据有效负载大小确定消息“类型”的风险。
您必须添加两个字节的填充来指示任何附加填充的长度。
例如,如果您没有添加填充,您将有两个值为 0 的字节,即不存在填充,在这两个字节之后,您将开始读取有效负载。如果您添加了 5 个字节的填充,则前两个字节的值将为 5,因此使用者将读取另外五个字节,然后开始读取有效负载。
const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeros, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);
然后,我们通过此密码运行我们的填充和有效负载。
const result = cipher.update(Buffer.concat(padding, payload));
cipher.final();
// Append the auth tag to the result -
// https://node.org.cn/api/crypto.html#crypto_cipher_getauthtag
const encryptedPayload = Buffer.concat([result, cipher.getAuthTag()]);
现在我们有了加密的有效负载。耶!
剩下的就是确定如何将此有效负载发送到推送服务。
加密的有效负载标头和正文
要将此加密的有效负载发送到推送服务,我们需要在我们的 POST 请求中定义几个不同的标头。
Encryption 标头
“Encryption”标头必须包含用于加密有效负载的盐。
16 字节的盐应进行 base64 URL 安全编码,并添加到 Encryption 标头,如下所示
Encryption: salt=[URL Safe Base64 Encoded Salt]
Crypto-Key 标头
我们看到在“应用服务器密钥”部分下使用了 Crypto-Key
标头来包含公共应用服务器密钥。
此标头也用于共享用于加密有效负载的本地公钥。
结果标头如下所示
Crypto-Key: dh=[URL Safe Base64 Encoded Local Public Key String]; p256ecdsa=[URL Safe Base64 Encoded Public Application Server Key]
Content type、length 和 encoding 标头
Content-Length
标头是加密有效负载中的字节数。“Content-Type”和“Content-Encoding”标头是固定值。如下所示。
Content-Length: [Number of Bytes in Encrypted Payload]
Content-Type: 'application/octet-stream'
Content-Encoding: 'aesgcm'
设置这些标头后,我们需要将加密的有效负载作为请求的正文发送。请注意,Content-Type
设置为 application/octet-stream
。这是因为加密的有效负载必须作为字节流发送。
在 NodeJS 中,我们将像这样执行此操作
const pushRequest = https.request(httpsOptions, function(pushResponse) {
pushRequest.write(encryptedPayload);
pushRequest.end();
更多标头?
我们已经介绍了用于 JWT/应用服务器密钥的标头(即,如何使用推送服务标识应用),并且我们已经介绍了用于发送加密有效负载的标头。
推送服务使用其他标头来更改已发送消息的行为。其中一些标头是必需的,而另一些是可选的。
TTL 标头
必需
TTL
(或生存时间)是一个整数,指定您希望推送消息在推送服务上保留多少秒才能传递。当 TTL
过期时,消息将从推送服务队列中删除,并且不会传递。
TTL: [Time to live in seconds]
如果您将 TTL
设置为零,推送服务将尝试立即传递消息,但是如果无法访问设备,您的消息将立即从推送服务队列中删除。
从技术上讲,推送服务可以根据需要减少推送消息的 TTL
。您可以通过检查推送服务响应中的 TTL
标头来判断是否发生了这种情况。
主题
可选
主题是字符串,如果待处理消息具有匹配的主题名称,则可以使用主题将待处理消息替换为新消息。
这在设备离线时发送多条消息的场景中非常有用,并且您实际上只希望用户在设备打开时看到最新消息。
紧急程度
可选
紧急程度向推送服务指示消息对用户的重要性。推送服务可以使用此信息来帮助节省用户设备的电池寿命,方法是仅在电池电量不足时唤醒以接收重要消息。
标头值定义如下所示。默认值为 normal
。
Urgency: [very-low | low | normal | high]
所有内容汇总
如果您对所有这些工作原理有更多疑问,您可以随时查看库如何在 web-push-libs org 上触发推送消息。
一旦您有了加密的有效负载和上面的标头,您只需要向 PushSubscription
中的 endpoint
发出 POST 请求即可。
那么我们如何处理对此 POST 请求的响应呢?
来自推送服务的响应
一旦您向推送服务发出请求,您需要检查响应的状态代码,因为这将告诉您请求是否成功。
状态代码 | 描述 |
---|---|
201 | 已创建。发送推送消息的请求已收到并接受。 |
429 | 请求过多。表示您的应用服务器已达到推送服务的速率限制。推送服务应包含“Retry-After”标头,以指示多久后可以再次发出请求。 |
400 | 无效请求。这通常意味着您的标头之一无效或格式不正确。 |
404 | 未找到。这表明订阅已过期且无法使用。在这种情况下,您应该删除 `PushSubscription` 并等待客户端重新订阅用户。 |
410 | 已消失。订阅不再有效,应从应用服务器中删除。可以通过在 `PushSubscription` 上调用 `unsubscribe()` 来重现这种情况。 |
413 | 有效负载大小过大。推送服务必须支持的最小有效负载大小为 4096 字节(或 4kb)。 |
您还可以阅读 Web Push 标准 (RFC8030) 以获取有关 HTTP 状态代码的更多信息。
后续步骤
- Web Push 通知概述
- Push 的工作原理
- 订阅用户
- 权限用户体验
- 使用 Web Push 库发送消息
- Web Push 协议
- 处理 Push 事件
- 显示通知
- 通知行为
- 常见通知模式
- Push 通知常见问题解答
- 常见问题和错误报告