把整个文档站与小工具存进设备,没有网络的时候照样可以打开¶
网络断掉的时候,你最想打开的那一页,常常就是打不开的那一页。飞机上、山区、换了国家还没买到网卡,都只是不方便。网络被切断、被限速、被封锁的地方,同一件事的代价完全不同。
9 月 4 日到 6 日,我们在葡萄牙 Estoril 参加 Global Gathering 2026,除了最后一天的展位,也在 Circle 的讨论里分享文档站上的隐私保护内容怎么写、怎么分层。谈的过程反复回到同一个前提,读者真正需要打开这些内容的时候,网络不一定在。

在 Global Gathering 收到的反馈¶
现场的回应几乎都是正面的。战争、政府干预、网络资源分配不均,三种原因不同的处境指向同一个结果,需要查一份资料的时候,那份资料在网络的另一端而你过不去。
离线阅读在 8 月底的文档站新增八个小工具那篇已经写过一段,当时我们把它当成给旅行者的方便功能。会场收到的反馈让我们把它提到主线,接下来补上的东西都围绕着它。
以下先用文字说明读者实际上会看到什么。后半段是技术的部分,跳过不看也不影响使用。
逐页阅读的时候就在后台存下来¶
用一般浏览器打开文档站,第一次只存下离线阅读页与它需要的样式,约 1 MB,用意是没有网络时至少有一个落脚的页面。
在首页选过阅读语言,或在同一个语言里翻到第二页之后,该语言的核心章节才会在后台存进设备,约 12 MB 的文字,涵盖概念、工具、进阶、在地四个章节,加上断网现场会用到的四个小工具,简体中文版目前是 48 页。多等一步是刻意的,只看一页就离开的人不必先付 12 MB 的移动数据流量。
切换语言时才会下载该语言的章节,所以设备上只会有读者实际读过的语言。之后浏览过的页面同样会被存下来,由离线阅读页上的开关控制。
记者、行动者、LGBTQ、家暴几类场景页不在预先下载的范围内,只有读者自己点开过才会留下。场景页留在设备上本身可能就是一个敏感信号,留与不留由读者决定。

登机前按一次,整个站带着走¶
离线阅读页上的「全部存到设备」会把当下语言的所有页面存进设备,简体中文版在截图当下是 209 页,飞机上、火车上、跨境移动的路上都可以继续读。
按钮存进来的内容不受网站换版影响,网站自动存的那批则会在换版时清掉重新下载。一次只处理当下所在的语言,三个语言都要带着走的话,切到每个语言的离线阅读页各按一次。
自动存的章节默认只存文字,离线打开时会缺图。页面上有一个选项可以连同核心章节的内文图一起存,大约多 7 MB,默认关着是因为多数人在移动数据上。读者自己勾选的页面不受该选项影响,一律连内文图一起存入。
只想带走某几章的话,展开章节逐页勾选,再按下「应用变更」。从你的身分开始那一页另有一键按钮,把该路径连到的页面一次存进来。
过海关前按一次,全部清掉¶
同一页上的「清除所有离线内容」会把网站自动存下的与读者自己勾选的一起清除,并且把自动存下内容的开关关掉,所以清完之后读过的页面也不会再留在设备上。要恢复,回到同一页把开关打开。
清除按钮的存在理由跟「全部存到设备」一样具体。设备可能被检查的时候,一份完整的匿名与隐私指引留在设备上本身就是要解释的东西。
离线阅读页本身与它需要的样式不受开关管,下次连上网时会补回,约 1 MB。想清掉设备上的东西、或想确认还有哪些内容可读的时候,往往正好是没有连接的时候,少了落脚页就只剩浏览器的错误界面。
小工具不需要安装,有浏览器就可以使用¶
小工具区目前有 14 个工具,涵盖威胁模型清单、密语与密码生成器、本机文件加密、passkey 钥匙、我的准备清单、QR code 的生成与读取与帧串流、文件哈希比对、文件 metadata 清除器、截图遮蔽、网址清理器、隐形字符检测,以及列出浏览器透露什么的示范页。
工具的程序与数据会跟页面一起存下来,存进设备之后没有网络也可以使用。QR code 生成器、读取器、帧串流与密语生成器四页跟核心章节一起自动存进设备,理由是它们在断网现场会用到。其余几页在离线阅读的清单里勾选,之后没有网络也可以打开。
没有安装步骤这件事,解掉的是几个平常很难绕过的限制。借来的电脑、公用机器、由组织管控而不允许装软件的设备,都不必先去申请权限。Windows、macOS、Linux、Android、iOS 有浏览器就一样。设备上不会多出一个隐私工具的图标,多出来的图标本身就是一个信号。也不会发生手上是三年前的旧版本、而修正过的问题还留着的状况。
工具断网之后照常运作,本身就是「没有偷偷送出数据」的一种证明。想自己确认的话,浏览器的开发者工具里有一个网络面板,会列出页面送出的每一个请求,用任何一个工具处理一次数据,看清单有没有动静就知道了。

把网站加到主屏幕,它就变成一个 App¶
这是浏览器早就支持、却很少人用过的功能。任何网站都可以加到手机的主屏幕或电脑的应用程序列表,加完之后它有自己的图标与窗口,开起来跟一般 app 没什么差别,而整个过程不经过任何应用商店。
对离线阅读来说,差别在入口。网络断掉的时候,人不会想「先开浏览器,再找书签,书签在哪个文件夹」,想的是「那个图标在哪」。花一分钟装好,断网时就是点一下的事。
- Android 的 Chrome、Edge、Firefox:开站之后点浏览器菜单,选「安装应用」、「安装」或「添加到主屏幕」
- 电脑的 Chrome、Edge:地址栏右侧会出现一个安装图标,或从菜单选「安装 anoni.net Docs」
- iPhone、iPad 的 Safari:点下方的「分享」钮,往下滚,选「添加到主屏幕」
第一次装的人容易卡在两件事上。iPhone 与 iPad 上,App 跟 Safari 各存各的内容,在 Safari 里存过的页面不会跟着进 App,要在 App 里再打开一次,让它重新存入一份。Android 上长按图标,快捷方式菜单里有「离线阅读」,可以直接开到管理页。
装好之后不必另外管更新,网站发新版时 App 里一样会浮出上面那张提示卡片,移除的方式也跟移除一般 app 一样。
代价要说在前面。装成 App 会在设备上留下一个图标,跟上一节说的「网页不留安装痕迹」正好相反。过境可能被检查的话就不要安装,用浏览器打开一样可以阅读离线内容,只是断网时要自己找到书签。Tor Browser 不提供这个功能,理由跟它不做离线存储一样。
以下是技术的部分¶
前面说的是读者会看到的东西。接下来这几节说明它怎么运作,只想把东西存进设备的话,读到这里就够了。
想留下来的话,后面有三件我们自己出过的错,一条 CDN 设置让新发布的内容四小时无法进入读者的手机、一颗清除按钮清完之后每读一页又自己存回去一页、一把在 iPhone 上可以建立、却无法算出密钥的钥匙。那三段说的是症状与后果,看不懂术语不影响阅读。
离线阅读由 Service Worker 逐页存下来¶
Service Worker 是浏览器提供的一段后台程序,注册之后由它接手该网站的每一个请求,决定要向网络请求还是从设备上的缓存回应。文档站用它做两件事,把页面存进设备,以及在没有网络时从设备上取回。
程序在 docs/zh-TW/sw.js,目前 1388 行。设备上的内容分成六堆存,而不是全部倒进同一堆,理由是它们该在不同的时候消失。
网站自动存的内容跟着网站版本走,每次部署都整批清掉重下,不然读者手上的内容会越来越旧。读者自己勾选的页面不能比照办理,刻意留下来的东西不该因为网站发了新版就不见。浏览过程中顺手存下的则需要条数上限,不然图多的页面逛几轮就会把前面两类挤掉。三种寿命不同,所以分成三处存放。
| 存放区 | 存什么 | 什么时候清掉 |
|---|---|---|
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 的,它挂住的话整页就一直是白的,实测四十五秒还没有任何内容出现。
网站发布新版本时,界面底部会浮出一条提示,写着「离线存的内容有新版本。更新会重新加载这一页。」按下「更新」才会应用。刻意要读者按一下,是因为应用的动作会重新加载当前页面,正在读的位置会消失,那是唯一会让人意外的地方,所以直接写在卡片上。没有按也可以,离线副本维持原样,直到读者自己决定。
装成 App 之后有一个额外的问题。浏览器的 soft update 靠 navigation 触发,在标签页里浏览经常会碰到,常驻在后台的 PWA 回到前台不产生新的 navigation,换版提示因此不会出现,读者连一颗更新按钮都没有。处理方式是回到前台时主动查询一次有没有新版,并加上节流,避免每次切换 app 都送出一个请求。
读者自己勾选留下的那批不随着换版清掉,离线阅读页上另有一颗「更新已存的内容」,按下去才会逐页重新抓回。

还有一层跟 HTTP 缓存有关。Service Worker 送出去的每一个请求都带着 cache: "no-cache"。这个名字容易误会,它的意思是每次都跟服务器确认一次,内容没变时服务器回 304,缓存照样留着,成本是一个往返,并不是跳过缓存。没带这个选项的 fetch 则会先查设备上的 HTTP 缓存,觉得还没过期就直接回应,网络请求根本不出门,network-first 于是看起来向网络确认过,实际上确认的是自己手上那一份,旧回应还会被写回 runtime 缓存,让旧内容更久留在设备上。
2026-08-28 就发生过一次。Cloudflare 上一条把 HTML 一律设成 max-age=14400 的 Cache Rule,让新发布的内容有四小时无法进入 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 里¶
建立 passkey 时站上生成一把随机的数据密钥,放进凭证的 user.id 字段,之后每一次验证,保管 passkey 的地方都会把它原样交回来。读者在任何有这把 passkey 的设备上验证一次,数据密钥就回到网页,网页用它解开存在设备里的密文。
我的准备清单、威胁模型清单的存档、本机文件加密里的收件人簿都放在同一个暂存区,共用一份密文。

用法二,PRF 现场算出密钥¶
这个用法把 passkey 变成一台只在读者按指纹时才回答的密钥计算器,机制是 WebAuthn 的 PRF 扩展。passkey 内部多藏一把秘密,永远不离开验证器,网页每次验证附一段输入,读者同意之后,验证器返回固定 32 字节的输出,同一把 passkey 配同一段输入永远得到同一段输出。
本机文件加密拿这段输出去包 age 的文件密钥。没有 passkey 就无法算出密钥,数据就是一堆密文。跟「验证通过才显示」完全不同,后者是写在网页里的门禁,任何人都能绕过。
为什么暂存区刻意选了安全性较低的那条路¶
暂存区用 user.id,没有走 PRF。Apple 的实现不把 PRF 需要的数据交给 iCloud 钥匙串以外的保管方式,iPhone 配 Bitwarden 可以建立 passkey,却无法算出密钥。user.id 是核心字段,规范要求所有保管方式都交回来,我们在 iPhone 配 Bitwarden 与电脑的 Chrome 上验过。
代价写在页面上:数据密钥跟着凭证存在密码管理器里,安全等于那个管理器的安全。谁能解锁密码管理器,谁就取得密钥,密码管理器的导出文件、共享的 vault、服务端被入侵加上一组弱的主密码,走的都是同一条路。强度跟设备的屏幕锁是同一个等级,对多数人够用,对能接触到设备又可能要求解锁的对手不够。威胁模型清单遇到这一级的对手就不提供存档。
在覆盖面与安全性上限之间,我们选了覆盖面。设计时的判准是「方案在 iPhone 配第三方密码管理器上是否可行」,需要读者记住一段密语的方案会被否决,读者要做的只有验证。

限制¶
- 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 命令行工具的电脑都能解开,不需要这个网站:

age -d -o backup.tar backup.tar.age
三种钥匙模式对应不同处境。密语模式没有密钥需要管理,强度完全等于密语的强度。passkey 模式不需要记住任何东西,代价是文件只能在这个域名解开。公钥模式加密给别人,收件人一行一个,age1 开头的 X25519 公钥与 age1pq1 开头的后量子混合公钥都接受。
加密完成后,页面会用同一组钥匙把输出解回来比对,一致才提供下载。加解密用的库原封不动放在站上,一行都没有改动,所以想验证的人可以拿它跟上游的版本逐字节比对。改过就失去这个可对照性,剩下的只有相信我们的说法。
PGP 留在它该在的地方,站上的敏感数据上传流程用 PGP,因为那里需要长期的身分、要跟邮件生态兼容。分工是邮件与身分用 PGP,文件与备份用 age。
目前的状态,以及我们想听什么¶
小工具都还是基本的功能组件。每一个各自做好一件小事,彼此之间没有串起来,也还没有形成一套可以直接交给组织使用的流程。
整个文档站可以离线阅读是实验中的 prototype。核心的机制已经在正式站上运作,预先下载的范围怎么划分、清除的粒度要多细、装成 App 之后的入口该长什么样,都还在调整。
我们想把它做成一份完整的产品或服务,需要的是更多对这类功能的想象与反馈:
- 希望能离线带着走的页面:预先下载的范围目前依核心章节划分,实际需求可能不同
- 离线的情境长什么样:断网前有多少时间准备、设备上留下什么会造成风险、恢复连接之后要先做什么
- 工具在你的情境下缺了什么:某个字段没有被识别、某种文件格式无法打开、说明看不懂都算
- passkey 用起来卡在哪里:建立时选错保管方式、换设备之后打不开、看不懂钥匙页在说什么,都欢迎反馈
不方便具名的部分可寄到下方的匿名信箱。
联络与反馈¶
- 即时讨论:Matrix 公开 room(家服务器
im.anoni.net,链接见社区工具页) - 匿名线索:whisper@anoni.net(GPG 公钥)
- 源代码与 issue:anoni-net/docs