iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Security

《30 天為金融法務部門打造 LLM 個資防護閘:去識別化、可控還原與紅隊驗證》系列 第 10

Day 10|格式保留加密:為什麼假資料要長得像真的

  • 分享至 

  • xImage
  •  

從一個實際問題開始

昨天說 Token 要「同型替換」。但為什麼?直接用一個隨機 UUID 當 Token 不是更安全嗎?

A123456789  →  550e8400-e29b-41d4-a716-446655440000

三個理由:

一、下游系統會壞。 法務系統的欄位長度是 10 個字元,塞不下 UUID。如果去識別化後的資料要經過任何既有系統,格式改變會造成大量整合工作。

二、模型表現會下降。 LLM 對「A123456789」的處理跟對一串 UUID 的處理不同。前者符合它訓練資料裡的模式,後者是異常字串,可能被切成大量 token,也可能干擾注意力分布。

三、資料仍然可用於測試。 格式保留的去識別化資料可以直接拿去當開發測試資料,因為它通過所有的格式驗證。這在實務上省下大量工作。

FPE 是什麼

Format-Preserving Encryption 的定義很簡單:加密後的密文,跟明文屬於同一個字元集合、同樣長度。

一般的 AES 加密會把 10 位數字變成一串二進位資料。FPE 會把 10 位數字變成另外 10 位數字。

NIST SP 800-38G 定義了兩個模式:FF1 和 FF3-1。FF1 用得比較廣,Google Cloud 的 SDP 支援的就是 FF1(CryptoReplaceFfxFpeConfig)。

它的底層是 Feistel 網路——把資料分成左右兩半,用底層的區塊加密(通常是 AES)做多輪混合。這個結構的好處是可逆:同樣的金鑰可以反向跑回去。

字元集的選擇

FPE 需要定義字元集(alphabet),這決定了輸出長什麼樣。

# 概念示意
FFX_ALPHABETS = {
    "NUMERIC":              "0123456789",
    "HEXADECIMAL":          "0123456789ABCDEF",
    "UPPER_CASE_ALPHA_NUMERIC": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ",
    "ALPHA_NUMERIC":        "0123456789abcdefghijklmnopqrstuvwxyzABCDEF...",
}

台灣身分證字號是「1 英文 + 9 數字」,這是一個混合格式,沒有現成的字元集完全對應。三種處理方式:

方式一:分段處理。 首字母用一個字元集加密(26 個英文字母),後 9 碼用 NUMERIC 加密。缺點是要自己組合,而且首字母的密文空間只有 26,很小。

方式二:整串當 ALPHA_NUMERIC。 簡單,但輸出可能變成「7A3B2C1D9E」,不再符合身分證格式。

方式三:只加密數字部分,首字母另外處理。 我實務上偏好這個——首字母帶有戶籍地資訊,可以視為準識別碼,直接概化成固定值或用查表替換。

def tokenize_tw_id(tw_id: str, fpe_encrypt) -> str:
    """首字母查表替換,後九碼 FPE,最後重算檢查碼"""
    letter = LETTER_SHUFFLE_MAP[tw_id[0].upper()]   # 固定但打亂的對應
    digits = fpe_encrypt(tw_id[1:], alphabet="NUMERIC")
    # 前八碼保留,最後一碼重算,確保 Token 通過檢查碼驗證
    body = digits[:8]
    return letter + body + compute_check_digit(letter, body)

最後一行很關鍵:重算檢查碼。如果不做,產生的假身分證字號會通不過檢查碼驗證,下游系統會拒收,而且一眼就能看出是假的。

一個安全上的權衡

讓 Token 通過檢查碼驗證,代表 Token 看起來就像真的身分證字號。這帶來一個風險:

Token 可能「撞到」某個真實存在的人的身分證字號。

假設 Token 化後產生了 B847263915,而這剛好是另一個真實的人的號碼。那麼:

  • 這份文件如果外流,會誤指到無辜的第三人
  • 如果 Vault 遺失,你無法區分哪些是 Token 哪些是原值

這是 FPE 的固有性質,不是實作缺陷。緩解方式有幾種:

一、Token 空間隔離。 用一個保留的號碼區段(例如首字母用某個實務上不會出現的字元),讓 Token 在格式上有效但在現實中不可能存在。

二、加註記。 在文件層級標明這是去識別化資料,不要讓 Token 化文件被誤認為原始文件。

三、接受風險並記錄。 對某些場景(例如純內部處理、絕不外流)這個風險可以接受,但要在風險評估文件裡寫明。

我的建議是方式一。用一個明確不會與真實資料衝突的 Token 空間,能同時解掉「誤指第三人」和「無法區分 Token 與原值」兩個問題,代價只是輸出不再 100% 像真的——但下游系統的格式驗證仍然會過。

什麼欄位適合 FPE、什麼不適合

欄位 適合 FPE? 原因
身分證字號 固定格式,下游有驗證
信用卡號 固定格式,可保留末四碼與卡別前綴
銀行帳號 固定長度
電話號碼 固定格式
姓名 不是固定字元集,要用查表替換
地址 自由文字,要用概化或替換
病歷描述 自由文字,要用遮罩

姓名這一列很重要。FPE 不適用於中文姓名——沒有一個合理的「中文姓名字元集」能讓加密後的結果仍然像人名(加密後可能變成「垚甯彧」這種不像名字的組合)。

正確做法是確定性的假名查表

import hmac, hashlib

def pseudonym_name(real_name: str, key: bytes, name_pool: list) -> str:
    """同樣的名字永遠對到同樣的假名(同一金鑰下)"""
    h = hmac.new(key, real_name.encode("utf-8"), hashlib.sha256).digest()
    idx = int.from_bytes(h[:8], "big") % len(name_pool)
    return name_pool[idx]

用 HMAC 而不是單純的 hash,是因為單純 hash 可以被字典攻擊——常見中文姓名的數量有限,攻擊者可以把所有可能的名字都 hash 一遍做成彩虹表。加了金鑰就不行。

這個函式有一個碰撞問題:兩個不同的真名可能對到同一個假名。在一份文件裡如果發生,會造成兩個人被合併。所以實際實作要在文件層級做碰撞檢查,撞到就往後取下一個。

保留部分原值:信用卡的例子

有些欄位需要保留一部分,例如信用卡末四碼(客服核對身分用):

{
  "primitive": {
    "crypto_replace_ffx_fpe_config": {
      "crypto_key": {"kms_wrapped": {...}},
      "common_alphabet": "NUMERIC",
      "surrogate_info_type": {"name": "TOKEN_CC"},
    }
  }
}

SDP 的 FPE 對整串加密。要保留末四碼,得自己切開:前段送 FPE,末四碼原樣接回。

但要注意:保留的部分本身就是洩漏。 末四碼加上卡別前綴,已經縮小了搜尋空間。這是可用性與安全性的權衡,要有意識地做,並且記錄在風險評估裡。


明天用 SDP 加 KMS 實作一條完整的可逆 Token 化管線。


關於作者

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

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


上一篇
Day 9|遮罩、概化、Token 化:只有一種可以回來
下一篇
Day 11|實作:SDP + Cloud KMS 的可逆 Token 化
系列文
《30 天為金融法務部門打造 LLM 個資防護閘:去識別化、可控還原與紅隊驗證》14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言