手把手教你用Python实现SM3哈希算法(附完整代码示例)
从零构建SM3哈希算法:深入原理与Python实战
如果你是一位Python开发者,最近在项目中遇到了需要实现数据完整性校验或数字签名的需求,并且希望采用符合国内密码标准的方案,那么SM3算法很可能已经进入了你的视野。与大家更熟悉的SHA-256不同,SM3是我国自主设计的密码杂凑算法标准,在金融、政务、物联网等对安全性有特定要求的领域应用越来越广泛。但当你真正开始动手实现时,可能会发现,虽然网上有一些代码片段,但要么过于简略缺少关键细节,要么直接调用封装好的库,对算法内部的运作机制语焉不详。
这篇文章就是为你准备的。我不会仅仅给你一个可以复制粘贴的“黑箱”代码,而是带你从最基础的比特操作开始,一步步推导出完整的SM3实现。我们会深入算法的每一个核心环节——消息填充、迭代压缩、消息扩展,并用纯Python代码将其具象化。更重要的是,我会分享在实际编码中容易踩到的“坑”,比如字节序的处理、边界条件的判断,以及如何设计有效的测试用例来验证你实现的正确性。读完本文并动手实践后,你不仅能获得一个可用的SM3实现,更能透彻理解其设计精髓,从而具备在更复杂场景下灵活应用甚至进行定制化优化的能力。
1. 理解SM3:不仅仅是另一个哈希函数
在直接跳进代码之前,我们有必要先厘清SM3在整个密码学体系中的位置,以及它试图解决的核心问题。哈希函数,或者说杂凑算法,是现代密码学的基石之一。它的核心任务是将任意长度的输入数据(消息)映射为一个固定长度(例如256比特)的输出,这个输出通常被称为“哈希值”或“摘要”。一个设计良好的哈希函数需要满足几个关键性质:
- 单向性:从哈希值反向推导出原始消息在计算上是不可行的。
- 抗碰撞性:找到两个不同的消息,但它们的哈希值相同,在计算上极其困难。
- 雪崩效应:原始消息哪怕只改变一个比特,产生的哈希值也会发生大约50%比特的改变。
SM3算法于2010年由国家密码管理局发布,其输出长度为256比特,与SHA-256相同。它在设计上借鉴了Merkle-Damgård结构(这也是SHA-256采用的结构),但在压缩函数、常量、布尔函数等具体组件上采用了全新的设计。这种独立设计并非为了“不同而不同”,其背后有明确的安全考量和技术演进逻辑。
提示:理解Merkle-Damgård结构对掌握SM3至关重要。简单来说,它先将长消息分割成固定大小的块,然后像一个链条一样,将前一个块的输出作为下一个块输入的的一部分,依次处理所有块,最终产生哈希值。SM3的消息分组大小是512比特。
那么,在已经有了SHA-256这样国际通用的算法后,为什么还需要SM3?这涉及到几个层面的考虑。首先,密码算法的自主可控对于国家安全和关键信息基础设施至关重要,避免潜在的后门风险。其次,SM3在部分硬件平台上的实现效率经过优化,可能具有更好的性能表现。最后,在一些特定的行业规范和国家标准中,使用SM3是合规性的硬性要求。
为了更直观地对比SM3与SHA-256的定位和特性,可以参考下表:
| 特性维度 | SM3 (国密杂凑算法) | SHA-256 (NIST标准) |
|---|---|---|
| 发布机构 | 中国国家密码管理局 (2010) | 美国国家标准与技术研究院 (2001) |
| 输出长度 | 256比特 | 256比特 |
| 消息分组 | 512比特 | 512比特 |
| 结构 | Merkle-Damgård | Merkle-Damgård |
| 核心应用场景 | 国内数字签名、消息认证、随机数生成 (与SM2/SM9配套) | 国际通用数字签名、完整性校验、区块链(比特币) |
| 设计目标 | 满足国内商用密码安全需求,实现自主可控 | 替代早期SHA-1,提供更强的抗碰撞能力 |
| 性能对比 | 在特定国产硬件上有优化,软件实现效率与SHA-256相近 | 经过长期优化,在通用CPU上实现高度成熟 |
了解这些背景后,我们就能明白,学习SM3的实现不仅是一项编程练习,更是深入理解一类重要密码原语设计思想的窗口。接下来,我们将从最基础的准备工作开始。
2. 环境准备与基础工具函数
我们将使用纯Python来实现SM3,这意味着你不需要安装任何特殊的第三方密码学库(如cryptography)。标准库的struct模块将是我们处理字节和整数转换的得力助手。确保你的Python版本在3.6以上即可。
首先,创建一个新的Python文件,比如sm3_impl.py。让我们从导入必要的模块和定义一些在整个算法中都会用到的常量开始。
import struct
import binascii
# SM3算法中使用的初始哈希值 (IV)
# 这是8个32位的寄存器初始状态,对应A, B, C, D, E, F, G, H
IV = [
0x7380166F, 0x4914B2B9, 0x172442D7, 0xDA8A0600,
0xA96F30BC, 0x163138AA, 0xE38DEE4D, 0xB0FB0E4E
]
# 常量T_j,用于压缩函数中的计算。
# 当 0 <= j <= 15 时, T_j = 0x79CC4519
# 当 16 <= j <= 63 时, T_j = 0x7A879D8A
T = [0x79CC4519] * 16 + [0x7A879D8A] * 48
这些常量是SM3标准中明确定义的,直接使用即可。IV是算法的初始向量,对于相同的输入,它保证了哈希计算的起点一致。T常量则在后续的每一轮压缩计算中都会用到。
SM3算法大量使用32位无符号整数的循环左移操作。Python的整数没有固定位宽,我们需要通过掩码操作来模拟32位整数的溢出行为。此外,算法中还定义了多个布尔函数(FFj, GGj)和置换函数(P0, P1),我们需要将它们实现为工具函数。
def left_rotate(n, b):
"""将32位整数n循环左移b位。"""
return ((n << b) | (n >> (32 - b))) & 0xFFFFFFFF
def ff_j(x, y, z, j):
"""布尔函数FF_j,根据j的范围有不同的定义。"""
if 0 <= j <= 15:
return x ^ y ^ z
else: # 16 <= j <= 63
return (x & y) | (x & z) | (y & z)
def gg_j(x, y, z, j):
"""布尔函数GG_j,根据j的范围有不同的定义。"""
if 0 <= j <= 15:
return x ^ y ^ z
else: # 16 <= j <= 63
return (x & y) | ((~x) & z)
def p0(x):
"""置换函数P0。"""
return x ^ left_rotate(x, 9) ^ left_rotate(x, 17)
def p1(x):
"""置换函数P1。"""
return x ^ left_rotate(x, 15) ^ left_rotate(x, 23)
这里有几个细节需要注意:
left_rotate函数中的& 0xFFFFFFFF操作至关重要,它确保了结果始终保持在32位范围内(即模2^32)。ff_j和gg_j函数中的j参数代表当前处理的轮数(0到63),函数逻辑根据轮数范围不同而改变,这是SM3算法设计的一部分。p0和p1是SM3中定义的两个线性变换,用于在消息扩展和压缩函数中打乱数据,增加算法的扩散性。
有了这些基础“积木”,我们就可以开始构建算法的核心流程了。第一步,是对任意长度的输入消息进行标准化处理,即消息填充。
3. 消息填充:让任意消息适应固定分组的艺术
哈希函数处理的是固定大小的数据块(SM3是512比特,即64字节)。但我们的输入消息长度是任意的。消息填充(Padding)规则就是用来解决这个矛盾的。SM3的填充规则与SHA-256类似,但有自己的填充常量。其规则可以概括为:
- 在原始消息的末尾追加一个比特
1(在字节层面,就是追加一个0x80字节)。 - 然后追加若干个比特
0,直到消息的长度(以比特为单位)满足长度 % 512 == 448。换句话说,填充后的消息长度模512余448。 - 最后,追加一个64比特(8字节)的无符号整数,表示原始消息的比特长度。
这个过程确保了填充后的消息总长度是512比特的整数倍,并且末尾包含了原始消息的长度信息,这能有效防御某些类型的密码学攻击(如长度扩展攻击)。
让我们用Python来实现这个填充过程。我们将输入视为字节串(bytes)。
def sm3_padding(message):
"""
对消息进行SM3填充。
参数: message - 原始消息,bytes类型。
返回: 填充后的消息,bytes类型,长度为512比特(64字节)的整数倍。
"""
# 转换为字节数组便于操作
msg = bytearray(message)
orig_len_in_bits = len(msg) * 8
# 1. 追加比特'1' (0x80)
msg.append(0x80)
# 2. 追加k个比特'0',使得 (orig_len_in_bytes + 1 + k) % 64 == 56
# 这里56字节 = 448比特。我们计算需要追加的0字节数。
while (len(msg) % 64) != 56:
msg.append(0)
# 3. 追加原始消息长度的64位表示(大端序)
# struct.pack('>Q', ...) 将整数打包为8字节大端序
msg.extend(struct.pack('>Q', orig_len_in_bits))
return bytes(msg)
为了帮助你理解填充过程,我们来看一个简单的例子。假设原始消息是字符串"abc"的UTF-8编码,即b'abc'(3字节,24比特)。
- 步骤1:追加
0x80,现在消息为61 62 63 80(hex)。 - 步骤2:当前长度4字节,
4 % 64 != 56,需要填充0直到长度模64等于56。计算得出需要填充52个0x00字节。填充后长度达到56字节。 - 步骤3:追加原始长度24比特(
0x18)的64位大端表示:00 00 00 00 00 00 00 18。 - 最终填充后的消息总长度为 56 + 8 = 64 字节,正好是一个512比特的分组。
你可以编写一个小测试来验证这个函数:
# 测试填充函数
test_msg = b"abc"
padded = sm3_padding(test_msg)
print(f"原始消息: {test_msg}")
print(f"填充后长度: {len(padded)} 字节")
print(f"填充后数据(Hex): {binascii.hexlify(padded)}")
运行后,你应该能看到填充后的数据,并且其长度是64的倍数。填充完成后,我们就可以将消息分割成多个512比特的分组,逐个送入核心的压缩函数进行处理了。
4. 核心引擎:压缩函数与消息扩展
这是SM3算法最复杂也最精妙的部分。压缩函数CF(V, B)接受两个输入:一个256比特的中间状态V(由8个32位变量A-H表示),和一个512比特的消息分组B。经过64轮复杂的迭代计算后,输出一个新的256比特状态。消息分组B本身并不会直接参与每一轮计算,而是需要先经过一个“消息扩展”过程,生成132个32位的字(W0到W67,以及W‘0到W’63),为64轮计算提供“燃料”。
4.1 消息扩展
消息扩展的目的是从原始的16个32位字(一个512比特分组正好是16个32位字)生成更多的字,以消除输入中的任何规律性,并让每一个输入比特都能影响后续多轮的计算。SM3的消息扩展分为两步:
- 生成W0到W67:
- 前16个字(W0到W15)直接取自消息分组B。
- 后续的字(W16到W67)由前面的字通过异或、循环左移和P1置换函数计算得出。具体公式为:
W[j] = P1(W[j-16] ^ W[j-9] ^ (left_rotate(W[j-3], 15))) ^ (left_rotate(W[j-13], 7)) ^ W[j-6]
- 生成W‘0到W’63:
W'[j] = W[j] ^ W[j+4]
让我们用代码来实现它:
def message_expansion(block):
"""
消息扩展。将一个64字节的消息分组扩展为132个32位字。
参数: block - 一个512比特(64字节)的消息分组,bytes类型。
返回: 两个列表,W (68个字) 和 W_ (64个字)。
"""
# 将64字节分组划分为16个32位字(大端序)
W = list(struct.unpack('>16I', block))
# 扩展W到68个字
for j in range(16, 68):
term1 = p1(W[j-16] ^ W[j-9] ^ left_rotate(W[j-3], 15))
term2 = left_rotate(W[j-13], 7) ^ W[j-6]
W.append(term1 ^ term2)
# 计算W'
W_ = []
for j in range(64):
W_.append(W[j] ^ W[j+4])
return W, W_
4.2 压缩函数
有了扩展后的消息字W和W‘,压缩函数就可以开始64轮的迭代了。每一轮都会更新8个寄存器(A, B, C, D, E, F, G, H)的状态。我们用V(一个包含8个整数的列表)来表示这组寄存器。压缩函数的流程如下:
- 将输入的状态向量
V复制到8个临时变量A到H中。 - 进行64轮迭代(j从0到63):
- 计算两个中间变量
SS1和SS2。 - 计算临时变量
TT1和TT2。 - 更新寄存器
A到H的值(注意更新顺序,是“螺旋式”的赋值)。
- 计算两个中间变量
- 将更新后的临时变量
A到H与输入状态V的对应值进行模加(模2^32),得到最终的输出状态。
注意:算法中所有的加法都是模2^32加法,在代码中我们用
& 0xFFFFFFFF来模拟溢出,并在最后进行真正的模加。
下面是压缩函数的实现代码,我添加了详细的注释来对应算法的每一步:
def cf(v, block):
"""
压缩函数 CF(V, B)。
参数:
v: 当前的状态向量,包含8个32位整数的列表。
block: 当前要处理的消息分组,64字节。
返回: 新的状态向量(8个32位整数)。
"""
# 1. 消息扩展
W, W_ = message_expansion(block)
# 2. 将输入状态V赋值给A-H
A, B, C, D, E, F, G, H = v
# 3. 64轮迭代
for j in range(64):
# 计算SS1
ss1_left = left_rotate(A, 12) + E + left_rotate(T[j], j % 32)
ss1 = left_rotate(ss1_left & 0xFFFFFFFF, 7)
# 计算SS2
ss2 = ss1 ^ left_rotate(A, 12)
# 计算TT1
tt1 = (ff_j(A, B, C, j) + D + ss2 + W_[j]) & 0xFFFFFFFF
# 计算TT2
tt2 = (gg_j(E, F, G, j) + H + ss1 + W[j]) & 0xFFFFFFFF
# 更新寄存器 (注意顺序:D=C, C=left_rotate(B,9), ...)
D = C
C = left_rotate(B, 9)
B = A
A = tt1
H = G
G = left_rotate(F, 19)
F = E
E = p0(tt2)
# 4. 与输入状态V进行模加,得到输出
v_new = [
(v[0] + A) & 0xFFFFFFFF,
(v[1] + B) & 0xFFFFFFFF,
(v[2] + C) & 0xFFFFFFFF,
(v[3] + D) & 0xFFFFFFFF,
(v[4] + E) & 0xFFFFFFFF,
(v[5] + F) & 0xFFFFFFFF,
(v[6] + G) & 0xFFFFFFFF,
(v[7] + H) & 0xFFFFFFFF,
]
return v_new
这个函数是SM3算法的“心脏”。每一轮迭代都混合了当前状态、扩展消息字、常量和置换函数,确保了信息的充分混淆和扩散。现在,我们已经拥有了处理单个消息分组的能力。接下来,我们需要将填充后的整个消息串联起来,通过反复调用压缩函数,最终生成哈希值。
5. 整合与主函数实现
主函数的逻辑遵循标准的Merkle-Damgård迭代结构:
- 对输入消息进行填充。
- 将填充后的消息按64字节分组。
- 初始化状态向量为
IV。 - 对每一个消息分组,用当前状态向量调用压缩函数
CF,输出作为下一个分组的状态向量输入。 - 处理完所有分组后,最终的状态向量就是哈希结果,将其拼接并转换为十六进制字符串输出。
def sm3(message: bytes) -> str:
"""
计算消息的SM3哈希值。
参数: message - 原始消息,bytes类型。
返回: 64个字符的十六进制哈希字符串。
"""
# 1. 填充
padded_msg = sm3_padding(message)
# 2. 初始化状态
v = IV.copy() # 使用初始向量IV的副本
# 3. 迭代处理每个分组
total_blocks = len(padded_msg) // 64
for i in range(total_blocks):
block = padded_msg[i*64:(i+1)*64]
v = cf(v, block) # 压缩并更新状态
# 4. 将最终状态向量转换为十六进制字符串
# 将8个32位整数按大端序拼接成256比特(32字节),再转为hex
hash_bytes = struct.pack('>8I', *v)
return binascii.hexlify(hash_bytes).decode('ascii')
至此,一个完整的、从零实现的SM3哈希函数就完成了。你可以立即测试一下:
if __name__ == "__main__":
# 测试用例1: 空字符串
print("SM3('') =", sm3(b""))
# 标准结果应为: 1ab21d8355cfa17f8e61194831e81a8f22bec8c728fefb747ed035eb5082aa2b
# 测试用例2: "abc"
print('SM3("abc") =', sm3(b"abc"))
# 标准结果应为: 66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0
# 测试用例3: 长消息
long_msg = b"a" * 1000
print('SM3("a"*1000) =', sm3(long_msg))
# 你可以用官方实现或可信库来验证这个结果
运行这段代码,如果实现正确,前两个测试用例的输出应该与注释中给出的标准SM3哈希值完全一致。第三个长消息测试用于验证算法对超过一个分组消息的处理是否正确。
6. 验证、优化与进阶思考
通过了基础测试,并不意味着我们的实现就万无一失了。在实际开发中,我们需要更全面的验证。一个有效的方法是使用官方测试向量(Test Vectors)。国家密码管理局的标准文档中通常会提供一系列标准输入和对应的输出。你可以将这些测试向量整理成列表,用你的实现去跑一遍,确保全部通过。
# 一个简单的测试向量验证框架示例
test_vectors = [
(b"", "1ab21d8355cfa17f8e61194831e81a8f22bec8c728fefb747ed035eb5082aa2b"),
(b"abc", "66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0"),
(b"abcd"*16, "debe9ff92275b8a138604889c18e5a4d6fdb70e5387e5765293dcba39c0c5732"), # 示例,非官方
]
def run_tests():
all_pass = True
for msg, expected in test_vectors:
result = sm3(msg)
if result == expected:
print(f"PASS: '{msg[:20]}...' -> {result}")
else:
print(f"FAIL: '{msg[:20]}...'")
print(f" Expected: {expected}")
print(f" Got: {result}")
all_pass = False
return all_pass
性能方面,我们目前的实现是纯Python的,注重清晰易懂。对于生产环境或处理大量数据,性能可能成为瓶颈。常见的优化方向包括:
- 使用本地库:对于核心的循环和位操作,可以考虑使用
numpy或ctypes调用C语言编写的优化库。 - 预计算:算法中的常量(如T_j)和固定置换可以预计算成表。
- 并行化:哈希算法本质是串行的,但如果你有多个独立的消息需要哈希,可以利用多线程或进程并行处理。
最后,我们来谈谈SM3在实际中的典型应用场景。最直接的就是数据完整性校验。比如,你从网上下载了一个软件安装包,同时网站提供了该文件的SM3哈希值。你下载后可以自己计算一遍哈希值进行对比,如果一致,就证明文件在传输过程中没有被篡改。另一个核心应用是数字签名。在SM2椭圆曲线数字签名算法中,对消息的哈希计算就是采用SM3。先对消息用SM3生成摘要,再对摘要用SM2私钥进行签名,效率和安全性都得到了保障。
在实现和使用过程中,我遇到过的一个典型“坑”是关于编码的。sm3函数接受bytes类型输入。如果你有一个字符串,务必先使用.encode('utf-8')将其转换为字节串。不同编码(如UTF-8和GBK)会产生不同的字节序列,进而得到完全不同的哈希值。确保所有参与计算的系统都使用相同的编码约定,这是保证哈希一致性的前提。
另一个需要注意的点是,我们自己实现的这个版本是用于学习和理解算法的。在至关重要的生产系统中,如果条件允许,更推荐使用经过严格审计和广泛测试的成熟密码学库(如gmssl库中的SM3实现),它们通常经过了更多的优化,并且在侧信道攻击防护等方面做得更好。但无论如何,亲手实现一遍的经历,会让你在调用这些库时更加自信,也更能理解其背后的逻辑,当遇到问题时,你不再是束手无策,而是有能力去深入探查。
更多推荐



所有评论(0)