news 2026/10/20 8:05:20

Python实现RSA算法:从数学原理到加解密实践完整解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python实现RSA算法:从数学原理到加解密实践完整解析

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的密钥生成是一个标准的五步流程:

  1. 生成两个大素数p和q
  2. 计算n = p × q
  3. 计算φ(n) = (p-1)(q-1)
  4. 选取公钥指数e,满足1 < e < φ(n)且gcd(e, φ(n)) = 1
  5. 计算私钥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,这些检查在调试阶段能帮你快速锁定到底是哪个环节出了问题,而不会等到解密失败才回来返工。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 8:03:51

中专大数据与会计专业学数据分析:从Excel到BI的实战价值

这几年我经常被两类人问同一个问题&#xff1a;中专大数据与会计专业学数据分析&#xff0c;到底有什么用&#xff1f;一类是学生本人&#xff0c;觉得专业名里挂着“大数据”三个字&#xff0c;但翻了课表也没搞明白大数据到底在哪&#xff1b;另一类是家长&#xff0c;担心孩…

作者头像 李华
网站建设 2026/10/20 8:03:39

ESP32+MicroPython驱动BNO08X:免写滤波的姿态解算方案

1. 一个自带姿态解算的IMU&#xff1a;BNO08X到底解决了什么问题如果嵌入式项目需要姿态数据&#xff0c;早期我第一反应肯定是买裸的九轴或六轴IMU传感器&#xff0c;然后自己写互补滤波、卡尔曼滤波&#xff0c;再解决陀螺零偏、加速度噪声这些问题。这套东西不是不能做&…

作者头像 李华
网站建设 2026/10/20 8:03:38

AI自主科学发现系统:从假设生成到实验闭环的全链条架构解析

1. 从一条专利公开公告说起&#xff1a;这套系统到底在解决什么问题前段时间在专利公布公告网上闲逛&#xff0c;刷到一件挺有意思的发明专利&#xff0c;名称叫“AI自主高效科学发现系统”&#xff0c;发明人署名是李鉴鸿老师。第一眼看到这个标题&#xff0c;我的反应是&…

作者头像 李华
网站建设 2026/10/20 8:03:26

SGM8740比较器实测:从推挽输出到滞回与窗口比较电路设计

第一次拿到SGM8740YN5G/TR这颗芯片的时候&#xff0c;我的第一反应是&#xff1a;就一个SOT-23-5封装的单通道比较器&#xff0c;能玩出什么花样&#xff1f;结果就是这颗看似不起眼的小东西&#xff0c;在后面的几次低压检测和波形整形小电路里&#xff0c;实实在在帮我省掉了…

作者头像 李华
网站建设 2026/10/20 8:03:19

STM32 F4实战:永磁同步电机无感FOC与滑模观测器调参全记录

看到标题里"研究"两个字&#xff0c;估计有人以为这又是一篇论文开题报告。实际上&#xff0c;我前几个月刚把一个基于STM32 F4的永磁同步电机无位置传感器控制项目从仿真搬到了实机上&#xff0c;中间踩了不少坑&#xff0c;也慢慢把滑模观测器、启动切换、参数标定…

作者头像 李华
网站建设 2026/10/20 8:02:52

脉冲压缩与匹配滤波:雷达信号处理核心原理及工程实现

脉冲压缩&#xff08;匹配滤波&#xff09;是雷达信号处理里最基础也最绕不开的一块内容。我最早接触它的时候&#xff0c;是被那一堆术语吓到的——LFM、模糊函数、时宽带宽积、旁瓣抑制、多普勒容限……感觉每个词都懂&#xff0c;连起来就一头雾水。直到自己动手把发射信号、…

作者头像 李华