技术文章

文档站离线阅读与本机加密的技术设计

文档站的读者常常在网络被切断、限速或封锁的地方需要这些内容。我们在 Global Gathering 2026 分享文档站时,收到的反馈大多与战争、政府干预、网络资源分配不均造成的断网有关,离线阅读因此成为文档站的主要方向。读者端的操作,包括逐页存下、整站存档、一键清除与加到主屏幕,写在文档站的博客文章与离线阅读页。

实现过程出过三个错:一条 CDN 设置让新发布的内容有四小时无法进入读者的手机、一颗清除按钮清完之后每读一页又存回设备一页、一把在 iPhone 上可以建立却无法算出密钥的 passkey。

Service Worker 的六个缓存

Service Worker 是浏览器提供的后台程序,注册之后,该网站的每一个请求都会先经过它,再依规则向网络请求,或从设备上的缓存回应。文档站用它把页面存进设备,并在没有网络时从设备取回。

程序在 docs/zh-TW/sw.js,发文时是 1388 行(2026-10 补注:已增加到 1644 行)。设备上的内容分成六个缓存存放,因为不同的内容应该在不同的时间点消失。

网站自动存的内容跟着网站版本走,每次部署都整批清掉重新下载,否则读者手上的内容会越来越旧。读者自己勾选的页面不能比照处理,刻意留下的内容不该因为网站换版就消失。浏览时顺手存下的页面则需要条数上限,否则逛过几轮图多的页面,就会把前两类挤掉。三种内容的寿命不同,所以分成三处存放。

(2026-10 补注:预缓存之后改成换版时先沿用上一版里没有变动的项目,只对有变动的项目发出带条件的请求,网络失败时也保留旧副本,细节见 sw.js 的 previousPrecaches 与 fetchForPrecache。)

缓存 内容 清除时机
PRECACHE app shell 与当下语系的核心章节 网站每次换版
RUNTIME_PAGES 浏览过程中顺手存下的页面,上限 120 页 超过上限时淘汰最旧的
RUNTIME_ASSETS 同一批页面引用的图与字体,上限 200 个 超过上限时淘汰最旧的
LIBRARY 读者自己勾选留下的页面 只在读者取消勾选或按下清除时
LIBRARY_ASSETS 那些页面引用的图与程序 跟着所属的页面
SETTINGS 两个开关的值 按下清除时重设为关闭

LIBRARY_ASSETS 与 LIBRARY 分开,界面上「你自己选存的 N 页」才数得准,几十张图不会被算成几十页。

runtime 的两个缓存只在「自动存下内容」开启时写入。最初是无条件写入,结果读者按下「清除所有离线内容」之后,每读一页又会存回设备一页。按下清除的人多半是因为设备可能被检查,读者表示了不留,设备上就不该再留下。

两个开关的值存在 Cache Storage,没有放在浏览器的 localStorage,因为 install 阶段的 Service Worker 无法读取 localStorage。读者清空过离线内容的记录必须延续到下一次部署的 install,否则清完隔天内容又被自动下载回来,等于没有清除。

基于隐私考虑,Tor Browser 以及 onion、IPFS 版刻意不注册 Service Worker。需要离线备援时,用一般浏览器在 anoni.net 准备,平时的匿名浏览再回到 Tor Browser。

离线副本的更新

页面的新鲜度靠 network-first 维持。导航请求一律先向网络取得一份,成功就使用新内容并存起来,超时或失败才改用缓存,缓存也没有时才回到该语系的离线页。在线浏览因此一直是最新内容,会变旧的只有设备上的离线副本。

超时要设多久是整段最难的决定,困难与网速无关。浏览器的 navigator.onLine 只有返回 false 时可信,而读者实际受阻的状态都是它返回 true 的情况,例如飞行模式下 Wi-Fi 仍开着、机上 Wi-Fi 没有购买套餐、公共热点把流量拦在登录页。设备上明明有一份完整的副本,读者却要在每一页等满超时。

导航的超时因此压到 1.2 秒,超时之后还有设备上的旧副本可用,读者取得的仍是完整内容,只是晚一轮更新。只要有一次导航超时,接下来一分钟内有缓存的页面就直接从缓存提供,网络请求照样在后台发出。请求成功后这个状态随即解除,读者接回网络之后不必自行操作。

资源的超时放宽到八秒,因为导航超时之后还有内容可以提供,资源超时之后只能回应失败,界面会少一块。八秒的上限是 2026-09-04 才补上,在那之前没有任何上限,连接成功却不回应的网络会让请求一直挂着。stylesheets/extra.css 会阻塞渲染(render-blocking),它挂着的话整页一直是空白,实测四十五秒都没有任何内容出现。

网站发布新版本时,界面底部会浮出一张提示卡片,写着「离线存的内容有新版本。更新会重新加载这一页。」,按下「更新」才会应用。应用时会重新加载当前页面,读到的位置会跟着消失,所以卡片上直接写明,由读者按下才应用。不按也可以,离线副本会维持原状,直到读者自行决定。

界面底部浮出的卡片,写着「离线存的内容有新版本。更新会重新加载这一页。」,下面是「更新」与「稍后」两颗按钮

浏览器的 soft update 靠 navigation 触发,在标签页里浏览时经常发生,装成 App 的 PWA 常驻后台,回到前台时却不会产生新的 navigation。换版提示因此不会出现,读者连更新按钮都看不到。处理方式是在回到前台时主动查询一次有没有新版,并加上节流,避免每次切换 App 都发出请求。

读者自己勾选的页面不随换版清掉,离线阅读页另有一颗「更新已存的内容」按钮,按下才会逐页重新下载。

HTTP 缓存的影响

Service Worker 发出的每一个请求都带着 cache: "no-cache"。no-cache 的名称容易误会,它的意思是每次都向服务器确认一次,并没有跳过缓存。内容没变时服务器返回 304,缓存照样保留,成本是一个往返。

不带这个选项的 fetch 会先查设备上的 HTTP 缓存,尚未过期就直接回应,网络请求根本没有发出。network-first 看起来向网络确认过,实际确认的是设备上自己那一份,旧回应还会被写回 runtime 缓存,让旧内容在设备上留得更久。

2026-08-28 的事故就是这种情况,Cloudflare 上有一条 Cache Rule 把 HTML 一律设成 max-age=14400,新发布的内容有四小时无法进入 PWA。在标签页里用 Safari 阅读的读者不会察觉,因为从地址栏进站或下拉刷新本来就会绕过 HTTP 缓存。装成 App 之后的冷启动与站内链接则不会绕过,加上 iOS 主屏幕 App 有独立的存储分区,Safari 取得的新内容也无法传到 App,结果只有 PWA 一直停在旧版。

同一天的修正分成两层,服务器的 nginx 对 /docs/ 的页面与两份索引发出 no-cache,Cloudflare 那条规则改成尊重源站设置,让标头传到读者端。Cloudflare 的边缘照样缓存 24 小时,由部署时执行的 cf_purge.py 清除。Service Worker 这一侧照样自己带着 no-cache。两层各自独立,是因为上游的设置可能被人修改,修改的人未必知道 Service Worker 依赖它,而读者无法取得刚发布的内容时,在浏览器上逐页点阅也很难察觉。

没有账号与服务器的 passkey

passkey 是存在设备或密码管理器里的一把钥匙,用指纹、面部或 PIN 解锁,一般用来登录。文档站的用法没有登录、账号与服务器,我们拿它当读者数据的钥匙,站上连读者有没有建立过 passkey 都无从得知。准备清单的进度、威胁模型的答案、常用的联络对象,都加密之后存在读者自己的设备上。

同一把 passkey 在站上有两种用法,机制不同,可用的环境也不同。

user.id 里的数据密钥

建立 passkey 时,网页生成一把随机的数据密钥,放进凭证的 user.id 字段,之后每一次验证,保管 passkey 的地方都会原样交回。读者在任何有这把 passkey 的设备上验证一次,数据密钥就回到网页,网页再用它解开存在设备里的密文。

我的准备清单、威胁模型清单的存档与本机文件加密的收件人簿放在同一个暂存区,共用一份密文。

passkey 钥匙页的界面,三个步骤分别是建立 passkey、试一次解锁、生成备援密钥,每一步各有说明与按钮

PRF 现场计算的密钥

PRF 的用法让 passkey 成为一台只在读者验证指纹时才产生结果的密钥计算器,机制是 WebAuthn 的 PRF 扩展。passkey 内部另外保存一把不离开验证器的秘密,网页每次验证时附上一段输入,读者同意之后,验证器返回固定 32 字节的输出。同一把 passkey 配上同一段输入,永远得到同一段输出。

本机文件加密用这段输出包住 age 的文件密钥。没有 passkey 就无法算出密钥,数据只是一堆密文。「验证通过才显示」的做法则完全不同,那是写在网页里的门禁,任何人都能绕过。

暂存区采用 user.id 的取舍

暂存区用 user.id,没有走 PRF。Apple 的实现只把 PRF 需要的数据交给 iCloud 钥匙串,不交给其他保管方式,所以 iPhone 配 Bitwarden 可以建立 passkey,却无法算出密钥。user.id 是核心字段,规范要求所有保管方式都要交回,我们在 iPhone 配 Bitwarden 与电脑的 Chrome 上验证过。

页面上写明了这个代价,数据密钥跟着凭证存在密码管理器里,安全程度等于该密码管理器的安全程度。能解锁密码管理器的人就能取得密钥,密码管理器的导出文件、共享的 vault、服务端遭入侵再加上一组弱主密码,走的都是同一条路。强度与设备的屏幕锁同级,对多数人足够,对能接触设备、又可能要求解锁的对手不够。威胁模型清单遇到这一级的对手时,就不提供存档。

在覆盖面与安全性上限之间,我们选择覆盖面,判准是方案在 iPhone 配第三方密码管理器时是否可行。需要读者记住一段密语的方案会被否决,读者要做的只有验证。

我的准备清单在还没解锁时的界面,说明这台设备上还没有清单,并列出用已有的钥匙开、建一把新的钥匙、用另一台的钥匙登录这台三个选项

passkey 的限制

  • passkey 绑在 anoni.net 这个 RP ID 上,浏览器只允许在同一个域名使用,镜像站与 onion 地址无法使用
  • Tor Browser 整个关闭 WebAuthn,默认配置里的 security.webauth.webauthn 是 false
  • passkey 丢失或密码管理器的账号消失时,暂存区就无法解开,用它加密的文件只剩备援密钥可以解开
  • 浏览器基于隐私不让网页查询某个域名有没有 passkey,站上连读者有没有建立过都无从得知,钥匙页每次打开都是空的

采用 age 的理由

读者三年后要解开一份备份时,手上可能只剩一台装了命令行工具的电脑,网站还在不在都不一定,所以加密格式用公开格式,不用自定义格式。

在公开格式里选 age 而不选 PGP,原因是以下几项具体的差异:

age OpenPGP
用途 只加密文件 加密、签名、身分与信任网
算法 固定一组:X25519、ChaCha20-Poly1305、scrypt、HMAC-SHA-256 多种可选,双方协商
密钥的样子 一行 62 个字符 一个区块,上千字符,含身分与期限
配置项 没有 GnuPG 的配置文件有上百项
规范长度 一页 上百页
实现 Go、Rust、TypeScript 三份互通 GnuPG 为主,其余实现各自覆盖部分规范

没有选项就没有设错的机会,规范短才能在浏览器里实现得够小、可以审查,密语模式也不需要任何密钥管理。1999 年的可用性研究「Why Johnny Can't Encrypt」请十二个人用 PGP 5.0 寄出一封加密信,多数人在九十分钟内无法完成,还有人把私钥寄了出去。2018 年的 EFAIL 攻击则利用旧格式的密文可以被篡改,而邮件软件在验证失败时只给警告、照样显示内容。两者的根源相同,选项太多,可能设错的地方也太多。

站上的本机文件加密输出标准 age 文件,任何装了 age 命令行工具的电脑都能解开,不需要这个网站:

本机文件加密的界面,钥匙有密语、passkey、公钥三种模式,下方是密语字段与加密并下载的按钮

age -d -o backup.tar backup.tar.age

密语模式没有密钥需要管理,强度完全取决于密语。passkey 模式不需要记住任何东西,代价是文件只能在 anoni.net 域名解开。公钥模式用来加密给别人,收件人一行一个,接受 age1 开头的 X25519 公钥与 age1pq1 开头的后量子混合公钥。

加密完成后,页面会用同一组钥匙把输出解密比对,一致才提供下载。加解密用的库原封不动放在站上,没有改动任何一行,想验证的人可以拿它跟上游的版本逐字节比对。改过就失去这个可对照性,读者只剩下相信我们的说法。

PGP 用在它适合的地方,上传机敏信息流程用 PGP,因为那里需要长期的身分,也要与邮件生态兼容。邮件与身分用 PGP,文件与备份用 age。

实验阶段的范围

整站离线阅读仍是实验中的 prototype。核心机制已经在正式站上运作,预先下载的范围、清除的细致程度与装成 App 之后的入口都还在调整。小工具也还是各自独立的基本组件,彼此之间没有串联。

对实现的意见欢迎到 anoni-net/docs 开 issue,想收到的读者反馈列在文档站博客文章的文末。