做密码学相关的东西,绕不开一个坎:算法里的加减乘除,和你平时写的加减乘除不是一回事。我第一次在分组密码的字节代换环节栽跟头,就是因为顺手写了(a * b) % 256,结果前十几个字节的输出和参考实现一模一样,从某个位置开始全错,且错得毫无规律。后来把中间值一个个打出来对照才想明白,那个位置上的乘法根本不在整数环里发生,它落在有限域 GF(2^8)上——一个只有 256 个元素、加法和乘法都被重新定义过、但依然满足所有域公理的封闭世界。
这个系列走到第四篇,前三篇把编码、哈希、对称与非对称的整体轮廓铺了一遍,很多读者在后台问的问题其实都集中在一个地方:那些算法里的模运算到底在模什么、为什么一定要挑素数、为什么密钥长度和运算所在的代数结构强相关。这一篇就来把这块地基挖开。有限域不是我为了讲得高级而搬出来的名词,它是分组密码、纠错码、秘密共享、椭圆曲线签名背后共用的同一套地基。不管你是刚学密码学、能跑通库函数但说不清原理的新手,还是写业务代码时调过加密接口、想搞清楚里面发生了什么的老手,读完至少能做到两件事:第一,看到GF(p)、GF(2^8)这类记号不再发怵;第二,自己动手写一个能通过标准测试向量的有限域小工具,并且知道哪些地方最容易写错。
1. 密码学为什么要给自己造一个"封闭的小世界"
1.1 从一次对不上的乘法说起
那次排查花了我整整一个下午。背景是要实现一个字节级的变换,参考实现给出的中间结果是一个固定的对照表,我照着论文里的公式写(a * b) % 256,按理说该对得上。跑起来之后,绝大多数输入能对上,偏偏有些输入错得离谱,尤其是乘数里带高位的时候。当时的第一反应是查表写错了,于是把表逐行打印出来比对,没错;第二反应是怀疑字节序,翻来覆去也没问题。
真正点醒我的是一组具体数字:某个位置我算出来是0x00,参考实现是0xC1。乘出 0,这个信号太明显了——在正常整数乘法里,两个非零数相乘绝不可能得 0,除非发生了溢出截断。而 0x00 恰恰说明,我用的那个"乘法"实际上经历了某种让结果归零的路径。后来换成一位一位移、溢出时异或一个常数,结果立刻就对了。
那次之后我才真正理解:密码学里的运算不是"顺手拿编程语言的运算符凑一下"就能对付的。它要求一个非常强的性质——任何非零元素都能被除回去。而普通的整数取模,只要模数不是素数,这个性质立刻就崩。加密要能解密、签名要能验证、密钥交换双方要能算出同一个值,本质上全都依赖"可逆"这两个字。
1.2 群、环、域:三层递进,密码学只认最严的那层
要讲清楚有限域,得先把代数结构的三层台阶摆在明面上。这不是学院派的术语炫耀,而是决定"哪些运算能放心用"的判据。
群最宽松,只需要一个运算,满足封闭性、结合律、有单位元、每个元素有逆元。如果还满足交换律,就叫交换群。整数加法就是一个交换群,0 是单位元,-a 是 a 的逆元。
环要求两个运算,加法构成交换群,乘法满足封闭性和结合律,并且乘法对加法满足分配律。整数集合配上加法和乘法就是一个典型的环。环的问题是乘法不保证可逆——整数环里 2 的乘法逆元根本不存在,因为找不到整数 x 使得 2x=1。
域站在最顶上:加法构成交换群,去掉零元素之后的乘法也构成交换群,再加上分配律。翻译成人话就是——加减乘除四则运算全都能做,除法唯一不允许的就是除以零。有理数、实数都是域,元素无限多,叫无限域;元素个数有限的域,就是有限域,也叫伽罗瓦域,记作 GF(q) 或 F_q,q 是元素个数,称为域的阶。
密码学为什么死盯最严的那一层?因为密码算法的每一步几乎都在做"变换—逆变换"的操作。字节代换要有逆代换,混合列要有逆混合列,椭圆曲线上点加要有对应的点减,Shamir 秘密共享要靠插值多项式还原秘密。只要某一步的乘法不能求逆,整个算法就断了链子,安全性和正确性一起没了。环、群在某些环节也有用途(比如哈希函数里大量用的是群结构),但只要涉及"从结果反推输入"的正经密码协议,基本都要求域或者至少是有限域上的乘法群。
这里插一个常被忽略的点:域里没有"零因子"这个东西。也就是说,如果 a·b=0,那必然 a=0 或者 b=0。这条看起来平淡的性质,恰恰是模合数运算最致命的缺陷——在模 12 的环里,3×4=0,两个非零数乘成了零,你永远不知道该怎么除回去。密码算法对这种"信息凭空蒸发"的现象是零容忍的。
1.3 有限域的两条硬性定理:阶只能是 p^n,且同阶必同构
初学有限域,最需要立刻记住的是下面这条结论:
有限域的阶必然是某个素数 p 的正整数次幂 p^n;反过来,对任意素数 p 和任意正整数 n,都存在唯一(在同构意义下)的 p^n 阶有限域。
这条定理有两层杀伤力。第一层是否定性的:不存在 6 阶有限域,不存在 12 阶有限域,不存在 100 阶有限域。6 = 2×3,两个不同的素数因子,凑不出域来。如果你在设计协议时随手写"模 6 运算构成一个域",那从根上就错了,后面无论怎么调都救不回来。第二层是肯定性的:只要阶是素数幂,域就一定存在,而且本质上只有一种。这意味着 GF(8) 在数学上就只有那么一个,不管你是用 x³+x+1 还是 x³+x²+1 来构造,得到的两个域之间必然存在一一对应的同构映射,运算结构完全一致。
那个素数 p 有个专门的名字,叫这个域的特征。它的含义是:把单位元 1 反复和自己相加,加到第 p 次时必然得到 0,而 p 是使这件事发生的最小正整数。特征必须是素数,因为如果特征是合数 m=ab,那么 (a 个 1 相加)·(b 个 1 相加) = m 个 1 相加 = 0,而这两个因子都不是零,又冒出零因子了,和域的定义直接冲突。这个论证很短,但非常漂亮,值得自己动手在纸上推一遍。
于是有限域天然分成两大家族。一类是n=1 的素域 GF(p),元素就是 0 到 p-1 的整数,运算就是普通取模——这一族最贴近程序员的直觉。另一类是n≥2 的扩域 GF(p^n),元素不再是整数而是一组系数或者多项式,运算规则要重新定义。密码学里出场频率最高的两个成员,恰好一个来自前者(椭圆曲线、DH 密钥交换使用的 GF(p)),一个来自后者(分组密码、纠错码使用的 GF(2^8))。下面分开讲。
2. 素域GF(p):看着最像普通取模,坑都藏在边界上
2.1 封闭性之外,真正值钱的是"人人可逆"
GF(p) 的构造可以说是所有有限域里最省事的:元素集合取 {0, 1, 2, ..., p-1},加法定义为 (a+b) mod p,乘法定义为 (a×b) mod p,其中 p 必须是素数。就这么两行,其余全部性质自动成立。
封闭性几乎不需要验证,取模之后必然还落在集合里。结合律、交换律、分配律继承自整数环,取模不破坏它们。加法单位元是 0,a 的加法逆元是 p-a。这些都很平。真正需要盯住的是乘法群那部分:对每一个非零的 a,必须存在 b 使得 a·b ≡ 1 (mod p)。
这条性质成立的原因并不神秘,靠的是数论里一个基本事实——当 p 为素数、a 不被 p 整除时,gcd(a, p)=1,也就是 a 与 p 互素。而裴蜀定理告诉我们,两个数互素就必然存在整数 x、y 使得 ax + py = 1。把这个式子放到模 p 下看,py 那一项整个归零,于是 ax ≡ 1 (mod p),这个 x mod p 就是 a 的乘法逆元。整个推理链条的关键卡点只有一处:gcd(a,p)=1。p 是素数,这一条就自动满足;p 是合数,就一定能找到反例。
顺带说一个在实现里经常被用到的推论,费马小定理:对任意非零 a,a^(p-1) ≡ 1 (mod p)。两边同除 a,得到 a^(p-2) ≡ a^(-1) (mod p)。这条式子直接给出了一种求逆元的方法,也解释了为什么在素域上"除法"可以写成"乘以 p-2 次幂"。不过它有个前提——p 必须是素数,用在合数上会得到完全错误的结果,还不会报错,属于安静失败型 bug。
2.2 求逆的三条路:扩展欧几里得、快速幂、预计算表
实际工程里求素域逆元有三条常见路线,各有各的适用场景,选错了不一定出错,但性能会差出数量级。
| 方法 | 核心思路 | 时间复杂度 | 适用场景 | 主要短板 |
|---|---|---|---|---|
| 扩展欧几里得 | 解 ax+py=1,取 x | O(log p) 次除法 | 单次求逆,p 很大 | 有分支,常数时间难保证 |
| 快速幂 | 算 a^(p-2) mod p | O(log p) 次模乘 | 硬件实现、避免分支 | 模乘代价高,比前者慢数倍 |
| 预计算表 | 启动时算好全部逆元 | 查表 O(1) | p 较小、调用极频繁 | 内存 O(p),p 大时不可行 |
扩展欧几里得的写法值得贴一遍,因为这里最容易写错符号:
def egcd(a, b): if b == 0: return a, 1, 0 g, x, y = egcd(b, a % b) return g, y, x - (a // b) * y def inv_mod(a, p): a %= p if a == 0: raise ZeroDivisionError("0 没有乘法逆元") g, x, _ = egcd(a, p) if g != 1: raise ValueError(f"{a} 在模 {p} 下不可逆,p 可能不是素数") return x % p这段代码里有两个细节是新手常翻车的。第一,递归返回时坐标的交换顺序必须是(g, y, x - (a//b)*y),写成(g, x, y - (a//b)*x)是最常见的错误,会算出一堆看起来像模像样但实际错误的值。第二,最后一定要x % p,因为扩展欧几里得给出的 x 可能是负数,Python 的%会返回非负结果,但换成 C 或 Java 就必须手动加上 p。
关于第三条路,也就是预计算表,在 p 比较小的场合非常值得做。有个经典技巧是利用逆元的递推关系 inv[i] = -(p//i) * inv[p%i] % p,从 inv[1]=1 开始一路推到 inv[p-1],整体是线性时间。这个式子第一次看会觉得莫名其妙,本质上是把 p 拆成 p = (p//i)*i + (p%i),在模 p 下移项变形得到的。我在做批量运算的场合用过很多次,比逐个调扩展欧几里得快一个数量级以上。
2.3 一个最容易翻车的地方:p 到底是不是素数
讲真,素域相关的 bug,九成以上不是因为算法写错,而是因为模数根本不是素数。
这种情况在小规模测试里极少暴露,因为大家习惯用 7、11、13、97 这类一眼就是素数的值。一旦换成大数,尤其是有特殊形式的数,问题就来了。比如有人觉得 2^8 = 256 挺整齐,直接拿 256 当模数做运算,结果 2×128=0,一大堆元素没有逆元,整个乘法群塌掉。有人用 2^32 做模数,情况一样,因为它是 2 的幂,全是合数。还有人用 10^9+7 和 10^9+9,这两个确实是素数,是竞赛和工程里的常见选择,但如果换成 10^9+8,立刻就不是了,而且肉眼完全看不出来。
写代码时的自保手段很直接:在初始化阶段做一次素性检测。小素数用试除就够,大数用概率性检测算法,通常做若干轮底数的 Miller-Rabin 就能把误判率压到可以忽略的程度。这类算法判定"合数"是绝对可靠的,判定"素数"是概率性的,但漏判概率随轮数指数下降,工程上做 20 轮以上已经足够。
注意:别把"某一次 Miller-Rabin 通过"当成"确定是素数"。单轮检测对某些精心构造的合数有可能蒙混过关,轮数不足时结论只是"大概率是素数"。要绝对确定,得用确定性的判定方案,代价会高很多。
还有一个容易忽略的点是大素数的生成流程。正确做法是随机取一个指定位数的奇数,先拿小素数表快速筛掉绝大多数合数,剩下的再用 Miller-Rabin 逐轮验证,直到通过为止。跳过小素数预筛直接上 Miller-Rabin,速度会明显变慢,因为大部分随机数在第一轮就被淘汰了,而每一轮都要做一次模幂运算。
3. 扩域GF(2^m):让一个字节变成一个"数"
3.1 把二进制串当多项式:这不是比喻,是定义
GF(2^8) 是整篇文章的重头戏,因为几乎所有和字节打交道的密码算法都在这上面跑。它的元素个数是 256,恰好和一个字节的取值一一对应——这不是巧合,而是设计使然。
构造 GF(2^8) 的方式,是把每一个字节看成一个系数只能取 0 或 1 的七次以内多项式。比如字节0x57,二进制是0101 0111,从高位到低位对应 x^6、x^4、x^2、x、1 的系数,于是它就代表
x^6 + x^4 + x^2 + x + 1这不是一种比喻或者记法上的方便,而是定义本身。GF(2^8) 的元素就是这个集合里的 256 个多项式,运算规则完全按多项式来定。
加法极其简单:两个多项式相加,是对应系数相加,而系数只能取 0 和 1,所以 1+1=0,等价于不进位的按位异或。这意味着 GF(2^8) 的加法就是a ^ b,一行代码,没有任何进位处理,也就永远不会有"溢出"的概念。这一点和整数加法差别巨大,也是让很多人第一次看到时觉得别扭的地方——在这个世界里,0x57 + 0x83不是0xDA,而是0xD4。
乘法则复杂一些:先做普通的多项式乘法,把系数卷起来,得到的最高可能到 14 次,超出 8 位范围了,所以要再对一个八次多项式取模,把结果压回七次以内。这个"取模"的地位,和在整数里对素数取模完全对应——被模的那个多项式必须不可约,也就是不能分解成两个次数更低的非零多项式之积。不可约多项式在多项式环里的角色,等同于素数在整数环里的角色。这个类比抓住了,整个构造就通了。
3.2 不可约多项式的挑选与被选错之后的连锁反应
GF(2^8) 的构造依赖一个八次不可约多项式。工程上用得多的是下面两个:
| 多项式(十六进制) | 代数形式 | 典型用途 |
|---|---|---|
| 0x11B | x^8 + x^4 + x^3 + x + 1 | 分组密码 AES 的字节域 |
| 0x11D | x^8 + x^4 + x^3 + x^2 + 1 | RS 纠错码、二维码编码 |
这两个都是不可约的,都能造出合法的域,但它们造出的是两个不同的域——虽然作为域它们同构,但同一个字节在两边代表的"数值"不同,乘法结果也不同。这意味着:用 0x11B 算出来的表,拿去做 0x11D 的算法,结果一定是错的,而且不会报错,只会算出一堆错值。
我在跨模块对接时踩过这个坑。两个模块各自实现了一份 GF(2^8) 乘法,一个用 0x11B,一个用 0x11D,单测都通过了(因为各自的测试向量是对的),联调时数据全乱。排查时把两个模块的乘法表各导出 256 行对比,才发现第 3 行开始就分叉了。从那以后,我在所有涉及扩域的实现里都会把模多项式定义成一个显式常量,并在接口文档里写明,绝不允许隐式默认。
如果选了可约多项式会怎样?后果比选错更严重,因为域直接不成立。比如随手拿一个能分解的多项式做模运算,就会出现两个非零元素乘起来等于零的情况,乘法群塌掉,某些元素根本没有逆元。这在实现加密时会表现为:加密某些字节正常,加密另一些字节直接报错或者输出恒定值。表现得太像"随机偶发 bug",非常难查。
判断一个八次多项式是否不可约,理论上要做完整的因式分解检查,工程上更省事的办法是:优先使用标准里已经给出并验证过的值,自己造的新多项式一定要跑一遍不可约性验证再上线。验证思路是检查它是否能被所有次数不超过 4 的不可约多项式整除——因为两个次数都大于 4 的多项式相乘必然超过 8 次。这个检查的规模很小,写个循环就能跑完。
还有一个概念值得区分:本原多项式。不可约只保证域能建起来,本原多项式额外保证元素 x(也就是字节 0x02)是这个乘法群的生成元,能走遍全部 255 个非零元素。0x11B 和 0x11D 都是本原的,所以拿 0x02 或 0x03 反复乘,能遍历所有非零值。这个性质在做对数表的时候是必需的——如果生成元走不满一圈,表就建不全,后面查表会拿到未初始化的值。
3.3 乘法实现:朴素移位异或 vs xtime
GF(2^8) 乘法的朴素实现只有一个循环,思路是"边乘边降次":
POLY = 0x11B def xtime(a: int) -> int: """乘以 x(也就是乘以 0x02),必要时对 0x11B 取模""" a <<= 1 if a & 0x100: # 溢出了八次,需要降次 a ^= POLY return a & 0xFF def gf_mul(a: int, b: int) -> int: r = 0 while b: if b & 1: r ^= a a = xtime(a) b >>= 1 return r & 0xFF关键在xtime那三行。左移一位相当于乘以 x,如果左移后跑到了第九位(a & 0x100),说明出现了 x^8 这一项,而根据定义 x^8 ≡ x^4 + x^3 + x + 1,也就是 0x1B。所以异或上 0x1B 就完成了降次。注意这里异或的是 0x1B 而不是 0x11B,因为最高位那一项已经通过异或自身消掉了——这是新手写这段代码时最常见的错误,写成a ^= 0x11B会让高位的 1 重新出现。
这套写法的好处是直观、易验证,坏处是慢:每次乘法要跑 8 轮循环,每轮带分支。在需要大量乘法的场合(比如纠错码解码),这个开销不能接受。
优化方向是查表。既然非零元素只有 255 个,而且乘法群是循环群,那就可以取一个生成元 g,把每个元素表示成 g 的幂,于是乘法变成指数相加:
def build_tables(): exp = [0] * 512 log = [0] * 256 x = 1 for i in range(255): exp[i] = x log[x] = i x = gf_mul(x, 0x03) # 0x03 是生成元 for i in range(255, 512): exp[i] = exp[i - 255] # 多复制一份,省掉取模 return exp, log EXP, LOG = build_tables() def gf_mul_fast(a: int, b: int) -> int: if a == 0 or b == 0: return 0 return EXP[LOG[a] + LOG[b]]exp数组开成 512 长度是个很实用的小技巧:两个对数最大各是 254,加起来最多 508,直接索引不用做% 255。这种"用空间换掉一次取模"的做法在底层实现里非常常见,因为取模在多数 CPU 上都是相对昂贵的操作。
提示:查表法有两个必须处理干净的边界。一是零元素,它在乘法群外面,没有对数,必须提前判掉,否则
LOG[0]拿到的会是垃圾值;二是表的初始化顺序,必须在任何乘法调用之前完成,否则第一次调用拿到的是全零数组,结果全是 0,症状是"加密结果全是零字节",很容易被误判成密钥错误。
3.4 逆元与对数表:查表换速度
扩域上求逆元有两条主流路线,和素域那边思路一致但细节不同。
第一条是快速幂。因为乘法群的阶是 255,对任意非零 a 都有 a^255 = 1,所以 a^(-1) = a^254。实现上就是平方乘算法:
def gf_inv(a: int) -> int: if a == 0: raise ZeroDivisionError("0 在 GF(2^8) 中没有逆元") return gf_pow(a, 254) def gf_pow(a: int, n: int) -> int: r = 1 while n: if n & 1: r = gf_mul(r, a) a = gf_mul(a, a) n >>= 1 return r第二条就是刚才的对数表:a^(-1) = g^(255 - log_g(a)),一次查表加一次索引,常数时间。
| 方式 | 单次成本 | 是否需要额外内存 | 典型使用场景 |
|---|---|---|---|
| 快速幂 | 约 8 次乘法 + 8 次平方 | 无 | 内存紧张、调用稀疏 |
| 对数表 | 2 次查表 + 1 次加法 | 约 3KB | 高频调用、批量处理 |
| 完整逆元表 | 1 次查表 | 256 字节 | 极端追求速度、内存充裕 |
完整逆元表是最快的,直接在初始化时把 255 个非零元素的逆元全部算出来存好,调用时一次数组索引。代价是再多占 256 字节,对现代设备来说可以忽略。我在做一个需要反复做字节代换的离线工具时用的就是这个方案,实测比每次快速幂快了将近两个数量级,因为快速幂里那一堆乘法本身又调用了查表函数,层层嵌套的开销远大于一次直接索引。
这里有个必须强调的验证手段:把 1 到 255 全部跑一遍,检查 a 乘上它的逆元是否恒等于 1。这个循环只有 255 次,跑起来瞬间完成,但能一次性抓出绝大多数实现错误,包括表建错、边界漏判、生成元选错。如果某一个值出了问题,把它单独拎出来对照标准值排查,定位会非常快。
4. 有限域落到密码系统里的几个真实位置
4.1 分组密码:字节代换与列混合
AES 的字节代换层(S 盒)是有限域最经典的应用。构造过程分两步:先求每个字节在 GF(2^8) 下的乘法逆元(0 映射到 0),再对结果做一次固定的仿射变换。这个仿射变换本身是一个 8×8 的比特矩阵乘法加一个常量异或,作用是打散比特间的线性关系。
为什么非要用求逆?因为求逆是一个高度非线性的操作——在 GF(2^8) 里,a 到 a^(-1) 这个映射用任何低次多项式都逼近不了。密码学里的 S 盒最怕的就是"能用简单代数式表达",一旦能被低次多项式拟合,就存在代数攻击的可能。求逆恰好把这种线性结构彻底破坏了,这也是为什么 S 盒能在几十年的分析里始终保持良好性质。
第一步的 S 盒前 16 个值是:
63 7C 77 7B F2 6B 6F C5 30 01 67 2B FE D7 AB 76如果你自己写的 GF(2^8) 逆元加仿射变换能生成这一行,说明整个域的构造、模多项式、运算定义全都对了。这是我最喜欢用的一个"全链条"测试向量,因为它同时覆盖了乘法、求逆和比特级变换三层。
列混合层也依赖 GF(2^8),但形式上稍微绕一点。它把 4 个字节看成一个多项式,和一个固定多项式在 GF(2^8) 上相乘,再对 x^4 + 1 取模。注意这里模的 x^4+1 在 GF(2) 上是可约的(x^4+1 = (x+1)^4),所以这个结构只是一个环,不是域。这一点经常被误解,很多资料写"列混合在 GF(2^8) 上做多项式乘法",写得含糊,容易让人以为整个是个域运算。实际情况是:系数在 GF(2^8) 里,多项式本身在一个环里。这个区别不影响正确性,但影响你理解为什么列混合的矩阵可逆性需要单独验证。
还有一个细节值得说:列混合用到的乘数是 0x02 和 0x03,也就是乘以 x 和 x+1。乘以 0x02 就是前面写的xtime,乘以 0x03 就是xtime(a) ^ a。这么设计不是随意的,因为这两个乘数在硬件上可以只用移位和异或实现,不需要任何查表,非常适合资源受限的芯片。轮到逆列混合时,乘数变成 0x09、0x0B、0x0D、0x0E,就复杂一些,通常靠查表或者多层 xtime 叠加来实现。
4.2 秘密共享与纠错码:同一套数学两种用法
Shamir 秘密共享是我认为最能体现有限域价值的例子,因为它把"为什么必须用域"讲得特别清楚。
场景是这样:把一个秘密拆成 n 份,任意 k 份凑齐能还原,少于 k 份什么都得不到。做法是在 GF(p) 上构造一个 k-1 次多项式,常数项是秘密 S,其余系数随机,然后给每个参与者算一个点 (i, f(i))。还原时用拉格朗日插值,k 个点唯一确定一条 k-1 次多项式,代回 x=0 就得到 S。
这套方案的正确性完全依赖域的性质。插值公式里有除法,分母是 (x_i - x_j),只要所有 x_i 互不相同且域是域,这个差就一定非零、一定可逆,除法永远做得下去。如果换成模合数运算,就可能出现两个不同的 x_i 使差为零因子,插值直接失败。更要命的是安全性:如果模数不是素数,多项式在某些点上的取值分布会不均匀,攻击者能从中推出关于秘密的额外信息。
这里有个实操细节:参与者编号 i 必须从 1 开始,不能从 0 开始。因为 x=0 对应的就是秘密本身,把编号 0 分给某个参与者,等于直接把秘密给了他。这个 bug 我在审别人代码时见过一次,写得很隐蔽,用了一个从 0 开始的循环变量。
RS 纠错码是另一个方向的应用,用的是 GF(2^8) 而不是 GF(p)。思路是把数据块看成多项式系数,在若干个点上求值,把求值结果一起发出去。接收方拿到部分损坏的数据后,只要错误数量在纠错能力范围内,就能通过插值恢复原始多项式,进而还原数据。这里选 GF(2^8) 的原因很实际:一个符号正好一个字节,和存储、传输的粒度对齐,不需要额外的位打包。
二维码用的就是这个思路,只不过它用的模多项式是 0x11D 而不是 AES 的 0x11B。这也是为什么我说模多项式必须写进接口约定——同一个 GF(2^8) 名义,两个标准用的根本不是同一套乘法表。
4.3 公钥体系:离散对数与椭圆曲线
公钥密码这边,有限域的存在感更强,因为整个安全性的基石就架在"域上某些运算容易、反过来极难"这个不对称性上。
最典型的是基于 GF(p) 乘法群的离散对数:给定 g 和 g^x mod p,求 x。当 p 足够大、且 g 生成的子群阶含有大素数因子时,这个问题目前没有多项式时间的经典算法。Diffie-Hellman 密钥交换、ElGamal 加密、DSA 签名都建立在它上面。这里对 p 的要求非常具体:不只是素数,还得让 p-1 含有一个大素数因子,否则会被 Pohlig-Hellman 算法分而治之。这也是为什么工程上常用"安全素数"(p = 2q+1,q 也是素数)或者直接用素域上的子群。
椭圆曲线密码走的是另一条路,但地基还是有限域。曲线方程 y² = x³ + ax + b 定义在 GF(p) 上,所有坐标都是模 p 的元素,点的加法、倍点都在这套运算下进行。安全性来自椭圆曲线离散对数问题,在同等安全强度下密钥比 GF(p) 上的方案短得多——256 位左右的椭圆曲线密钥,安全性大致相当于 3072 位的模幂方案。这个差距的来源是:目前已知最快的求椭圆曲线离散对数的算法复杂度是 O(√n),而素域乘法群上有指标计算法这类亚指数算法,所以后者只能靠加大 p 来补偿。
椭圆曲线也支持 GF(2^m) 上的版本,曾经在硬件实现上有优势(因为加法是异或,无进位,电路简单),但近些年主流实践明显偏向素域,因为二元扩域上的一些曲线存在额外的攻击路径,且素域的实现生态更成熟。如果要做选型,我的建议很直接:新项目用素域,不要因为"二元域硬件快"这种老说法去冒险。
5. 自己写一个有限域小工具:从接口到测试向量
5.1 API 设计与测试向量
把前面所有东西落地成一个可复用的小库,接口设计其实很有限,但每一项都有讲究:
class GF2M: def __init__(self, m: int, modulus: int): # modulus 传入时应已去掉最高位,例如 0x11B 传 0x1B self.m = m self.mod = modulus self.mask = (1 << m) - 1 def add(self, a, b): return (a ^ b) & self.mask def mul(self, a, b): r = 0 for _ in range(self.m): if b & 1: r ^= a b >>= 1 carry = a & (1 << (self.m - 1)) a = (a << 1) & self.mask if carry: a ^= self.mod return r & self.mask这个通用版本可以直接构造 GF(2^4)、GF(2^8)、GF(2^16),只要传对模多项式。注意modulus参数传入的是去掉最高位的版本,这个约定要在文档里写死,因为两种传法都有人在用,混用必然出错。
测试向量我通常准备三层,从简到繁:
| 层级 | 检查内容 | 具体用例 |
|---|---|---|
| 基础性质 | 单位元、零元、交换律 | a·1=a、a·0=0、a·b=b·a |
| 标准向量 | 已知公开结果 | 0x57·0x83=0xC1、inv(0x53)=0xCA |
| 全域遍历 | 逆元完整性 | 对 1..255 全部验证 a·inv(a)=1 |
0x57 × 0x83 = 0xC1这个向量我从第一次实现一直用到现在,它同时覆盖了溢出的情况,能一次性验证降次逻辑写没写对。inv(0x53) = 0xCA则专门验证逆元,而且这个值可以顺着推出 S 盒中S[0x53] = 0xED,等于把整条链路都串起来了。
全域遍历那一步千万别省。它只要 255 次循环,跑完不到一毫秒,但能抓到所有"某些值对、某些值错"的问题。我遇到过一种情况:乘法表建的时候生成元选错了(用了 0x02 但在某个非本原多项式下),导致表只覆盖了 51 个元素,其余位置全是 0。单测里随手挑的几个值恰好都落在覆盖范围内,全过了;上线后遇到特定数据才炸。加上全域遍历后,这种问题一秒钟就暴露。
5.2 常见错误与排查对照表
把这几年见过和踩过的问题整理成一张表,遇到症状可以直接对号入座:
| 症状 | 最可能的原因 | 排查动作 |
|---|---|---|
| 部分输入结果正确,部分全错 | 用了整数乘法取模,而非域乘法 | 打印中间值,检查是否走了 xtime 路径 |
| 结果出现大量 0 | 对数表未初始化或生成元阶不足 | 检查表的前 10 项是否覆盖不同值 |
| 与参考实现从固定位置开始分叉 | 模多项式不一致(0x11B vs 0x11D) | 导出两边乘法表逐行对比 |
| 求逆报错或返回 0 | 未单独处理输入为 0 的情况 | 在函数入口加显式判零 |
| 逆元验证在个别值上失败 | 降次时异或了 0x11B 而非 0x1B | 检查 xtime 常量 |
| 解密结果与原文差几个字节 | 逆变换用了正变换的常量 | 核对逆 S 盒、逆列混合乘数 |
| 大素数场景下偶发异常 | 模数不是素数,存在零因子 | 初始化时加素性检测 |
这张表里最值得展开的是第一条。(a * b) % 256这个写法太自然了,自然到很多人不会怀疑它。它的错误之处在于:整数乘法的进位会污染结果,而 GF(2^8) 的乘法不允许进位。举个具体例子,0x80 * 0x02,整数算法得到 256,取模 256 得 0;正确做法是 0x80 左移变成 0x100,超出八位,异或 0x1B 降次,得到 0x1B。一个得 0,一个得 0x1B,差距就是从这里开始滚雪球的。
第二条也值得说。生成元选错的表现特别有欺骗性,因为四则运算看起来都正常,只是覆盖面不全。验证方法很简单:从 1 开始反复乘生成元,记录访问过的值,走 255 步之后看是否正好回到 1 且中途没有重复。这个检查比任何单点测试都可靠。
5.3 性能取舍与工程实践
最后聊点实际问题:这套东西在真实项目里怎么用才不拖后腿。
第一个取舍是表驱动还是现场计算。经验值是:单次调用场景(比如初始化时算几个密钥)用现场计算,代码短、无状态、不易出错;批量场景(纠错码编解码、大量字节代换)必须上表,否则性能差一到两个数量级。我做过一个粗略测量,纯位运算实现每字节乘法大约几十纳秒,查表版本能压到几纳秒,差距在高频路径上非常明显。
第二个取舍是表占多少内存。完整逆元表 256 字节,对数表加指数表大约 3KB,对一个嵌入式设备来说 3KB 可能就是预算的很大一块。这种情况下可以退一步,只用 exp 表(512 字节)配合每次乘法的 log 计算——但 log 又得查表,省不掉。真要极限省内存,就老老实实用位运算版本,牺牲速度换空间。
第三个是常量时间的问题。这个在安全敏感的实现里必须考虑:如果乘法或者求逆的执行时间依赖于输入值(比如位运算版本里的分支、查表版本的缓存命中差异),就可能被侧信道攻击利用。对策是查表时用扫描整个表的方式取值,或者改用不依赖数据的位运算加掩码。这个层面的优化已经超出"入门"的范畴,但知道有这回事,比不知道要强——至少在设计密钥相关路径时,不会随手把逆元表当成普通数组来用。
我自己的习惯是:把有限域的所有实现放在一个独立模块里,把模多项式、生成元、阶这些常量全部定义在模块顶部,对外只暴露 add、mul、inv 四个接口,任何调用方都不允许自己写位运算。这个约束看着麻烦,但它挡住了绝大部分跨模块不一致的问题。前面提到的模多项式不一致导致联调失败那次,之后我就把这个规矩定下来了,再没犯过同类错误。
还有一个小技巧是把域元素封装成带类型的值。如果语言支持,用一个轻量的包装类型而不是裸整数,能让"这个变量属于哪个域"在代码里显式可见。Python 里可以继承 int 做个子类,或者用 dataclass 加一个 field 字段。多写几行代码,换来的是编译器或者运行时的早期拦截——比上线后发现数据全乱要划算得多。
最后分享一个我认为了解有限域之后才真正体会到的事:密码学里那些看起来"莫名其妙"的常量,绝大多数都不神秘。S 盒的偏移量、列混合的乘数、曲线方程的系数、纠错码的生成多项式,背后都能追到某个具体的域、某个具体的不可约多项式、某个具体的安全权衡上。一旦把这块地基搞明白,再去看那些标准文档,会觉得它们其实写得非常直接——只是以前缺少了那把钥匙。