国密算法SM2/SM3/SM4源码分析:从实现到攻击验证的完整链路
2026/9/14 1:21:33 网站建设 项目流程

简介:一份基于Python与C++的密码学与加密系统项目源码,面向密码学初学者、信息安全专业学生及算法实现开发者,覆盖哈希、对称加密、非对称加密与数字签名等核心主题。资源共153个文件,压缩包大小5.15MB,主要包含C++源文件(24个cpp、15个hpp、4个h)、Python脚本(19个py)及编译产物(15个o),同时配有Markdown文档(13个md)和示意图(41个png),便于阅读与调试。已有70人学习。项目亮点在于不仅实现了SM3哈希、AES/SM4对称加密、SM2非对称加密及数字签名算法,还包含生日攻击、Rho方法、长度扩展攻击、Merkle树、SM2两方签名与解密等高级应用,并提供基于ARM指令集的AES优化实现。源码目录结构清晰,注释与文档配套,适合作为课程设计、毕业设计或密码学工程实践的参考。

1. 一个把国密算法和攻击脚本放在同一目录下的源码工程,值得拆的是从测到攻的完整链路

一套同时包含 Python 和 C++ 的密码学与加密系统源码,最有价值的点不在单个算法写得多惊艳,而是它把三个层次叠在了一起:C++ 端实现 SM3、SM4、SM2 与 AES,Python 端放入生日攻击、Rho 方法、长度扩展攻击脚本,中间再用 user.cpp、trusted_third_party.cpp 模拟两方签名与解密协议的真实调用。这正好对应商用密码改造里最常遇到的三个问题:算法怎么落地、安全性怎么验证、多方交互怎么设计。适合等保测评里做国密改造方案的技术人员、自研加密工具的开发者,以及正在同时看“算法实现”与“算法攻击”两类代码的密码学入门者。

2. 哈希函数与攻击验证:SM3 迭代、生日攻击与长度扩展的脚本化

2.1 从 SM3 的填充和消息扩展看迭代结构

SM3 输出的 256 位摘要由 8 个 32 位字构成,IV 是 GB/T 32905-2016 里固定的一组常量。处理过程以 512 位为一块,先补 1 和若干 0,再把原始消息长度写成 64 位 big-endian。与 SHA-256 相比,SM3 的消息扩展更复杂:它将 16 个字扩展成 132 个字共 64 轮压缩函数使用,每一轮的 P0、P1 置换和布尔函数的组合方式与 SHA-256 错开,使得字节级雪崩效应更明显。项目里的 SM3 实现拆成了两条线:一条是标准版,用于哈希与签名验签;一条是简化版,把输出截断并减少轮数,给攻击脚本当靶子。这样的隔离设计在攻击性验证场景里很常见,能避免演示代码污染生产路径。

def sm3_pad(msg: bytes) -> bytes: # 处理 Python bytes,返回按 512 位分组的填充块 bit_len = len(msg) * 8 # 先补一个 0x80,再补 0 直到 448 mod 512 pad = b'\x80' + b'\x00' * ((56 - len(msg) - 1) % 64) # 末尾追加 64 位原始长度,big-endian return msg + pad + bit_len.to_bytes(8, 'big')

这段代码把任意字节串换算成 SM3 的填充格式。要点有两个:(56 - len(msg) - 1) % 64负责处理消息长度恰好是 64 的倍数或余数不够的场景,不会出现补 0 补过头或多补的问题;末尾的bit_len.to_bytes(8, 'big')必须用无符号大端,因为它会被压缩函数当成两个 32 位字串接。项目中把它封装成sm3_update的一部分,每收到一块就先做一轮压缩,避免把整个消息加载进内存。实际替换消息长度类型时容易踩坑,例如把追加后长度忘记加上,就会导致计算出来的摘要与标准库结果不一致。

算法输出位长分组位长压缩轮数是否抗长度扩展
MD512851264
SHA-25625651264
SM325651264

2.2 生日攻击:用截断 SM3 演示碰撞复杂度

生日攻击利用的是“寻找任意两个消息哈希值相同”的难度远低于“找一个给定哈希值”的难度。目标哈希长度为 n 位时,标准碰撞只需要约 2^(n/2) 次实验。对于真实 SM3 的 256 位输出,这需要 2^128 次哈希运算,在桌面机上没有意义;所以项目把 SM3 的输出截断到 24 位,用来在可接受的时间内验证攻击链路。实现上写一个单线程脚本,每轮生成一个随机消息,计算其截断哈希并写入字典,一旦发现字典里已存在相同键,就输出碰撞对。

import os, hashlib from sm3_impl import sm3 def birth_attack(truncate_bits: int = 24): seen = {} mask = (1 << truncate_bits) - 1 while True: msg = os.urandom(16) digest = int.from_bytes(sm3(msg)[: truncate_bits // 8], 'big') key = digest & mask if key in seen and seen[key] != msg: return seen[key], msg, hex(key) seen[key] = msg

这里的truncate_bits对应“将 SM3 的摘要截取多少位”,我一般会从 16 开始测,把输出范围压到 65536 时大概几万次就能碰到碰撞,用来确认字典没有写错。mask这行的意义是从摘要里取出低truncate_bits位,等价于对截断哈希做模运算。条件seen[key] != msg排除了同一条随机消息恰好被重复采样的可能。真实项目中需要注意的内存问题是seen会随碰撞位数增长,到 2^32 级别时字典体积超过内存,这时应该换成布隆过滤器或者基于磁盘的哈希表。这就是生日攻击的原理与工程实现之间的分界线:算法复杂度相同,但内存模型决定了工具能不能跑完。

2.3 Rho 方法与长度扩展攻击:验证两个常见误用场景

Rho 方法用 Floyd 判圈来降低内存占用,它是生日攻击的流式版本。给定一个哈希函数 H 和初始值 x0,不断迭代 xi := H(xi),序列总会进入循环。若在进入循环前找到冲突点,就等价于找到了两个不同输入具有相同哈希值。判圈部分只用两个指针,速度上比字典法慢一点,但空间复杂度是常数。项目中把两种方法做成两个独立模块,方便对比内存曲线。从实用角度,Rho 方法更适合在硬件资源受限的验证脚本里使用,字典法更适合本地快速验证较小哈希空间。

SM3 与 MD5、SHA-256 一样,直接拿H(secret || message)当作消息认证码会引入长度扩展攻击。攻击者拿到H(secret || message)后,不需要知道 secret 的具体值,就能构造一个新消息message || padding || append,并推导出新的合法 MAC。SM3 的 512 位分组、64 轮压缩和固定 IV 结构决定了这一点无法通过修改算法内部规避。攻击脚本的做法是把原摘要的 8 个字反解析成压缩函数的内部状态,然后用伪造的填充长度继续执行压缩。

def length_extension(old_digest, original_len, key_len, append_msg): # 旧摘要被当成压缩函数状态,重新计算追加数据后的摘要 state = bytes.fromhex(old_digest) new_len = key_len + original_len # 为伪造消息重新填充 fake_msg = append_msg return sm3_compress_from_state(state, fake_msg, new_len)

关键点在于key_len是猜出来的,常见做法是遍历 1~64 字节并逐个验签,然后请求服务端返回合法的 MAC。original_len是已消息长度,作用在于构造与真实服务器一致的分组长短;进攻代码里sm3_compress_from_state不重新计算旧摘要,而是直接从内部状态继续压数据。在实施层面,应对长度扩展的方式是升级为 HMAC-SM3 或在 key 后加一个固定后缀,例如SM3(key || message || 0x00),这样伪造填充就无法对齐分组。

3. 对称加密的落地:SM4 轮函数、AES 查表与 ARM 指令集优化

3.1 从 SM4 的 T 变换看 S 盒与循环移位

SM4 是 128 位分组、128 位密钥的国密对称算法,32 轮非平衡 Feistel 结构。每一轮只更新状态的一个字,另外三个字继续参与下一轮运算。项目里的 sm4.cpp 核心是T函数:输入 32 位字,先拆成 4 个字节查 S 盒,再做一次线性变换,线性变换等价于 4 次循环左移后的异或。这里有两个容易写错的地方:S 盒表必须严格按 GB/T 32907-2016 的顺序排列,任何一行位序错就会导致加密输出与验证值不一致;循环左移参数分别是 2、10、18、24 位,写错后加密结果看似正常但无法解密。项目里把 S 盒单独拆成 sbox.h,并在编译期加了一段自检逻辑,每次启动验证 S 盒头尾若干字节是否匹配标准值,这个方法值得沿用。

uint32_t sm4_t(uint32_t in) { uint8_t b[4]; b[0] = sbox[in >> 24 & 0xFF]; b[1] = sbox[in >> 16 & 0xFF]; b[2] = sbox[in >> 8 & 0xFF]; b[3] = sbox[in & 0xFF]; // 线性变换 L,等价于 4 次循环左移再异或 uint32_t x = (b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3]; return x ^ rotl32(x, 2) ^ rotl32(x, 10) ^ rotl32(x, 18) ^ rotl32(x, 24); }

rotl32在 C++11 以下需要手工实现,C++20 里可以直接用std::rotl。参数层面最重要的一个坑是 S 盒查表要按大端字节序查:in >> 24取出的是最高字节,这跟很多芯片上常见的 bswap 顺序相反。代码里的 4 次查表和 4 次移位组成了一轮中的 T 变换,32 轮轮密钥扩展后才是完整加解密。项目中 SM4 模块推荐先用 ECB 模式验证单个分组,再切到 CBC 或 CTR,理由是轮函数 bug 和模式实现 bug 混在一起时很难定位。

算法分组长度密钥长度轮数硬件指令支持
SM4128 位128 位32部分 CPU 扩展
AES-128128 位128 位10AES-NI / ARMv8 Crypto

3.2 查表法与 ARM 指令集加速的实现边界

AES 在纯软件实现上一般用 T-table 法:把 SubBytes、ShiftRows、MixColumns 三层合并成 4 张 256 项、每项 32 位的大表。一轮加密从“对 16 字节逐一查表再换位”变成 4 次内存访问和若干次异或,性能能提升一个数量级。但查表法的缺点是查表过程会产生基于密钥的 cache 访问模式,侧信道攻击可以通过计时判断当前密钥字节的范围。项目里同时给出了普通查表版和 ARM 指令集版,后者使用 ARMv8-A 的 AESD/AESE 指令完成轮函数,顺带消除了查表访问的缓存泄漏。

#include <arm_neon.h> uint8x16_t aes_enc_one_block(uint8x16_t key, uint8x16_t block) { // AES 的单轮加密指令,对应 SubBytes + ShiftRows + MixColumns + AddRoundKey for (int r = 0; r < 10; r++) { block = vaeseq_u8(block, key); block = vaesmcq_u8(block); key = vld1q_u8((const uint8_t*) round_keys[r]); } return block; }

这段代码要配合最后一轮加密指令完成 AddRoundKey,不能只靠vaeseq_u8跑完前十轮,否则少一次密钥加。我一般会在编译时加-march=armv8-a+crypto打开 crypto 扩展,否则 GCC 不会暴露这些内建函数。实测中,ARM 指令集版比查表版快 3~6 倍,在树莓派 4 上差距更明显;但需要留意 ARMv7 设备不支持 AES 扩展,只能继续走查表路径。AES-128 的轮密钥扩展代码在这个项目里比较经典:前 4 个字是初始密钥,后面每个字依赖前一个字的 g 函数,g 函数含 S 盒代换、循环左移和轮常量异或。

3.3 模式选型:从 ECB 到 GCM 的工程取舍

项目中的对称加密模块没有停留在算法实现,还提供了 ECB、CBC、CTR、GCM 四套上层封装。ECB 只能用作自检或密钥一致性验证,相同明文块会产生相同密文,会泄露明文结构,生产环境至少要用 CBC。CBC 的 IV 要求每次加密都更换且不可预测,IV 固定会让相同前缀的明文呈现相同前缀的密文。CTR 把分组密码变成同步流密码,支持随机访问,适合分块加密场景。GCM 则在 CTR 基础上用 GHASH 做认证,密文和认证标签一起传输,能同时保证机密性和完整性。项目中 SM4 的 GCM 是用 CTR 循环附加 GHASH 实现,链路较长,但每一层都可以单独调试。

// CBC 模式:前一块密文先与当前明文异或,再走 SM4 轮函数 cipher[i] = sm4_encrypt_block(key, plain[i] ^ cipher[i - 1]);

参数说明:前一块密文cipher[i-1]是密文依赖链的载体,第一块的cipher[-1]就是 IV。这个设计决定了加密必须串行,解密却可以并行,所以工程上解密吞吐永远高于加密。如果项目用 ARM 指令集加速,我会把 GCM 拆成两个独立路径验证:先用普通分组验证 GHASH 运算,再用指令集版本跑全链路,避免共同错误点掩盖问题。

模式是否需要 IV能否并行加密是否带认证典型用途
ECB自检、密钥测试
CBC文件加密、消息块
CTR流式加密、分块存储
GCM网络传输、TLS 记录层

4. SM2 签名、加密与两方安全协议:从曲线参数到交互设计

4.1 推荐曲线与签名验签流程

SM2 使用 256 位素数域 GF(p),椭圆曲线方程 y² = x³ + ax + b,国密推荐参数 a、b、G、n 均采用固定值。私钥是 1 到 n-1 之间的随机整数 dA,公钥 PA = dA * G。做签名时先计算杂凑值 ZA = SM3(ENTLA || IDA || a || b || xG || yG || xA || yA),与消息 M 一起生成 e = SM3(ZA || M)。签名算法随后取随机数 k,计算曲线点 (x1, y1) = kG,得到 r = (e + x1) mod n,s = ((1 + dA)^(-1) * (k - r * dA)) mod n。项目里把这一长串拆成 sm2_sign 和 sm2_verify 两个模块,用 Python 写了一个便捷验签脚本:

def sm2_verify(m, sig_r, sig_s, PA, e): # 将 r 和 s 从元组中取出,先保证它们落在 1..n-1 范围 if not (1 <= sig_r <= n - 1 and 1 <= sig_s <= n - 1): return False t = (sig_r + sig_s) % n if t == 0: return False # 计算椭圆曲线点 (x,y) = s*G + t*PA x1, y1 = point_add(scalar_mult(sig_s, G), scalar_mult(t, PA)) # 验签条件:r 必须等于 e + x1 mod n return sig_r == (e + x1) % n

这个验签函数把双标量乘法sG + tPA合并成一次点加运算,而不是做两次独立乘法再手工验证,可以减少一次模乘与坐标转换。scalar_mult建议采用 Montgomery ladder,让运行时循环次数与私钥无关,配合随机点盲化可降低侧信道泄露风险。point_add的输入采用雅可比坐标系,避免频繁做模逆,否则速度会慢一个数量级。项目中gaussian.cpp在签名链路里提供随机数 k 的采样能力:通过离散高斯采样生成熵,避免直接用rand()产生可预测 k,这属于随机性基础设施,容易被忽略但直接决定签名安全性。

4.2 RFC6979:确定性签名为何是刚需

SM2 签名的随机数 k 一旦泄露,私钥会被直接算出,其危害比私钥丢失更隐蔽:攻击者不需要暴力破解,只需要做一次简单的签名数学运算即可恢复 dA。真实世界里因为 k 重复导致私钥泄露的事件有多次公开报道。RFC6979 提供的确定性签名方案用“私钥 + 消息”代替随机源,通过 HMAC-SM3 迭代生成 k,从根上消除随机数重复问题。项目实现了 RFC6979 的完整流程,核心算法如下:

def rfc6979_generate_k(privkey: int, msg_hash: bytes, algo=hmac_sm3): x = privkey.to_bytes(32, 'big') h1 = msg_hash V = b'\x01' * 32 K = b'\x00' * 32 # K = HMAC_K(V || 0x00 || int2octets(x) || bits2octets(h1)) K = algo(K, V + b'\x00' + x + bits2octets(h1)) V = algo(K, V) K = algo(K, V + b'\x01' + x + bits2octets(h1)) V = algo(K, V) # 循环生成 k,并判断是否在 1..n-1 范围内 while True: V = algo(K, V) T = b'' while len(T) < 32: V = algo(K, V) T += V k = int.from_bytes(T[:32], 'big') if 1 <= k < n: return k

bits2octets是把消息摘要转换成模 n 的字节表示,等价于int(h1, 16) % n再转回固定长度;int2octets同理转换私钥。若直接使用裸私钥字节而不做模 n 归约,换一组曲线参数时无法适配。这个循环存在的原因极少数情况下 T 生成的整数大于 n-1 或等于 0,必须重试。项目里 SM2 签名的两方版本也复用了这段代码,只是把私钥替换成秘密共享分片。

4.3 两方签名与解密协议:user.cpp 与 trusted_third_party.cpp 的分工

两方 SM2 签名的设计目标是私钥不落在单一设备上,而是拆成两份,分别存放在用户端和可信第三方端。协议采用加法分享 d = d1 + d2 mod n,用户端持有 d1,第三方持有 d2。签名时,第三方对消息哈希 e 生成部分结果,用户端用 d1 把部分结果合成为完整的 (r, s)。项目里 user.cpp 与 trusted_third_party.cpp 做的事可以看成如下时序:

步骤user.cpptrusted_third_party.cpp
1生成随机数 k,计算 kG接收消息哈希 e
2计算 r = (e + x1) mod n用 d2 计算部分签名 σ2
3本地用 d1 合成 s返回 σ2 与随机承诺
4验证 sG 与预期点一致清理临时密钥与日志
// user.cpp 中合成签名的核心逻辑 // sk_user 为私钥分片, partial_s 来自 trusted_third_party BigInt s = (partial_s + sk_user * (r + partial_s)) % n;

这里partial_s是第三方内部先算好的部分结果,配合用户自己的分片sk_user完成最终 s 的计算,任何一方都无法单独推出完整私钥。trusted_third_party.cpp里必须实现的操作包括随机数覆盖、临时密钥释放,以及输出前的一次权限校验。两方解密协议与签名对称:第三方先把密文变换到自己的分片域并生成中间结果,用户端拿中间结果结合 d1 完成解密。生产中更安全的变体是 Paillier 同态辅助,但项目用小素数域上的加法分享演示了最小可用实现,这让协议更清晰可读。

SM2 加密部分也值得单独看:采用类似 ECIES 的方式,发送方用临时私钥 k 计算点 kG 和共享秘密点 S = kPA,然后用派生密钥对消息做对称加密,接收方用 dA 还原 S 后解密。整体安全依赖椭圆曲线上的离散对数假设与 KDF 的正确实现。

5. 收尾技巧:把 C++ 密码学模块编译成 Python 扩展并验证

5.1 用 pybind11 让 SM4 模块被 Python 调用

项目中 C++ 与 Python 是两个独立工程,最省力的接通方式是 pybind11。它不需要写 SWIG 接口文件,也不需要手动管理引用计数,代码里加一层宏就能生成 Python 模块。把 sm4.cpp 编译成共享库时,在绑定文件里声明函数签名与参数名,Python 侧就可以用关键字参数调用。编译命令与绑定代码如下:

g++ -O3 -std=c++17 -fPIC -shared \ -I/path/to/pybind11/include sm4.cpp sm4_bind.cpp \ -o sm4_core$(python3-config --extension-suffix)
// sm4_bind.cpp #include <pybind11/pybind11.h> #include "sm4.hpp" namespace py = pybind11; PYBIND11_MODULE(sm4_core, m) { m.def("sm4_encrypt", &sm4_encrypt, "SM4 ECB encrypt", py::arg("key"), py::arg("data")); m.def("sm4_decrypt", &sm4_decrypt, "SM4 ECB decrypt", py::arg("key"), py::arg("data")); }

编译参数说明:-O3对查表法影响明显,能提升 S 盒访问性能;-fPIC是生成动态库的必要条件;python3-config --extension-suffix返回.cpython-3xx-x86_64-linux-gnu.so,拼进文件名后才能被 Python 导入。绑定文件里py::arg("key")让 Python 侧能用sm4_encrypt(key=b"...", data=b"...")写法调用,比位置参数更清晰。这样处理后,Python 侧可以直接用import sm4_core调用 C++ 版本,压测与单元测试的代码量会明显降低。

5.2 验证算法正确性的四类测试向量

合入代码之前,我一般会跑四类测试。第一类是标准算法测试向量,从国密标准文档或已认证的 C 库中复制固定密钥和固定明文的加解密结果,要求加密后密文与标准一致、解密能还原。第二类是边界条件:空消息、1 字节消息、刚好一个分组、跨分组长度的数据,重点看填充逻辑是否溢出或越界。第三类是回归对比,把 Python 实现与 C++ 实现互相对打,确认输出一致。第四类是安全用例:签名验证错误签名必须返回 false,修改一个字节密文后 GCM 的认证标签必须校验失败。

# 回归对比脚本:C++ 版与 Python 版互相对打 from sm4_core import sm4_encrypt as cpp_encrypt from sm4_python import sm4_encrypt as py_encrypt import os, secrets for i in range(1000): key = secrets.token_bytes(16) data = os.urandom(i % 128 + 1) assert cpp_encrypt(key, data) == py_encrypt(key, data)

测试脚本里用secrets.token_bytes而不是random.bytes,因为 Python 的random是梅森旋转伪随机数生成器,用于安全测试不合适。这个断言能同时验证密钥扩展、S 盒一致性、C++ 端与 Python 端的填充顺序是否完全一致。跑完随机数据后,我通常还会用标准向量再跑 3 组,作为最终回归兜底。这样整个源码包里的 C++ 算法、Python 攻击脚本和两方协议代码,就串成了一个可编译、可调用、可验证的完整链路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询