昨天說 FPE 是無狀態的,不需要 Vault。那還需要 Vault 的是哪些?
一、自由文字欄位的對應。 人名、地址、公司名——這些用查表替換,必須存對應關係。
二、跨文件一致性所需的索引。 就算用 FPE,你也可能需要知道「TOKEN_X 出現在哪些案件裡」來做 D17 的跨文件查核。
三、稽核所需的最小資訊。 哪份文件在什麼時候被去識別化、用了哪個金鑰版本、產生了哪些 Token。
其中第一項是真正敏感的——它是一張明文對應表。
所以第一個設計原則是:能不進 Vault 的就不要進。 每一個可以改用 FPE 的欄位,都應該改用 FPE。Vault 越小越好。
CREATE TABLE token_map (
token_id BYTEA PRIMARY KEY, -- HMAC(原值) 作為查詢鍵
token_value TEXT NOT NULL, -- 假名/替代值(非敏感)
encrypted_value BYTEA NOT NULL, -- 加密後的原值
key_version TEXT NOT NULL, -- 用哪個 DEK 版本加密
entity_type TEXT NOT NULL, -- PERSON / ADDRESS / ORG
scope_id TEXT NOT NULL, -- 案件/session 範圍(見 D18)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
CREATE INDEX idx_token_lookup ON token_map (token_value, scope_id);
CREATE INDEX idx_expiry ON token_map (expires_at) WHERE expires_at IS NOT NULL;
幾個設計重點:
token_id 用 HMAC 而不是原值。 這樣可以做「這個原值之前是否已經有 Token」的查詢,而不需要在索引裡存明文。
encrypted_value 而不是明文。 就算整張表被 dump 出去,沒有金鑰也還原不了。這是最後一道防線。
key_version 必須記錄。 金鑰輪替後,舊 Token 要用舊版金鑰解。沒記這個欄位,輪替就等於資料遺失。
scope_id 從一開始就要有。 這是 D18 Token 隔離的基礎。事後要加這個欄位極其痛苦,因為既有資料無法回溯判斷 scope。
expires_at 讓 Vault 會自己縮小。 案件結案後 Token 應該過期,過期後資料被清除。這是唯一能阻止 Vault 無限成長的機制。
第一道:儲存加密。 資料庫層級的加密(Cloud SQL CMEK / TDE),加上欄位層級的加密(encrypted_value)。兩層都要,因為它們防的是不同的攻擊——前者防磁碟被拿走,後者防資料庫被查詢。
第二道:網路隔離。 Vault 不應該有公開端點。放在私有子網、用 Private Service Connect、加上 VPC Service Controls 邊界。
gcloud access-context-manager perimeters create pii_vault_perimeter \
--title="PII Vault Perimeter" \
--resources=projects/VAULT_PROJECT_NUMBER \
--restricted-services=sqladmin.googleapis.com,cloudkms.googleapis.com \
--policy=POLICY_ID
第三道:身分隔離。 這一道是最容易被做錯的。列一下權限矩陣:
| 身分 | 寫入 Vault | 讀取 token_value | 解密 encrypted_value |
|---|---|---|---|
| 去識別化服務 | ✅ | ❌ | ❌ |
| 應用服務 | ❌ | ❌ | ❌ |
| 還原服務 | ❌ | ✅ | ✅ |
| DBA | ❌ | ❌ | ❌ |
去識別化服務只寫不讀——它產生 Token 之後不需要再查。還原服務只讀不寫。應用服務兩者都不行,它只能呼叫還原服務的 API。
DBA 全部打叉,靠的是 CMEK:DBA 有資料庫管理權限,但沒有 KMS 解密權限,所以他能看到 encrypted_value 這個欄位存在,但看不到內容。
第四道:存取記錄。 每一次對 Vault 的查詢都要記錄。不只是「還原 API 被呼叫了」,而是「哪個 token_id 被查了」。這樣才能偵測 D3 提到的批次還原濫用。
第五道:備份保護。 這是最常出事的一道。備份必須加密、備份的存取權限要跟本體同級、備份不能存在權限較鬆的儲存體。
我看過的實際案例:Vault 本體防護做得很好,但每日備份用 pg_dump 導出到一台檔案伺服器,那台伺服器的權限是整個 IT 部門都能讀。整個架構的安全等級等於最弱那一環。
DEK 輪替時,既有的 encrypted_value 需要用新金鑰重新加密。作法是漸進式:
def rotate_batch(batch_size=1000):
rows = db.query(
"SELECT token_id, encrypted_value, key_version "
"FROM token_map WHERE key_version != %s LIMIT %s",
(CURRENT_KEY_VERSION, batch_size),
)
for row in rows:
plaintext = kms_decrypt(row.encrypted_value, row.key_version)
new_cipher = kms_encrypt(plaintext, CURRENT_KEY_VERSION)
db.execute(
"UPDATE token_map SET encrypted_value=%s, key_version=%s "
"WHERE token_id=%s",
(new_cipher, CURRENT_KEY_VERSION, row.token_id),
)
del plaintext # 明文在記憶體中的存在時間最小化
輪替期間 plaintext 會短暫存在於記憶體。這個過程應該:
最後一個容易被忽略的問題:這些資料要留多久?
法務案件結案後,Token 對應還需要嗎?多數情況下不需要。但如果沒有明確的清除機制,Vault 會一直長大,風險一直累積。
建議的政策:
| 狀態 | 保留期 | 動作 |
|---|---|---|
| 案件進行中 | 不限 | 保留 |
| 結案後 | 依案件類型(例如 1–3 年) | 保留 |
| 保留期滿 | — | 刪除對應,但保留稽核紀錄 |
注意最後一列:刪除 Token 對應,但保留「曾經存在過這個 Token」的稽核紀錄。這樣既縮小了洩漏面,又保留了事後查核的能力。
自動清除的實作:
DELETE FROM token_map
WHERE expires_at IS NOT NULL AND expires_at < now();
排程執行,並且把刪除筆數寫進稽核日誌。清除本身也是一個需要被稽核的動作——因為惡意的大量刪除也是一種攻擊(破壞可用性與可稽核性)。
明天換個角度,看地端自建的作法。
我是 Fngi,專注在 AI 資安、LLM 紅隊與 AI 治理框架落地。
IG:@aid3fend
有想討論的架構細節或不同意見,留言或私訊都歡迎。