WebPush 工作原理

2022-07-28 ⏳6.3分钟(2.5千字) 🕸️

在今年的开发者大会上,苹果宣布 Safari 将会支持 WebPush 标准。首先支持的是 mac 平台,会在今年秋天发布。然后是 iOS 平台,会在明年上半年。届时主流的浏览器都会支持 WebPush 特性。这是 Web 领域的一个里程碑!现在的互联网市场都在拼命推广移动应用。一个主要的原因就是 Web 平台留存率很低,而留存率低的主要原因就是不支持推送。希望 WebPush 技术的普及能够促进 Web 生态的繁荣。为了这个愿望,我今天把 WebPush 技术的工作原理整理成文,分享给大家。也算尽自己一份力。

在讨论细节之前,我们需要先了解一下 WebPush 的整个技术架构。

技术架构

+-------+           +--------------+       +-------------+
|  UA   |           | Push Service |       | Application |
+-------+           +--------------+       |   Server    |
    |                      |               +-------------+
    |      Subscribe       |                      |
    |--------------------->|                      |
    |       Monitor        |                      |
    |<====================>|                      |
    |          Distribute Push Resource           |
    |-------------------------------------------->|
    :                      :                      :
    |                      |     Push Message     |
    |    Push Message      |<---------------------|
    |<---------------------|                      |

这里的 UA 就是 User Agent,也就是浏览器。Application Server 是应用服务器,可以简单理解为主动发推送消息的服务器。Push Service 则是推送服务,其核心功能就是跟浏览器保持长连接。

推送的工作流程如下:

整个过程跟移动应用的推送非常相似,但 WebPush 跟移动推送又有很多不同。

第一个不同点是 WebPush 不需要注册开发者帐号。只要用户同意,任何网站都可以发送消息。而移动应用的推送必须在各个厂商注册开发者账号才行。

但是这样的设计会不会被滥用呢?肯定会。所以 WebPush 使用一种 VAPID 的协议来标记发送方。后面我会详细说明。但 VAPID 只用来标记,也不要求注册。

第二个不同点是 WebPush 非常注重保护用户的隐私。WebPush 使用加密技术确保推送服务无法查看推送的内容。也就就说,应用服务器推送的消息是加密的,而且只有浏览器能够解密。推送服务只起了一个中转的作用,它无法看到推送的真实内容。

也正因为有以上两个特点,WebPush 技术才会显得比较复杂。接来来我们就详细讨论 WebPush 的工作原理。

VAPID 协议

先说 VAPID 协议。VAPID 全称是 Voluntary Application Server Identification,完整的规范定义在RFC8292。如果网络需要申请推送权限,首先就要在服务端生成椭圆曲线加密密钥对的公钥。VAPID 就是这里面的公钥。椭圆曲线用的是 P-256,WebPush 所有的非对称加密都是用这条曲线。

有了 VAPID,就可以向浏览器申请推送权限。但推送相关的功能都需要在 ServiceWorker 进行,所以代码稍微复杂一些:

let serviceWorker = await navigator.serviceWorker.ready;
let subscription = await serviceWorker.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: pushServerPublicKey
});

userVisibleOnly 表示所有推送必须展示通知界面。也就是说要让用户能看到,不能在后台搞小动作。目前所有的浏览器都要求该字段传true

applicationServerKey 就是服务器生成的 VAPID,也就是椭圆曲线公钥。

如果用户同意接收推送消息,浏览器就会把对应的 VAPID 发送给推送服务器:

POST /subscribe/ HTTP/1.1
Host: push.example.net
Content-Type: application/webpush-options+json
Content-Length: 104
{ "vapid": "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrIxH
            F6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs" }

当服务器给用户推送消息时,它会调用推送服务的 HTTP 接口,带上 Authorization 头信息:

Authorization: vapid t=XXX, k=YYY

vapid 是固定前缀。t后面的时一个 JWT 令牌。它的签名算法是 ES256,表示使用 SHA-256 计算摘要值,使用 P-256 椭圆曲线做签名。JWT 的必须选字段有三个:

JWT header = { "typ": "JWT", "alg": "ES256" }
JWT body = { "aud": "https://push.example.net",
             "exp": 1453523768,
             "sub": "mailto:push@example.com" }

因为使用 P-256 椭圆曲线签名,所以还需要附带签名的公钥。就是k字段对应的部分。这里是把公钥转换成 X9.62 格式,然后再做 base64 转码获得。

因为推送服务事先保存了应用服务器的 VAPID,所以可以根据k字段判断是否为合法的调用方。另一方面,k字段的 JWT 又提供了过期时间和联系方式等信息。如果推送服务认为应用服务器行为异常,则可以通过 JWT 的联系方式来通知推送方。这样完成了对推送方的识别。

不过我们应该看到,这也只是一种有限的识别。推送服务只能禁用某个 VAPID,而不能禁用真正的服务。但是,一但某个 VAPID 被禁用,那跟它关联的所有推送信息都将失效。这一点足以让推送方不敢做恶。

以上是服务器标识部分。接下来我们讲加密部分。

内容加密

我们在前面的代码中获得了 subscription 对象,转成 JSON 后结构如下:

{
 "endpoint": "https://updates.push.services.mozilla.com/wpush/v2/XXX",
 "expirationTime": null,
 "keys": {
  "auth": "AAA",
  "p256dh": "BBB"
 }
}

endpoint 表示推送的链接。浏览器会为每个网站的每个用户生成不同的推送链接。应用服务器需要保存该链接与用户的对应的关系,以便在推送的时候使用。expirationTime表示推送信息的过期时间,也就是说用户可以允许服务器在一段时间内发送推送。目标所有浏览器都不支持该特性。

keys 中的 auth 是浏览器生成的随机序列,长度为 16 字节。以 base64 编码方式保存。这是浏览器为服务器生成的鉴权密码,跟endpoint配套使用。

keys 中的 p256dh 是另外一对 P-256 密钥的公钥。用于跟应用服务器交换消息加密密钥。

Web 应用获取 subscription 信息后需要发送给服务器保存。

加密算法

服务端加密过程如下:

  1. 随机生成 16 位盐值 salt
  2. 临时生成一组椭圆曲线密钥 (as_private, as_public)
  3. 使用自己的私钥跟浏览器的公钥协商公共密钥 ecdh_secret = ECDH(as_private, ua_public)
  4. 使用 HMAC-based key derivation function (HKDF) 来计算实际加密用的 key。

WebPush 用的是 HMAC-SHA-256 算法。

首先计算 Input-keying material(IKM) 密钥:

PRK_key = HMAC-SHA-256(auth_secret, ecdh_secret)
key_info = "WebPush: info" || 0x00 || ua_public || as_public
IKM = HMAC-SHA-256(PRK_key, key_info || 0x01)

这里的||表示将两侧的内容连接成一个整体,下同。

然后计算内容加密(CEK)密钥:

PRK = HMAC-SHA-256 (salt, IKM)
cek_info = "Content-Encoding: aes128gcm" || 0x00
CEK = HMAC-SHA-256(PRK, cek_info || 0x01)[0..15]

最后计算 Nonce 密钥:

nonce_info = "Content-Encoding: nonce" || 0x00
NONCE = HMAC-SHA-256(PRK, nonce_info || 0x01)[0..11]

密钥生成的详细过程请参考RFC8291

有了 CEK 和 Nonce 我们才可以对消息内容进行加密。

传输编码

HTTPS 只能保证应用服务器到推送服务器的通信不被窃听。推送服务器收到数据后会解密,可以读取所有信息。显然单靠 HTTPS 并不能实现 WebPush 的加密功能。为此,WebPush 使用了RFC8188定义的加密传输编码。

HTTP 协议定义了多种 Content-Encoding。最常见的就是 gzip,表示传输的内容已经被压缩过。RFC8188 定义了一种新的类型叫 aes128gcm,表示传输的内容使用 AEAD_AES_128_GCM 加密过。

aes128gcm 类型的数据有特定的头信息:

+-----------+--------+-----------+---------------+
| salt (16) | rs (4) | idlen (1) | keyid (idlen) |
+-----------+--------+-----------+---------------+

头信息之后,就是加密后的数据了。

aes128gcm 加密需要指定分组长度,每一组从零开始编号,需要计算不同的 Nonce。但是 WebPush 的消息比较短,不超过 4096 字节。只需要一个分组就行,计算第一个 Nonce 的时候不需要考虑分组编号。

另外,AES 是对称加密,显然不能把密钥保存到 keyid 字段。这里真正保存的是应用服务器生成的临时椭圆密钥对的公钥 as_public。

服务端最后使用 AEAD_AES_128_GCM 算法通过 CEK 和 Nonce 将推送消息加密,最终给推送服务发送如下 HTTP 请求:

POST /push/JzLQ3raZJfFBR0aqvOMsLrt54w4rJUsV HTTP/1.1
Host: push.example.net
Content-Length: 145
Content-Encoding: aes128gcm
Authorization: vapid t=${JWT}, k=${JWK}

${binayr-header}${encrypted-data}

开源实现

服务端的根据 subscription 的整个过程都被封装成工具库,大家可以直接使用。GitHub 上有一个WebPush libraries组织,提供多种语言的封装。

以 nodejs 为例,使用方式如下:

const webpush = require('web-push');
const vapidKeys = webpush.generateVAPIDKeys();

webpush.setVapidDetails(
  'mailto:example@yourdomain.org',
  vapidKeys.publicKey,
  vapidKeys.privateKey
);

const pushSubscription = {
  endpoint: '.....',
  keys: {
    auth: '.....',
    p256dh: '.....'
  }
};

webpush.sendNotification(pushSubscription, 'Your Push Payload Text');

浏览器收到推送消息后用自己的私钥和 keyid 生成公共密钥

ecdh_secret = ECDH(ua_private, as_public)
# 也就是说
ecdh_secret == ECDH(as_private, ua_public)

双方不暴露各自的私钥就能完成密钥协商。得到公共密钥后,浏览器就可以重复应用服务器计算过程,从而得到 CEK 和 Nonce。最后再使用 aes128gcm 算法完成解密。

消息格式

有心的读者肯定会有疑问,从服务端推送到浏览器的数据有什么格式要求吗?答案是没有。因为浏览器拿到解密后的推送消息并不会亲自处理,而是会触发 serviceWorker 的push事件。所以希望用户接收推送的网站还需要注册自己的 serviceWorker 来处理推送消息。

// 注册 serviceWorker
navigator.serviceWorker.register("/sw.js").
// sw.js 内容
function receivePushNotification(e) {
  const { url, title, text } = e.data.json();

  const options = {
    data: url,
    title: title,
    body: text,
  };
  e.waitUntil(self.registration.showNotification(title, options));
}

function openPushNotification(e) {
  e.notification.close();
  e.waitUntil(clients.openWindow(e.notification.data));
}

self.addEventListener("push", receivePushNotification);
self.addEventListener("notificationclick", openPushNotification);

浏览器收到推送后会调用receivePushNotification函数。我们需要在该函数中解决推送数据,提取需要的字段,然后调用showNotification函数来展示推送消息。注意,这里一定要使用e.waitUntil来等待showNotification返回,不然会出现一些七七八八的问题。用户战胜消息通知的时候浏览器会再次触发notificationclick事件,然后执行openPushNotification函数。

showNotification函数的参数有很多,大家可以参考MDN。不同的参数在不同的浏览器和平台上的支持情况也不一样,大家可以按需要选用。

总结

以上就是本文的主要内容。本文基本上涵盖了 WebPush 的主要内容和相关的 RFC 标准。对于初学者理解 WebPush 的工作原理应该有一定的帮助。但是限于篇幅,不可能对文中提及的各类加密算法做详细评论,不能不说是个遗憾。我会考虑在后面写一些专门的文章来介绍。此外,考虑到 WebPush 对用户隐私的保护,再看看国内各平台的做派,我只能说故乡的月亮🌙没有外国的圆😂