3. FF1 算法详解:标准规范、参数定义、加密与解密过程、边界条件处理

FF1 算法,说白了就是 NIST 专门为格式保留加密定制的 Feistel 网络方案。我第一次接触它,是在做一个银行卡号脱敏项目——客户要求加密后卡号还得是 16 位数字,而且前 6 位和后 4 位保持明文。传统的 AES 一加密,数据全乱套了,根本没法用。后来查了半天,发现 FF1 正好解决这个问题。

它的核心思路其实不复杂:用 Feistel 网络把 AES 和取模运算组合起来,保证加密后的字符集跟输入一模一样。你想想看,普通的加密算法哪管你什么字符集,吐出来全是乱码。FF1 却能让你加密完的身份证号还是 18 位,银行卡号还是 16 位数字。

3.1 标准规范:NIST SP 800-38G

FF1 写在了 NIST SP 800-38G 标准里。这套标准 2016 年正式定稿,但算法本身在 2010 年左右就由 Voltage Security 提出来了。我个人习惯把 SP 800-38G 当成 FPE 领域的"核心参考书"——里面的 FF1 和 FF3 是两个主力算法。

标准里明确了几个要点:

  • FF1 基于 Feistel 网络结构,轮数固定为 10 轮
  • 底层加密原语使用 AES(128/192/256 位密钥)
  • 支持任意字符集,通过 radix 参数控制
  • 提供可选的 Tweak 参数,用于区分不同的加密上下文

我在项目里翻标准的时候,发现一个容易忽略的细节:FF1 的轮函数里用到了 模幂运算,这个在标准附录里有专门的实现算法。如果你自己手写实现,千万别图省事用 Math.pow(),精度会出问题。

3.2 参数定义

FF1 的参数其实不多,但每个都很关键。我整理了一张表,方便你对照:

参数 符号 说明 约束
密钥 K AES 密钥 128 / 192 / 256 位
调整值 T Tweak,公开的字节串 长度 0 ~ 2³²-1 字节
明文 X 待加密字符串 长度 n ≥ 2
字符集基数 radix 如数字 10,字母数字 36 2 ≤ radix ≤ 2¹⁶
分组大小 u, v u = floor(n/2), v = n - u u + v = n

我记得第一次调 FF1 的时候,忘了限制 radix 的范围,传了个 100 进去,结果算法直接崩了。后来一查标准,radix 必须 ≤ 65536,而且 radixn ≥ 100 这个条件很多人都会忽略。

⚠️ 特别注意: radixn ≥ 100 是 FF1 的硬性约束。如果 n 太小或 radix 太小,算法会直接报错。比如 n=1, radix=10,10¹=10 < 100,不满足条件。

3.3 加密过程

下面这张图展示了 FF1 加密的完整流水线。我建议你先把图看一遍,再读后面的步骤,会清晰很多。

FF1 算法加密流程 输入 明文P + Tweak T 拆分 L₀ || R₀ Feistel 轮函数 AES + 取模 + 异或 循环 10 轮 合并 L || R 输出 密文 C FF1 基于 Feistel 网络,每轮使用 AES 加密后取模,保证输出字符集与输入一致 解密时只需将轮序反转,轮函数本身不需要改动

加密的具体步骤如下:

  1. 拆分明文:将明文 X 拆分为 A 和 B,长度分别为 u = floor(n/2) 和 v = n - u
  2. 编码 Tweak:将 Tweak T 转换为固定格式的字节串,作为轮函数的额外输入
  3. 执行 10 轮 Feistel 变换:
    • 构造轮输入:将 B、轮序号 i、Tweak 编码拼接成 16 字节块
    • 用 AES 加密,得到 16 字节密文
    • 将加密结果转换为整数,对 radix 取模
    • 更新 A = B, B = (A + mod_result) % radixv
  4. 输出密文:最终 Y = A || B

举个例子,加密 16 位数字卡号:n=16, radix=10, u=8, v=8。前 8 位作为 L₀,后 8 位作为 R₀,经过 10 轮迭代后合并。我在项目中遇到过性能问题:每轮都要做一次 AES 加密,数据量大的话开销不小。后来用了 AES-NI 指令集加速,性能提升了将近 4 倍。

💡 实用技巧: 如果加密大量短字符串,可以批量处理——把多条记录拼成一个长字符串,一次加密。但要注意,FF1 不支持流式加密,每次必须处理完整的 n 个字符。

3.4 解密过程

FF1 的解密和加密是高度对称的。说白了,就是把加密流程倒过来走一遍,轮函数本身不用改。我习惯用一个比喻:加密是"正着转 10 圈",解密就是"反着转 10 圈",齿轮本身没有变。

解密步骤对比加密:

步骤 加密 解密
输入 明文 X 密文 Y
拆分 X 拆为 A, B Y 拆为 A, B
轮序 i = 0 → 9 i = 9 → 0
轮函数 AES(B || i || T) AES(B || i || T) — 完全相同
更新规则 A = B, B = (A + d) % r A = B, B = (A - d) % r
输出 A || B A || B

注意更新规则里,加密用的是加法,解密用的是减法(取模减法)。这个差别很容易搞反——我曾经在一个项目里调试了整整一下午,最后发现就是把加号写成了减号。

3.5 边界条件处理

边界条件往往是坑最多的地方。我在几个项目里踩过的雷,帮你总结出来了:

  • 最小长度限制:n 必须 ≥ 2,且 radixn ≥ 100。如果 n=1,不管 radix 多大都不行。我建议在接口层做校验,提前拦截非法输入。
  • Tweak 长度:标准允许 0 到 2³²-1 字节,但实际实现中建议控制 ≤ 256 字节。太长会影响性能——每轮都要把 Tweak 编进轮输入里。
  • 字符集映射:需要建立字符到数字的双向映射表。例如身份证号包含数字和 X,radix=11。映射表必须是一一对应、满射的,不能有重复。
  • 空字符串处理:n=0 时算法未定义。我曾经接手过一个遗留系统,传了空字符串过去,底层 C 库直接段错误。后来加了个前置判断才解决。
⚠️ 避坑指南: 字符集映射表是容易出 bug 的地方。我见过有人用 ASCII 码直接当数字用,结果加密完的字符串里出现了不可见字符。正确的做法是先定义一套字符→整数的映射,再传给 FF1。

总结一下 FF1 的使用要点:

  • radixn ≥ 100 是硬约束,输入前必须检查
  • 解密时轮序反转,加/减运算互换
  • Tweak 用于区分上下文,同一密钥下不同场景用不同 Tweak
  • 字符集映射表必须是一一对应且满射的