如何在公開API隱藏自增ID,不加一列

妙用AES

有這樣一個尋常的需求:後端資料庫對每行記錄使用數字自增ID作為主鍵,同時也是每條資料的唯一識別符號。但我們不希望在公開API中暴露這個自增ID——連續遞增的數字會洩漏資料規模和增長趨勢。

想要摩擦最小地做到這一點,可以怎麼做?下面提供兩種不太常見,但成本低廉,且極其有效的方法。

方法一:AES-128-ECB

把自增ID填充到128 bit,做一次AES加密,得到128 bit密文,以hex輸出。

import os
import struct
from Crypto.Cipher import AES

# 16 位元組金鑰,以 hex 編碼儲存在環境變數中
# 生成方式:python3 -c "import os; print(os.urandom(16).hex())"
_key = bytes.fromhex(os.environ["ID_ENCRYPTION_KEY"])

def to_opaque_id(original_id: int) -> str:
    """自增 ID -> 32 字元 hex 字串"""
    plaintext = struct.pack(">QQ", 0, original_id)  # 高 64 bit 補零,低 64 bit 放 ID
    cipher = AES.new(_key, AES.MODE_ECB)
    return cipher.encrypt(plaintext).hex()

def to_original_id(opaque_id: str) -> int:
    """32 字元 hex 字串 -> 自增 ID"""
    cipher = AES.new(_key, AES.MODE_ECB)
    plaintext = cipher.decrypt(bytes.fromhex(opaque_id))
    _, original_id = struct.unpack(">QQ", plaintext)
    return original_id

效果:

>>> to_opaque_id(114514)
'1a8af8d9bab8e5f1de4d421c76cc352d'
>>> to_original_id('1a8af8d9bab8e5f1de4d421c76cc352d')
114514

輸出是32個hex字元,128 bit,與UUID等寬。

注意,這個輸出雖然和UUID一樣寬,但如果直接插入橫槓按 8-4-4-4-12 格式顯示,其版本和變體位不保證符合RFC 9562。不要把它當作合法的UUID傳給會校驗格式的庫或系統,叫它opaque ID或者public ID什麼的就好。

方法二:FF1

如果對輸出形態有要求,比如希望輸入是數字,輸出也是等長的數字,可以使用格式保留加密(FPE)。

FF1是NIST推薦的FPE演算法,能在任意基數和固定長度的域上做雙射加密。

import os
from FPE import FPE

# key: 16 位元組 hex
# tweak: 任意長度 hex(FF1 允許可變長 tweak)
_key   = bytes.fromhex(os.environ["ID_ENCRYPTION_KEY"])
_tweak = bytes.fromhex(os.environ["ID_ENCRYPTION_TWEAK"])

_cipher = FPE.New(_key, _tweak, FPE.Mode.FF1)

WIDTH = 10  # 10 位十進位制數,支援最多 9_999_999_999 條記錄

def to_opaque_id(original_id: int) -> str:
    """自增 ID -> 等長數字字串"""
    plaintext = str(original_id).zfill(WIDTH)
    return _cipher.encrypt(plaintext, FPE.Format.DIGITS)

def to_original_id(opaque_id: str) -> int:
    """等長數字字串 -> 自增 ID"""
    return int(_cipher.decrypt(opaque_id, FPE.Format.DIGITS))

效果:

>>> to_opaque_id(114514)
'7484127525'
>>> to_original_id('7484127525')
114514

十位數字進,十位數字出。適合需要對人類展示、且不希望暴露順序資訊的編號場景,比如工單號、訂單號。

原理

AES是一個128 bit到128 bit的雙射——同一個明文永遠加密成同一個密文,不同明文永遠加密成不同密文,持有金鑰即可解密還原。在此基礎上,AES還是一個偽隨機置換(PRP):對於不持有金鑰的觀察者,其輸出與一個真隨機的雙射在計算上不可區分。把自增ID填充到128 bit然後過一遍AES,就得到了一個不可預測、不可逆推、保證不碰撞的128 bit識別符號。

FF1的思路類似,只是它解決的問題更特殊:當你需要密文和明文保持相同的格式與長度,比如都是十位十進位制數,FF1能在任意基數的有限域上構造雙射。(顯然地,密文長度不宜太短,對於十進位制數應至少>= 6

這兩個方案不需要對資料庫表結構(schema)做任何改動。對映完全發生在應用層:請求進來時,解密一次拿到自增主鍵,直接查資料庫;響應出去時,加密一次生成opaque ID。資料庫裡從始至終只有一列自增ID。

為什麼?

對於“如何在公開API隱藏自增ID”,無論是用搜索引擎還是向AI提問,最常得到的答案要麼是在表上新增一列UUID,要麼是引入Hashids/Sqids這類編碼庫,這些方案都能解決問題,但各有劣勢。

加一列隨機UUID(v4)意味著每行多16位元組儲存、多一個二級索引(在InnoDB上隨機UUID會導致頁分裂,呸!到底是誰還在用MySQL!)、schema遷移,以及所有查詢路徑上從UUID到主鍵的一次額外索引查詢。UUIDv7解決了隨機帶來的索引效能問題,它的高位是時間戳,但這也意味著它會直接洩漏每條記錄的建立時間。觀察者能輕易地從UUIDv7中讀出建立時間的分佈,進而推斷增長趨勢,恰好是我們想要隱藏的資訊。

Sqids一類的方案更輕量,同樣不需要改schema,但它本質上是編碼而非加密——Sqids已在FAQ中明確說明不適用于敏感資料,且自定義字母表可以被逆向工程。更實際的是,Sqids是一個額外的依賴,而AES是幾乎所有語言的標準庫都自帶的基礎設施——既然AES能用更少的依賴做到更強的保證,引入一個專門的ID混淆庫就顯得沒有必要了。

FAQ

用AES是不是殺雞用牛刀?XOR一下或者手搓幾輪Feistel不就行了?

這裡選AES不是為了追求最強的密碼學安全性,而是為了用最低的心智成本保證下限不低。

手搓Feistel或者滾動XOR當然也能產出看起來隨機的輸出,但它們引入了一段與業務無關的額外複雜度:輪數選多少?輪函式用什麼?模逆元的引數怎麼選才能避免統計偏差?這些問題每一個都需要自己判斷和驗證,AES的好處在於這些問題已經在數十年的使用中得到了充分驗證,而且獲取這個保證的成本幾乎為零——只需要一行標準庫呼叫,並且還能自動得到AES-NI硬體加速,效能幾乎總是強於手搓輪子。

ECB不是教科書上的反面教材嗎?

ECB的經典問題是:多塊加密時,相同的明文塊產生相同的密文塊,會洩漏資料中的重複模式。但這裡的輸入填充後只有一個塊,不存在“多塊”的前提。

另一方面,ECB的確定性——相同輸入永遠產生相同輸出——在這個場景下正是需求本身。我們需要同一個ID每次都對映到同一個opaque ID,如果用CBC或CTR這類需要隨機IV的模式,就必須把IV/nonce存下來,又回到了新增列的老路。

自增ID只有64 bit,高64 bit全補零,這麼多固定結構會不會讓密文具備統計特徵?

AES的PRP性質保證了這一點不會是問題,如前文所述:對於不持有金鑰的觀察者,AES的輸出與一個真隨機置換在計算上不可區分,無論輸入有多少已知結構。這個性質經過了數十年的密碼學分析驗證。也正是因為這一點,AES比自行設計的置換更讓人放心——自研構造是否能做到同等程度的無統計特徵,往往很難自證。

可以把AES換成chacha20或者salsa20嗎?

絕對不行。chacha20和salsa20是流密碼,加密方式是將明文與金鑰流逐位異或。固定金鑰和nonce下,金鑰流是固定的,這意味著encrypt(a) ^ encrypt(b) = a ^ b——攻擊者只要對兩個opaque ID做異或,就能得到原始ID之間的差異;知道任意一組明文與密文的對應關係,就能還原金鑰流,進而解密全部ID。這本質上是一個固定的異或掩碼,而非偽隨機置換。

這個問題不僅限於chacha20和salsa20,所有流密碼都有同樣的問題:它們本質上是一個線性變換,不構成PRP,雖然技術上仍是雙射,但對觀察者而言是平凡可逆的。

注意事項

金鑰管理

這個方案的安全性完全取決於金鑰,金鑰洩漏等於所有opaque ID與自增ID的對映關係全部暴露。要像保護你的Stripe API Key一樣保護金鑰,起碼不要硬編碼在前端程式碼裡。

金鑰輪換

更換金鑰意味著同一個自增ID會對映到不同的opaque ID。如果外部系統會持久化引用你的opaque ID,輪換時需要保留舊金鑰做fallback解密:先嚐試用新金鑰解密,失敗再用舊金鑰。

輸入校驗

對於AES-ECB方案,解密後應檢查高64 bit是否為零。如果不為零,說明輸入不是由你的系統加密生成的合法opaque ID,應直接拒絕或觸發安全警報,相當於一次免費的完整性校驗。

FPE的安全餘量

FF1是目前NIST唯一保留的FPE標準(SP 800-38G Rev. 1 Draft 2已於2025年2月撤回了FF3系列)。FF1每次加解密的開銷約為方法一的10倍(10輪Feistel,每輪使用AES-CBC-MAC作為輪函式),對於單次ID轉換仍在微秒級,不構成瓶頸。

但是,由於FPE在一個遠小於2^128的域上構造置換,其安全餘量天然比AES這樣的標準分組密碼更少。對於隱藏自增ID的用途足夠了;但如果沒有“數字進,數字出”的絕對需求,方法一的AES-ECB是更合適的選擇。

鳴謝

寒冰提出了這個需求。它很可愛,快去看看吧