儲存層:信封加密

2026-10-22 · 4,252 字 · 10 分鐘

保管別人的機密 — 第 2 篇:儲存層

第 1 篇列了三個設計目標,這篇處理第一個:拿到資料庫,不等於拿到機密。先看主圖這次亮起來的部分。

主圖的儲存層視角:亮起來的是 Secret service、KMS、平台 DB、注入元件之間的第 4 到 7 步與第 11 到 14 步

儲存層的視角。亮起來的是第 4 到 7 步(加密後存進去)和第 11 到 14 步(拿出來解開)。其他步驟先淡掉,第 3、4 篇再處理。看完整主圖

這篇只回答一個問題:Org A 的 DATABASE_URL 躺在資料庫裡的時候,長什麼樣子,才能讓偷走資料庫的人看不懂?

先試試最直覺的修法

明文存在資料庫不行,那就加密吧。用 AES 把 DATABASE_URL 加密,存密文,問題解決?

還沒有。加密需要一把鑰匙,下一個問題馬上就來:這把鑰匙要放哪?

Zeabur 的報告裡剛好有一個活生生的例子。共享叢集上的 n8n,資料庫裡的第三方憑證本來是加密的,但檢查的 607 個 n8n 中,有 602 個把加密金鑰寫在環境變數裡。攻擊者讀到環境變數,就順手拿到了金鑰,加密等於白做。

鑰匙跟鎖放在一起,就像把鑰匙插在鎖上。信封加密要解決的,就是「鑰匙放哪」這個問題。

用實體的信來想

先忘掉電腦,想像你要寄一封很重要的信,而且這封信要在一間誰都可能闖進來的倉庫裡放很久。

資料庫裡存的包裹 鎖好的信 用 DEK 鎖的資料 + 封好的信封 裡面裝 DEK,用 KEK 封口 拆信封要找它 KMS 保險箱 KEK 永遠不離開

小偷闖進倉庫也沒關係:信鎖著,開鎖的小鑰匙封在信封裡,能拆信封的大鑰匙在銀行保險箱。分三步看:

STEP 1

用小鑰匙 DEK 把信鎖起來

信就是 DATABASE_URL。用一把小鑰匙把它鎖進盒子,鎖好的信叫「密文」。小偷拿到盒子也看不到內容。

STEP 2

小鑰匙裝進信封,用大鑰匙 KEK 封起來

DEK 跟盒子放一起,等於鑰匙插在鎖上。所以把 DEK 裝進小信封,再用另一把大鑰匙封住。封好的信封可以放心跟盒子綁在一起。

STEP 3

大鑰匙鎖在銀行保險箱(KMS)

KEK 放在保險箱,永遠不外借。要拆信封時,把信封交給櫃員,櫃員核對你的身分,替你拆開,只把 DEK 交回給你。

「信封」指的是什麼?

每筆機密實際存下來的是一個包裹:鎖好的信(密文),加上封好的小信封(加密過的 DEK),再貼一張標籤註明是哪把大鑰匙封的(kek_ref)。把 DEK 裝進信封封起來的動作叫 wrap,整套做法因此叫「信封加密」(envelope encryption)。

要讀信時就倒過來走:拿著小信封找櫃員(KMS)→ 櫃員確認身分後拆開,交回 DEK → 用 DEK 打開盒子讀信 → 讀完把 DEK 丟掉。

小偷就算把整個倉庫搬走,拿到的也只是鎖好的盒子和封好的信封,他過不了櫃員那一關。這就是第 1 篇說的「拿到資料庫,不等於拿到機密」。

換個說法:用 zip 加密來講

如果信件的比喻還不夠具體,換成每個人都用過的 zip 壓縮檔:

  1. 我把檔案用密碼 A 壓成 zip。密碼 A 是隨機產生的亂碼,沒有人背得起來。這就是 DEK。
  2. 我把密碼 A 寫在一張紙條上,再把紙條用密碼 B 壓成另一個 zip。密碼 B 就是 KEK。
  3. 兩個 zip 一起放在共用硬碟。被偷了也沒關係,兩個都打不開。
  4. 密碼 B 只有一位保管員(KMS)知道,他從不說出來。要開檔案時,把「紙條 zip」交給他,他確認你的身分後幫你解開,只把密碼 A 交回給你。
  5. 用密碼 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 裡是這樣:

平台資料庫的一筆紀錄 密文 DATABASE_URL = 9f2a…c41e(用 DEK 加密) 加密過的 DEK key_id = org-a/kek-v3 KMS / HSM KEK(Org A) 只拿來加密 DEK 金鑰本身不會離開這裡 KEK 才解得開

資料庫裡只有兩樣東西:用 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 都會重新隨機產生,所以同樣的明文每次的密文都不一樣。

  1. 明文
    postgres://app:s3cret@db:5432/main
    34 bytes。整個系統裡唯一不能被看到的東西。
  2. 產生 DEK
    dcc64f265a7153d7dea590b435e711507b30bcd9d9933ab386f886661be3e494
    隨機產生 32 bytes(256 bits)。只在記憶體裡出現,等一下就丟掉。
  3. 產生 nonce
    ONXpN5B0VNxpBLiM
    隨機 12 bytes,讓同一把 DEK 加密兩次也不會得到相同結果。不是祕密,可以跟密文放一起。
  4. 用 DEK 加密
    R+AizRibYSE1mA8w/GMPLjye6JhTiLqRI3jkiAZKiQZvdmDmKpgGpODhI7+XHjH2T7Y=
    AES-256-GCM(DEK, nonce, 明文)。50 bytes:34 bytes 資料,加上 16 bytes 防竄改的驗證標籤。
  5. KMS 用 KEK 封 DEK
    kZ8Un4qF1JfaLC5I7etGzSueeef51dmUiCkvfSSqtxJmpOxtrDnRzwkVHBFt4pxOgaf0osvNe/MFLwcR
    把 DEK 送進 KMS,回傳加密過的 DEK。KEK 本身頁面看不到(示範頁用一把寫在程式裡的 KEK 代替 KMS)。
  6. 丟掉 DEK 明文
    dcc64f265a7153d7dea590b435e711507b30bcd9d9933ab386f886661be3e494
    可以丟,是因為上一步已經拿到封好的 DEK,需要時隨時能請 KMS 解回來。要丟,是因為留著的明文 DEK 能繞過 KMS 的身分檢查和稽核。
  7. 寫進資料庫
    {
      "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。

有兩個細節值得多看一眼。

第一,「丟掉 DEK」有順序。封好的 DEK 一定要先確定寫進資料庫,才能丟掉明文 DEK。如果反過來,寫入失敗時這筆資料就永遠解不開了。

第二,示範裡是「自己產生 DEK,再請 KMS 封」,而主圖第 4 步寫的是 GenerateDataKey,由 KMS 直接給你一把 DEK 和它封好的副本。兩種都是標準做法:AWS KMS 提供 GenerateDataKey,Google Cloud KMS 的文件則建議在本機產生 DEK 再交給 KMS 封。差別只在 DEK 由誰產生,不變的規則是:明文 DEK 從不落地,拿到封好的副本後就丟掉。

對照主圖走一遍

現在回頭看主圖亮起來的那幾步,每一步都對得上了:

  1. Secret service 向 KMS 要一把新的 DEK(GenerateDataKey)。
  2. KMS 回傳兩樣東西:DEK 的明文,以及用 Org A 的 KEK 封好的 DEK。
  3. Secret service 在記憶體裡用 DEK 加密 DATABASE_URL,然後丟掉 DEK 和明文。
  4. 把「密文 + 封好的 DEK + kek_ref」寫進平台 DB,就是上面示範的那筆 JSON。
  5. 容器要啟動時,注入元件從平台 DB 讀出同一筆紀錄。
  6. 注入元件把封好的 DEK 送給 KMS,請它拆信封。
  7. KMS 確認身分後,用 KEK 解開,交回 DEK,同時寫一筆稽核紀錄。
  8. 注入元件用 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 重新封一次,密文本身完全不用動。

輪換前

密文 A(DEK-1 加密)
DEK-1,被 KEK v3 封著
密文 B(DEK-2 加密)
DEK-2,被 KEK v3 封著

輪換後

密文 A(不變)
DEK-1,改由 KEK v4 封著
密文 B(不變)
DEK-2,改由 KEK v4 封著

每把 DEK 只有幾十 byte,重新封一次很快。如果是直接用 KEK 加密所有資料,換一次鑰匙就要把整個資料庫重新加密一輪。實務上 KMS 也會保留舊版 KEK 一段時間,讓還沒重新封的 DEK 照樣解得開,所以輪換可以慢慢做,不必停機。

同一個結構還帶來一個很實用的能力:要刪掉某個 org 的所有機密時,只要銷毀它的 KEK,所有被這把 KEK 封住的 DEK 就再也解不開,連散落在各處的舊備份也一起失效。這叫 crypto-shredding,用銷毀鑰匙代替逐筆刪除資料。對一個要處理退租、刪除帳號、甚至像 Zeabur 已宣布將停止營運的服務來說,這比去每份備份裡找資料刪除可靠得多。

實作時別自己拼

上面的示範是為了把每一步攤開來看。真的要做,不建議自己拿 AES-GCM 一行一行拼,有幾個坑很容易踩:

這一層擋住了什麼

用第 1 篇的四個攻擊位置檢查:

位置 有了儲存層之後
A 平台 DB 擋住。只拿到密文和封好的 DEK
B 備份 擋住,前提是備份的是加密後的資料庫。第 4 篇會講備份另外要注意的事
C 內部帳號 還沒擋住。如果這個帳號也能呼叫 KMS 拆信封,加密就形同虛設
D 控制面 擋不住。容器裡本來就有明文

C 這一格是下一篇的起點。信封加密把問題從「誰能讀資料庫」換成了「誰能請 KMS 拆信封」。這是很大的進步,因為後者可以被嚴格管理、而且每次都留下紀錄。但前提是真的有人去管。

儲存層檢查清單

下一篇

信封加密把鑰匙收進了保險箱,但保險箱的櫃員要替誰開門?下一篇看主圖的另一個視角:從 Org A 按下儲存,到容器拿到明文,每一步是誰在經手、誰有權限解開。

下一篇:存取層,誰能打開信封 →