做密码学的人,早晚都会在最基础的地方栽个跟头。两年前我帮某公司排查一个加解密模块的稳定性问题,对方一脸无奈,说RSA密钥生成偶尔会在初始化流程里抛异常,提示“非法的公钥指数”。我让他们在关键路径上打印一行日志,把 e 和 φ(n) 输出看了一眼,问题立刻清楚了——两个数不互素,而他们当时用的互素校验,竟然是纯枚举试除。这种方法放在 512 位参数上还能忍,放到 2048 位场景里直接不可用。当时我和这位朋友说得很直白:“你缺的不是运气,是欧几里得算法。”
这篇要聊的内容,就是密码学算法里这个看起来平平无奇、却承担了大量脏活累活的基础工具:欧几里得算法。它解决的是什么问题?简单说,求两个整数的最大公约数。密码学里为什么离不开它?因为RSA要验证公钥指数与欧拉函数互素,要计算私钥指数的模逆元;SM2、ECC、ElGamal、DSA这些算法里,原点运算、签名校验、密钥恢复,到处都需要最大公约数和模逆运算。对刚接触密码学实现的开发者来说,把欧几里得算法的原理、扩展形态、工程实现和侧信道风险一次性搞清楚,后面看密钥生成代码、排查签名失败问题,都会顺畅很多。
1. 为什么密码学离不开这个“老古董”算法
1.1 从RSA密钥生成说起
如果你写过RSA密钥生成,一定对这两步有印象。生成两个大素数 p 和 q,计算 n = p * q,再算欧拉函数 φ(n) = (p - 1) * (q - 1),然后要选一个公钥指数 e,让它满足 1 < e < φ(n),并且 gcd(e, φ(n)) = 1。这里 gcd 就是最大公约数,e 和 φ(n) 互素,说明两者不存在除 1 以外的公共因子,这样才能保证后面 e 在模 φ(n) 下存在逆元。
第二个地方更重要:计算私钥指数 d,让 d 满足 e * d ≡ 1 (mod φ(n))。这个 d 就是解一个模线性方程,本质上是求 e 在模 φ(n) 意义下的乘法逆元。这一步不能暴力枚举,也不能拿高中数学的“试除法”硬凑,标准做法就是扩展欧几里得算法。我见过不少教学代码把这一步直接用数学库的某个内置函数糊过去了,比如某些平台提供的 inv 方法,这当然可以,但如果不理解底层原理,一旦换到没有内置支持的平台或者需要自己做硬件移植,马上就会卡壳。
欧几里得算法的价值,恰恰体现在它只用了非常基础的整数运算——取模、除法和减法,就完成了上述两个核心步骤。可以说,没有它,RSA密钥生成的效率和可靠性都会遭到毁灭性打击。
1.2 互素判断的本质,就是最大公约数为1
很多人第一次看到 gcd(e, φ(n)) = 1 这个条件时,会下意识地从“因数分解”的角度去理解,觉得需要把 e 和 φ(n) 分别因式分解,再比较有没有公共因子。这个思路在小数字上没问题,但放在密码学的大数场景里就是灾难。
换个角度想:如果 e 和 φ(n) 有公共因子 g(g > 1),那么它们的最大公约数肯定是 g 的倍数;反过来,如果最大公约数是 1,那它们就没有任何大于 1 的公共因子。所以“互素判断”简化为“算一次最大公约数,看结果是否为 1”。这个转换非常关键,它把“因数分解”这一步完全绕开了。你可能会有疑问:最大公约数不也要做分解吗?欧几里得算法的巧妙之处就是它不需要分解,只用反复取模就能把最大公约数“磨”出来。
我习惯用一个很土但好记的类比来解释这个算法。假设你手头有两根长度分别为 a 和 b 的绳子,想找一根最长的尺子,能同时把两根绳子都分成整数段。欧几里得的思路是:拿长绳子去比短绳子,长绳剪掉若干个短绳的长度,剩下的余绳长度一定小于短绳;接着用余绳去比短绳,再取余,直到某一次刚好整分完,最后一次作为“尺子”的余绳就是答案。这个过程跟密码学里的大数计算完全一致,只是把绳子换成了大整数。
2. 朴素欧几里得算法:原理与实现
2.1 辗转相除的数学证明
先看严格定义。给定整数 a 和 b(通常假设 a >= b, b > 0),反复做带余除法:
a = k * b + r,其中 0 <= r < b
如果 r = 0,那么 b 就是 a 的因子,显然 gcd(a, b) = b。如果 r != 0,就继续计算 gcd(b, r)。这个过程能收敛,因为余数 r 严格小于 b,每次数字规模都在下降,最终某一步余数会变成 0。
为什么可以这样递推?因为 a 和 b 的任意公约数 d,同时整除 a 和 b,那么由 a = k * b + r 可以推出 d 整除 r;反过来,d 若同时整除 b 和 r,则 d 也整除 a。所以集合 {a, b} 的公约数集合与 {b, r} 的公约数集合完全一致,最大公约数自然相同。这就是转发相除的正确性证明。
实际工程里还有一个重要的边界条件:当 b 为 0 时,gcd(a, 0) = a。因为任何数都是 0 的约数的模糊说法会导致误导,但在这个定义下,0 能被所有非零整数整除,所以最大公约数就是 a 本身。很多实现会漏掉这个边界判断,导致死循环或除以零错误。
2.2 迭代实现与递归实现的选择
我见过很多人写欧几里得算法,第一反应是递归版本,因为它和数学定义几乎一一对应:
def gcd_recursive(a, b): return a if b == 0 else gcd_recursive(b, a % b)
这个代码没问题,在Python里对于常见位长也不会真的栈溢出。但在C或C++这类语言里,如果递归深度较大,栈开销仍然是个隐患。虽然欧几里得迭代次数不会太多,但写成循环更稳妥,也更容易让阅读者明白计算机实际做了什么:
def gcd_iterative(a, b): while b != 0: a, b = b, a % b return a if a >= 0 else -a
注意我在返回值上做了取绝对值处理,因为负数参与取模运算时,不同语言的余数符号规则不同。严格来说,密码学里我们几乎总是对正整数求最大公约数,所以这个细节一般不用操心,但写通用工具函数时最好考虑进去。
这里给个忠告:求最大公约数的代码,优先用迭代版本,别图省事写递归。不是因为递归算法有问题,而是为了避免在嵌入式环境或者限制栈大小的场景里出现意外崩溃,毕竟这个函数往往会被放在库的最底层频繁调用。
2.3 复杂度:比你想的快得多
欧几里得算法的复杂度结论是 O(log min(a, b)) 次取模运算。也就是说,哪怕 a 和 b 是 1024 位的大整数,也只需要几千次以内的大数除法就能完成。这里的对数增长来源是:每次取模后余数至少比原数小一半。最坏情况出现在输入是斐波那契数列的相邻两项时,迭代次数才会逼近理论最大值。
这个复杂度特性,是它能在密码学场景站稳脚跟的根本原因。你可能觉得一次大数除法本身就很慢,但 RSA 密钥生成时,用扩展欧几里得计算一次私钥指数,消耗的时间相对于大素数检索来说几乎可以忽略不计。我在某次性能测试里用 2048 位参数跑过,单次模逆运算占比不到整个密钥生成流程的 0.5%。所以不要一听到“大数除法”就觉得是性能瓶颈,关键是控制除法次数。
3. 扩展欧几里得算法:密码学真正的重头戏
3.1 从一个线性方程说起
朴素欧几里得只做了求最大公约数这一件事,但密码学还需要另一件事:已知 a 和模数 m,求 a 在模 m 下的乘法逆元。这个需求精确对应扩展欧几里得算法。
扩展欧几里得要解的不定方程是:
a * x + m * y = gcd(a, m)
当 gcd(a, m) = 1 时,方程变成:
a * x + m * y = 1
两边同时模 m,得到 a * x ≡ 1 (mod m)。也就是说,x 就是 a 在模 m 下的逆元。这是非常漂亮的一步转化,把“找逆元”变成了“解二元一次不定方程的一组整数解”。
递归公式也不复杂。设 ext_gcd(a, b) 返回 (g, x, y),满足 g = gcd(a, b) 且 a * x + b * y = g。当 b = 0 时,返回 (a, 1, 0);否则先递归计算 (g, x1, y1) = ext_gcd(b, a % b),然后有:
x = y1
y = x1 - (a // b) * y1
这个公式是从等式 a * x + b * y = b * x1 + (a % b) * y1 展开后整理出来的。数学上没错,但初学者常常搞混返回系数的含义,导致求出的逆元不对。
3.2 手算一遍:求17在模101下的逆元
我建议每个做密码学的开发都把扩展欧几里得手算一遍,这比背代码有用得多。拿个小例子:求 17 在模 101 下的逆元。
先用朴素欧几里得做求余链条:
101 = 5 * 17 + 16
17 = 1 * 16 + 1
16 = 16 * 1 + 0
最大公约数是 1,存在逆元。现在反过来,从倒数第二步开始回代:
1 = 17 - 1 * 16
16 = 101 - 5 * 17
带入第一个式子:
1 = 17 - (101 - 5 * 17) = 6 * 17 - 101
这就得到了 6 * 17 ≡ 1 (mod 101)。所以 17 在模 101 下的逆元是 6。验证一下:6 * 17 = 102,模范后确实等于 1。
小数例子看起来轻松,但把这个回代过程翻译成代码,就是扩展欧几里得的递归公式。回代时容易犯错的是符号,尤其在多个减法串联的时候。我建议先手算两三个例子,再去看代码,会豁然开朗。
3.3 代码实现与三个常见坑
用Python实现扩展欧几里得,递归版本如下:
def extended_gcd(a, b): if b == 0: return (a, 1, 0) g, x1, y1 = extended_gcd(b, a % b) return (g, y1, x1 - (a // b) * y1)
调用时注意顺序:如果传参是 extended_gcd(a, b),返回的 x 满足 a * x + b * y = g。如果传参顺序写反,逆元结果就成了 b 的逆元而不是 a 的,这是一个高频错误。
迭代版本更复杂一点,核心是维护两组系数(旧系数和新系数),每次用商更新。这里不展开代码,因为性能差异在密码学场景里通常不是首要考虑因素,递归的栈深也不会很大。
三个坑你必须记住:
第一个坑是负数逆元的处理。在C语言里,-17 % 101 的结果通常是一个负数,而逆元要求落在 0 到 m-1 之间。所以拿到扩展欧几里得返回的 x 之后,要做一次归一化:if (x < 0) x += m。在Python里则简单得多,因为Python的 % 运算符天然返回非负结果,直接 x % m 就行。
第二个坑是系数溢出。虽然 gcd(a, b) 本身不会超过输入大小,但中间系数 x、y 的绝对值可能超过输入的数位长度,在C里用 int 保存 2048 位参数的系数一定会溢出。这也是为什么生产环境都要用大数库的原因之一。
第三个坑是性能误判。有些人看到扩展欧几里得里有一两次取模,就以为和朴素版一样快,实际上同样数量级,都是 O(log n) 次迭代,每次迭代包含一次大数除法和几次大数乘法。对于密钥生成来说完全够用,但在高频签名场景中,如果每个签名都要做一次求逆,仍然需要仔细优化,后面第五部分会讲更安全的做法。
4. 完整的RSA密钥生成实操流程
4.1 两个调用点,一张流程清单
前面说了,欧几里得算法在RSA密钥生成里有两个调用点。把完整流程拉出来看,会更清楚它的位置。
第一步,生成大素数 p 和 q。第二步,计算 n = p * q 和 φ(n) = (p - 1) * (q - 1)。第三步,选公钥指数 e。工程上最常用的 e 值是 65537,也就是 0x10001。第四步,调用 gcd(e, φ(n)),验证是否等于 1。如果等于 1,继续;否则说明参数不满足条件,需要重新选择 p 或 q,或者更换 e。第五步,调用 extended_gcd(e, φ(n)),拿到一组满足 e * d + φ(n) * y = 1 的系数,取 d = x 并规整为正数。第六步,输出公钥 (n, e) 和私钥 (n, d)。
这个流程里有一个容易被忽视的细节:如果第四步发现 e 和 φ(n) 不互素,正确的处理方式是重新生成 p 或 q,而不是继续往下算。有些实现图省事,直接把 e 换成下一个素数,这在特定参数下会引入安全隐患。为什么?因为一旦 e 和 φ(n) 有公因子,私钥 d 根本不存在,程序要么报错,要么生成一个错误密钥,这是绝对不可接受的。
为什么大家都选 65537 固定值?因为它的二进制形式是 1 后面跟 16 个 0 再跟 1,只有两个 1 位,模幂运算时乘法次数少,加密和验证签名都很快。同时它的互素失败概率极低,几乎不会导致重新选参的循环频繁发生。我个人的建议是:教学代码和初版原型都可以直接固定 65537,生产环境按照标准库的参数生成即可。
4.2 大数库里的实现思路
在Python里写RSA教学实现,直接用递归扩展欧几里得完全没有压力,因为Python自带大整数,底层维护了足够的精度。但在C/C++环境里,你得把大数表示成数组或者用现成库。很多大数库的扩展欧几里得实现并不直接用自然语言里的 int 数组,而是经过精心设计的长除法流程。
我曾经看过某中间件团队自己造的一个教学用大数库,他们实现的 gcd 就是先做一个大数取模,而取模本身依赖大数除法。大数除法的复杂度是 O(n^2) 级别的位操作,n 是数字位数。如果实现得很粗糙,用“逐位试商”做除法,128 位以上的数字就明显变慢。所以生产环境优先使用成熟的库,这不是不让你理解原理,而是不要重复造质量欠佳的轮子。
如果你想自己实现一遍,可以从 Python 的借用式大数除法开始,先把 divmod 功能做出来,再做 gcd 和扩展欧几里得,整个过程对理解底层非常有帮助。但产品上线时,我会直接替换成经过安全审计的库,这是原则问题。
4.3 二进制欧几里得变体:嵌入式场景
乘法指令和除法指令的代价在通用CPU上差异不大,但在部分嵌入式处理器和专用加密芯片上,除法可能非常昂贵,或者根本没有硬件除法指令。这时候可以使用二进制欧几里得算法,也叫 Stein 算法。
它的核心思路是不断把因子 2 提取出来:
如果 a、b 都是偶数,gcd(a, b) = 2 * gcd(a/2, b/2)。
如果 a 偶、b 奇,gcd(a, b) = gcd(a/2, b)。
如果 a、b 都是奇数,gcd(a, b) = gcd((a-b)/2, a)(这里 a > b 调整一下顺序即可)。
不断循环,直到某个数变成 0。
这个算法只用了移位、减法和比较,非常适合不支持除法的环境。代价是迭代次数可能比朴素欧几里得稍多,但每次操作更快。对应的还有二进制扩展欧几里得,在椭圆曲线密码实现中经常被视为求逆运算的备选方案。
写代码就很简单:
def gcd_stein(a, b): if a == 0: return b if b == 0: return a shift = 0 while ((a | b) & 1) == 0: a >>= 1 b >>= 1 shift += 1 while (a & 1) == 0: a >>= 1 while b != 0: while (b & 1) == 0: b >>= 1 if a > b: a, b = b, a b -= a return a << shift
注意这个版本的循环次数与输入二进制长度相关,实际调试时可以用随机数对拍,确保和朴素版本输出一致。这里给一个专业建议:如果你的目标是嵌入式无除法平台,优先考虑 Stein 算法;如果你的目标是简单可读、便于审计,朴素欧几里得更合适;如果目标是硬件侧信道安全,两种都不够,看第五部分。
5. 安全视角:求逆元的时间线也是隐私
5.1 侧信道风险:不只有数学正确,还要执行安全
很多刚接触密码学实现的人会犯一个思维定势:只要函数返回的数学结果是对的,就认为实现是安全的。实际完全不是这样。现代密码工程里,私钥相关的运算必须考虑侧信道攻击,其中包括时间攻击、功耗分析和电磁泄漏。
朴素的扩展欧几里得算法,每一步取模运算的耗时和当前操作数的实际位长相关。如果操作数是私钥相关数据,那么攻击者通过数千次测量签名时间,就可能推断出数据的某些结构信息。我见过一份安全测试报告,某个智能卡固件的ECDSA实现中,素域求逆函数耗时与输入值的汉明重量高度相关,测试团队通过统计恢复了一部分秘密随机数的低位信息。后来修复方式是换成固定步数的蒙哥马利求逆,并在无关路径上填充伪运算。
这不是危言耸听,而是已经被反复验证过的攻击面。所以在写涉及私钥处理的代码时,我的第一反应是:这个函数会不会在秘密数据上直接运行?如果在,就要检查它的执行时间是否和输入相关。
5.2 常数时间求逆的常见路线
想要常数时间求逆,有几种思路。
第一,使用费马小定理。在素数域上,a 的逆元等于 a^(p-2) mod p。因为指数是固定值,配合上固定步数的模幂实现,整体执行路径趋于常数时间。这个方案在ECC素域求逆中用得很多。
第二,使用蒙哥马利求逆。它把普通除法和取模转换为针对二进制表示的迭代移位操作,可以在固定轮数内完成,兼容蒙哥马利域中的运算。
第三,在使用扩展欧几里得的同时,给私钥相关输入做盲化。也就是先用随机数 r 乘以待求逆的值,求出逆元后再用 r 的逆元修正结果,让攻击者观察到的输入与真实秘密无关。这个方案实现简单,但对随机数质量有要求。
我再强调一句判断标准:公钥操作中使用扩展欧几里得完全没问题,因为它处理的 e、n、公钥都是公开数据;私钥相关的求逆才必须认真考虑侧信道问题。搞清楚这条界限,就不会被“所有求逆都要常数时间”这种过度泛化的建议带偏。
5.3 ECC、SM2等场景下的求逆
椭圆曲线密码里的核心运算是点加和标量乘法。直接使用仿射坐标表示椭圆曲线点时,一次点加就需要一次模逆运算,标量乘法要做几百次,光求逆就能拖垮性能。所以工程实现普遍使用射影坐标或者雅可比坐标,把求逆推迟到整个标量乘法结束后的最后一次坐标转换。
这个设计思路,本质上就是用更宽的数据表达换取了“少算几次求逆”。最后一次求逆可以用扩展欧几里得,也可以用费马小定理。在国密SM2的推荐参数下,素域 p 是固定的 256 位大素数,费马小定理求逆的模幂运算在实际跑分中通常不慢,因为可以预计算底数和窗口。
所以你会看到,虽然欧几里得算法本身非常古老,但现代密码学里它依然在幕后默默工作:要么直接计算最大公约数,要么作为求逆工具的数学基石。理解了这一点,再看各种密码学标准里的优化技巧,就不会觉得它们是在空中楼阁上跳舞。
6. 常见问题与排查心得
6.1 求出的逆元是负数,怎么处理
这是刚写扩展欧几里得最容易撞上的问题。在C语言中,如果计算出的 x 是负的,直接参与后续点乘或者密钥计算几乎一定出错。处理方式很简单,归一化到 [0, m-1]:
if (x < 0) { x += m; }
注意这里可能要加多次吗?不用。因为 x 的绝对值通常不会大于 m,一般加一次就够了,但保险起见可以用 while。在Python里直接 x % m 就能拿到规范结果,这也是我推荐新手先用Python验证算法正确性的原因之一。
6.2 偶发的密钥生成失败,往往是互素验证没做好
我在开头提到的例子就是典型。那个模块的互素校验用枚举质数的方式判断,在位数较低时碰巧能过,但一升级到 2048 位参数就开始随机失败。排查时,我先让日志打出 e 和 φ(n) 的十进制值,然后用一次 gcd 就定位了问题。后来改成扩展欧几里得验证,再没复发。
如果你的RSA密钥生成偶尔失败,第一反应不要去看随机数种子,先查 gcd(e, φ(n)) 这一步的返回值和重试逻辑。很多实现只做了“报错”而没有“重选参数”,导致偶发失败被放大成线上事故。正确做法是循环重新生成 p 和 q,直到互素条件满足,并设置重试上限。
6.3 大数取模太慢
如果在生产代码里发现扩展欧几里得调用很慢,先别急着优化算法,看看底层大数除法的实现是否高效。有些实现用逐位试商,性能极差。可以试着把除法换成“短除法+长除法混合”,或者优化基数的选择,比如使用 2^32 或 2^64 作为基,可以让每次取模只处理更少的字。
另一条优化路径就是换用 Stein 算法或者蒙哥马利求逆,跳过除法。不过我要提醒:优化前先做性能剖析,别靠猜。我曾经遇到过某同事把 gcd 替换成 Stein 后反而变慢,因为目标平台的除法并不慢,而分支和移位的开销更大。
6.4 速查表:欧几里得算法在不同场景的选型
| 场景 | 推荐算法 | 注意事项 | 复杂度参考 |
|---|---|---|---|
| 验证互素 | 朴素欧几里得 | 用迭代实现,避免栈风险 | O(log n) 次取模 |
| RSA求私钥指数 | 扩展欧几里得 | 结果需要归一化为正数 | O(log n) 次迭代,每次大数除法 |
| 嵌入式无除法平台 | Stein算法 | 只做移位和减法,注意分支数量 | 迭代次数略多于朴素版 |
| ECC坐标恢复求逆 | 费马小定理或扩展欧几里得 | 私钥相关场景要评估侧信道风险 | 跟模幂长度和窗口有关 |
| 侧信道敏感环境 | 蒙哥马利求逆或盲化处理 | 优先固定执行时间 | 通常每次执行固定轮数 |
这张表不是标准答案,而是我基于实际项目经验的取舍。用的时候还是要结合自己的平台和性能目标做调整。
我个人在实际操作中的体会是:欧几里得算法很少成为性能瓶颈,但经常成为正确性和安全性的缺口。每次在新环境里搭建加解密模块,我都会先写一个对拍脚本——用 Python 的 math.gcd 验证自己的 gcd 实现,再用费马小定理的逆元结果和扩展欧几里得的结果互相对照,跑几百组随机数,全部通过才放心往下走。这个小习惯已经帮我抓出过好几回参数顺序传反、负数没有取模之类的问题。如果你正在读RSA源码或者自己写密钥生成,不妨也先把这个基础环节做扎实,它值得你花一个下午认真抠一遍。