FPE 核心原理:Feistel 网络、块大小与 Tweak
这一章我们来啃 FPE 最核心的骨架。别看外面包装得花里胡哨,剥开看就三个事儿:
- Feistel 网络结构 — FPE 的“发动机”
- 块大小与字符集 — 决定了你能加密多长的数据
- Tweak 的作用与设计原则 — 这是个“安全调味料”,用不好会翻车
Feistel 网络结构:FPE 的发动机
先问个问题:普通加密算法(比如 AES)为什么不能直接用?因为它们要求的输入是固定位数的二进制。而 FPE 要加密的是“信用卡号”、“身份证号”、“手机号”这种特定字符集的数据。你没法把一串 16 位数字直接塞进 AES 里,对吧?
Feistel 网络就是为了解决这个问题的。它最早用在 DES 里,后来被 FPE 借过来。核心思想很简单——把数据切成两半,轮番处理。
我举个简化例子。假设我们要加密一个 6 位数字“123456”:
- 分割:切成左半“123”和右半“456”。
- 轮函数:把右半“456”和密钥放进一个函数 F,算出个伪随机数,比如“789”。
- 异或/映射:把左半“123”和这个“789”做一次“加法”(其实是模10加法,因为数字集只有0-9),得到“802”。
- 交换:左半变成原来的右半“456”,右半变成新值“802”。
- 重复:再做第二轮……做够 8~10 轮。
最终的左右拼起来就是密文。整个过程是可逆的——解密时反向走一遍就行。
关键点:Feistel 网络能保证输入输出格式一致,因为它处理的是“位置”而不是“比特”。轮函数 F 必须是强伪随机函数,但 F 的输出可以映射到任意字符集上。
我做过一个项目,加密 18 位的统一社会信用代码。客户说“不能多一位也不能少一位”。用 Feistel 网络,把 18 位切成 9+9,每轮用 SM3 做哈希再映射到字符集——完美。
块大小与字符集:细粒度决定一切
FPE 不是只能处理整字节。你可以定义“块大小”为任意长度,只要它能被 Feistel 切分。
具体来说:
- 字符集:比如数字 [0-9],大小是 10;英文字母 [a-zA-Z],大小是 52;字母数字混合 [0-9a-zA-Z],大小是 62。
- 块大小:每个块包含的字符个数。比如加密一个 16 位银行卡号,块大小就是 16。
- 比特位 vs 字符位:Feistel 工作在整数域上,不是二进制域。密钥和轮函数的输出要换算到字符集的模。
一个常见的坑:块大小必须是偶数。为什么?因为 Feistel 把块切成左右两半,两半长度必须相等。如果你要加密奇数个字符(比如 15 位手机号),有两种处理办法:
- 用 FF1 算法(NIST SP 800-38G):支持奇数长度,内部做了平衡处理。
- 或者自己补一位,加密完再截掉。我建议别自己造轮子——直接用 FF1。
注意:块大小不是可以无限大。太大会导致 Feistel 的轮数不够,安全强度下降。经验值是:字符集大小 × 块长度 对应的比特位数,应不小于 128 位。
我曾经在一个金融系统里看到他们用 FPE 加密 32 位数字串,没设 block size 上限,结果上线后性能爆炸。查了一下,32 位的 Feistel 网络要做 10 轮轮函数计算,每轮要哈希一个 32 字符的字符串……后来改成 8+8+8+8 分块加密,性能好了 10 倍。你想想看,块大小不是越大越好。
Tweak 的作用与设计原则
Tweak 是 FPE 特有的一把“盐”。它像 AES 的 IV,但更灵活。同一个密钥,用不同的 Tweak 加密相同的明文,会得到完全不同的密文。但 IV 是随机的,Tweak 可以是确定的——比如数据库行号、用户 ID、当前时间戳。
核心作用:
- 提供上下文分离:加密用户 A 的身份证号和用户 B 的身份证号,用不同的 Tweak(比如用户ID),即使明文相同,密文也不同。
- 绕过格式长度限制:Tweak 不影响密文长度,它只是 Feistel 网络的一个额外输入。
- 防重放攻击:如果用交易流水号做 Tweak,攻击者没法把一个密文拿到另一个上下文里解密。
我的建议:Tweak 的长度至少 12 字节(96 位),熵够用。如果 Tweak 太短,例如只有 1 字节,那只有 256 种可能,穷举空间太小。
设计原则有哪些?我踩过几个坑,这里直接列给你:
| 原则 | 解释 | 反面案例 |
|---|---|---|
| 不可预测性 | 攻击者不能控制 Tweak 的取值 | 用自增 ID 做 Tweak,攻击者可枚举 |
| 唯一性 | 同一个密钥下,Tweak 不应重复 | 用当前日期做 Tweak,一天内重复 |
| 长度合理 | 太短丧失安全性,太长浪费算力 | 用 4 字节做 Tweak,熵不够 |
| 可复现 | 解密时要能重新构造相同的 Tweak | 随机生成并且不存储,解密时没法再次拿到 |
特别提醒:Tweak 不能当密钥用。它只是“调味料”,密钥才是主料。我曾经接过一个代码 review,有人把 Tweak 设成和密钥一样……那就是双重加密,没什么意义,反而浪费了 Tweak 的设计优势。
一张图看懂 FPE 核心架构
避坑指南·经验谈
写到这里,我想起以前刚接触 FPE 时犯的一个错。我在一个支付系统里加密用户邮箱——邮箱地址包含 '@' 和 '.',字符集是 [a-zA-Z0-9.@_-]。我用的是 FF1,但忘记把字符集大小设对。默认的 FF1 实现假设字符集是 [0-9],大小 10。结果一跑,加密出的结果虽然格式对,但解密失败。查了一天,发现是字符集映射表没配好。
所以我的建议:
- 字符集定义要按 ASCII 码排好顺序,不要跳字符。
- 块大小必须能被 2 整除。如果非要奇数,老老实实用 FF1 或 FF3(NIST 标准)。
- Tweak 的构造最好做成字符串拼接,比如 "user_"+userId+"_seq_"+sequence,这样可读性强,方便调试。
- 轮数不能低于 8。我曾经偷懒设 4 轮,结果用 chosen-plaintext attack 两下就破了。教训惨痛。
小技巧:生产环境建议直接使用成熟的库,比如 Python 的 ff3 或 pyffx,别自己写 Feistel。标准实现的轮函数 HMAC-SHA256 已经经过经年累月的安全验证。自己写容易被侧信道攻击盯上。