WebPush 工作原理
涛叔在今年的开发者大会上,苹果宣布 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 则是推送服务,其核心功能就是跟浏览器保持长连接。
推送的工作流程如下:
- Web 页面向浏览器请求推送权限。此时浏览器会向用户展示对应界面。
- 用户同意后,浏览器会生成一组订阅信息,并将此订阅信息跟请求推送的应用服务器关联起来,发给推送服务。
- Web 应用将订阅信息连同用户的其他信息发送给应用服务器保存。
- 需要给用户推送消息时,应用服务器会按照规范构造数据,然后发送给推送服务。
- 推送服务收到消息后做必要的鉴权,然后将消息发送给浏览器。
- 浏览器收到推送后展示提示信息,用户点击推送消息后会打开指定页面或者执行其他操作。
整个过程跟移动应用的推送非常相似,但 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 的必须选字段有三个:
aud
推送服务的域名,比如 https://push.example.netexp
过期时间戳sub
推送方的联系方式,可以是 mailto: 邮箱地址,也可以是 https 链接
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 信息后需要发送给服务器保存。
加密算法
服务端加密过程如下:
- 随机生成 16 位盐值 salt
- 临时生成一组椭圆曲线密钥 (as_private, as_public)
- 使用自己的私钥跟浏览器的公钥协商公共密钥 ecdh_secret = ECDH(as_private, ua_public)
- 使用 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) |
+-----------+--------+-----------+---------------+
- salt 占十六字节,表示加密用的盐值,由应用服务器生成
- rs 全称 record size,表示 AES 分段加密的长度
- idlen 表示后面 keyid 的长度,最多 255 字节
- keyid 表示 aes128gcm 加密用的密钥标识
头信息之后,就是加密后的数据了。
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();
.setVapidDetails(
webpush'mailto:example@yourdomain.org',
.publicKey,
vapidKeys.privateKey
vapidKeys;
)
const pushSubscription = {
endpoint: '.....',
keys: {
auth: '.....',
p256dh: '.....'
};
}
.sendNotification(pushSubscription, 'Your Push Payload Text'); webpush
浏览器收到推送消息后用自己的私钥和 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,
;
}.waitUntil(self.registration.showNotification(title, options));
e
}
function openPushNotification(e) {
.notification.close();
e.waitUntil(clients.openWindow(e.notification.data));
e
}
.addEventListener("push", receivePushNotification);
self.addEventListener("notificationclick", openPushNotification); self
浏览器收到推送后会调用receivePushNotification
函数。我们需要在该函数中解决推送数据,提取需要的字段,然后调用showNotification
函数来展示推送消息。注意,这里一定要使用e.waitUntil
来等待showNotification
返回,不然会出现一些七七八八的问题。用户战胜消息通知的时候浏览器会再次触发notificationclick
事件,然后执行openPushNotification
函数。
showNotification
函数的参数有很多,大家可以参考MDN。不同的参数在不同的浏览器和平台上的支持情况也不一样,大家可以按需要选用。
总结
以上就是本文的主要内容。本文基本上涵盖了 WebPush 的主要内容和相关的 RFC 标准。对于初学者理解 WebPush 的工作原理应该有一定的帮助。但是限于篇幅,不可能对文中提及的各类加密算法做详细评论,不能不说是个遗憾。我会考虑在后面写一些专门的文章来介绍。此外,考虑到 WebPush 对用户隐私的保护,再看看国内各平台的做派,我只能说故乡的月亮🌙没有外国的圆😂