ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

MD5与RSA从零实现:签名、验签与接口认证实战

MD5与RSA从零实现:签名、验签与接口认证实战

简介:该压缩包是一份聚焦网络安全基础算法的代码示例集,主要面向希望理解并实践MD5与RSA的开发者或安全入门者。在开发中,数据完整性校验与安全通信是常见需求,包内MD5示例演示了如何通过哈希计算验证文件是否被篡改,并介绍了碰撞攻击带来的安全局限;RSA示例则围绕密钥生成、公钥加密、私钥解密及数字签名展开,直观呈现非对称加密的核心机制,为后续学习SSL/TLS、PKI等安全体系提供代码级参考。压缩包大小约1004KB,内部代码示例按功能组织,结构清晰,适合边读边调试。目前已有877人学习下载,具有较高的参考价值。通过动手运行这些示例,读者可以快速理解两种算法的适用场景与安全边界,在实际项目中合理选择加密方案,增强对网络威胁的应对能力,同时为深入研读更复杂的网络安全技术打下扎实基础。

1. 网络安全代码包:一份能直接落地的密码学工具箱

这个压缩包彻底改变了我的一个误判——原以为里面又是网上满天飞的加密脚本边角料,拆开才发现是一整套能直接落地的网络安全代码实现:MD5 哈希从消息填充到四轮压缩全程手写,RSA 从大素数生成到签名验签一条龙,除了 Python 标准库不依赖任何第三方密码学库。它解决的是开发里最日常的两个问题——文件下载后怎么确认没被动过手脚,两个服务之间怎么确认消息确实来自对方。适合想彻底看懂 MD5 与 RSA 原理的学生,也适合需要把签名逻辑嵌进自己项目的开发者。我花两小时跑通,又花一个晚上把签名模块改成了接口认证中间件,下面把整个过程拆给你看。

2. 项目架构与模块划分:哈希、密钥、签名三条主线怎么组织

拿到任何代码包,我习惯先不急着跑,而是把目录结构过一遍。这份代码解压后的布局非常规整,几乎一眼就能看出作者的模块边界意识。整个项目围绕两条算法主线展开:一条是 MD5 哈希,负责给数据生成固定长度的指纹;另一条是 RSA,负责密钥生成、加解密和数字签名。签名模块再把两条线拧在一起,变成完整的认证闭环。

2.1 目录结构与职责划分:每个文件管一件事

先看整体布局:

security-toolkit/ ├── md5_core.py # MD5 哈希算法:消息填充、分组压缩、摘要输出 ├── rsa_core.py # RSA 密钥生成、加密、解密 ├── signature.py # 数字签名与验签流程,串联 MD5 与 RSA ├── file_guard.py # 大文件分块哈希与完整性校验 ├── cli.py # 命令行入口,统一调度各模块 ├── tests/ │ ├── test_md5.py # 与 hashlib 标准库对照的测试用例 │ └── test_rsa.py # 密钥生成、加解密往返、签名验签测试 └── README.md # 使用说明与设计思路

md5_core.py 是整个哈希链路的核心,打开约三百行,算法部分没有借助任何现成摘要函数,完全从零手写。rsa_core.py 负责密钥对生成,包含 Miller-Rabin 素性检测和扩展欧几里得求逆元两个关键函数。signature.py 做的事情很纯粹:把 MD5 算出来的摘要当作待加密的整数,用 RSA 私钥加密得到签名,用公钥解密还原摘要再比对。file_guard.py 是对外的实用层,提供大文件分块读取和哈希校验能力。cli.py 把上述能力暴露成命令行,方便脚本调用。

这样的分层有一个好处:你如果想替换算法,比如把 MD5 换成 SHA-256,只需要动 md5_core.py 和调用方,signature.py 的流程骨架完全不用改。模块之间通过函数签名和返回结构通信,没有类之间互相 new 的耦合。

2.2 MD5 哈希模块:从消息填充到四轮压缩

MD5 的处理流程可以拆成四个阶段:消息填充、初始化向量、四轮压缩、输出摘要。消息填充是第一步,也是初学者最容易翻车的地方。规则是:先补一个0x80字节,然后补0x00直到消息长度模 512 余 448,最后把原始消息的 bit 长度用 64 位整数追加进去,保证填充后总长度是 512 的整数倍。

阶段输入输出关键参数
消息填充任意字节串512bit 对齐的分组0x80补位、64bit 位长
初始化向量无A/B/C/D 四个 32bit 寄存器A=0x67452301,依次递减
四轮压缩512bit 分组 + 当前状态更新后的状态64 步、每步一个 K 常数
输出摘要最终状态32 个十六进制字符小端序写入

四个初始向量不是随便选的,它们是十六进制表示的低位字节序常量,所有标准 MD5 实现共用同一组。四轮压缩里每一轮用了不同的非线性函数和非线性索引规则,这部分代码细节放在第 3 章展开。

2.3 RSA 密钥模块:大素数生成与密钥结构

RSA 密钥生成是三件事:找到两个大素数、算出模数和欧拉函数、用扩展欧几里得求逆元。大素数用 Miller-Rabin 概率检测,候选数先生成再判断。n 是 p 和 q 的乘积,密钥长度就是 n 的二进制位宽。公钥指数固定选 65537 这个质数,因为它的二进制只有两个 1,模幂计算快。私钥指数 d 是 e 模 φ(n) 的乘法逆元。

public_key = {'n': 0x..., 'e': 65537} private_key = {'n': 0x..., 'd': 0x..., 'p': 0x..., 'q': 0x...}

公钥字典只有 n 和 e,私钥额外保存 p 和 q。p、q 留着是为了将来做中国剩余定理加速解密,单独验证私钥合法性时也会用到。密钥对生成后,公钥可以被任何人拿到,私钥必须留在本地。代码里生成密钥的耗时主要花在 Miller-Rabin 反复检测上,这部分我在第 4 章会给实测建议。

2.4 签名与校验链路:三模块如何组成闭环

签名的完整链路是这样的:发送方对消息做 MD5 摘要得到 32 字节十六进制字符串,转成整数后用私钥 d 做模幂运算,得到签名值;接收方拿到消息和签名后,先用公钥 e 做模幂恢复出整数,转回字符串,再对本地消息算 MD5 摘要,比较两者是否相等。任何一个环节不一致,验签就失败。

值得注意:这份代码做的是教科书式 RSA 签名,没有加填充的裸 RSA。它把摘要直接当作"明文"扔进模幂,优点是代码直白、能看到完整的数学过程,但生产环境必须用 PSS 这类带随机填充的方案,否则同样的消息会得到同样的签名,存在被选择明文攻击的风险。README 里也明确标了这是教学用途,想直接拿上生产线的同学记得看第 6 章的加固方案。

3. 核心代码拆解:一个签名请求从摘要到验签的完整链路

这一章是全文浓度最高的部分。我按数据流的方向走:先是 MD5 的压缩函数,然后是 RSA 的密钥生成,最后是签名和验签主流程。每一段代码都来自实际文件,注释是我自己加的,方便你对照原文件看。

3.1 MD5 四轮压缩:位运算与循环左移的实现细节

MD5 的核心在一个 64 步的循环里。先看辅助函数和旋转量:

# md5_core.py def _F(x, y, z): return (x & y) | (~x & z) def _G(x, y, z): return (x & z) | (y & ~z) def _H(x, y, z): return x ^ y ^ z def _I(x, y, z): return y ^ (x | ~z) # 64 个常量:k[i] = floor(abs(sin(i + 1)) * 2^32) import math _K = [int(abs(math.sin(i + 1)) * (1 << 32)) for i in range(64)] # 循环左移位数,每轮 16 步按固定节奏轮换 _SHIFT = [7, 12, 17, 22] * 4 + [5, 9, 14, 20] * 4 \ + [4, 11, 16, 23] * 4 + [6, 10, 15, 21] * 4 def _rotl(x, n): return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF

F、G、H、I 是四个把三个 32 位输入映射成一个 32 位输出的非线性函数,分别对应四轮循环。K 表来自正弦函数,作用是引入与消息内容无关的随机常量,破坏规律的数学结构。_rotl是循环左移:因为 Python 整数没有长度限制,需要显式把结果& 0xFFFFFFFF截断回 32 位,否则左移的高位会一路飙升。

然后是分组压缩函数:

def _compress(state, block): a, b, c, d = state x = struct.unpack('<16I', block) # 把 64 字节约成 16 个小端 32 位整数 for i in range(64): if i < 16: f = _F(b, c, d) g = i # 第一轮按原始消息顺序 elif i < 32: f = _G(b, c, d) g = (5 * i + 1) % 16 # 第二轮用 5i+1 置换下标 elif i < 48: f = _H(b, c, d) g = (3 * i + 5) % 16 # 第三轮用 3i+5 else: f = _I(b, c, d) g = (7 * i) % 16 # 第四轮用 7i temp = d d = c c = b b = (b + _rotl((a + f + x[g] + _K[i]) & 0xFFFFFFFF, _SHIFT[i])) & 0xFFFFFFFF a = temp return [ (state[0] + a) & 0xFFFFFFFF, (state[1] + b) & 0xFFFFFFFF, (state[2] + c) & 0xFFFFFFFF, (state[3] + d) & 0xFFFFFFFF, ]

struct.unpack('<16I', block)里的<是小端标志,这个细节决定了摘要结果对不对。每轮的状态轮换是 a 拿旧 d,d 拿旧 c,c 拿旧 b,b 自己加上一轮的混合结果,和 SHA-1 那种 a 左移后再旋转的方式不一样。_K[i]来自常量表,_SHIFT[i]决定循环左移位数。

MD5 的流式接口需要维护缓冲区、状态向量和累计位长,核心是下面这个类:

class MD5: def __init__(self): self._state = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476] self._buffer = b'' self._bits = 0 def update(self, data: bytes) -> None: self._bits += len(data) * 8 self._buffer += data while len(self._buffer) >= 64: chunk, self._buffer = self._buffer[:64], self._buffer[64:] self._state = _compress(self._state, chunk) def _build_padding(self) -> bytes: pad = b'\x80' pad += b'\x00' * ((56 - (len(self._buffer) + 1) % 64) % 64) pad += struct.pack('<Q', self._bits) # 小端 64 位总位长 return pad def hexdigest(self) -> str: final_buf = self._buffer + self._build_padding() state = list(self._state) for i in range(0, len(final_buf), 64): state = _compress(state, final_buf[i:i + 64]) return struct.pack('<4I', *state).hex()

重点看_build_padding:补位时如果缓冲区已经有一个0x80占位,(len(self._buffer) + 1) % 64就是已经占掉的字节数,目标是要让整体模 64 余 56,这样再加 8 字节长度字段正好是 64 的倍数。原始总位长存在self._bits里而不是从缓冲区反推,这是分块处理和一次性处理结果一致的关键。

3.2 RSA 密钥生成:Miller-Rabin 与扩展欧几里得的配合

RSA 的安全性建立在找大素数这件事上。这里的 Miller-Rabin 实现先做了小素数过滤:

def miller_rabin(n: int, rounds: int = 40) -> bool: if n < 2: return False for p in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37): if n % p == 0: return n == p d = n - 1 r = 0 while d % 2 == 0: d //= 2 r += 1 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(r - 1): x = (x * x) % n if x == n - 1: break else: return False return True

先和 12 个小素数做整除测试,这一步能滤掉 99% 以上的合成数,让真正的 Miller-Rabin 只在少数候选数上跑。rounds 默认 40 轮,误判概率低于 2 的 -40 次方,教学和一般工程都够用。pow(a, d, n)是 Python 内置模幂函数,内部用快速幂算法,避免a**d产生天文数字。

私钥指数 d 靠扩展欧几里得求逆元:

def mod_inverse(e: int, phi: int) -> int: old_r, r = e, phi old_s, s = 1, 0 while r: q = old_r // r old_r, r = r, old_r - q * r old_s, s = s, old_s - q * s return old_s % phi

这段迭代维护的不变式是old_s * e + old_r * phi = old_r,当old_r变成 1 时,old_s就是 e 模 phi 的逆元。最后取模保证结果落在合法范围内。

def generate_keypair(bits=1024): p = _gen_prime(bits // 2) q = _gen_prime(bits // 2) n = p * q phi = (p - 1) * (q - 1) e = 65537 d = mod_inverse(e, phi) return ( {'n': n, 'e': e}, {'n': n, 'd': d, 'p': p, 'q': q}, )

生成候选数时用random.getrandbits(bits) | (1 << (bits - 1)) | 1,最高位保证位数精确,最低位保证是奇数,跳过所有偶数候选。

3.3 签名与验签主流程:摘要转整数与模幂运算

签名模块是整条链路的收口:

# signature.py def md5_hex(data: bytes) -> str: h = MD5() h.update(data) return h.hexdigest() def sign_bytes(data: bytes, private_key: dict) -> int: digest = md5_hex(data) m_int = int.from_bytes(digest.encode('utf-8'), 'big') return pow(m_int, private_key['d'], private_key['n']) def verify_bytes(data: bytes, signature: int, public_key: dict) -> bool: digest = md5_hex(data) m_int = int.from_bytes(digest.encode('utf-8'), 'big') recovered = pow(signature, public_key['e'], public_key['n']) return recovered == m_int

这里的关键是把 32 字节的十六进制摘要字符串按 ASCII 编码成字节,再以'big'大端序转成整数。这个转换规则必须同时用在签名和验签两侧,否则两边得到的整数完全不同,验签必败。digest.encode('utf-8')与digest.encode('ascii')在这里结果一致,但实际项目中建议统一成同一个工具函数。

注意:摘要整数必须小于秘钥模数 n。MD5 摘要是 128 bit,转整数后只有 256 bit,对 512 bit 以上的秘钥都没问题;但如果拿 256 bit 的秘钥来签,就会触发模幂截断。

4. 运行实战:把这套代码跑通并集成到文件校验场景

4.1 环境准备:纯标准库的依赖边界

这份代码只依赖 Python 标准库,模块导入分别用到struct、math、random、argparse、os、sys,不需要执行任何pip install。版本建议 3.8 以上,因为hash_file里用了海象运算符:=,3.8 以下会直接语法报错。如果你要跑在 3.6 环境,把while chunk := f.read(block_size)改成两行就能兼容。

测试目录里提供了对照用例,test_md5.py会把自实现的 MD5 输出和hashlib.md5()逐一把比较,test_rsa.py验证加解密往返和签名验签。克隆到本地后先跑一遍测试,确认环境没问题再动业务代码。

4.2 命令行入口:四大命令的用法与参数

cli.py 把四个能力暴露成命令,核心用法如下:

# 计算单个文件的 MD5 python cli.py md5 ./release.tar.gz # 生成 1024 位密钥对,输出到 keys 目录 python cli.py genkey --bits 1024 --out ./keys # 用私钥对文件内容签名,签名写入 sig.bin python cli.py sign ./release.tar.gz --private ./keys/private.pem -o sig.bin # 用公钥验签 python cli.py verify ./release.tar.gz --public ./keys/public.pem --sig sig.bin
命令必填参数可选参数退出码
md5file--block-size0/1
genkey--out--bits,默认 10240/1
sign--private-o,默认 sig.bin0/1
verify--public、--sig无0/1

补充一个容易误解的设计:sign 和 verify 读取的是文件字节内容,不是文件名。所以把文件改名后重新验签,结果仍然是成功的,因为字节没变;反之哪怕只改了一个字节,摘要就变了,验签立刻失败。这是完整性校验该有的行为。发布软件包时把签名文件和安装包放一起,用户侧先验签再解压,能挡住不少传输过程被替换的场景。

4.3 大文件分块哈希:内存占用与性能取舍

file_guard.py 里提供了流式哈希实现:

def hash_file(path: str, block_size: int = 1 << 16) -> str: h = MD5() with open(path, 'rb') as f: while chunk := f.read(block_size): h.update(chunk) return h.hexdigest()

update 机制把 64KB 数据吞进内部缓冲区,凑满 512 位就压缩一次,内存峰值只有缓冲区大小加上状态向量这几百字节。对比一次性f.read()整个 2GB 镜像,这个方案能稳定控制内存占用。块大小选 2 的 16 次方是因为对机械硬盘连续读取友好,IO 次数适中,如果你改成 4KB,同样的文件会让 update 被调用 16 倍次数,Python 层函数调用开销会明显拉长耗时。

实测一个 2.1GB 的系统镜像,用默认 64KB 分块跑下来大约 7 秒多,单核 CPU 占满,内存几乎不动。如果你在 SSD 上跑,把块调大到 1MB 可能更快,但收益会逐渐缩小,不是越大越好。

5. 避坑指南:MD5 与 RSA 实现中的五个经典翻车现场

这部分是我自己跑这段代码时真实踩过的坑,每一条都花过时间查资料,在这里按「现象 → 原因 → 解决」写清楚,希望帮你省掉排查功夫。

5.1 MD5 输出与在线工具对不上:字节序的锅

现象:拿空字符串和abc测,输出是一串大写字母或者中间某几节乱序的十六进制,和在线 MD5 工具完全对不上。

原因:八成是字节序问题。MD5 要求小端序读取消息分组,struct.unpack('<16I', block)里的小端符号'<'漏掉,或者输出时用了大写'>',都会导致寄存器状态错乱。另一个高发点是填充 64 位长度时把高低 32 位写反。

解决:先用 RFC 1321 的测试向量校准——abc的摘要必须是900150983cd24fb0d6963f7d28e17f72。从填充函数开始,逐字节验证0x80补位、0x00个数、长度字段的字节序,再检查输出struct.pack('<4I'),这四个环节里一定有一个写反了。

5.2 RSA 解密偶尔乱码:消息长度超过模数

现象:加密一段短字符串,解密后内容前面多出零字节,或者干脆变成乱码。

原因:RSA 的数学内核是m^e mod n,要求明文整数 m 严格小于 n。一旦消息转成整数后位长接近甚至超过 n 的位宽,模运算就会截断结果,解密出来自然对不上。

解决:在加密入口加断言m.bit_length() < n.bit_length(),快速暴露问题。真要加密超过密钥长度的数据,正确姿势是生成一个随机会话密钥,用 RSA 加密会话密钥,数据本体用 AES 之类对称加密,这是混合加密的通行做法。裸 RSA 扛长消息的写法属于教学演示,生产环境别这么干。

5.3 密钥生成慢到卡死:缺少小素数过滤

现象:genkey命令跑了五分钟还没结束,CPU 单核占满,偶发出一对密钥但极不稳定。

原因:_gen_prime直接拿随机数丢给 Miller-Rabin,没做小素数整除过滤。1024 位随机数是素数的概率只有几百分之一,每次检测都要做几十轮大数模幂,计算量非常大。另一个隐藏问题是候选数如果为偶数,Miller-Rabin 会在第一轮直接返回 False,白白浪费一次检测。

解决:生成候选式random.getrandbits(bits) | (1 << (bits - 1)) | 1,最高位保证位数、最低位保证奇数;进 Miller-Rabin 前先对 2、3、5、7 一直到 37 做整除测试。这套组合拳改完,1024 位密钥生成从玄学变成了稳定几百毫秒到两秒。

5.4 签名验签总是失败:摘要转整数规则不一致

现象:签名后验签永远是 False,某些数据偶尔能过,换一段数据又失败。

原因:摘要到整数的转换规则没统一。签名侧用digest.encode('utf-8')转字节再int.from_bytes(..., 'big'),验签侧却用了int(digest, 16),两边得到的整数完全不是一回事。

解决:把转换逻辑抽成一个公共函数,比如def digest_to_int(digest: str) -> int: return int.from_bytes(digest.encode('utf-8'), 'big'),签名和验签都调用它。凡是二进制和整数互相转换,一律走同一个工具函数,杜绝两处实现漂移。

5.5 大文件摘要不稳定:分块 update 的位长没累计

现象:同一个大文件,分块 update 算出来的摘要和一次性读入对不上,甚至连续两次运行结果不一样。

原因:MD5 填充时需要原始消息的总 bit 数,而分块 update 过程中self._bits没有累加更新,导致最后补位长度永远是 0,摘要自然错乱。

解决:在update方法第一行加上self._bits += len(data) * 8,_build_padding里用累计值而不是从缓冲区长度反推。这个坑最隐蔽,表面看逻辑都正常,实际是状态维护漏了一条计数线。

6. 二次开发:把签名模块改装成接口认证中间件

6.1 改造思路:从函数调用到 HTTP 请求签名

把 signature.py 的纯函数能力套一层时间戳,就能得到一个防重放的请求签名方案:

def make_auth_token(data: bytes, private_key: dict) -> str: ts = str(int(time.time())) digest = md5_hex(data) payload = f"{ts}.{digest}".encode("utf-8") sig = sign_bytes(payload, private_key) return f"{ts}.{hex(sig)}" def check_auth_token(data: bytes, token: str, public_key: dict, window=300) -> bool: try: ts, sig_hex = token.split(".", 1) sig = int(sig_hex, 16) except ValueError: return False if abs(int(time.time()) - int(ts)) > window: return False digest = md5_hex(data) payload = f"{ts}.{digest}".encode("utf-8") return verify_bytes(payload, sig, public_key)

调用方在请求头里带X-Timestamp和X-Signature,服务端先检查时间戳窗口内再验签,五分钟外的请求直接丢弃。时间戳字段同时防止了记录重放,body 摘要保证请求内容不被篡改。改造时只需要把data替换成序列化后的请求体,再把中间件挂到服务入口即可。

6.2 安全加固:密钥长度、填充方案与随机源

照着这套代码上生产之前,有三件事必须改。第一,密钥长度至少 2048 位,1024 位在当今算力下已经不适合保护真实系统。第二,把裸 RSA 换成带填充的方案,加密用 OAEP,签名用 PSS,否则同类消息会产生可预测的签名结构,教科书式实现扛不住选择明文攻击。第三,把random.getrandbits换成secrets.randbits,前者是伪随机数生成器,用于密钥生成这类安全场景存在被预测的风险。

MD5 本身也有已知碰撞问题,如果签名对象是公开可选的,攻击者可以构造两个摘要相同的不同文件。建议保留这份代码做学习用途,线上系统把md5_hex整体替换成 SHA-256,签名和验签逻辑一行不用改——当初模块边界划得清楚,此时就体现出改动的代价有多小了。

那一次把签名模块接到接口认证之后,我开始养成两个习惯:凡是开放给别人的接口,签名里的时间戳窗口一律不超过五分钟;密钥文件只在部署机内存里生成,磁盘上不留私钥。从那以后,每次写加密相关代码,我都会强制走一遍「算法选型 → 填充方案 → 密钥生命周期」这条检查链。这套代码的模块边界和文档都算清楚,改起来不费劲,希望这篇拆解能帮到你。

本文还有配套的精品资源,点击获取

返回列表