文件站離線閱讀與本機加密的技術設計
文件站的讀者常常在網路被切斷、限速或封鎖的地方需要這些內容。我們在 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 的裝置上驗證一次,資料金鑰就回到網頁,網頁再用它解開存在裝置裡的密文。
我的準備清單、威脅模型清單的存檔與本機檔案加密的收件人簿放在同一個暫存區,共用一份密文。

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 命令列工具的電腦都能解開,不需要這個網站:

age -d -o backup.tar backup.tar.age
密語模式沒有金鑰需要管理,強度完全取決於密語。passkey 模式不需要記住任何東西,代價是檔案只能在 anoni.net 網域解開。公鑰模式用來加密給別人,收件人一行一個,接受 age1 開頭的 X25519 公鑰與 age1pq1 開頭的後量子混合公鑰。
加密完成後,頁面會用同一組鑰匙把輸出解密比對,一致才提供下載。加解密用的函式庫原封不動放在站上,沒有改動任何一行,想驗證的人可以拿它跟上游的版本逐位元組比對。改過就失去這個可對照性,讀者只剩下相信我們的說法。
PGP 用在它適合的地方,上傳機敏資訊流程用 PGP,因為那裡需要長期的身分,也要與郵件生態相容。郵件與身分用 PGP,檔案與備份用 age。
實驗階段的範圍
整站離線閱讀仍是實驗中的 prototype。核心機制已經在正式站上運作,預先下載的範圍、清除的細緻程度與安裝成 App 之後的入口都還在調整。小工具也還是各自獨立的基本元件,彼此之間沒有串連。
對實作的意見歡迎到 anoni-net/docs 開 issue,想收到的讀者回饋列在文件站部落格文章的文末。