我们已经了解了如何使用库触发推送消息,但这些库究竟在做什么?
它们会发出网络请求,同时确保此类请求的格式正确无误。定义此网络请求的规范是 Web 推送协议。
本部分概述了服务器如何使用应用服务器密钥标识自己,以及如何发送加密载荷和关联数据。
这不是 Web Push 的亮点,我也不是加密方面的专家,但我们还是来看看各个部分,因为了解这些库在后台执行的操作很有用。
应用服务器密钥
为用户订阅时,我们会传入 applicationServerKey
。此键会传递给推送服务,并用于检查订阅用户的应用是否也是触发推送消息的应用。
在触发推送消息时,我们会发送一组标头,以允许推送服务对应用进行身份验证。(这由 VAPID 规范定义。)
这一切到底意味着什么?具体会发生什么?以下是应用服务器身份验证的步骤:
- 应用服务器使用其私有应用密钥对某些 JSON 信息进行签名。
- 这些已签名的信息会作为 POST 请求中的标头发送到推送服务。
- 推送服务使用从
pushManager.subscribe()
收到的存储公钥来检查收到的信息是否由与公钥相关的私钥签名。注意:公钥是传递给 subscribe 调用的applicationServerKey
。 - 如果签名信息有效,推送服务会向用户发送推送消息。
下面是此信息流的示例。(请注意左下角用于指示公钥和私钥的图例。)
添加到请求中标头的“已签名信息”是 JSON Web 令牌。
JSON Web 令牌
JSON Web 令牌(简称 JWT)是一种向第三方发送消息的方式,以便接收方可以验证发件人。
第三方收到消息后,需要获取发件人公钥,并使用该公钥验证 JWT 的签名。如果签名有效,则 JWT 必须使用匹配的私钥进行签名,因此必须来自预期的发件人。
https://jwt.io/ 上提供了许多可为您执行签名的库,建议您尽可能使用这些库。为完整起见,我们来看看如何手动创建已签名的 JWT。
Web 推送和已签名的 JWT
已签名的 JWT 只是一个字符串,但可以视为三个字符串通过句点连接而成。
第一个和第二个字符串(JWT 信息和 JWT 数据)是经过 base64 编码的 JSON 片段,这意味着它们是可供公开读取的。
第一个字符串是 JWT 本身的相关信息,指明了创建签名时所用的算法。
Web Push 的 JWT 信息必须包含以下信息:
{
"typ": "JWT",
"alg": "ES256"
}
第二个字符串是 JWT 数据。它提供有关 JWT 发件人、收件人和有效期的相关信息。
对于网站推送,数据采用以下格式:
{
"aud": "https://some-push-service.org",
"exp": "1469618703",
"sub": "mailto:example@web-push-book.org"
}
aud
值是“受众群体”,即 JWT 的目标对象。对于网站推送,受众群体是推送服务,因此我们将其设置为推送服务的来源。
exp
值是 JWT 的失效时间,这可防止窥探者在拦截 JWT 后重复使用 JWT。到期时间是一个以秒为单位的时间戳,且不得超过 24 小时。
在 Node.js 中,使用以下命令设置到期时间:
Math.floor(Date.now() / 1000) + 12 * 60 * 60;
为避免发送应用和推送服务之间出现时钟差异问题,请使用 12 小时而不是 24 小时。
最后,sub
值需要是网址或 mailto
电子邮件地址。这样,如果推送服务需要与发件人联系,便可以从 JWT 中找到联系信息。(这就是 Web 推送库需要电子邮件地址的原因)。
与 JWT 信息一样,JWT 数据会编码为网址安全的 base64 字符串。
第三个字符串(签名)是通过将前两个字符串(JWT 信息和 JWT 数据)用点字符(我们将其称为“未签名令牌”)连接起来,然后对其进行签名而得出的。
签名流程需要使用 ES256 对“未签名令牌”进行加密。根据 JWT 规范,ES256 是“使用 P-256 曲线和 SHA-256 哈希算法的 ECDSA”的缩写。使用 Web 加密功能,您可以按如下方式创建签名:
// 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(即三个字符串,以点连接)作为带有 WebPush
前缀的 Authorization
标头发送到 Web 推送服务,如下所示:
Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';
Web Push 协议还规定,应用服务器公钥必须以网址安全 base64 编码字符串的形式发送在 Crypto-Key
标头中,并在其前面附加 p256ecdsa=
。
Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]
载荷加密
接下来,我们来看看如何通过推送消息发送载荷,以便当我们的 Web 应用收到推送消息时,它可以访问收到的数据。
使用过其他推送服务的用户都会有一个常见问题,即为什么需要对网站推送载荷进行加密?对于原生应用,推送消息可以以纯文本形式发送数据。
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 的概要说明。如需了解详情,建议您观看此视频。
在代码方面,大多数语言 / 平台都附带库,可让您轻松生成这些密钥。
在节点中,我们会执行以下操作:
const keyCurve = crypto.createECDH('prime256v1');
keyCurve.generateKeys();
const publicKey = keyCurve.getPublicKey();
const privateKey = keyCurve.getPrivateKey();
HKDF:基于 HMAC 的密钥派生函数
维基百科对 HKDF 进行了简要说明:
HKDF 是一种基于 HMAC 的密钥派生函数,可将任何弱密钥材料转换为强加密密钥材料。例如,它可用于将 Diffie-Hellman 交换的共享密钥转换为适用于加密、完整性检查或身份验证的密钥材料。
从本质上讲,HKDF 会接受不太安全的输入,并使其更安全。
定义此加密的规范要求使用 SHA-256 作为哈希算法,并且 Web 推送中 HKDF 的生成密钥不应超过 256 位(32 字节)。
在节点中,可以按如下方式实现:
// 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);
}
此示例代码参考了 Mat Scale 的文章。
ECDH 是一种安全的方式,可用于共享公钥和生成共享密钥。HKDF 是一种将不安全的材料转变为安全材料的方法。
在加密载荷时,系统会使用此值。接下来,我们来看看我们将哪些内容作为输入,以及这些内容是如何加密的。
输入
当我们想要向用户发送包含载荷的推送消息时,需要提供以下三项输入:
- 载荷本身。
PushSubscription
中的auth
Secret。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
)用作输入,加密过程的结果将是加密的载荷、盐值和仅用于加密数据的公钥。
Salt
盐需要是 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();
我们将这些密钥称为“本地密钥”。它们仅用于加密,与应用服务器密钥没有任何关系。
将载荷、身份验证密钥和订阅公钥作为输入,并使用新生成的盐和一组本地密钥,我们就可以实际进行加密了。
共享密钥
第一步是使用订阅公钥和新私钥创建共享密钥(还记得 Alice 和 Bob 的 ECDH 说明吗?就这样)。
const sharedSecret = localKeysCurve.computeSecret(
subscription.keys.p256dh,
'base64',
);
此值将在下一步中用于计算伪随机密钥 (PRK)。
伪随机标识名
伪随机密钥 (PRK) 是推送订阅的身份验证密钥与我们刚刚创建的共享密钥的组合。
const authEncBuff = new Buffer('Content-Encoding: auth\0', 'utf8');
const prk = hkdf(subscription.keys.auth, sharedSecret, authEncBuff, 32);
您可能想知道 Content-Encoding: auth\0
字符串有什么用。
简而言之,它没有明确的用途,尽管浏览器可以解密传入的消息并查找预期的 content-encoding。\0
会向缓冲区末尾添加一个值为 0 的字节。解密消息的浏览器会预期内容编码有这么多字节,后跟一个值为 0 的字节,后跟加密数据。
我们的伪随机密钥只是通过 HKDF 运行身份验证、共享密钥和一段编码信息(即提高其加密强度)。
上下文
“上下文”是一组字节,用于稍后在加密浏览器中计算两个值。它本质上是一个字节数组,包含订阅公钥和本地公钥。
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,因此使用方将额外读取 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://nodejs.org/api/crypto.html#crypto_cipher_getauthtag
const encryptedPayload = Buffer.concat([result, cipher.getAuthTag()]);
现在,我们有了加密的载荷。耶!
现在只需确定如何将此载荷发送到推送服务。
加密的载荷标头和正文
如需将此加密载荷发送到推送服务,我们需要在 POST 请求中定义一些不同的标头。
加密标头
“Encryption”标头必须包含用于加密载荷的盐。
16 字节的盐值应采用 base64 网址安全编码,并添加到“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-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 推送库发送消息
- Web 推送协议
- 处理推送事件
- 显示通知
- 通知行为
- 常见通知模式
- 推送通知常见问题解答
- 常见问题和报告 bug