格式保留令牌化(FPE)原理:FF1、FF3算法详解
聊到令牌化,其实很多团队第一步想到的是拿哈希或者直接加密。但有个现实问题——信用卡号16位,加密后变成32位乱码,数据库字段得改,业务系统也得跟着改。谁受得了?
所以有了FPE,格式保留加密。说白了就是:明文是16位数字,密文还是16位数字,长相和业务规则完全兼容。我在一个支付合规项目里,客户要求保留卡号前6后4的明文显示,中间6位令牌化。用FF1做下来,改动量是真小。
今天聊聊FPE的两大主流算法——FF1和FF3。它们都是NIST标准,看似差不多,实际坑不少。
FPE的核心思想
格式保留的关键不是简单的截断哈希,而是用一种叫“Feistel网络”的轮函数结构。你想想看,传统AES加密改变一位明文,密文会天翻地覆。FPE不一样,它用可逆的轮函数在指定字符集内做置换。
FF1和FF3都基于这个思路,区别在于轮数、参数长度和适用范围。
关键点:FPE不是对任意长度的数据都灵。FF1支持长度 >= 2字符,FF3支持长度 >= 4字符。过短的数据,Feistel网络安全性会塌。
FF1算法详解
FF1是NIST SP 800-38G标准里的主力。说白了就是先把数据分成左右两半,然后多轮迭代。每轮把右半部分用AES加密的结果和左半部分异或,得到下一轮的左半部分,原来的左半部分变成新的右半部分。最后再合并,保证输出和输入字符集完全一致。
我个人习惯把FF1理解成“带字符集映射的Feistel”。它内部会把数字/字母映射到整数0~radix-1,在整数域上做一轮加密,再映射回字符。
FF1的轮数是固定的10轮(当radix >= 10且len <= 2*radix时)。如果长度和基数更大,轮数会增加到14轮。嗯,这里要注意:轮数不够很容易被差分攻击,所以别自作聪明减少轮数。
FF1的局限
- 性能:每一轮都要调用一次AES(实际是CMAC或CBC-MAC),10轮下来开销不小。
- 块大小:最小明文块长度2,但太短(如2~5字符)安全性受质疑。我在一个银行项目里被迫把卡号从16位拆成2段各8位分别加密,才避开短块风险。
- radix限制:radix不能超过256,所以纯数字(radix=10)没问题,但全大写字母(26)也行,混合字符集就得注意映射表。
实战经验:用FF1加密16位数字卡号时,可以选用AES-256作为底层分组密码,并设置tweak为固定的卡类型标识。我曾经见过别人tweak留空导致多个卡号加密后结果相同的惨案。
FF3算法详解
FF3是2016年加入标准的新算法。它跟FF1最大的不同是轮函数更轻量——只有8轮,且用了一种叫“双交易Feistel”的结构。什么意思?每一轮同时更新左右两部分,收敛速度更快。
但是,FF3有坑。2017年有学者发现,当radix=2且块长度很小时,FF3可以用很少的明密文对破解密钥。所以后来标准紧急修订,禁止对radix=2且长度小于6块的情况使用FF3。
我在一个物联网设备标识令牌化项目里尝试过FF3,因为设备ID通常是10~12位数字,长度够了,且8轮加密比FF3的10轮快20%。实际压测中QPS提升了约15%。
FF3与FF1的对比
| 维度 | FF1 | FF3 |
|---|---|---|
| 轮数 | 10或14轮 | 8轮(固定) |
| 最小明文长度 | 2字符 | 4字符 |
| radix上限 | 256 | 256(但radix=2且len<6被禁用) |
| 底层分组密码 | AES或3DES | AES(仅支持) |
| 标准成熟度 | 高,经过广泛分析 | 存在已知攻击边界 |
| 典型应用 | 银行卡号、身份证号 | 短ID、短验证码 |
说白了,如果你要令牌化的数据长度在4~9位之间,用FF3可能更快且安全;如果数据短至2位或长且需要高安全余量,用FF1更稳妥。
注意:不要直接把FF3用在只有4位数字的CVV上。我曾见过一个原型把CVV用FF3加密后再存储,每次解密返回不同值——因为FF3在短块时存在碰撞概率。
算法流程对比图
代码示例:FF1的伪代码实现
这里给一段python风格的伪代码,帮你理解核心逻辑。实际工程不要手写,用OpenSSL或cryptography库。
def ff1_encrypt(plaintext, key, tweak, radix):
# 字符映射:数字"0123456789" -> 0~9
chars = list(radix_map) # 预设字符集
n = len(plaintext)
# 分割为左右两半 (u向上取整)
u = (n + 1) // 2
v = n - u
A = plaintext[:u]
B = plaintext[u:]
for i in range(10): # 10轮
# 把B映射成整数,再用AES生成掩码
b_int = map_to_int(B, chars) # 整数 0~radix^v-1
mask = aes_prf(key, tweak + i + B) # 伪随机函数
c_int = (b_int + mask) % (radix ** u) # 加性组合
C = map_to_str(c_int, chars, u)
# 交换 A, B
new_A = B
new_B = A ^ C # 异或或加性操作(细节略)
A, B = new_A, new_B
return A + B # 最终密文
致命警告:上面代码少处理了tweak长度检查、radix越界、以及某些轮的“区间溢出”问题。我曾经没加radix**u的模运算,结果输出字符集外字符,业务系统直接报错。
工程选型建议
到底用FF1还是FF3?我给你一个判断树:
- 数据长度:如果明文长度 < 4,只能选FF1。
- 性能敏感:如果QPS上十万且数据长度≥4,优先FF3。
- 安全等级:国家密码局检测或金融级场景,用FF1更稳妥。
- tweak使用:两者都支持tweak,但FF1的tweak长度灵活,FF3的tweak必须固定为8字节。我记得有一次迭代升级tweak长度从8改成16,FF3直接炸了。
小提示:在实现FPE时,预留一个算法版本字段。线上切算法可以灰度切换,不需要停机。
好了,关于FF1和FF3的原理差异就聊这么多。两种算法没有的好坏,关键看你的业务场景在哪。下一段我们在代码里真刀真枪地做一次令牌化集成。