儲存層:信封加密
保管別人的機密 — 第 2 篇:儲存層
第 1 篇列了三個設計目標,這篇處理第一個:拿到資料庫,不等於拿到機密。先看主圖這次亮起來的部分。
儲存層的視角。亮起來的是第 4 到 7 步(加密後存進去)和第 11 到 14 步(拿出來解開)。其他步驟先淡掉,第 3、4 篇再處理。看完整主圖
這篇只回答一個問題:Org A 的 DATABASE_URL 躺在資料庫裡的時候,長什麼樣子,才能讓偷走資料庫的人看不懂?
先試試最直覺的修法
明文存在資料庫不行,那就加密吧。用 AES 把 DATABASE_URL 加密,存密文,問題解決?
還沒有。加密需要一把鑰匙,下一個問題馬上就來:這把鑰匙要放哪?
- 放在同一個資料庫:偷資料庫的人連鑰匙一起偷走,等於沒加密。
- 寫在程式碼或設定檔裡:看得到原始碼或設定儲存庫的人都拿得到。Zeabur 的管理權杖就是明文放在設定儲存庫,最後成了入口。
- 放在環境變數:容器被攻破時,鑰匙跟資料一起曝光。
Zeabur 的報告裡剛好有一個活生生的例子。共享叢集上的 n8n,資料庫裡的第三方憑證本來是加密的,但檢查的 607 個 n8n 中,有 602 個把加密金鑰寫在環境變數裡。攻擊者讀到環境變數,就順手拿到了金鑰,加密等於白做。
鑰匙跟鎖放在一起,就像把鑰匙插在鎖上。信封加密要解決的,就是「鑰匙放哪」這個問題。
用實體的信來想
先忘掉電腦,想像你要寄一封很重要的信,而且這封信要在一間誰都可能闖進來的倉庫裡放很久。
小偷闖進倉庫也沒關係:信鎖著,開鎖的小鑰匙封在信封裡,能拆信封的大鑰匙在銀行保險箱。分三步看:
用小鑰匙 DEK 把信鎖起來
信就是 DATABASE_URL。用一把小鑰匙把它鎖進盒子,鎖好的信叫「密文」。小偷拿到盒子也看不到內容。
小鑰匙裝進信封,用大鑰匙 KEK 封起來
DEK 跟盒子放一起,等於鑰匙插在鎖上。所以把 DEK 裝進小信封,再用另一把大鑰匙封住。封好的信封可以放心跟盒子綁在一起。
大鑰匙鎖在銀行保險箱(KMS)
KEK 放在保險箱,永遠不外借。要拆信封時,把信封交給櫃員,櫃員核對你的身分,替你拆開,只把 DEK 交回給你。
「信封」指的是什麼?
每筆機密實際存下來的是一個包裹:鎖好的信(密文),加上封好的小信封(加密過的 DEK),再貼一張標籤註明是哪把大鑰匙封的(kek_ref)。把 DEK 裝進信封封起來的動作叫 wrap,整套做法因此叫「信封加密」(envelope encryption)。
要讀信時就倒過來走:拿著小信封找櫃員(KMS)→ 櫃員確認身分後拆開,交回 DEK → 用 DEK 打開盒子讀信 → 讀完把 DEK 丟掉。
小偷就算把整個倉庫搬走,拿到的也只是鎖好的盒子和封好的信封,他過不了櫃員那一關。這就是第 1 篇說的「拿到資料庫,不等於拿到機密」。
換個說法:用 zip 加密來講
如果信件的比喻還不夠具體,換成每個人都用過的 zip 壓縮檔:
- 我把檔案用密碼 A 壓成 zip。密碼 A 是隨機產生的亂碼,沒有人背得起來。這就是 DEK。
- 我把密碼 A 寫在一張紙條上,再把紙條用密碼 B 壓成另一個 zip。密碼 B 就是 KEK。
- 兩個 zip 一起放在共用硬碟。被偷了也沒關係,兩個都打不開。
- 密碼 B 只有一位保管員(KMS)知道,他從不說出來。要開檔案時,把「紙條 zip」交給他,他確認你的身分後幫你解開,只把密碼 A 交回給你。
- 用密碼 A 打開檔案,用完就把密碼 A 忘掉。
常見誤解:「他 zip 一次,我再 zip 一次,各自只知道自己的密碼」
這是把同一個檔案鎖兩層,要兩個人的密碼都在才打得開,屬於雙重加密,不是信封加密。信封加密的檔案只鎖一層(密碼 A),第二層鎖的是「密碼 A 本身」。而且沒有人需要記住密碼 B,重點不在誰知道密碼,而在保管員肯不肯替你解開。
為什麼要兩層?
讀到這裡,通常會冒出兩個疑問:
為什麼不只用一把 DEK?
DEK 總得存在某個地方。跟資料放一起,偷資料庫就連鑰匙一起偷走。放到別處,那個地方又要上鎖,又需要另一把鑰匙。這樣一路推下去,最後一定需要一把「永遠不離開保險箱」的鑰匙,那就是 KEK。
為什麼不直接用 KEK 鎖信?
KEK 不能離開 KMS,所以每次加解密都得把整份資料送進 KMS:又慢,又有大小限制(AWS KMS 一次最多 4 KB)。而且所有資料共用一把鑰匙,換鑰匙就得全部重新加密。分兩層後,大量資料在本機用 DEK 處理,送進 KMS 的只有幾十 byte 的 DEK。
比喻跟實際元件的對照:
| 比喻 | 實際上是 |
|---|---|
| 信 | 機密的明文,例如 DATABASE_URL |
| 鎖好的盒子 | 密文(用 DEK 加密的資料) |
| 小鑰匙 | DEK,資料加密金鑰 |
| 封好的小信封 | 加密過的 DEK(wrapped DEK) |
| 大鑰匙 | KEK,金鑰加密金鑰 |
| 銀行保險箱 | KMS 或 HSM,例如 AWS KMS、GCP Cloud KMS、HashiCorp Vault |
| 櫃員核對身分 | KMS 的存取規則,加上每次解密的稽核紀錄 |
一筆機密在資料庫裡長什麼樣子
回到真實系統。加密之後,Org A 的 DATABASE_URL 在平台 DB 裡是這樣:
資料庫裡只有兩樣東西:用 DEK 加密的密文,以及用 KEK 加密過的 DEK。兩樣都是鎖著的。DEK 的明文只在加解密那一瞬間出現在記憶體裡,用完就丟。
| DEK 資料加密金鑰 | KEK 金鑰加密金鑰 | |
|---|---|---|
| 加密對象 | 實際資料(環境變數) | DEK |
| 數量 | 很多,每筆機密一把 | 很少,例如每個 org 一把 |
| 存放 | 被 KEK 加密後,跟密文放在一起 | 只在 KMS / HSM 裡 |
| 誰在用 | 應用程式,在記憶體裡短暫使用 | 只有 KMS 自己 |
每個 org 一把 KEK 是刻意的。Org A 和 Org B 的資料用不同的 KEK 封,KMS 的規則就能寫成「只有 Org A 的身分能用 Org A 的 KEK」。這個好處第 3 篇會用到。
實際跑一次
下面是真的 AES-256-GCM 加密,不是示意文字。改成你自己的字串,按「加密一次」,頁面會用瀏覽器內建的 Web Crypto 重新計算,每一步的中間值都攤開來給你看。
每按一次「加密一次」,DEK 和 nonce 都會重新隨機產生,所以同樣的明文每次的密文都不一樣。
- 明文postgres://app:s3cret@db:5432/main34 bytes。整個系統裡唯一不能被看到的東西。
- 產生 DEKdcc64f265a7153d7dea590b435e711507b30bcd9d9933ab386f886661be3e494隨機產生 32 bytes(256 bits)。只在記憶體裡出現,等一下就丟掉。
- 產生 nonceONXpN5B0VNxpBLiM隨機 12 bytes,讓同一把 DEK 加密兩次也不會得到相同結果。不是祕密,可以跟密文放一起。
- 用 DEK 加密R+AizRibYSE1mA8w/GMPLjye6JhTiLqRI3jkiAZKiQZvdmDmKpgGpODhI7+XHjH2T7Y=AES-256-GCM(DEK, nonce, 明文)。50 bytes:34 bytes 資料,加上 16 bytes 防竄改的驗證標籤。
- KMS 用 KEK 封 DEKkZ8Un4qF1JfaLC5I7etGzSueeef51dmUiCkvfSSqtxJmpOxtrDnRzwkVHBFt4pxOgaf0osvNe/MFLwcR把 DEK 送進 KMS,回傳加密過的 DEK。KEK 本身頁面看不到(示範頁用一把寫在程式裡的 KEK 代替 KMS)。
- 丟掉 DEK 明文dcc64f265a7153d7dea590b435e711507b30bcd9d9933ab386f886661be3e494可以丟,是因為上一步已經拿到封好的 DEK,需要時隨時能請 KMS 解回來。要丟,是因為留著的明文 DEK 能繞過 KMS 的身分檢查和稽核。
- 寫進資料庫
{ "name": "DATABASE_URL", "ciphertext": "R+AizRibYSE1mA8w/GMPLjye6JhTiLqRI3jkiAZKiQZvdmDmKpgGpODhI7+XHjH2T7Y=", "nonce": "ONXpN5B0VNxpBLiM", "wrapped_dek": "kZ8Un4qF1JfaLC5I7etGzSueeef51dmUiCkvfSSqtxJmpOxtrDnRzwkVHBFt4pxOgaf0osvNe/MFLwcR", "kek_ref": "org-a/kek-v3", "alg": "AES-256-GCM" }這就是資料庫裡的一筆:密文、nonce、加密過的 DEK、KEK 的名字。沒有 DEK,也沒有 KEK。
- 讀出那一筆wrapped_dek、ciphertext、nonce、kek_ref從資料庫拿到的全是鎖著的東西。
- 請 KMS 拆信封KMS 依 kek_ref 找到 KEK,確認身分後解開,交回 DEK。
- 用 DEK 解密明文回來了。用完再把 DEK 丟掉。
有兩個細節值得多看一眼。
第一,「丟掉 DEK」有順序。封好的 DEK 一定要先確定寫進資料庫,才能丟掉明文 DEK。如果反過來,寫入失敗時這筆資料就永遠解不開了。
第二,示範裡是「自己產生 DEK,再請 KMS 封」,而主圖第 4 步寫的是 GenerateDataKey,由 KMS 直接給你一把 DEK 和它封好的副本。兩種都是標準做法:AWS KMS 提供 GenerateDataKey,Google Cloud KMS 的文件則建議在本機產生 DEK 再交給 KMS 封。差別只在 DEK 由誰產生,不變的規則是:明文 DEK 從不落地,拿到封好的副本後就丟掉。
對照主圖走一遍
現在回頭看主圖亮起來的那幾步,每一步都對得上了:
- Secret service 向 KMS 要一把新的 DEK(GenerateDataKey)。
- KMS 回傳兩樣東西:DEK 的明文,以及用 Org A 的 KEK 封好的 DEK。
- Secret service 在記憶體裡用 DEK 加密
DATABASE_URL,然後丟掉 DEK 和明文。 - 把「密文 + 封好的 DEK + kek_ref」寫進平台 DB,就是上面示範的那筆 JSON。
- 容器要啟動時,注入元件從平台 DB 讀出同一筆紀錄。
- 注入元件把封好的 DEK 送給 KMS,請它拆信封。
- KMS 確認身分後,用 KEK 解開,交回 DEK,同時寫一筆稽核紀錄。
- 注入元件用 DEK 解開密文,用完丟掉 DEK。
同樣的流程換成 AWS KMS 的指令,大概是這樣:
# 第 4–5 步:向 KMS 要一把 DEK,同時拿回明文與封好的副本
aws kms generate-data-key --key-id alias/org-a --key-spec AES_256 \
--encryption-context org=org-a,name=DATABASE_URL
# 回傳的 JSON 裡:Plaintext 是 DEK 明文(base64),CiphertextBlob 是封好的 DEK
# 第 6–7 步:用 Plaintext 在本機加密,存下 CiphertextBlob,然後丟掉 Plaintext
# 第 12–13 步:把封好的 DEK 交回 KMS 解開,encryption context 必須跟加密時一致
aws kms decrypt --ciphertext-blob fileb://wrapped-dek.bin \
--encryption-context org=org-a,name=DATABASE_URL \
--query Plaintext --output text
實際系統不會用 CLI 做這件事,而是用 SDK 或下面會提到的函式庫,但呼叫的 API 和參數是一樣的。
第 12、13 步的「確認身分」是整套設計的關鍵:誰有資格請 KMS 拆信封?這是下一篇的主題。
換鑰匙時,資料不用動
鑰匙用久了要換,這叫輪換(rotation)。信封加密在這裡又佔了一個便宜:換 KEK 時,只要把每把 DEK 解開、用新的 KEK 重新封一次,密文本身完全不用動。
輪換前
輪換後
每把 DEK 只有幾十 byte,重新封一次很快。如果是直接用 KEK 加密所有資料,換一次鑰匙就要把整個資料庫重新加密一輪。實務上 KMS 也會保留舊版 KEK 一段時間,讓還沒重新封的 DEK 照樣解得開,所以輪換可以慢慢做,不必停機。
同一個結構還帶來一個很實用的能力:要刪掉某個 org 的所有機密時,只要銷毀它的 KEK,所有被這把 KEK 封住的 DEK 就再也解不開,連散落在各處的舊備份也一起失效。這叫 crypto-shredding,用銷毀鑰匙代替逐筆刪除資料。對一個要處理退租、刪除帳號、甚至像 Zeabur 已宣布將停止營運的服務來說,這比去每份備份裡找資料刪除可靠得多。
實作時別自己拼
上面的示範是為了把每一步攤開來看。真的要做,不建議自己拿 AES-GCM 一行一行拼,有幾個坑很容易踩:
- nonce 絕對不能重複。同一把 DEK 搭配重複的 nonce,AES-GCM 的安全性會完全瓦解。每筆機密一把新 DEK 可以大幅降低這個風險。
- 把密文綁在它的位置上。加密時附上額外的驗證資料(associated data),例如
org-a/DATABASE_URL。這樣有人把 Org A 的密文複製到 Org B 的欄位,解密時會直接失敗,而不是讓 Org B 的容器拿到 Org A 的機密。AWS 把這個叫 encryption context。 - 用現成的函式庫。Google 的 Tink 有 KMS envelope AEAD,AWS 有 Encryption SDK,都已經把產生 DEK、封裝、格式、associated data 處理好。
這一層擋住了什麼
用第 1 篇的四個攻擊位置檢查:
| 位置 | 有了儲存層之後 |
|---|---|
| A 平台 DB | 擋住。只拿到密文和封好的 DEK |
| B 備份 | 擋住,前提是備份的是加密後的資料庫。第 4 篇會講備份另外要注意的事 |
| C 內部帳號 | 還沒擋住。如果這個帳號也能呼叫 KMS 拆信封,加密就形同虛設 |
| D 控制面 | 擋不住。容器裡本來就有明文 |
C 這一格是下一篇的起點。信封加密把問題從「誰能讀資料庫」換成了「誰能請 KMS 拆信封」。這是很大的進步,因為後者可以被嚴格管理、而且每次都留下紀錄。但前提是真的有人去管。
儲存層檢查清單
- [ ] 資料庫、備份、log 裡沒有任何機密的明文
- [ ] 每筆機密用自己的 DEK 加密,演算法是 AES-256-GCM 這類有驗證的加密
- [ ] DEK 只以「被 KEK 封住」的形式存放,明文用完就丟
- [ ] KEK 只存在 KMS 或 HSM,每個 org 各自一把
- [ ] 密文綁定 associated data(org 與機密名稱)
- [ ] KEK 可以輪換,輪換時只重新封 DEK
- [ ] 刪除 org 時可以銷毀它的 KEK
下一篇
信封加密把鑰匙收進了保險箱,但保險箱的櫃員要替誰開門?下一篇看主圖的另一個視角:從 Org A 按下儲存,到容器拿到明文,每一步是誰在經手、誰有權限解開。