网络推送 API
推送通知通常用于作恶,但我认为我们都同意,如果使用得当,它们将是无价的。
长期以来,我一直是Web Push API的粉丝。它提供了一种向独立于浏览器的用户发送推送通知的方法( 尽管 Google 努力要求注册gcm_sender_id
)。不仅如此,而且它是端到端加密的!
“后端 API”也是标准化的,是可用于非 Web 应用程序的通用协议。我认为很多 Google Play Services FCM 替代品都应该考虑这一点,而不是他们通常没有经过深思熟虑的家庭推出的解决方案。
事实上,如果您有公共 IP(或受信任的代理),您甚至可以在本地运行 HTTP Web Push 后端。所以你的设备可以是它自己的推送服务。
文件推送
最近,我的脑海里冒出一个念头。虽然 Web Push API 旨在将推送消息从服务器发送到客户端,但为什么我们不能将它们发送到客户端呢?这将是一种无需后端服务即可拥有交互式、通信网络应用程序的方法。有人说Web3吗? (呃,也许最好避免这个词。)
有了我脑海中的技术,我只需要构建一个应用程序,一个寻找问题的经典解决方案。幸运的是我遇到了问题!
在设备之间发送文件可能会很痛苦。您可以通过电子邮件将其发送给自己,但这很慢,并且您需要事后清理以避免浪费大量电子邮件空间。 AirDrop非常适合 Apple 硬件之间的传输,但我想要一些独立于硬件和跨平台的东西。我经常使用Snapdrop 、 ShareDrop和FilePizza等服务在设备到设备之间传输文件。然而,这些都不是完全适合我的需要。前两个是通过公网IP发现的,所以只有在同一个网络上才能共享。他们还需要在两种设备上打开网站,这是一个小小的不便。 FilePizza 缺乏端到端加密,并且在传输文件之前有一个缓慢的“处理”步骤。 (它将文件变成一个 Torrent,非常适合分享给很多人,但这与我在这里所针对的问题不同。)
我认为设备到设备的文件传输将是一个简单的概念证明。这个想法很简单。
- 通过 URL 共享(链接或二维码)进行带外联系人设置。
- 选择联系人。
- 选择文件。
- 接收方收到推送通知。
- 点击通知,接收文件。
联系设置使它比第一次传输的其他解决方案更有效,但我的目标用例是我自己的设备之间的重复传输。因此,一次性联系设置很快就会得到回报。
我把它放在一起,它在filepush.kevincox.ca上直播。它似乎工作得很好。该代码也可用。
网络推送
第一步是 Web Push API。这用于发送 WebRTC 传输信号(实际传输发生的地方)。
Web Push 需要一些设置。接收方生成一个PushSubscription
,然后将其发送给发送方。发件人然后使用此信息发送推送消息。它看起来像这样:
endpoint : https://push.example/secretsubscriptionid keys : p256dh : secrete2ekey auth : secretauthkey expirationTime : null
endpoint
只是发送通知的地方,推送服务应该将发送到该端点的消息路由到用户的浏览器。这通常是由您的浏览器供应商运营的服务器。 keys . p256dh
是接收者的公钥。这就是为推送消息提供端到端机密性的原因。 keys . auth
是一个共享的身份验证密钥,这可以确保推送服务无法注入消息。如果订阅有一个设置的过期时间,它将在expirationTime
中提供。 Firefox 和 Chromium 似乎都有无限期订阅,所以我暂时忽略了这个值。
此订阅只是嵌入在“邀请”URL 中。一旦其他设备获得此信息,它就可以发送带有自己信息的推送消息以建立双向通信。
发件人身份验证
最后一个皱纹。推送服务运营商希望能够识别发件人以解决问题。这是通过Web Push 的自愿应用程序服务器标识 (VAPID) 完成的。这是“服务器”生成的密钥对,用于验证推送。大概如果您的服务器有问题或滥用,他们可能会尝试联系您(通过 VAPID 签名中的电子邮件地址)或阻止您的密钥。然后,为了恢复通知,您需要生成一个新密钥并重新创建所有订阅。
这给我们的计划造成了麻烦,因为我们不应该公开分享这个密钥……但整个应用程序都是公开的。起初,我以为我可以为每个发送者生成一个密钥对,但这不起作用,因为 Push API 只允许每个设备进行一次订阅,因此所有发送者都需要为特定接收者使用相同的 VAPID 密钥。这实际上意味着需要在设备之间共享密钥。
现在,我刚刚将一个密钥对硬编码到代码中。也许将来每个设备都可以生成一个用于传入通知的密钥对,然后作为设置的一部分,它可以与发送者共享该密钥对。但是,我有点担心单个 Web 源的 100 多个密钥对无论如何都会引发流行的推送服务的标志,所以现在最好还是坚持使用一个。我不认为有太多滥用的可能性,因为订阅喜欢应用程序的来源,所以所有滥用者可以做的就是提醒自己。
客户端推送
下一步是发送推送通知。这相对简单。
fetch ( subscription . endpoint , { method : " POST " , headers : { urgency : " high " , authorization : `WebPush ${ vapidToken } ` , ttl : " 300 " , }, body : body . cyphertext , });
如前所述, vapidToken
只是使用我们的“服务器密钥”签名的JWT 。
CORS
但是哦不,我们的请求被拒绝了!这是由于一个称为跨域资源共享 (CORS)的遗留问题。这是一个选择退出保护系统,可以阻止某些请求(基本上是在引入系统时还不可能的任何请求)。由于我们的请求包含不在 CORS 安全列表中的标头,因此我们需要服务器明确批准它。不幸的是,Firefox 和 Chromium 的推送服务都不支持这种选择加入。
但一切都没有丢失。 CORS 主要是为了避免自动发送信息的问题,例如Cookie
。由于我们的请求不需要 cookie,CORS 既无用又容易绕过。我们只需要一个 CORS 代理。这只是一个服务,它通过执行 CORS 舞蹈来授权跨域请求,但除此之外只是代理请求(希望剥离 cookie)。通过确保不发送 cookie,不再需要 CORS。
CORS 仍然有一些用途。除了更改来源之外,CORS 代理还确保了“公共”网络视角。这可以避免网站敲打您的 Wi-Fi 路由器可能不安全的 Web 界面。 …当然,许多路由器可以仅通过 CORS 豁免请求被劫持,所以这无论如何都不是万无一失的保护。
但是,除了 CORS 之外,Firefox 推送服务还会阻止带有Origin
标头的请求。大多数公开可用的 CORS 代理都会保留此标头,因此我编写了自己的简单代理,该代理也删除了此标头。这足以推动 Firefox 和 Chromium 的工作。我看不出其他浏览器不能正常工作的任何原因。
不幸的是,这个代理确实提供了一些服务器端基础设施并降低了我们的去中心化。但是,它非常简单且易于复制。为用户提供一个选项来使用他们选择的代理来恢复完全去中心化是很容易的。当然,如果浏览器的推送服务只是启用了 CORS,那将是理想的,也许我会向浏览器提交一些错误。
加密
您可能已经注意到最后一个请求没有正文。这是推送通知的简单形式,只是一个 ping。不清楚这里有多少价值,因为你只能告诉用户“发生了什么事!”。我猜假设您的应用程序将与服务器同步以获取详细信息,然后再向用户显示通知。
但是我们没有要同步的服务器,所以让我们添加一个主体。谷歌为此提供了一些推荐的库,但是我发现所有内容都只支持Node.js 。所以我不得不推出自己的加密货币!
经过大量仔细的代码和规范阅读后,据我所知,我想出了第一个基于浏览器的 Web 推送加密实现。尽管推出自己的加密货币很可怕,但我相当有信心它可以正常工作,因为如果我搞砸了核心协议并且我试图避免边缘情况出现问题,那么经过身份验证的加密就会失败。所有艰难的决定都是由设计协议的专家做出的,所以我觉得用它来传输敏感文件是安全的。当然,审查和反馈将不胜感激。
网络RTC
Push API 仅支持最多 4078 字节的消息负载。虽然我们可以发送“无限制”的消息,但我怀疑如果我们尝试传输千兆字节的文件,推送服务操作将不会很开心。我也怀疑我会写回家关于表演的文章。
幸运的是,我们有WebRTC 。 WebRTC 是一个易于使用的 API,用于直接对等加密连接(或可选的中继连接)和端到端加密。它支持低延迟,最重要的是对我们来说是高带宽。
信令
WebRTC 是一个高级 API,它为我们管理大部分工作。最大的缺失部分是连接设置。这在 WebRTC 中通常被称为“信令”,尽管我们只需要进行初始信令。连接后,浏览器将负责其余的工作。即使这主要是为我们完成的,我们只需要从发起者到接收者洗牌和“提供”,然后洗牌一个“答案”。如果一切顺利,这足以让两人建立联系。
由于我们已经设置了 Web Push,因此协调会话设置很简单。处理接收器打开 0、1 或多个选项卡的可能性有点棘手,但通过一些逻辑,我们可以处理这些情况并建立连接。
转移
接下来是文件传输。这相当容易,只需将钻头推入管道即可。 WebRTC 的RTCDataChannel甚至可以区分“字符串”和“字节”,因此我们可以滥用它来将我们的控制数据作为 JSON 字符串发送,将文件数据作为字节发送,以避免在那里需要额外的框架或标记。
一个复杂的问题是缓冲。 RTCDataChannel.send()非常无用。文档包含很棒的引号,例如:
NetworkError DOMException
当需要缓冲指定的数据并且缓冲区中没有空间时抛出。在这种情况下,底层传输立即关闭。
缓冲区有多大?我看不到任何方法可以找出答案。但是,如果您填写它,您会立即关闭连接。太糟糕了。
为了“解决”这个问题,我使用了令人难以置信的科学方法,即使用参数玩 Numberwang,直到它起作用。我最终发送了 ¼ MiB 块并等到当前缓冲的数量为 ¼ MiB 或更少。似乎缓冲区大小约为 1 MiB。不令人满意,但适用于 Firefox 和 Chromium。我没有费心编写代码来重新建立具有较小块大小的连接并在达到限制后恢复。也许 API 会在某个时候得到改进。
表现不错,但并不出色。但是,我不确定在网络和文件系统之间的这么多抽象层上我能做得更好。除非您正在传输非常高质量的电影,否则您不太可能注意到速度缓慢。
下载
另一个复杂之处是下载文件。令我惊讶的是,网络没有任何适当的 API 可以从 JavaScript 开始下载。标准解决方案是:
const LINK = document . createElement ( " a " ); LINK . style . display = " none " ; document . body . append ( LINK ); function saveFile ( name : string , blob : Blob ) { var url = URL . createObjectURL ( blob ); LINK . setAttribute ( " href " , url ); LINK . setAttribute ( " download " , name ); LINK . click (); URL . revokeObjectURL ( url ); }
是的,创建一个链接并伪造点击。感觉不对,但确实有效。
然而,这有一个重大问题。似乎无法开始流式下载。理想情况下,我们可以在传输开始后立即开始流式传输到磁盘(例如通过 HTTP 下载常规文件),但没有用于此的 API。我发现您可以使用ReadableStream
然后使用它来创建Response
,然后您可以使用 Response 将其转换Blob
Response . blob ()
。但是, Response . blob ()
在下载整个响应之前不会解析。从Blob . size
需要知道Blob . size
才能创建Blob
,而ReadableStream
在生产者关闭控制器之前不知道大小。 Response . blob
似乎将流缓冲到磁盘,然后当文件在浏览器之外“下载”时将启动一个新副本。这意味着用户实际上需要等待文件的两个副本。这并不理想,但至少可以复制任意大的文件,而且它比我之前使用IndexedDB将块缓存到磁盘的方法更干净(并且可能更有效)。
结论
基本上就是这样。我认为我们可以在没有服务器(不包括代理)的情况下进行跨平台、分散的设备到设备通信是非常酷的。
该应用程序本身可以稍加修饰,但传输效果很好。我已经证明了这个概念并使自己成为一个有用的工具。
原文: https://kevincox.ca/2022/11/02/decentralized-via-webpush/