如何在公开API隐藏自增ID,不加一列
有这样一个寻常的需求:后端数据库对每行记录使用数字自增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是更合适的选择。
鸣谢
寒冰提出了这个需求。它很可爱,快去看看吧