Web 推送协议

Matt Gaunt

我们已经了解了如何使用库来触发推送消息, 这些库到底在做什么?

它们发出了网络请求 同时又确保了 格式是否正确定义此网络请求的规范是 网络推送协议

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

本部分概述了服务器如何通过应用识别自身 服务器密钥以及如何发送加密的载荷和相关数据。

这与网络推送并不相关,而且我也不是加密方面的专家, 因为了解这些库在后台执行的操作非常方便。

应用服务器密钥

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

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

这究竟意味着什么?到底发生了什么?这些就是我们采取的 应用服务器身份验证:

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

以下是这种信息流的示例。(请注意左下角的图例, 公钥和私钥)。

发送
消息

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

JSON Web 令牌

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

第三方收到邮件时,需要获取发件人 公钥并使用它来验证 JWT 的签名。如果 签名有效,则 JWT 必须使用匹配的签名 因此必须来自预期的发送者。

https://jwt.io/ 上有很多库 可以为您执行签名,我建议您 。为完整起见,我们来看看如何手动创建已签名的 JWT。

Web 推送和签名的 JWT

已签名的 JWT 只是一个字符串,但可以将其视为由连接在一起的三个字符串 以英文句点表示

JSON Web 中的字符串图示
词元

第一个和第二个字符串(JWT 信息和 JWT 数据) 以 base64 编码的 JSON,这意味着可公开读取。

第一个字符串是与 JWT 本身相关的信息,表明哪种算法 用于创建签名。

网络推送的 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 推送 audience 是推送服务,因此我们将其设置为推送的源站 服务

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 spec,ES256 是“使用 P-256 曲线和 “SHA-256 哈希算法”。您可以使用网络加密来创建签名,如下所示:

// 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 标头加上前置 WebPush,如下所示:

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

网络推送协议还规定了应用服务器公钥 在 Crypto-Key 标头中以可在网址中安全使用的 base64 编码字符串形式发送, p256ecdsa= 前缀。

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

载荷加密

接下来,我们来看看如何通过推送消息发送载荷 收到推送消息后,它可以访问收到的数据。

使用过其他推送服务的用户会遇到的一个常见问题是,为什么网络 载荷?对于原生应用,推送消息可以纯文本形式发送数据。

Web 推送的部分优点在于,所有推送服务都使用 使用相同 API(网络推送协议),因此开发者无需关注 我们可以用正确的格式提出请求, 要发送的推送消息。其缺点在于,开发者可以 向不可信的推送服务发送消息。修改者 加密载荷,推送服务将无法读取发送的数据。 只有浏览器才能解密此类信息。这可以保护用户的 数据。

有关有效负载的加密,请参见 Message Encryption 规范

在介绍加密推送消息载荷的具体步骤之前, 我们应该介绍在加密过程中使用的一些技术 过程。(马特·斯凯尔斯发表了一篇关于推杆的优秀文章,向 Mat Scales 致敬 encryption.)

ECDH 和 HKDF

整个加密过程中都会用到 ECDH 和 HKDF, 目的是加密信息

ECDH:椭圆曲线 Diffie-Hellman 密钥交换

假设您有两个人想要分享信息,爱丽丝和小刚。 Alice 和 Bob 都有自己的公钥和私钥。爱丽丝和小刚 共享它们的公钥。

使用 ECDH 生成的密钥的有用属性是,Alice 可以使用她的 私钥和 Bob 的公钥来创建密钥值“X”。Bob 可以执行的操作 同样地,它会将他的私钥和 Alice 的公钥 创建相同的值“X”。这就使得“X”共享密钥 而爱丽丝和鲍勃只需分享他们的公钥即可。现在,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 与 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

ECDH 可安全地共享公钥和生成共享密钥。HKDF 是一种 确保材料的安全性。

这将在加密载荷的过程中使用。接下来,我们来看看 以及加密方式。

输入

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

  1. 载荷本身。
  2. PushSubscription 中的 auth Secret。
  3. PushSubscription 中的 p256dh 键。

我们看到过从 PushSubscription 检索 authp256dh 值,但对于 快速提醒,对于订阅项目,我们需要这些值:

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

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

auth 值应被视为 Secret,不得在应用外部共享。

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) 是推送订阅身份验证的组合 Secret 以及我们刚刚创建的共享 Secret。

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 的字节,接着是 加密数据。

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

上下文

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

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 运行,它将 salt 和 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()]);

现在,我们有了加密的载荷。耶!

剩下的任务就是确定如何将此载荷发送到推送服务。

加密载荷标头和正文

为了将此加密的载荷发送到推送服务,我们需要定义一些 使用不同的标头

加密标头

“加密”部分标头必须包含用于加密载荷的

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-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 组织中测试和推送消息。

有了加密的载荷和上述标头后,您只需发出 POST 请求即可 传递给 PushSubscription 中的 endpoint

那么,我们该如何处理针对此 POST 请求的响应呢?

来自推送服务的响应

向推送服务发出请求后,您需要检查 因为这能看出请求是否成功 。

状态代码 说明
201 已创建。发送推送消息的请求已被接收并接受。
429 请求数量过多。这意味着您的应用服务器已达到某个速率, 使用推送服务进行限制推送服务应包含“重试之后”操作 标头来指示多久才能发出另一个请求。
400 请求无效。这通常表明您的某个标头无效 或格式不正确
404 未找到。这表示订阅已过期 因此无法使用。在这种情况下,您应该删除“PushSubscription” 并等待客户端重新订阅该用户。
410 走了。订阅已失效,应将其移除 从应用服务器复制代码。可通过调用 对 `PushSubscription` 使用 `unsubscribe()`。
413 有效负载过大。推送服务必须达到的最小载荷大小 支持为 4096 字节 (或 4kb)。

下一步做什么

Codelab