昨天說 Token 要「同型替換」。但為什麼?直接用一個隨機 UUID 當 Token 不是更安全嗎?
A123456789 → 550e8400-e29b-41d4-a716-446655440000
三個理由:
一、下游系統會壞。 法務系統的欄位長度是 10 個字元,塞不下 UUID。如果去識別化後的資料要經過任何既有系統,格式改變會造成大量整合工作。
二、模型表現會下降。 LLM 對「A123456789」的處理跟對一串 UUID 的處理不同。前者符合它訓練資料裡的模式,後者是異常字串,可能被切成大量 token,也可能干擾注意力分布。
三、資料仍然可用於測試。 格式保留的去識別化資料可以直接拿去當開發測試資料,因為它通過所有的格式驗證。這在實務上省下大量工作。
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,而這剛好是另一個真實的人的號碼。那麼:
這是 FPE 的固有性質,不是實作缺陷。緩解方式有幾種:
一、Token 空間隔離。 用一個保留的號碼區段(例如首字母用某個實務上不會出現的字元),讓 Token 在格式上有效但在現實中不可能存在。
二、加註記。 在文件層級標明這是去識別化資料,不要讓 Token 化文件被誤認為原始文件。
三、接受風險並記錄。 對某些場景(例如純內部處理、絕不外流)這個風險可以接受,但要在風險評估文件裡寫明。
我的建議是方式一。用一個明確不會與真實資料衝突的 Token 空間,能同時解掉「誤指第三人」和「無法區分 Token 與原值」兩個問題,代價只是輸出不再 100% 像真的——但下游系統的格式驗證仍然會過。
| 欄位 | 適合 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
有想討論的架構細節或不同意見,留言或私訊都歡迎。