iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0

金鑰架構:為什麼要 wrapped key

先講一個容易做錯的地方。

FPE 需要一把金鑰。最直覺的做法是產生一把金鑰、存在設定檔裡、呼叫 API 時帶上。這是錯的,因為:

  • 金鑰以明文存在應用程式的設定裡
  • 應用程式被入侵 = 金鑰洩漏 = 所有 Token 可還原
  • 無法輪替、無法稽核誰用過金鑰

正確的架構是三層:

KEK(Key Encryption Key)      存在 Cloud KMS,永遠不離開 KMS
  ↓ 加密
DEK(Data Encryption Key)     加密後的形式存在你的設定裡
  ↓ 實際用來做 FPE
Token

你的應用程式手上只有「被 KEK 加密過的 DEK」(wrapped key)。SDP 呼叫時把 wrapped key 交給它,SDP 去 KMS 解開,用完就丟。

你的應用程式從頭到尾沒有摸過明文金鑰。

建立金鑰

# 1. 建立 KMS keyring 與 key
gcloud kms keyrings create pii-vault-ring \
  --location asia-east1

gcloud kms keys create pii-fpe-kek \
  --location asia-east1 \
  --keyring pii-vault-ring \
  --purpose encryption \
  --rotation-period 90d \
  --next-rotation-time "$(date -u -d '+90 days' +%Y-%m-%dT%H:%M:%SZ)"

# 2. 產生 DEK(AES-256)
openssl rand -out dek.bin 32

# 3. 用 KEK 包裝 DEK
gcloud kms encrypt \
  --location asia-east1 \
  --keyring pii-vault-ring \
  --key pii-fpe-kek \
  --plaintext-file dek.bin \
  --ciphertext-file dek-wrapped.bin

# 4. 立刻銷毀明文 DEK
shred -u dek.bin

第 4 步不能省。明文 DEK 存在磁碟上的每一秒都是風險。

--rotation-period 90d 設定 KEK 自動輪替。要注意 KEK 輪替不影響已經包裝好的 DEK(KMS 會保留舊版本用於解密),但如果你要輪替 DEK,那所有既有 Token 都需要重新產生——這是一個需要規劃的維運事件,D12 會談。

去識別化:呼叫 SDP

import base64
from google.cloud import dlp_v2

dlp = dlp_v2.DlpServiceClient()
PARENT = "projects/YOUR_PROJECT/locations/asia-east1"

with open("dek-wrapped.bin", "rb") as f:
    WRAPPED_DEK = base64.b64encode(f.read()).decode()

CRYPTO_KEY = {
    "kms_wrapped": {
        "wrapped_key": WRAPPED_DEK,
        "crypto_key_name": (
            "projects/YOUR_PROJECT/locations/asia-east1/"
            "keyRings/pii-vault-ring/cryptoKeys/pii-fpe-kek"
        ),
    }
}

deidentify_config = {
    "info_type_transformations": {
        "transformations": [
            # 身分證字號:FPE,數字字元集
            {
                "info_types": [{"name": "TW_ID"}],
                "primitive_transformation": {
                    "crypto_replace_ffx_fpe_config": {
                        "crypto_key": CRYPTO_KEY,
                        "common_alphabet": "ALPHA_NUMERIC",
                        "surrogate_info_type": {"name": "TOKEN_TWID"},
                    }
                },
            },
            # 電話:FPE,純數字
            {
                "info_types": [{"name": "PHONE_NUMBER"}],
                "primitive_transformation": {
                    "crypto_replace_ffx_fpe_config": {
                        "crypto_key": CRYPTO_KEY,
                        "common_alphabet": "NUMERIC",
                        "surrogate_info_type": {"name": "TOKEN_PHONE"},
                    }
                },
            },
            # 健康資訊:遮罩,不可還原
            {
                "info_types": [{"name": "HEALTH_INFO"}],
                "primitive_transformation": {
                    "replace_config": {
                        "new_value": {"string_value": "【健康資訊已遮蔽】"}
                    }
                },
            },
            # 出生日期:概化到年
            {
                "info_types": [{"name": "DATE_OF_BIRTH"}],
                "primitive_transformation": {
                    "date_shift_config": {
                        "upper_bound_days": 180,
                        "lower_bound_days": -180,
                        "crypto_key": CRYPTO_KEY,
                    }
                },
            },
        ]
    }
}

def deidentify(text: str) -> str:
    resp = dlp.deidentify_content(
        request={
            "parent": PARENT,
            "deidentify_config": deidentify_config,
            "inspect_config": INSPECT_CONFIG,   # 見 Day 7
            "item": {"value": text},
        }
    )
    return resp.item.value

surrogate_info_type 是還原的關鍵

注意每個 FPE 設定裡的 surrogate_info_type。它的作用是在輸出裡標記這是一個 Token

原文:  身分證字號 A123456789
輸出:  身分證字號 TOKEN_TWID(B847263915)

還原時,SDP 靠這個標記知道哪些片段要還原、用哪個設定還原。

這帶來一個實務問題:這個標記會出現在送給 LLM 的文字裡,看起來很醜,也可能干擾模型。

兩種處理方式:

方式一:送出前剝除標記、還原前補回去。 需要自己維護一份「哪個位置是哪種 Token」的側錄資訊。

import re

SURROGATE_RE = re.compile(r"(TOKEN_\w+)\(([^)]+)\)")

def strip_surrogates(text):
    mapping = []
    def _sub(m):
        mapping.append({"type": m.group(1), "value": m.group(2)})
        return m.group(2)
    return SURROGATE_RE.sub(_sub, text), mapping

方式二:接受標記存在,並在 prompt 裡說明。 例如 system prompt 加一句「文中 TOKEN_XXX(...) 格式為去識別化標記,請原樣保留、不要改寫」。

我偏好方式一,因為方式二依賴模型聽話——而模型不一定聽話,尤其在文件內容裡有 injection 的時候(D22)。不要把資料完整性寄託在模型的服從性上。

還原

reidentify_config = {
    "info_type_transformations": {
        "transformations": [
            {
                "info_types": [{"name": "TOKEN_TWID"}],
                "primitive_transformation": {
                    "crypto_replace_ffx_fpe_config": {
                        "crypto_key": CRYPTO_KEY,
                        "common_alphabet": "ALPHA_NUMERIC",
                        "surrogate_info_type": {"name": "TOKEN_TWID"},
                    }
                },
            },
        ]
    }
}

def reidentify(text: str) -> str:
    resp = dlp.reidentify_content(
        request={
            "parent": PARENT,
            "reidentify_config": reidentify_config,
            "inspect_config": {
                "custom_info_types": [
                    {
                        "info_type": {"name": "TOKEN_TWID"},
                        "surrogate_type": {},
                    }
                ]
            },
            "item": {"value": text},
        }
    )
    return resp.item.value

這段程式碼絕對不能放在一般的應用服務裡。 它需要 KMS 的解密權限,等於握有還原能力。它應該:

  • 部署為獨立服務,有自己的服務帳號
  • 該服務帳號是唯一被授予 cloudkms.cryptoKeyVersions.useToDecrypt 的身分
  • 每次呼叫都要通過授權檢查並寫入稽核日誌
  • 一般應用服務只能呼叫它的 API,不能直接碰 KMS

這就是 D2 說的「邊界二:應用/Vault」。

一個重要的性質:SDP 的 FPE 是無狀態的

值得注意的是,用 FPE 做的 Token 化不需要 Vault——還原是靠金鑰算回去的,不是靠查表。

這是一個很大的優勢:沒有 Mapping Table,就沒有 Mapping Table 被偷的風險。

但它也有限制:

  • 只適用於格式規整的欄位(見 D10)
  • 人名、地址這類自由文字仍然需要查表 → 仍然需要 Vault
  • 金鑰洩漏等於全部 Token 可還原(但金鑰在 KMS 裡,比資料庫好保護)

所以實際架構通常是混合:規整欄位走無狀態 FPE,自由文字走 Vault 查表。這樣 Vault 裡只存人名和地址,體積小很多,風險也小很多。


明天談 Vault:那些不能靠算的、只能靠存的部分。


關於作者

我是 Fngi,專注在 AI 資安、LLM 紅隊與 AI 治理框架落地。
IG:@aid3fend

有想討論的架構細節或不同意見,留言或私訊都歡迎。


上一篇
Day 10|格式保留加密:為什麼假資料要長得像真的
下一篇
Day 12|Token Vault:整個架構風險最集中的一點
系列文
《30 天為金融法務部門打造 LLM 個資防護閘:去識別化、可控還原與紅隊驗證》13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言