Web 推送协议

Matt Gaunt

我们已经了解了如何使用库来触发推送消息,但这些库到底有何用途?

它们发出网络请求,同时确保此类请求的格式正确。定义此网络请求的规范是 Web 推送协议

从服务器向推送服务发送推送消息的示意图

本部分概述了服务器如何使用应用服务器密钥标识自己,以及如何发送加密载荷和关联数据。

这并非 Web 推送的有趣方面,我也不擅长加密,但下面我们来看一下每一部分,因为了解这些库在后台执行的操作非常方便。

应用服务器密钥

当我们订阅用户时,会传入 applicationServerKey。此密钥会被传递给推送服务,并用于检查订阅用户的应用是否也是触发推送消息的应用。

当我们触发推送消息时,我们会发送一组标头,允许推送服务对应用进行身份验证。(这由 VAPID 规范定义。)

这究竟意味着什么?究竟发生了什么?以下是应用服务器身份验证的步骤:

  1. 应用服务器使用其应用私钥对一些 JSON 信息进行签名。
  2. 该签名信息将作为 POST 请求中的标头发送到推送服务。
  3. 推送服务使用从 pushManager.subscribe() 接收的已存储公钥来检查接收的信息是否由与公钥相关的私钥进行签名。注意:公钥是传入订阅调用的 applicationServerKey
  4. 如果签名信息有效,推送服务会将推送消息发送给用户。

以下为信息流的示例。(请注意左下角的图例,这些图例分别表示公钥和私钥。)

发送消息时如何使用应用服务器私钥的图示

添加到请求标头中的“签名信息”是一个 JSON Web 令牌。

JSON 网络令牌

JSON 网络令牌(简称 JWT)是一种向第三方发送消息,以便接收者验证消息发送方的方式。

第三方收到消息后,需要获取发送者公钥,并使用该公钥验证 JWT 的签名。如果签名有效,则 JWT 必须已使用匹配的私钥进行签名,因此必须来自预期的发送者。

https://jwt.io/ 上提供了许多库可以为您执行签名,建议您尽可能这么做。为完整起见,我们来看看如何手动创建已签名的 JWT。

Web 推送和签名的 JWT

已签名的 JWT 只是一个字符串,但可以视为由点连接的三个字符串。

JSON Web 令牌中的字符串图示

第一个和第二个字符串(JWT 信息和 JWT 数据)是采用 base64 编码的 JSON 片段,表示其可公开读取。

第一个字符串是有关 JWT 本身的信息,指示用于创建签名的算法。

Web 推送的 JWT 信息必须包含以下信息:

{
  "typ": "JWT",
  "alg": "ES256"
}

第二个字符串是 JWT 数据。它提供了有关 JWT 发送者、目标对象以及有效期的信息。

对于 Web 推送,数据格式如下:

{
  "aud": "https://some-push-service.org",
  "exp": "1469618703",
  "sub": "mailto:example@web-push-book.org"
}

aud 的值是“目标设备”,即 JWT 的目标用户。对于 Web 推送,目标设备是推送服务,因此我们将其设置为推送服务的来源

exp 值是 JWT 的到期时间,这可防止窥探者在拦截 JWT 时重复使用 JWT。到期时间是以秒为单位的时间戳,不能超过 24 小时。

在 Node.js 中,到期时间是使用以下字段设置的:

Math.floor(Date.now() / 1000) + 12 * 60 * 60;

将模式设置为 12 小时而不是 24 小时,以避免发送应用和推送服务之间的时钟差异问题。

最后,sub 值必须是网址或 mailto 电子邮件地址。这样一来,如果推送服务需要与发送者联系,可以从 JWT 找到联系信息。(这就是 web-push 库需要一个电子邮件地址的原因)。

与 JWT 信息一样,JWT 数据编码为可在网址中安全使用的 base64 字符串。

第三个字符串是签名,是获取前两个字符串(JWT 信息和 JWT 数据)并用点字符连接(我们称之为“未签名令牌”)并对其签名的结果。

签名流程需要使用 ES256 加密“未签名令牌”。根据 JWT 规范,ES256 是“使用 P-256 曲线和 SHA-256 哈希算法的 ECDSA 算法”的简称。您可以使用网络加密来创建签名,如下所示:

// 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 推送服务,并在末尾加上 WebPush,如下所示:

Authorization: 'WebPush [JWT Info].[JWT Data].[Signature]';

Web 推送协议还规定,必须在 Crypto-Key 标头中以可在网址中安全使用的 base64 编码字符串的形式发送公共应用服务器密钥,并在其前面附加 p256ecdsa=

Crypto-Key: p256ecdsa=[URL Safe Base64 Public Application Server Key]

载荷加密

接下来,我们来看看如何使用推送消息发送载荷,以便在 Web 应用收到推送消息时,可以访问收到的数据。

使用过其他推送服务的用户会遇到一个常见问题:为什么 Web 推送载荷需要加密?在原生应用中,推送消息可以纯文本形式发送数据。

Web 推送的优点在于,由于所有推送服务都使用同一 API(Web 推送协议),因此开发者无需关注推送服务是谁。我们可以以正确的格式发出请求,并预期发送推送消息。这样做的缺点是,开发者可能会将消息发送到不可信的推送服务。加密载荷后,推送服务无法读取发送的数据。只有浏览器可以解密这些信息。这样可以保护用户的数据。

Message Encryption 规范中定义了载荷的加密。

在我们介绍加密推送消息载荷的具体步骤之前,应该先介绍加密过程中会用到的一些技术。(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 文章的帽子提示。

其中大致涵盖 ECDHHKDF

ECDH 是一种共享公钥和生成共享密钥的安全方式。HKDF 是一种获取不安全材料并确保安全的方法。

这将在加密载荷期间使用。接下来,我们看一下将哪些内容视为输入以及加密方式。

输入内容

当我们想要向用户发送包含载荷的推送消息时,我们需要三项输入:

  1. 载荷本身。
  2. 来自 PushSubscriptionauth Secret。
  3. PushSubscription 中的 p256dh 键。

我们已经看到从 PushSubscription 中检索 authp256dh 值,但快速提醒一下,假设有一个订阅,我们需要这些值:

subscription.toJSON().keys.auth;
subscription.toJSON().keys.p256dh;

subscription.getKey('auth');
subscription.getKey('p256dh');

auth 值应被视为密钥,并且不得在应用外部共享。

p256dh 密钥是一个公钥,有时称为客户端公钥。在这里,我们将 p256dh 称为订阅公钥。订阅公钥由浏览器生成。浏览器将对私钥保密,并用它来解密载荷。

需要这三个值(authp256dhpayload)作为输入,加密过程的结果将是加密载荷、盐值和用于加密数据的公钥。

此盐必须是 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 字符串的用途。简而言之,浏览器可以对传入的消息进行解密并查找预期的内容编码,但其用途并不明确。\0 会在缓冲区末尾添加一个值为 0 的字节。浏览器会解密消息,因为内容编码需要很多字节,然后是值为 0 的字节,接着是加密数据。

我们的伪随机密钥只是通过 HKDF 运行身份验证、共享密钥和一段编码信息(即提高其加密强度)。

背景信息

“context”是一组字节,用于稍后在加密浏览器中计算两个值。它本质上是一个包含订阅公钥和本地公钥的字节数组。

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 请求数量过多。这意味着您的应用服务器已达到推送服务的速率限制。推送服务应包含“重试后重试”标头,以指示在多长时间后可以发出其他请求。
400 请求无效。这通常意味着您的某个标题无效或格式不正确。
404 未找到。这表明订阅已过期且无法使用。在这种情况下,您应该删除“PushSubscription”并等待客户端重新订阅用户。
410 不见了。订阅不再有效,应从应用服务器中移除。这可以通过对“PushSubscription”调用“unsubscribe()”来重现。
413 载荷大小过大。推送服务必须支持的最小载荷为 4096 字节(或 4kb)。

下一步做什么

Codelab