跳轉到

把整個文件站與小工具存進裝置,沒有網路的時候照樣可以開啟

網路斷掉的時候,你最想打開的那一頁,常常就是打不開的那一頁。飛機上、山區、換了國家還沒買到網卡,都只是不方便。網路被切斷、被限速、被封鎖的地方,同一件事的代價完全不同。

9 月 4 日到 6 日,我們在葡萄牙 Estoril 參加 Global Gathering 2026,除了最後一天的攤位,也在 Circle 的討論裡分享文件站上的隱私保護內容怎麼寫、怎麼分層。談的過程反覆回到同一個前提,讀者真正需要打開這些內容的時候,網路不一定在。

離線閱讀頁的上半部,佔用空間統計、自動存下內容與內文圖兩個開關,以及全部存到裝置、更新已存的內容、清除所有離線內容三顆按鈕

在 Global Gathering 收到的回饋

現場的回應幾乎都是正面的。戰爭、政府干預、網路資源分配不均,三種原因不同的處境指向同一個結果,需要查一份資料的時候,那份資料在網路的另一端而你過不去。

離線閱讀在 8 月底的文件站新增八個小工具那篇已經寫過一段,當時我們把它當成給旅行者的方便功能。會場收到的回饋讓我們把它提到主線,接下來補上的東西都圍繞著它。

以下先用文字說明讀者實際上會看到什麼。後半段是技術的部分,跳過不看也不影響使用。

逐頁閱讀的時候就在背景存下來

用一般瀏覽器打開文件站,第一次只存下離線閱讀頁與它需要的樣式,約 1 MB,用意是沒有網路時至少有一個落腳的頁面。

在首頁選過閱讀語言,或在同一個語言裡翻到第二頁之後,該語言的核心章節才會在背景存進裝置,約 12 MB 的文字,涵蓋概念、工具、進階、在地四個章節,加上斷網現場會用到的四個小工具,正體中文版目前是 48 頁。多等一步是刻意的,只看一頁就離開的人不必先付 12 MB 的行動網路流量。

切換語言時才會下載該語言的章節,所以裝置上只會有讀者實際讀過的語言。之後瀏覽過的頁面同樣會被存下來,由離線閱讀頁上的開關控制。

記者、行動者、LGBTQ、家暴幾類場景頁不在預先下載的範圍內,只有讀者自己點開過才會留下。場景頁留在裝置上本身可能就是一個敏感訊號,留與不留由讀者決定。

離線閱讀頁展開場景那一章,13 頁逐頁列出,網站自動存過的四頁勾選框是灰的並標著「網站已存」,記者、家暴、LGBTQ 幾頁的勾選框是空的,要讀者自己勾

登機前按一次,整個站帶著走

離線閱讀頁上的「全部存到裝置」會把當下語言的所有頁面存進裝置,正體中文版在截圖當下是 232 頁,飛機上、火車上、跨境移動的路上都可以繼續讀。

按鈕存進來的內容不受網站換版影響,網站自動存的那批則會在換版時清掉重新下載。一次只處理當下所在的語言,三個語言都要帶著走的話,切到每個語言的離線閱讀頁各按一次。

自動存的章節預設只存文字,離線開啟時會缺圖。頁面上有一個選項可以連同核心章節的內文圖一起存,大約多 7 MB,預設關著是因為多數人在行動網路上。讀者自己勾選的頁面不受該選項影響,一律連內文圖一起存入。

只想帶走某幾章的話,展開章節逐頁勾選,再按下「套用變更」。從你的身分開始那一頁另有一鍵按鈕,把該路徑連到的頁面一次存進來。

過海關前按一次,全部清掉

同一頁上的「清除所有離線內容」會把網站自動存下的與讀者自己勾選的一起清除,並且把自動存下內容的開關關掉,所以清完之後讀過的頁面也不會再留在裝置上。要恢復,回到同一頁把開關打開。

清除按鈕的存在理由跟「全部存到裝置」一樣具體。裝置可能被檢查的時候,一份完整的匿名與隱私指引留在裝置上本身就是要解釋的東西。

離線閱讀頁本身與它需要的樣式不受開關管,下次連上網時會補回,約 1 MB。想清掉裝置上的東西、或想確認還有哪些內容可讀的時候,往往正好是沒有連線的時候,少了落腳頁就只剩瀏覽器的錯誤畫面。

小工具不需要安裝,有瀏覽器就可以使用

小工具區目前有 14 個工具,涵蓋威脅模型清單、密語與密碼產生器、本機檔案加密、passkey 鑰匙、我的準備清單、QR code 的產生與讀取與影格串流、檔案雜湊比對、檔案 metadata 清除器、截圖遮蔽、網址清理器、隱形字元偵測,以及列出瀏覽器透露什麼的示範頁。

工具的程式與資料會跟頁面一起存下來,存進裝置之後沒有網路也可以使用。QR code 產生器、讀取器、影格串流與密語產生器四頁跟核心章節一起自動存進裝置,理由是它們在斷網現場會用到。其餘幾頁在離線閱讀的清單裡勾選,之後沒有網路也可以開啟。

沒有安裝步驟這件事,解掉的是幾個平常很難繞過的限制。借來的電腦、公用機器、由組織管控而不允許裝軟體的裝置,都不必先去申請權限。Windows、macOS、Linux、Android、iOS 有瀏覽器就一樣。裝置上不會多出一個隱私工具的圖示,多出來的圖示本身就是一個訊號。也不會發生手上是三年前的舊版本、而修正過的問題還留著的狀況。

工具斷網之後照常運作,本身就是「沒有偷偷送出資料」的一種證明。想自己確認的話,瀏覽器的開發者工具裡有一個網路分頁,會列出頁面送出的每一個請求,用任何一個工具處理一次資料,看清單有沒有動靜就知道了。

小工具索引頁的 14 張卡片,每張是一個工具的名稱與一句用途說明

把網站加到主畫面,它就變成一個 App

這是瀏覽器早就支援、卻很少人用過的功能。任何網站都可以加到手機的主畫面或電腦的應用程式清單,加完之後它有自己的圖示與視窗,開起來跟一般 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_ASSETSLIBRARY 分開,是為了讓畫面上的「你自己選存的 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 的裝置上驗證一次,資料金鑰就回到網頁,網頁用它解開存在裝置裡的密文。

我的準備清單威脅模型清單的存檔、本機檔案加密裡的收件人簿都放在同一個暫存區,共用一份密文。

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

本機檔案加密的畫面,鑰匙有密語、passkey、公鑰三種模式,下方是密語欄位與加密並下載的按鈕

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

三種鑰匙模式對應不同處境。密語模式沒有金鑰需要管理,強度完全等於密語的強度。passkey 模式不需要記住任何東西,代價是檔案只能在這個網域解開。公鑰模式加密給別人,收件人一行一個,age1 開頭的 X25519 公鑰與 age1pq1 開頭的後量子混合公鑰都接受。

加密完成後,頁面會用同一組鑰匙把輸出解回來比對,一致才提供下載。加解密用的函式庫原封不動放在站上,一行都沒有改動,所以想驗證的人可以拿它跟上游的版本逐位元組比對。改過就失去這個可對照性,剩下的只有相信我們的說法。

PGP 留在它該在的地方,站上的敏感資料上傳流程用 PGP,因為那裡需要長期的身分、要跟郵件生態相容。分工是郵件與身分用 PGP,檔案與備份用 age。

目前的狀態,以及我們想聽什麼

小工具都還是基本的功能元件。每一個各自做好一件小事,彼此之間沒有串起來,也還沒有形成一套可以直接交給組織使用的流程。

整個文件站可以離線閱讀是實驗中的 prototype。核心的機制已經在正式站上運作,預先下載的範圍怎麼劃分、清除要細到什麼程度、安裝成 App 之後的入口該長什麼樣,都還在調整。

我們想把它做成一份完整的產品或服務,需要的是更多對這類功能的想像與回饋:

  • 希望能離線帶著走的頁面:預先下載的範圍目前依核心章節劃分,實際需求可能不同
  • 離線的情境長什麼樣:斷網前有多少時間準備、裝置上留下什麼會造成風險、恢復連線之後要先做什麼
  • 工具在你的情境下缺了什麼:某個欄位沒有被辨識、某種檔案格式無法開啟、說明看不懂都算
  • passkey 用起來卡在哪裡:建立時選錯保管方式、換裝置之後打不開、看不懂鑰匙頁在說什麼,都歡迎回報

不方便具名的部分可寄到下方的匿名信箱。

聯絡與回報