格式保留令牌化(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与FF3核心流程对比 FF1 算法流程 FF3 算法流程 输入:明文 (len≥2, radix) 字符→整数映射 (0~radix-1) 10轮 Feistel 网络 (AES CMAC) 整数→字符映射 输出:格式保留的密文 输入:明文 (len≥4, radix) 字符→整数映射 8轮双交易Feistel (AES) 整数→字符映射 输出:格式保留的密文 10~14轮,安全性高 适合长明文块 8轮,性能好 注意短块攻击

代码示例: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?我给你一个判断树:

  1. 数据长度:如果明文长度 < 4,只能选FF1。
  2. 性能敏感:如果QPS上十万且数据长度≥4,优先FF3。
  3. 安全等级:国家密码局检测或金融级场景,用FF1更稳妥。
  4. tweak使用:两者都支持tweak,但FF1的tweak长度灵活,FF3的tweak必须固定为8字节。我记得有一次迭代升级tweak长度从8改成16,FF3直接炸了。

小提示:在实现FPE时,预留一个算法版本字段。线上切算法可以灰度切换,不需要停机。

好了,关于FF1和FF3的原理差异就聊这么多。两种算法没有的好坏,关键看你的业务场景在哪。下一段我们在代码里真刀真枪地做一次令牌化集成。