存取層:誰能打開信封

2026-10-22 · 3,475 字 · 8 分鐘

保管別人的機密 — 第 3 篇:存取層

上一篇用信封加密,讓偷走資料庫的人只拿到看不懂的密文。但結尾留了一個問題:KMS 這個保險箱的櫃員,要替誰拆信封?如果平台上隨便一個服務、一個內部帳號都能叫 KMS 解密,加密就只是多繞一圈。

這篇換到主圖的另一個視角,看「人和服務」這條線。

主圖的存取層視角:亮起來的是第 1 到 3 步的寫入路徑,以及第 9、10、12、13、15 步的身分與解密路徑

存取層的視角。亮起來的是寫入路徑(第 1 到 3 步)和執行時的身分路徑(第 9、10、12、13、15 步)。看完整主圖

先列出誰會碰到這筆機密

把主圖上的九個參與者一個一個點名,問兩個問題:它在流程中會經手 DATABASE_URL 的哪種形式?它真的需要看到明文嗎?

參與者 經手的形式 需要看到明文嗎
使用者 明文,他自己填的 需要,但存完之後就不用再看
API、內部帳號、log 只會經過 不需要
Secret service 寫入時短暫看到 只在加密那一瞬間
KMS 只看到 DEK,不看資料 不需要
平台 DB、備份 密文 不需要
注入元件 執行時短暫看到 只在解密那一瞬間
容器 明文 需要,這是整件事的目的
控制面 不經手 不需要

列完會發現,真正需要明文的只有兩端:填寫的使用者和使用的容器。中間的參與者裡,只有 Secret service 和注入元件會短暫看到,其他全部都不需要。

拿 Zeabur 對照:報告寫到內部唯讀資料庫帳號能讀取所有資料,而資料是明文,所以一個「唯讀」帳號實際上能讀全部機密。上面的表格裡,內部帳號那一列寫的是「不需要」。設計上要做的,就是讓實際權限跟這張表一致。

原則:放進去和拿出來,是兩種權限

寫入和讀出要拆成兩種權限,能送機密進來,不代表能把明文拿出去。從平台 API 的角度看,應該長這樣:

PUT  /v1/orgs/org-a/secrets/DATABASE_URL   → 201,值被加密後存起來
GET  /v1/orgs/org-a/secrets                → 只回名稱、摘要、最後更新時間
GET  /v1/orgs/org-a/secrets/DATABASE_URL   → 404,沒有這個 API

背後跑 API 的服務,在 KMS 那邊也只拿得到加密需要的權限。在 API 伺服器上用它自己的身分試著解密,應該直接被拒絕:

aws kms decrypt --ciphertext-blob fileb://wrapped-dek.bin \
  --encryption-context org=org-a
# 預期結果:AccessDeniedException

Fly.io 公開的 secrets 架構就是這個樣子。他們的文件直接寫明:API 伺服器只能加密,不能解密。機密的值只有在機器啟動時,由主機上的 agent 解開、注入機器。使用者自己也讀不回來,fly secrets list 只顯示名稱和摘要。

下面分寫入和讀出兩段來看。

寫入:從按下儲存到存進資料庫

使用者 CLI / 網頁 API / log 只看到密文 Secret service 解開 sealed box 拿 DEK、本機加密 沒有 Decrypt 權限 KMS 發 DEK + 封好的 DEK 平台 DB 密文 + 封好的 DEK 1 2 4–5 7

圖上的數字對應主圖的步驟編號。第 3、6 步發生在 Secret service 內部,所以沒有箭頭。

照步驟編號走一次:

  1. Org A 在網頁或 CLI 填入 DATABASE_URL。送出之前,瀏覽器先用平台的公鑰把它加密成 sealed box。
  2. API 收到的已經是密文,原封不動轉交給 Secret service。
  3. Secret service 用私鑰拆開 sealed box,明文這時短暫出現在它的記憶體裡。
  4. Secret service 向 KMS 要一把 DEK(第 2 篇講過的 GenerateDataKey)。
  5. KMS 回傳 DEK 和封好的 DEK。
  6. 用 DEK 加密,丟掉 DEK 和明文。
  7. 把密文和封好的 DEK 存進平台 DB。

為什麼送出前要先加密一次?

既然到了 Secret service 就會被解開,第 1 步先加密一次好像多此一舉?其實不然,因為從瀏覽器到 Secret service 之間,經手的東西比想像中多:負載平衡器、API gateway、request log、錯誤追蹤服務、效能監控工具。任何一個環節把 request body 記下來,明文就躺進了某個 log 檔,而 log 通常保存很久、看得到的人很多。

先用 sealed box 加密,這一整段路上流過的都是密文,誰記下來都沒關係。GitHub Actions 的 secrets 就是這樣做的:每個 repository 有一把公鑰,瀏覽器或 CLI 用 libsodium 的 sealed box 加密後才送出,上傳之後由 GitHub 解開並保存。

Secret service 的私鑰是新的弱點

第 3 步需要一把私鑰,這把私鑰長期放在 Secret service 上。如果 Secret service 被入侵,攻擊者能看到什麼?

所以這把私鑰要當成機密來管:定期輪換、不寫在設定檔,條件允許的話放進 KMS 或 HSM,讓解開 sealed box 這個動作也在保險箱裡完成。寫入端和讀出端拆成兩個服務,就是為了讓「寫入端被攻破」的損害只限於這段期間新送進來的資料。

存了就讀不回來

寫入之後,平台不提供任何「顯示明文」的功能。後台只顯示名稱、最後更新時間、值的摘要(例如前幾碼的雜湊),想改就整個覆蓋。

這看起來對使用者不太方便,但它帶來一個很強的保證:平台上根本沒有一條 API 能把明文送出來。管理後台被入侵、某個工程師帳號外洩、API 有權限檢查的漏洞,這些情況都拿不到明文,因為那條路本來就不存在。GitHub 和 Fly.io 都是這樣做的。

執行時:容器怎麼拿到明文

控制面 排程容器、發臨時身分 Org A 的容器 使用者的服務 明文只在這裡 注入元件 主機上的 agent 唯一有 Decrypt 權限 KMS 驗身分 → KEK 解 DEK 稽核 每次都記 平台 DB 密文 + 封好的 DEK 9 10–11 12–13 15

數字一樣對應主圖。第 14 步(用 DEK 解密)發生在注入元件內部。

  1. 控制面決定在某台主機上啟動 Org A 的容器,同時發給那台主機的注入元件一張臨時身分證:「接下來一段時間,你可以代表 Org A 的這個服務」。
  2. 注入元件到平台 DB 讀 Org A 的機密。
  3. 拿到密文和封好的 DEK。
  4. 把封好的 DEK 交給 KMS,附上那張臨時身分證。
  5. KMS 檢查:這張身分證是 Org A 的嗎?還在有效期限內嗎?這把 KEK 允許它用嗎?全部通過才解開 DEK 交回,同時寫一筆稽核紀錄。
  6. 注入元件解密後,把明文交給容器,放進環境變數或掛載成檔案。不寫 log、不進備份。

怎麼證明「我是 Org A」?

第 13 步是整個存取層的核心。KMS 怎麼知道來要 DEK 的是 Org A 的服務,而不是某個冒充的程式?

最糟的做法是給注入元件一組長期有效的 KMS 帳號密碼。這組密碼一旦外洩,任何人都能冒充它,而且一直有效。Zeabur 的管理權杖和停用四個月仍有效的部署金鑰,都是長期憑證出事的例子。

比較好的做法叫 workload identity:身分不是一組寫死的密碼,而是平台在排程時發給執行中程式的臨時憑證。

雲端平台都有現成的機制:GCP 的 Workload Identity、AWS 的 IAM Roles for Service Accounts,都是讓程式不用持有長期金鑰,就能向 KMS 證明身分。

KMS 的規則長什麼樣子

有了身分,KMS 的存取規則就能寫得很具體。以下是示意,不是任何一家 KMS 的真實語法:

規則:允許 Decrypt
  金鑰:org-a/kek-*
  呼叫者:Org A 的 workload 身分
  附加條件:encryption context 的 org 必須等於 org-a

規則:允許 GenerateDataKey
  金鑰:所有 org 的 KEK
  呼叫者:Secret service

其他人:一律拒絕

第一條規則裡的「附加條件」,就是第 2 篇提到的 associated data。即使有人把 Org B 的封好的 DEK 拿來,用 Org A 的身分申請解密,也會因為 context 對不上而被拒絕。

權限表

把前面講的整理成一張表。設計時要做的,是讓實際的 IAM 設定跟這張表一模一樣:

參與者 送入新機密 讀 DB 的密文 KMS GenerateDataKey KMS Decrypt 看到明文
使用者 可以 不行 不行 不行 只有自己填的那一刻
API、內部帳號 轉交 視職務 不行 不行 不行
Secret service 可以 不需要 可以 不行 寫入那一瞬間
注入元件 不行 只讀自己負責的 org 不行 只限該 org 解密那一瞬間
容器 不行 不行 不行 不行 自己的機密
控制面 不行 不行 不行 不行 不行(但見第 4 篇)

整張表只有一格是「KMS Decrypt:只限該 org」。這就是第 1 篇第二個目標「能解開機密的身分越少越好」的具體樣子。

每次解開都留下紀錄

第 13 步還有一件事:每次 Decrypt,KMS 都會寫一筆稽核紀錄,記下誰、什麼時候、用哪把 KEK、解了哪筆。雲端的 KMS 預設就會把這些寫進 CloudTrail 或 Cloud Audit Logs 這類服務。

這些紀錄有兩個用途:

這一層擋住了什麼

位置 有了存取層之後
A 平台 DB 擋住(第 2 篇)
B 備份 擋住(第 2 篇),細節見第 4 篇
C 內部帳號 擋住。讀得到密文,但沒有 Decrypt 權限,也沒有讀回明文的 API
D 控制面 還是擋不住

D 為什麼還是擋不住?回頭看主圖第 9 步:控制面負責發臨時身分給注入元件。能發身分的人,就能替自己的程式發一張 Org A 的身分證。更直接的是,控制面能在任何容器裡執行指令,而容器裡本來就有明文。加密和權限保護的是機密從資料庫送到容器的過程,控制面卻可以直接進到容器裡,不必經過這段過程。這是下一篇的主題。

存取層檢查清單

下一篇

鎖上好了,開鎖的權限也收緊了,但主圖上還有兩條 lane 沒處理:控制面和備份。它們不走「請 KMS 拆信封」這條路,而是繞過去。下一篇處理這些加密擋不住的路,也是 Zeabur 這次真正的入口。

下一篇:加密擋不住的路,控制面與生命週期 →