RSA算法是信息安全领域最经典的公钥加密方案,其核心数学基础涉及素数判定、欧拉函数、模逆元运算和快速模幂算法。这篇文章我会把RSA从数学原理到Python代码实现完整过一遍,覆盖密钥生成的每个环节、加解密流程的完整设计,以及我实际调试中踩过的坑。适合正在学习密码学、信息安全数学基础课程,或者想用代码验证数论知识的同学,看完可以直接照着实现一遍。
1. 项目概述与设计思路
1.1 核心需求拆解
标题是“Python实现RSA算法(信息安全数学基础)”,这句话其实透露了项目的三个关键诉求:第一是算法本身,需要能生成密钥、加密、解密;第二是数学基础,实现过程必须建立在数论原理之上,而不是简单调用现成库;第三是教学验证,需要代码与公式能一一对应,让人看得懂每一步在算什么。
很多人在这一步会直接安装第三方密码学库,但这样做的代价是完全遮蔽了算法内部的运行机制。RSA的核心价值恰恰在于它是数论知识的最佳落地案例:素性检测依赖费马小定理,密钥生成依赖欧拉函数,私钥计算依赖扩展欧几里得算法,加解密速度快慢依赖快速模幂。如果你把这些都封在库里,只调用一个函数,那数学基础这门课等于白学。所以这次实现我的原则是:不依赖任何第三方密码学包,只用Python内置模块,让每个数论概念都对应一段代码。
1.2 技术选型与版本说明
Python版本我用的是3.10,在不引入第三方依赖的情况下,有几个内置能力非常关键:
random.getrandbits(k)生成k比特随机整数,用于产生候选素数math.gcd(a, b)求最大公约数,用于e与φ(n)互质检查- 内置函数
pow(base, exp, mod)能在模数下高效计算大数幂,这个函数底层已经实现了快速模幂算法,性能远优于手写循环取模 int.from_bytes和int.to_bytes完成字节串与大整数之间的转换,承担明文的编码与解码
整个项目不需要额外安装任何包,读完代码在任意一台装有Python 3.8以上环境的机器上都能直接运行。如果你只在学习阶段跑通流程,这样一个纯标准库实现完全够用;如果后续要做性能测试或者超大密钥实验,再考虑引入GMPY2这类高性能大数库也不迟。
2. RSA背后的数学原理
2.1 模运算与同余:一切密码的起点
RSA的全部加解密过程都发生在模运算的空间里,理解模运算是第一步。所谓模运算,就是定义一个整数集合,所有数字在模n的规则下只取0到n-1的范围,任何超过n的值都回绕到对应的余数。这个概念和钟表走完12小时归零是同一个道理:14点实际上是下午2点,20点实际上是晚上8点,所有的计算都在12这个模下收缩。
同余是模运算的语言表达:如果a和b除以n的余数相同,就记为a ≡ b (mod n)。RSA加密时,把明文消息m做一次m^e mod n运算得到密文c;解密时,把密文c再送入c^d mod n,最后得到的结果必须等于原始的m,这个过程的正确性证明完全依赖于模运算的同余性质。
2.2 欧拉函数与欧拉定理
欧拉函数φ(n)定义为“小于n且与n互素的正整数的个数”。如果n是一个素数p,那么φ(p) = p - 1;如果n是两个不同素数p和q的乘积,那么φ(n) = (p-1)(q-1)。RSA之所以选择p和q的乘积作为公钥模数,建立安全性的同时也在数学上给自己留了后门——知道p和q就能算出φ(n),不知道p和q就难以算出来。
欧拉定理说:如果a与n互素,则 a^φ(n) ≡ 1 (mod n)。这个定理是整个RSA加解密一致性的基石。我们来推导一下为什么加密和解密能互相抵消:
- 加密:c ≡ m^e (mod n)
- 解密:m' ≡ c^d ≡ (m^e)^d ≡ m^(ed) (mod n)
如果密钥构造时保证 ed ≡ 1 (mod φ(n)),那么 ed = kφ(n) + 1,于是:
m' ≡ m^(kφ(n)+1) ≡ (m^φ(n))^k · m ≡ 1^k · m ≡ m (mod n)
关键的那一步用了欧拉定理把m^φ(n)归约为1,整个过程是数论中非常经典的“指数模周期收缩”。这也是为什么RSA被称为“基于欧拉函数”的加密方案。
2.3 费马小定理与素性检测
费马小定理可以看成欧拉定理在模数为素数时的特例:如果p是素数,a不被p整除,则 a^(p-1) ≡ 1 (mod p)。RSA需要生成一个随机的512位、1024位甚至更大的素数,不可能用试除法一个个检查到平方根,那样计算量大到无法接受。Miller-Rabin素性检测就是基于费马小定理的推广。
具体思想是:取一个候选素数n,写成 n-1 = 2^s · d(d为奇数)。然后随机选择底数a,计算 a^d mod n,再不停平方s次。如果这过程中没有出现1或n-1,那么n可以判断为合数;如果对一系列随机底数都没能判定为合数,则n有极大概率是素数。在实际实现中我通常取20轮随机底数,误判概率降至4^(-20)以下,实际工程中完全够用。
这里必须提一个陷阱:存在一类合数叫卡迈克尔数,比如561、1105,它们对任意与自身互素的底数都满足费马小定理,但毕竟不是素数。单纯用费马小定理判断会把这些数当素数放过去。这也是Miller-Rabin比朴素费马检测强的原因——它增加了“平方过程中出现非平凡平方根”这一检测条件,能识别卡迈克尔数。
2.4 扩展欧几里得算法求模逆元
RSA的私钥d需要满足 e·d ≡ 1 (mod φ(n)),也就是d是e在模φ(n)下的乘法逆元。求模逆元的常规办法是扩展欧几里得算法。欧几里得算法本身是求最大公约数,扩展版本在这基础上逆推,找到一组整数x、y满足 ax + by = gcd(a, b)。当gcd(a, b) = 1时,x刚好就是a在模b下的逆元。
举个例子帮助理解:令p = 61,q = 53,那么n = 3233,φ(n) = 60 × 52 = 3120。如果选择e = 17,就需要解:17d ≡ 1 (mod 3120)。通过扩展欧几里得可以算出d = 2753,因为17 × 2753 = 46801,而46801除以3120正好余1。这个例子和教科书完全一致,适合用来验证自己的实现是否正确。
3. 核心模块代码实现
3.1 Miller-Rabin素性检测:先筛掉合数
素性检测是整个RSA实现里最讲求效率的部分。实际工程中不会直接把一个随机数丢进Miller-Rabin,而是先做一次小素数试除,把能被2、3、5、7等小素数整除的数快速淘汰,只有通过这些初筛的数才进入Miller-Rabin,这样能省下大量的大数模幂运算。
import random def miller_rabin(n, rounds=20): if n < 2: return False # 小素数快速判断 small_primes = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37] for p in small_primes: if n % p == 0: return n == p # 将 n-1 写为 2^s * d d = n - 1 s = 0 while d % 2 == 0: d //= 2 s += 1 # 进行 rounds 轮检测 for _ in range(rounds): a = random.randrange(2, n - 1) x = pow(a, d, n) if x == 1 or x == n - 1: continue for _ in range(s - 1): x = pow(x, 2, n) if x == n - 1: break else: return False return True这里有一个细节值得展开:为什么小素数试除能显著提升速度。测试一个1024比特的随机数,它被2整除的概率是1/2,被3整除的概率是1/3,做完前几个小素数检测后,大约有一半以上的候选数会被直接淘汰,根本不需要进入昂贵的Miller-Rabin运算。
3.2 随机大素数生成:p和q的约束条件
生成素数候选的核心是循环:生成随机奇数→试除→Miller-Rabin。每次生成新数后检查一次,如果不通过就换下一个。这个循环在概率上收敛很快,实际测下来生成一对1024比特素数平均耗时在几百毫秒到几秒之间,完全可以接受。
import random def generate_prime(bits): while True: # 确保最高位为1,保证位数正确 candidate = random.getrandbits(bits) | (1 << (bits - 1)) | 1 if miller_rabin(candidate): return candidate这里有两个强制位操作用意很深。| (1 << (bits - 1))强制把最高比特位置为1,确保生成的数一定是bits比特;末尾的| 1强制把最低位置为1,确保是奇数,因为偶数除了2以外不可能是素数。如果没有这一步,每次生成偶数还要白做一轮检测,白白浪费性能。
在设置密钥参数时,p和q不能选得太接近。如果两个素数相差很小,它们的乘积n会接近一个完全平方数,攻击者可以通过费马因数分解法快速分解n。工程惯例是让p和q位数相同,但数值上保持足够的随机差。
3.3 密钥生成流程与e的选择
RSA的密钥生成是一个标准的五步流程:
- 生成两个大素数p和q
- 计算n = p × q
- 计算φ(n) = (p-1)(q-1)
- 选取公钥指数e,满足1 < e < φ(n)且gcd(e, φ(n)) = 1
- 计算私钥d = e^(-1) mod φ(n)
关于e的选择,常见选项有3、17、65537,实际中几乎清一色用65537(即2的16次幂加1)。原因有两个:其一是65537的二进制表示只有两个1,做快速模幂时平方的次数少,加密速度更快;其二是e太小容易遭受低指数攻击,比如多个用户使用同一个e且明文相关时可能被破解。65537在速度和安全性之间取了一个合理的平衡点。
import math def generate_keypair(bits=1024): p = generate_prime(bits // 2) q = generate_prime(bits // 2) n = p * q phi = (p - 1) * (q - 1) e = 65537 # 检查 e 与 phi 是否互质,如果不互质需要重新选择 e while math.gcd(e, phi) != 1: e += 2 d = modinv(e, phi) return (e, n), (d, n)3.4 扩展欧几里得与模逆元实现
模逆元的代码实现非常短,但背后逻辑值得反复看。扩展欧几里得算法在递归返回时同时计算x和y系数,当gcd结果为1时,x即是a在模m下的逆元。Python的取模运算符%保证结果落在[0, m-1]范围内,符合我们对模逆元的预期。
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 modinv(a, m): g, x, _ = egcd(a, m) if g != 1: raise ValueError("模逆元不存在") return x % m一个容易踩的坑是:当你算出d后,发现用d解密失败,多半不是扩展欧几里得的问题,而是前面e和φ(n)不互质但没检查出来。所以每一步都应该写断言来验证中间结果,特别是在学习调试阶段,多一行检查代码能节省大量排查时间。
3.5 快速模幂运算:内置pow的强大之处
新手实现RSA最容易犯的错误是直接用(m ** e) % n来加密,这在e很小、n很大时还能勉强度日,一旦e和n都换到大数,光是计算m的e次幂这一步就会直接让内存溢出。正确的做法是使用平方乘算法,边乘边取模,让中间结果始终控制在n的范围内。
你可能觉得手写平方乘算法很酷,但Python内置的pow(a, b, m)本身就是用C语言实现的快速模幂,性能远超Python层的手写版本。所以我建议直接使用pow(m, e, n),算法原理参考下面的代码理解即可:
def fast_pow(base, exp, mod): result = 1 base %= mod while exp > 0: if exp & 1: # 当前比特位为1 result = (result * base) % mod base = (base * base) % mod exp >>= 1 return result平方乘算法的核心思想是把指数写成二进制,比如d = 2753,其二进制为101011000001。加密过程只需要对每个二进制位做一次平方和一次可选乘法,乘法规模从d次降至log2(d)次,效率提升是指数级的。这就是为什么RSA能在没有特殊硬件的条件下完成大指数运算。
4. 加解密实操与完整Demo
4.1 分组与填充:字符串不能直接加密
RSA能加密的明文长度受限于模数n的大小。假设n是2048比特,那么一次最多能加密的整数范围是0到2^2048-1,换算成字节就是256字节。但这里有个更严格的约束:理论上明文整数必须小于n,工程上还必须比n小更多,因为要预留填充空间。
如果不做任何填充,直接把字符串切成小块塞进RSA加密,会暴露出严重的数学结构问题。RSA是乘法同态的:Enc(m1)·Enc(m2) = Enc(m1·m2),这个性质在无填充的情况下会被攻击者利用,构造选择明文攻击。所以实际使用中必须有填充方案,常见的是PKCS#1 v1.5和OAEP。我的教学demo里用了一个简化的PKCS#1 v1.5方式:加密前在明文中填充随机字节,让相同明文每次加密出不同的密文。
def pkcs1_pad(msg: bytes, k: int) -> bytes: # k 是模数 n 的字节长度 if len(msg) > k - 11: raise ValueError("消息长度超出限制") pad_len = k - len(msg) - 3 padding = b'\x00' + b'\x01' + b'\xff' * pad_len + b'\x00' return padding + msg def pkcs1_unpad(padded: bytes) -> bytes: if padded[0] != 0x00 or padded[1] != 0x01: raise ValueError("填充格式错误") idx = 2 while idx < len(padded) and padded[idx] == 0xff: idx += 1 if padded[idx] != 0x00: raise ValueError("缺少分隔符") return padded[idx + 1:]4.2 完整加密解密流程演示
完整的流程可以分为七个步骤:字符串转字节→填充→字节转大整数→模幂加密→密文大整数→模幂解密→去填充→字节转字符串。下面这个demo演示了完整闭环,你可以直接运行验证。
import os import random import math def rsa_encrypt(message: str, public_key): e, n = public_key k = (n.bit_length() + 7) // 8 # 模数的字节长度 msg_bytes = message.encode('utf-8') padded = pkcs1_pad(msg_bytes, k) m = int.from_bytes(padded, byteorder='big') c = pow(m, e, n) return c def rsa_decrypt(ciphertext: int, private_key): d, n = private_key k = (n.bit_length() + 7) // 8 m = pow(ciphertext, d, n) padded = m.to_bytes(k, byteorder='big') msg_bytes = pkcs1_unpad(padded) return msg_bytes.decode('utf-8') # 测试 message = "Hello RSA!" pub, priv = generate_keypair(bits=2048) c = rsa_encrypt(message, pub) decrypted = rsa_decrypt(c, priv) print(f"密文长度: {c.bit_length()} 比特") print(f"解密结果: {decrypted}")我实测了一组数据:当n是2048比特时,加密一个144字符的消息,公钥加密耗时约0.5毫秒,私钥解密耗时约35毫秒。解密比加密慢一个数量级,原因是私钥d通常和n差不多大,而公钥e是65537这种小值,指数长度差了很多。这个性能特性也是RSA在实际应用中通常只用于加密对称密钥(密钥封装)而不直接加密大量数据的原因。
4.3 密钥长度与安全性对照
密钥长度的选择直接决定安全级别,整理一张参数对照表供大家参考:
| 安全级别 | 模数n长度 | p、q各自长度 | 公钥操作耗时(加密) | 私钥操作耗时(解密) | 适用场景 |
|---|---|---|---|---|---|
| 较低 | 1024比特 | 512比特 | 约0.1毫秒 | 约5毫秒 | 仅供学习测试,不适合实际使用 |
| 通用 | 2048比特 | 1024比特 | 约0.5毫秒 | 约35毫秒 | 个人项目、多数业务系统安全要求 |
| 较高 | 3072比特 | 1536比特 | 约1.5毫秒 | 约80毫秒 | 安全要求较高的金融场景 |
| 最高 | 4096比特 | 2048比特 | 约3毫秒 | 约200毫秒 | 长期密钥、高安全敏感环境 |
1024比特的RSA在今天的计算能力下已经可以被有效分解,是不安全的状态。2024年前的规范已经明确禁止新的1024比特密钥用于生产环境,最低建议直接上2048比特。如果你做课程项目,跑1024比特做原理验证没问题,但如果项目说明里写了“信息安全”,那密钥长度至少要向2048比特看齐。
5. 常见问题与排查技巧实录
5.1 解密结果乱码或直接报错
解密出来一堆乱码或者抛出UnicodeDecodeError,十次有八次是编码问题,剩下两次是填充问题。
第一步检查明文编码是否统一:我这里统一用UTF-8编码,如果你在加密时用了GBK,解密却用UTF-8,必然乱码,这是最简单的错误。第二步检查解密后pkcs1_unpad是否能正确识别填充结构。我在一个项目中遇到过奇怪的现象:解密后的字节最前面多了两个0x00,结果padded[0]不等于0x00,直接抛错。排查下来发现是用户在加密前手动把字符串前面加了\x00,这个Null字节在编码后进入了明文区,导致填充解析错位。修复方式是在加密前明确剔除所有非可见控制字符,或者在解密时容忍开头有多余的0x00并自动跳过。
还有一个隐蔽的坑:int.to_bytes(k, byteorder='big')的k必须是模数n的字节长度。如果解密时算的k比实际n的字节长度小,高位丢失会直接造成数据损坏。建议统一写成k = (n.bit_length() + 7) // 8,不要在不同函数里分别造轮子。
5.2 素数生成死循环或误判
generate_prime死循环的常见原因是Miller-Rabin实现里的小素数试除部分返回了错误信号。我曾经在循环条件里写错了边界:if n % p == 0: return False,结果把素数本身也当成合数弹掉了。比如候选素数就是7,然后7 % 7 == 0返回False,永远找不到素数,从根上死循环。
另一个误判来源是卡迈克尔数。如果为了性能把Miller-Rabin轮数降到个位数,而候选数又恰巧是卡迈克尔数时,误判概率会上升。我建议学习阶段统一用20轮,不要为了快而省轮数;如果跑性能测试,要先用一批已知合数和卡迈克尔数验证你的detector能正确识别,再开始生成密钥。
5.3 pow用错导致内存爆炸
这是个非常经典的低级错误。有一次我帮一个同学排查程序卡死,发现他写的是pow(m, e)而不是pow(m, e, n)。2048比特模数下,e尽管只有65537,但m^e已经是一个指数级增长的巨型数字,处理完直接内存爆掉或者CPU跑满。排查时用任务管理器看到Python进程内存涨到几个GB,基本就是这个问题。
用内置三参数pow不会错,但如果好奇手写了平方乘算法,要注意底数初始就要取模:base %= mod,否则第一轮平方就会把数据撑爆。每次乘法结束后立即% mod,从始至终保证中间结果小于mod,这才是平方乘算法的正确姿势。
5.4 安全误区速查表
教科书式RSA是对数学原理的精简,直接拿来做工程是极其危险的。下面这几个误区别再踩:
| 错误做法 | 风险说明 | 正确做法 |
|---|---|---|
| 不填充直接加密 | 相同明文产生相同密文,且容易被字典攻击 | 必须使用PKCS#1 v1.5或OAEP填充 |
| 使用过小的e且不填充 | 低指数攻击可能在数学上直接恢复明文 | e至少取65537,配合随机填充 |
| 多用户共享同一个n | 已知两个私钥可反推出第三者密钥,模数共享漏洞 | 每个用户独立生成自己的n |
| p和q差距过小 | n接近完全平方,费马分解法可破 | p和q均为独立随机生成 |
| 用1024比特密钥生产使用 | 当前计算能力下存在分解风险 | 密钥至少2048比特 |
| 解密后不验证填充格式 | 可能陷入Oracle攻击的风险模式 | 解密后严格校验填充字节 |
填充问题尤其在实现OAEP时要注意字节序和随机种子配对,我见过填充写对了但随机种子生成部分犯错的案例,导致每次加密结果固定,破坏了填充本来要提供的语义安全性。
5.5 析构对象与敏感数据清理
一个经常被忽略的点是Python中int和bytes是不可变对象,私钥d和使用过的明文会留在内存中无法主动清除。对于教学demo这不是问题,但如果项目标榜“信息安全”,那敏感数据清理机制就应该被考虑到。标准的做法是将私钥保存在安全硬件或者使用专业的密钥管理服务,代码运行环境中尽量避免密钥明文落盘。你在跑完测试后,至少应该养成不把私钥打印到标准输出的习惯,因为Shell日志可能会记录所有输出。
最后聊一点实操体会
我带学生做RSA相关课程项目时,十次里有七八次的问题不是出在数学公式上,而是出在编码转换和填充格式上。数学部分有公式可以参考,出错很直观;反而是数据格式这种软性知识,教材上往往一句带过,实际运行时各种细节全暴露出来。建议你跑通这个demo后,不要急着去写2048位的“生产级代码”,先拿几个小素数对着课本上的手算例子逐一验证每个中间变量,确认密钥参数与参考值完全一致后,再往大位数方向扩展。等你能把608比特(p=31, q=19)的小例子里每个数字和代码输出的每个字段对应起来,RSA在你的知识体系里才算真正落地了。
整个项目源码不到200行,但它把信息安全数学基础里的核心概念全部串联了起来。最后再分享一个小技巧:在密钥生成阶段多写几个断言语句,比如断言n == p * q、断言(e * d) % phi == 1、断言pow(42, e*d, n) == 42,这些检查在调试阶段能帮你快速锁定到底是哪个环节出了问题,而不会等到解密失败才回来返工。