存取層:誰能打開信封
保管別人的機密 — 第 3 篇:存取層
上一篇用信封加密,讓偷走資料庫的人只拿到看不懂的密文。但結尾留了一個問題:KMS 這個保險箱的櫃員,要替誰拆信封?如果平台上隨便一個服務、一個內部帳號都能叫 KMS 解密,加密就只是多繞一圈。
這篇換到主圖的另一個視角,看「人和服務」這條線。
存取層的視角。亮起來的是寫入路徑(第 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 只顯示名稱和摘要。
下面分寫入和讀出兩段來看。
寫入:從按下儲存到存進資料庫
圖上的數字對應主圖的步驟編號。第 3、6 步發生在 Secret service 內部,所以沒有箭頭。
照步驟編號走一次:
- Org A 在網頁或 CLI 填入
DATABASE_URL。送出之前,瀏覽器先用平台的公鑰把它加密成 sealed box。 - API 收到的已經是密文,原封不動轉交給 Secret service。
- Secret service 用私鑰拆開 sealed box,明文這時短暫出現在它的記憶體裡。
- Secret service 向 KMS 要一把 DEK(第 2 篇講過的 GenerateDataKey)。
- KMS 回傳 DEK 和封好的 DEK。
- 用 DEK 加密,丟掉 DEK 和明文。
- 把密文和封好的 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 被入侵,攻擊者能看到什麼?
- 已經存進資料庫的機密:看不到。Secret service 只有向 KMS 要 DEK 的權限,沒有 Decrypt 權限,拆不開舊的信封。圖上那個綠色標籤就是在說這件事。
- 攻擊者待在裡面的期間,新送進來的機密:看得到,因為它們會在第 3 步被解開。
所以這把私鑰要當成機密來管:定期輪換、不寫在設定檔,條件允許的話放進 KMS 或 HSM,讓解開 sealed box 這個動作也在保險箱裡完成。寫入端和讀出端拆成兩個服務,就是為了讓「寫入端被攻破」的損害只限於這段期間新送進來的資料。
存了就讀不回來
寫入之後,平台不提供任何「顯示明文」的功能。後台只顯示名稱、最後更新時間、值的摘要(例如前幾碼的雜湊),想改就整個覆蓋。
這看起來對使用者不太方便,但它帶來一個很強的保證:平台上根本沒有一條 API 能把明文送出來。管理後台被入侵、某個工程師帳號外洩、API 有權限檢查的漏洞,這些情況都拿不到明文,因為那條路本來就不存在。GitHub 和 Fly.io 都是這樣做的。
執行時:容器怎麼拿到明文
數字一樣對應主圖。第 14 步(用 DEK 解密)發生在注入元件內部。
- 控制面決定在某台主機上啟動 Org A 的容器,同時發給那台主機的注入元件一張臨時身分證:「接下來一段時間,你可以代表 Org A 的這個服務」。
- 注入元件到平台 DB 讀 Org A 的機密。
- 拿到密文和封好的 DEK。
- 把封好的 DEK 交給 KMS,附上那張臨時身分證。
- KMS 檢查:這張身分證是 Org A 的嗎?還在有效期限內嗎?這把 KEK 允許它用嗎?全部通過才解開 DEK 交回,同時寫一筆稽核紀錄。
- 注入元件解密後,把明文交給容器,放進環境變數或掛載成檔案。不寫 log、不進備份。
怎麼證明「我是 Org A」?
第 13 步是整個存取層的核心。KMS 怎麼知道來要 DEK 的是 Org A 的服務,而不是某個冒充的程式?
最糟的做法是給注入元件一組長期有效的 KMS 帳號密碼。這組密碼一旦外洩,任何人都能冒充它,而且一直有效。Zeabur 的管理權杖和停用四個月仍有效的部署金鑰,都是長期憑證出事的例子。
比較好的做法叫 workload identity:身分不是一組寫死的密碼,而是平台在排程時發給執行中程式的臨時憑證。
- 憑證跟「哪個 org 的哪個服務、跑在哪台主機」綁在一起,換一個服務就是另一張。
- 有效期限很短,通常幾分鐘到一小時,過期就自動作廢。
- 容器被刪除,憑證就跟著失效。Fly.io 的文件寫到,機器銷毀後,主機就失去讀取該 app 機密的權限。
雲端平台都有現成的機制: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 這類服務。
這些紀錄有兩個用途:
- 平常拿來偵測異常。Org A 的服務每天開機幾次,Decrypt 就該是那個數量;突然一小時解了幾千次,就有問題。第 5 篇會講怎麼設告警。
- 出事時拿來劃定範圍。Zeabur 的報告裡有很多「無法確認」:沒有物件存取紀錄、雲端紀錄不含讀取操作、資料庫稽核只記指令不記輸出。結果就是很多東西只能「視為外洩」。如果每次解密都有紀錄,至少能回答「哪些 org 的機密被解開過」。
這一層擋住了什麼
| 位置 | 有了存取層之後 |
|---|---|
| A 平台 DB | 擋住(第 2 篇) |
| B 備份 | 擋住(第 2 篇),細節見第 4 篇 |
| C 內部帳號 | 擋住。讀得到密文,但沒有 Decrypt 權限,也沒有讀回明文的 API |
| D 控制面 | 還是擋不住 |
D 為什麼還是擋不住?回頭看主圖第 9 步:控制面負責發臨時身分給注入元件。能發身分的人,就能替自己的程式發一張 Org A 的身分證。更直接的是,控制面能在任何容器裡執行指令,而容器裡本來就有明文。加密和權限保護的是機密從資料庫送到容器的過程,控制面卻可以直接進到容器裡,不必經過這段過程。這是下一篇的主題。
存取層檢查清單
- [ ] 寫入和讀出拆成不同的服務、不同的權限
- [ ] 使用者端先用公鑰加密再送出,API 與 log 只經手密文
- [ ] sealed box 的私鑰當成機密管理,定期輪換
- [ ] 平台沒有任何讀回明文的 API 或後台功能
- [ ] KMS Decrypt 只開給注入元件,而且只限它當下負責的 org
- [ ] 注入元件用 workload identity,不持有長期憑證
- [ ] 每次 Decrypt 都有稽核紀錄
- [ ] 實際的 IAM 設定跟權限表一致,定期檢查
下一篇
鎖上好了,開鎖的權限也收緊了,但主圖上還有兩條 lane 沒處理:控制面和備份。它們不走「請 KMS 拆信封」這條路,而是繞過去。下一篇處理這些加密擋不住的路,也是 Zeabur 這次真正的入口。