密钥派生与变量分配:K、T、L、R

好,我们正式开始讲 FF1 算法里最绕不开的一块——键盘派生和变量分配。说实话,很多初学者栽就栽在这里。你想想看,主密钥直接拿去算?不行的,FF1 需要从主密钥 K 派生出若干子密钥,然后还要把输入 X 分成左右两半 L 和 R,再搭配一个可选调整值 T。这一章我们把这块彻底理清楚。

主密钥 K 与子密钥派生

FF1 的密钥派生过程,说白了就是用主密钥 K 和调整值 T 一起,通过一个伪随机函数(通常是 AES-CMAC 或 HMAC-SHA256)生成两个子密钥 K1 和 K2。为什么需要两个?因为每一轮 Feistel 结构里,轮函数 F 要用到两个不同的密钥来避免对称性。我接触的第一个 FF1 项目里,有人偷懒只用一个密钥,结果测试向量全挂……后来 debug 到半夜才找到原因。

派生的通用步骤很简单:

  1. 将主密钥 K 和调整值 T 拼接成字节串 (K || T)。
  2. 用 AES 加密(或 CMAC)对拼接结果做固定轮数的伪随机计算。
  3. 截断或拼接输出,得到 K1(长度与 K 相同)和 K2(同样长度)。

下面是一段 Python 风格的伪代码,你一看就明白:

from cryptography.hazmat.primitives.cmac import CMAC
from cryptography.hazmat.primitives.ciphers.algorithms import AES

def derive_key(K, T):
    # 1. 拼接
    data = K + T
    # 2. 使用 AES-CMAC 生成 16 字节伪随机输出
    c = CMAC(AES(K))
    c.update(data)
    prf_out = c.finalize()  # 16 字节
    # 3. 截断或直接使用前 16 字节作为 K1
    K1 = prf_out
    # 4. 再用同样的方法,但数据拼接 (K || T || 0x01)
    c2 = CMAC(AES(K))
    c2.update(data + b'\x01')
    K2 = c2.finalize()
    return K1, K2

请注意,这里我用的 CMAC,但 FF1 标准里推荐的是 AES-CMAC。实际项目中我还见过用 HMAC-SHA256 的,必须确认你依赖的库支持哪个。嗯,这个细节很容易踩坑。

Tweak T——调整值的分配

调整值 T 是 FF1 的一个灵活度体现。你可以传任意长度(0 到 216-1 字节)的额外数据进去,比如用户 ID、时间戳、银行账户类型。T 会影响整个加密结果,但又不改变明文长度。我的经验是:尽量把 T 设计成可还原的信息,这样解密时不需要额外存储。

T 在密钥派生阶段就被用上了(就像上面代码里拼接进去),而且在每轮 Feistel 中也会作为轮函数的输入一部分。具体地,FF1 会把 T 编码成固定长度的字节串 T',然后分成左右两块,分别喂给左右两半的计算。这部分细节有点绕,我建议你记两个要点:

  • T 的长度会影响子密钥的生成顺序。
  • T 的尾端一定要带长度标识,防止多组 T 拼接后歧义。
关键原则: T 永远不要包含敏感信息,因为调整值本身是不加密的。你想想看,如果 T 里存了密码哈希,那攻击者可以直接读取 T 来得到哈希值——这不就泄露了吗?我在一个金融项目里就犯过这个错误,还好只是内部测试环境。

左右半部分 L 和 R 的分配

输入 X(要加密的明文)首先要被分成左右两半 L 和 R。怎么分?很简单:根据字符集大小和长度 n,把前一半字符给 L,后一半给 R。但细节里有魔鬼——

FF1 的 n 是字符个数,不是字节长度。比如:数字 0-9 是 10 个字符,那么 L 的长度是 floor(n/2),R 的长度是 ceil(n/2)。然后按字符索引拆分,而不是按字节。这一点和很多其他加密方案不一样。我见过有人直接用字节切分,结果加密结果完全不对,折腾了一天才找到原因。

字符集类型示例n=7 时 L 长度n=7 时 R 长度
数字 [0-9]银行卡号34
字母 [A-Z]车牌代码34
十六进制 ID34

变量的生命周期与初始化

在 FF1 的一轮循环开始前,我们需要保证以下变量的值已经分配好:

  • K:主密钥,长度 16、24 或 32 字节。
  • K1, K2:子密钥,长度与 K 相同。
  • T:调整值,已编码为字节串。
  • L, R:已经按字符集分割好的两个列表(注意是字符索引,不是字节)。
  • n:明文长度(字符数)。
  • radix:字符集基数(数字为 10,字母为 26 等)。
实用技巧:我习惯在分配 R 的时候提前把 R 的字符列表转成整数(每个字符映射到 0~radix-1),这样后续的数值计算速度快很多。比如 '1234' 转成 int 列表 [1,2,3,4]。这个转换在 Python 里用 list(map(int, r_str)) 就能搞定。

核心流程图:密钥派生与变量分配全景

下面我用一张 SVG 图把整个流程串起来。你看完后应该能一目了然:

FF1 密钥派生与变量分配流程 主密钥 K 调整值 T 密钥派生函数 AES-CMAC / HMAC K₁ , K₂ 明文 X 按 radix 字符分割 L = X[0..⌊n/2⌋] R = X[⌈n/2⌉..n] L R 进入 Feistel 循环 说明 1. K₁ 用于奇数轮 2. K₂ 用于偶数轮 3. T 在每轮中参与混音

实践避坑指南

⚠️ 曾经我踩过的坑:
1. 子密钥派生时,拼接顺序必须严格遵守标准:先 K 后 T,而不是反过来。有一次我图省事直接 T+K,结果和标准库对不上。
2. T 的长度编码方式:如果 T 长度不是 0,一定要在 T 前面加上 2 字节大端长度。忘加这个,你的实现和任何其他 FF1 实现都不兼容。
3. 分割 L 和 R 时,注意 n 是字符索引数量,不是字节长度。多字节字符(如中文)必须先转成单字节字符集(如 Base32),否则分割位置会错。

好了,这一章把密钥派生和变量分配的骨架搭起来了。你做完这些基础设置后,下一步就可以直接开始 Feistel 轮循环了。在实际开发中,我建议你先把子密钥派生写成一个独立的函数,方便单元测试。调试的时候打印出 K1 和 K2 的十六进制值,看一眼就知道派生逻辑对不对。