RSA 加密算法
RSA 由罗纳德·李维斯特(Ronald Rivest)、阿迪·萨莫尔(Adi Shamir)和伦纳德·阿德曼(Leonard Adleman)于 1977 年提出,并在 1978 年发表原始论文;“RSA”来自三人姓氏的首字母。它是最早获得广泛应用的公钥算法之一,把公钥加密与数字签名从概念变成了可实现的方案。
RSA 主要用于加密少量密钥材料、生成数字签名,以及支撑数字证书、软件签名和 PKI 信任体系。PKCS #1 / RFC 8017 至今仍在规范 RSA 的加密与签名方案,NIST FIPS 186-5 也纳入了 RSA 数字签名。
Alice 要给 Bob 发送消息,她先获取 Bob 公开的公钥,再用公钥加密消息。密文可以通过公网传输,但只有 Bob 本地的私钥能恢复原文。
RSA 首先要解决的不是“怎样把消息算成一串数字”,而是一个通信难题:双方只能通过公网联系时,如何建立不依赖预先共享秘密的保密通信?
2.1 明文通过公网会发生什么
Section titled “2.1 明文通过公网会发生什么”Alice 要通过公网给 Bob 发送“明天 10 点开会”。如果这句话以明文传输,攻击者可能读到它,也可能把“10 点”替换成“11 点”。
公网通信需要同时满足三个目标:
- 机密性:攻击者不能读懂消息。
- 完整性:Bob 能够发现消息被修改。
- 身份可信:Bob 能够确认消息确实来自 Alice。
加密首先解决机密性,防篡改和确认身份还需要其他机制。
2.2 共享一把密钥可以保护消息吗
Section titled “2.2 共享一把密钥可以保护消息吗”一个直接方法是:Alice 先把密钥交给 Bob,随后用这把密钥加密消息;Bob 收到密文后,再用同一把密钥解密。加密和解密依赖同一个共享秘密,这就是对称加密。
对称加密计算快,适合保护大量数据。但它把问题向前推了一步:如果 Alice 和 Bob 只能通过公网联系,Alice 如何安全地把密钥交给 Bob?
如果密钥在传递或保管时泄露,攻击者就可以读取由它保护的消息。当通信对象增多时,密钥的分发、保管和更换也会越来越复杂。
2.3 能否只公开加密方法
Section titled “2.3 能否只公开加密方法”密钥分发困难,是因为加密和解密共享同一个秘密。如果把它们拆成两把不同的密钥,就可以只公开加密所需的部分。
Bob 先在本地生成一对密钥:
- 公钥可以公开,任何人都能获取。
- 私钥只保留在 Bob 本地,不通过网络发送。
Alice 获取 Bob 的公钥,用它加密消息,然后通过公网发送密文。公钥可以生成密文,却不能直接用来解密;只有 Bob 本地的私钥能恢复原文。这就是非对称加密。
非对称加密避免了在通信前传递秘密密钥,但也带来了新问题:
- 公钥和私钥为什么能完成相反的运算?
- 公钥已经公开,为什么不能由它轻易算出私钥?
- Alice 怎样确认拿到的公钥真的属于 Bob?
- 非对称加密计算较慢,如何保护较长的消息?
RSA 利用数论构造这种公私钥关系,并把由公钥反推私钥的困难建立在大整数分解问题上。
RSA 的核心不是让公钥和私钥执行两种完全相反的计算,而是为两次模幂运算选择一对特殊的指数,使第二次运算能够抵消第一次运算。欧拉定理解释了这种抵消为什么成立。
3.1 公钥和私钥需要满足什么条件
Section titled “3.1 公钥和私钥需要满足什么条件”RSA 先把消息编码为整数 ,并要求 。使用公钥指数 加密:
再使用私钥指数 解密:
因此,问题可以归结为:怎样选择 和 ,才能保证
RSA 采用的条件是:
其中 是欧拉函数。这个条件为什么有效,需要从互质和简化剩余系说起。
3.2 什么是欧拉函数和简化剩余系
Section titled “3.2 什么是欧拉函数和简化剩余系”如果两个整数的最大公约数为 1,就称它们互质:
欧拉函数 表示小于 且与 互质的正整数个数。这些整数模 的余数组成一个简化剩余系。
例如,小于 10 且与 10 互质的正整数是:
因此 。将其中每个数乘以一个与 10 互质的数 3,再对 10 取模:
结果仍然是 ,只是顺序发生了变化。这个重排性质正是欧拉定理成立的关键。
3.3 欧拉定理为什么成立
Section titled “3.3 欧拉定理为什么成立”欧拉定理指出:如果 ,那么
设模 的一个简化剩余系为:
将每个元素乘以 ,得到:
要证明 只是 的重排,需要确认两件事。
第一,每个 仍然与 互质。因为 和 都与 互质,它们的乘积也与 互质,所以 对 取模后仍属于简化剩余系。
第二,不同元素相乘后不会得到相同的余数。假设
由于 , 在模 下存在乘法逆元,因此等式两边可以消去 :
简化剩余系中的余数互不相同,所以只有 时等式才成立。因此, 中的 个元素既都属于简化剩余系,又互不重复,只能是 的一次重排。
重排不会改变所有元素的乘积,所以:
整理左侧:
每个 都与 互质,因此它们的乘积在模 下存在逆元,可以从等式两边消去,最终得到:
3.4 RSA如何生成相互配合的公钥和私钥
Section titled “3.4 RSA如何生成相互配合的公钥和私钥”RSA 选择两个不同的大质数 和 ,并计算:
因为小于 的整数中,不能与 互质的数一定是 或 的倍数,所以:
接着选择与 互质的公钥指数 ,再求出 在模 下的乘法逆元 :
这个同余式等价于存在整数 ,使得:
扩展欧几里得算法可以求出满足
的一组整数系数,其中 对 取模后的最小正整数就是私钥指数。
3.5 为什么私钥能够还原明文
Section titled “3.5 为什么私钥能够还原明文”先考虑 与 互质的情况。将 代入解密结果:
根据欧拉定理,,所以:
例如,取 、,则 、。选择 ,通过扩展欧几里得算法得到 :
如果消息为 ,加密结果为:
解密时:
消息因此被还原。
3.6 消息与模数不互质怎么办
Section titled “3.6 消息与模数不互质怎么办”上面的推导使用了欧拉定理,所以要求 。但 RSA 不能假定每条消息都与 互质,还需要补全这种情况。
因为 ,可以分别在模 和模 下判断。以模 为例:如果 ,那么 和 模 都等于 0;如果 ,则可以根据费马小定理得到 ,仍有 。
模 的结论相同。因此, 同时是 和 的倍数。由于 、 是不同的质数,它也是 的倍数,所以对所有消息都有:
这说明 RSA 的加密和解密能够相互抵消;欧拉定理给出主要结构,分别对 、 讨论则补全了非互质消息的情况。
3.7 能否通过公钥破解RSA
Section titled “3.7 能否通过公钥破解RSA”攻击者能够获得公钥 和密文 ,但不知道组成 的质数 、。最直接的通用破解方法是先分解 ,再按照正常的私钥生成过程恢复 :
- 将 分解为 。
- 计算 。
- 计算 在模 下的逆元,得到私钥指数 。
- 计算 ,恢复消息。
NIST SP 800-56B 给出的 RSA 基本密钥形式也是公钥 、私钥 ,其中 ,而 、 必须保密。
在前面的示例中, 很容易被分解为 。攻击者随后得到 ,再由 求出 ,便可以解密密文。真实 RSA 使用极大的质数,目的就是让第一步无法在可接受时间内完成。
如果依次尝试不超过 的因数,试除法的时间复杂度是:
若 的二进制长度为 ,则 ,复杂度可以写成:
实际分解大型 RSA 模数不会使用试除法。目前已知最快的通用经典算法是通用数域筛,其启发式时间复杂度为:
它比 快,但相对于输入长度 仍不是多项式时间。分解出 、 后,计算 、求 和解密都可以高效完成,因此破解过程的主要成本集中在大整数分解。
这只描述了密钥正确生成、协议正确使用时的数学攻击。弱随机数、重复质数、错误填充和侧信道泄露可能绕过大整数分解,直接暴露私钥或明文。
3.8 RSA破解的代码实现
Section titled “3.8 RSA破解的代码实现”下面的代码实现上一节的破解过程:先分解 ,再计算 和私钥指数 ,最后通过快速模幂恢复明文。
这段代码只适用于题目刻意给出的较小模数。真实 RSA 的模数无法使用试除法在可接受时间内完成分解。
#include <iostream>#include <tuple>#include <utility>
using i64 = long long;using ExtGcdResult = std::tuple<i64, i64, i64>;
// 返回 (g, x, y),满足 g = a * x + b * yExtGcdResult ext_gcd(i64 a, i64 b) { i64 r0 = a, x0 = 1, y0 = 0; i64 r1 = b, x1 = 0, y1 = 1;
while (r1 != 0) { i64 q = r0 / r1; std::tie(r0, x0, y0, r1, x1, y1) = std::make_tuple( r1, x1, y1, r0 % r1, x0 - q * x1, y0 - q * y1 ); }
return {r0, x0, y0};}
// 求 a 在模 mod 下的乘法逆元i64 mod_inverse(i64 a, i64 mod) { auto [g, x, y] = ext_gcd(a, mod); if (g != 1) { return -1; } return (x % mod + mod) % mod;}
// 计算 base^exponent mod modi64 mod_pow(i64 base, i64 exponent, i64 mod) { i64 result = 1 % mod; base %= mod;
while (exponent > 0) { if (exponent & 1) { result = static_cast<__int128>(result) * base % mod; } base = static_cast<__int128>(base) * base % mod; exponent >>= 1; }
return result;}
// 试除分解 n = p * qstd::pair<i64, i64> factorize(i64 n) { if (n % 2 == 0) { return {2, n / 2}; }
for (i64 p = 3; p <= n / p; p += 2) { if (n % p == 0) { return {p, n / p}; } }
return {0, 0};}
// 已知公钥 (n, e) 和密文 c,恢复明文 mi64 decrypt(i64 n, i64 e, i64 c) { auto [p, q] = factorize(n); if (p == 0) { return -1; }
i64 phi = (p - 1) * (q - 1); i64 d = mod_inverse(e, phi); if (d == -1) { return -1; }
return mod_pow(c, d, n);}
int main() { std::ios::sync_with_stdio(false); std::cin.tie(nullptr);
i64 n, e, c; while (std::cin >> n >> e >> c) { std::cout << decrypt(n, e, c) << '\n'; }}分解 需要 次试除;扩展欧几里得算法和快速模幂分别需要 和 次迭代。因此,总时间复杂度由分解过程主导:
A.1 扩展欧几里得算法
Section titled “A.1 扩展欧几里得算法”扩展欧几里得算法的核心是:欧几里得算法产生的每个余数,始终是原始整数 和 的线性组合。 普通欧几里得算法只保留余数,扩展欧几里得算法同时记录这个线性组合的两个系数。
练习:洛谷 P1082「同余方程」。题目要求计算 的最小正整数解,本质是从 中求出 的系数 。
余数如何递推
令欧几里得算法最初的两个数为:
每轮进行带余除法:
因此,下一个余数为:
其中:
当余数变为 0 时停止,最后一个非零余数就是 。
线性组合的系数如何递推
假设每个余数都写成 和 的线性组合:
最初两个数对应的系数为:
所以:
将线性组合代入余数递推式:
因此,系数的递推公式是:
当算法到达最后一个非零余数 时,同时得到:
这就是扩展欧几里得算法比普通欧几里得算法“扩展”的部分。算法只需迭代维护相邻两个余数及其系数,并按照上面的公式不断更新。
返回值可以直接使用 std::tuple 表示,三个元素依次是 、 的系数 和 的系数 。循环始终保持两个不变量:
右侧的元组会先使用旧状态完成计算,再统一写回左侧变量,因此不会因为提前修改某个变量而破坏本轮递推。对于 P1082,输入保证 ;算法返回的 可能为负数,需要将它转换到模 的标准范围。
C++ 实现
#include <iostream>#include <tuple>
using ExtGcdResult = std::tuple<long long, long long, long long>;
// 返回 (g, x, y),满足 g = a * x + b * yExtGcdResult ext_gcd(long long a, long long b) { long long r0 = a, x0 = 1, y0 = 0; long long r1 = b, x1 = 0, y1 = 1;
while (r1 != 0) { long long q = r0 / r1; std::tie(r0, x0, y0, r1, x1, y1) = std::make_tuple( r1, x1, y1, r0 % r1, x0 - q * x1, y0 - q * y1 ); }
return {r0, x0, y0};}
int main() { std::ios::sync_with_stdio(false); std::cin.tie(nullptr);
long long a, b; std::cin >> a >> b;
auto [g, x, y] = ext_gcd(a, b); long long answer = (x % b + b) % b; std::cout << answer << '\n';}