跳至主要內容

中繼資料在 SQLite,密文在 BlobStore

閱讀時間 6 分鐘 作者 NT²

保管箱需要知道哪些檔案屬於哪些項目、它們有多大、以及如何解開金鑰。 它不需要把那些加密位元組塞進負責回答這些問題的關聯式資料庫裡。

中繼資料在 SQLite,密文在 BlobStore

我們的立場很直接:附件中繼資料屬於保管箱的 SQLite 資料庫;附件密文則屬於獨立的 BlobStore 檔案儲存。

這項切分聽起來像內務整理,其實是儲存邊界。資料庫可以回答關於附件的關聯式問題,而不必變成大型加密二進位檔的倉庫;檔案儲存可以串流密文,而不必假裝自己是查詢引擎。雙方各自對自己擁有的內容保持權威。

NT² Vault 把這項分離視為本機優先設計的一部分,而不是等到檔案「大到塞不進資料庫」才臨時補上的權宜之計。

限制:附件不只是另一種資料列

結構化保管箱項目大多是小型、具型別的欄位。附件則不同。

它可能是護照掃描、PDF、手寫筆記照片,或復原材料的壓縮檔。今天可能是數 MB,架構上明天可以更大。相較於標題與列表中繼資料,它變更較少;一旦變更,工作單位通常是整個檔案,而不是單一欄位。同步時,位元組可能走與關聯式 replica 批次不同的路徑。開啟時,使用者需要漸進讀寫,而不是把整個物件載入一條 SQL 陳述式。

若把這些加密位元組存成 SQLite BLOB,多種壓力會同時出現:

  • 資料庫分頁、日誌與 vacuum 行為開始承載檔案級負載。
  • 備份與 replica 匯出會圍繞不需要關聯式索引的二進位 blob 膨脹。
  • 列表與搜尋路徑為一個熱路徑其實是中繼資料、而非密文的儲存體付出代價。
  • 串流加密與解密變得彆扭,因為自然單位是資料列,而不是檔案位移。
  • 平台差異被放大:瀏覽器 OPFS、桌面檔案,以及可選的雲端物件儲存,都更偏好檔案導向的密文,而非 SQL BLOB。

你仍可硬逼設計運作;許多系統也這麼做。代價是 SQLite 不再主要是關聯式引擎,而開始兼差當「外掛索引的 blob 檔案系統」。

反過來的錯誤對保管箱更糟:把附件中繼資料塞進檔名或臨時 sidecar,卻把加密檔案當成真實來源。於是歸屬、配額、軟刪除、父項目關係,以及密碼學信封欄位,都難以用交易方式查詢。缺少檔案與缺少資料列也不再代表同一件事。

因此限制是雙重的。附件需要能參與保管箱交易的權威中繼資料;也需要能成長、分塊、串流與同步、卻不必住在每個 SQL 分頁裡的二進位密文儲存

設計:資料列負責權威,檔案負責 frame

NT² Vault 把附件中繼資料放在與項目、保管箱設定區段、搜尋索引相同的每座保管箱 SQLite 資料庫中。每一筆附件列記錄產品在不開啟檔案時仍須知道的內容:

  • 哪個項目擁有此附件;
  • 顯示名稱、MIME 類型,以及用於政策與配額的明文大小;
  • 附件內容加密金鑰的密碼學信封欄位;
  • 讓用戶端能重建加密物件的分塊配置欄位;
  • 同步與備份使用的生命週期時間戳與識別碼。

密文本身則住在每座保管箱的 BlobStore。在瀏覽器中,那是保管箱資料庫旁的 Origin Private File System:

vaults/{vaultId}/vault.sqlite
vaults/{vaultId}/attachments/{attachmentId}.{chunkIndex}.bin

在桌面上,同一個 BlobStore 契約對應到應用程式本機檔案。呼叫端不需要兩套心智模型:中繼資料走附件 repository;frame 走 BlobStore。

加密遵循與項目相同的信封規則。每個附件有自己的內容加密金鑰。該金鑰以 AES-GCM 加密檔案內容;保管箱金鑰再包裝內容金鑰。SQLite 儲存包裝後的金鑰與相關信封欄位;BlobStore 只儲存密文。

當架構允許時,大型檔案不會被加密成單一龐大 blob。明文會切成固定大小的邏輯區塊——每個 1 MiB,最後一塊可能較短。每個區塊再加密成實體 frame:密文加上用於驗證與放置該區塊所需的裝幀資訊。BlobStore 依區塊索引各寫一個 frame 檔。讀寫因此可以逐 frame 進行,而不必把整個附件載入記憶體。

flowchart LR
    Item[保管箱項目] --> Meta[SQLite 中的附件列]
    Meta -->|包裝 CEK 欄位| Env[信封中繼資料]
    Meta -->|指向| Frames[BlobStore frame 檔]
    Env -.->|解鎖後解開| CEK[附件 CEK]
    CEK -->|解密 frame| Plain[明文區塊]

權威仍在資料列。若 SQLite 說附件存在,用戶端就知道該期待哪些 frame、共有多少,以及解鎖後如何解開內容金鑰。若某個 frame 遺失或驗證失敗,那是單一物件的儲存完整性失敗——不是整座保管箱資料庫的模糊損毀。

這也符合可選雲端同步應有的行為。附件密文可以不透明 frame 的形式移動。replica 攝取路徑可以把下載的 frame 寫入 BlobStore,而不必解密。邊緣為了儲存或轉送密文,從不需要明文。中繼資料同步與 blob 同步保持相關、但可分離。

權衡:兩個儲存體,一套一致性故事

把中繼資料與密文分開並非沒有成本。

每一次建立、取代或刪除,都必須讓 SQLite 列與 BlobStore frame 保持對齊。只提交中繼資料卻沒有 frame,或只有 frame 沒有中繼資料,都會留下不完整物件。截斷、覆寫與清理需要明確規則。配額通常加總中繼資料列上的大小,因此那些大小必須可信。備份與還原必須兩邊都帶。測試必須涵蓋孤兒 frame、缺失 frame、被截短的最後一塊,以及與磁碟位元組不再相符的信封欄位。

開發者也容易想「先把 blob 放進 SQLite 再說」,因為單一交易看起來比較簡單。那條捷徑會崩解其餘堆疊所依賴的邊界:從不載入附件位元組的分頁列表、串流 frame 的 worker、把密文當不透明資料的同步,以及把 BlobStore 對應到 OPFS 或原生檔案的平台轉接層。

我們接受雙儲存紀律,因為替代方案更糟:一個儲存體同時假裝自己是關聯式引擎與大型物件檔案系統。兩條清楚的擁有權規則,勝過一個過載的原語。

一致性規則刻意且狹窄:

  1. SQLite 對「附件是否存在、該如何解讀」具有權威。
  2. BlobStore 對該中繼資料所命名的加密 frame 位元組具有權威。
  3. 可用的附件必須兩邊都齊全。

這比單一 BLOB 欄位多一些帳務工作。但這也是本機保管箱在項目數成長時仍可查詢,並在個別檔案變大時仍可開啟的方式。

我們拒絕什麼

當拒絕事項說清楚,架構也會更清楚。

我們拒絕在靜態儲存中保存明文附件。 使用者解鎖後檢視或編輯檔案時,暫存明文可以存在記憶體中。持久儲存只保存密文 frame 與非機密中繼資料。

我們拒絕把 SQLite 當成附件密文的主要居所。 關聯式儲存擁有關係與信封欄位;檔案導向的 BlobStore 擁有加密位元組。

我們拒絕用單一保管箱範圍的內容金鑰加密每個附件。 每個附件有自己的內容加密金鑰,再由保管箱金鑰包裝。沒有信封就搬走密文,並不會揭露檔案。

我們拒絕為了儲存或中繼而讓邊緣解密附件。 可選同步可以傳輸不透明 frame。盲目儲存是產品要求,不是效能優化。

我們拒絕讓列表與搜尋路徑「因為本機就在」而灌入附件位元組。 本機不等於常駐記憶體。列表視窗維持輕量列;開啟附件是另一次、刻意讀取 frame 的動作。

我們拒絕雙重真實來源。 檔名不能取代附件列。沒有 frame 的列是不完整的。沒有列的 frame 是應刪除或修復的孤兒,不是第二份目錄。

這些拒絕讓保管箱在瀏覽器 OPFS、桌面檔案、備份與可選雲端 replica 之間保持一致。

目錄保持關聯式,位元組留在檔案裡

隱私保管箱仍須完成普通產品工作:顯示哪些檔案屬於某個項目、執行大小政策、只同步有變更的內容,以及開啟大型加密文件,卻不把整座資料庫變成那份文件。

把中繼資料放在 SQLite、把密文放在 BlobStore,就是 NT² Vault 回應這項需求的方式。資料庫仍是目錄;BlobStore 仍是加密負載儲存。信封加密把它們綁在一起,卻不合併它們的儲存工作。

關於每個附件背後的金鑰層級,請讀每個物件一把金鑰:NT² Vault 內的信封加密。關於為什麼保管箱資料庫本身放在 OPFS 而非 IndexedDB,見為什麼我們將保管箱的 SQLite 資料庫放在 OPFS,而非 IndexedDB。關於在中繼資料仍可查詢時,讓互動列表避開全表載入,見永遠不要把整個保管箱載入 Svelte state。關於不透明密文如何經由可選邊緣移動,見在邊緣進行盲目 replica 同步

若這種儲存模型符合你對隱私保管箱的期待,可以試試 NT² Vault,或到 nt2.me 了解更多。

最後更新 2026-08-26

相關故事