SM3算法Python实现:核心代码编写、模块化设计、单元测试

上一节我们把SM3的算法原理讲透了,这节咱们直接上手写代码。我个人习惯先把核心逻辑跑通,再拆模块,最后补测试——这个顺序在项目里踩坑最少。

4.1 核心代码编写:从消息分组到杂凑值

SM3的Python实现,说白了就是把消息填充、分组、迭代压缩这三步用代码串起来。我刚开始写的时候,最容易翻车的地方是填充规则和字移位,咱们一个个来。

4.1.1 消息填充

def sm3_pad(message: bytes) -> bytes:
    """SM3消息填充:补1、补0、补长度"""
    m_len = len(message) * 8          # 比特长度
    message += b'\x80'                # 先补一个 '1'
    while (len(message) * 8) % 512 != 448:
        message += b'\x00'            # 补 '0' 直到长度 mod 512 == 448
    # 附加64位原始长度(大端)
    message += m_len.to_bytes(8, 'big')
    return message
提醒一句:填充后长度必须是512的整数倍,否则后面迭代会少处理一组。我在测试一个2048位消息时,就因为while条件写错成% 512 == 0,debug了半小时。

4.1.2 常量与置换函数

# SM3常量
IV = [
    0x7380166F, 0x4914B2B9, 0x172442D7, 0xDA8A0600,
    0xA96F30BC, 0x163138AA, 0xE38DEE4D, 0xB0FB0E4E
]

# 置换函数
def P0(X: int) -> int:
    return X ^ (ROTL(X, 9)) ^ (ROTL(X, 17))

def P1(X: int) -> int:
    return X ^ (ROTL(X, 15)) ^ (ROTL(X, 23))

为什么要有P0和P1?简单讲,P1用在消息扩展里增加非线性,P0用在压缩函数里扩散雪崩效应。你想想看,没有这两步,SM3的安全性就跟CRC差不多了。

4.1.3 消息扩展与压缩

def msg_expand(block: bytes) -> list:
    """将512位分组扩展为132个32位字:W0..W67, W'0..W'63"""
    W = list(struct.unpack('>16I', block))  # W0..W15
    for j in range(16, 68):
        tmp = P1(W[j-16] ^ W[j-9] ^ ROTL(W[j-3], 15)) ^ ROTL(W[j-13], 7) ^ W[j-6]
        W.append(tmp)
    W_ = [W[j] ^ W[j+4] for j in range(64)]
    return W, W_

def CF(V: list, block: bytes) -> list:
    """压缩函数:迭代64轮,更新8个32位字"""
    W, W_ = msg_expand(block)
    A, B, C, D, E, F, G, H = V
    for j in range(64):
        SS1 = ROTL((ROTL(A, 12) + E + ROTL(T[j], j % 32)) & 0xFFFFFFFF, 7)
        SS2 = SS1 ^ ROTL(A, 12)
        TT1 = (FF[j // 16](A, B, C) + D + SS2 + W_[j]) & 0xFFFFFFFF
        TT2 = (GG[j // 16](E, F, G) + H + SS1 + W[j]) & 0xFFFFFFFF
        D = C
        C = ROTL(B, 9)
        B = A
        A = TT1
        H = G
        G = ROTL(F, 19)
        F = E
        E = P0(TT2)
    return [(a + v) & 0xFFFFFFFF for a, v in zip([A,B,C,D,E,F,G,H], V)]
曾经踩过的坑:ROTL(循环左移)函数在Python里要自己实现((x << n) | (x >> (32 - n))) & 0xFFFFFFFF,否则移位会带出32位边界。我一开始直接用<<,结果杂凑值全错。

4.2 模块化设计:分层清晰,方便复用

核心代码写完后,我习惯把SM3拆成三个模块:core(基础函数)、sm3_api(对外接口)、sm3_hmac(用于密钥校验的HMAC包装)。这样测试时能单独mock某个层,维护起来也清爽。

模块职责主要函数/类
sm3_core.py常量、置换函数、消息扩展、单轮压缩ROTL, P0, P1, msg_expand, CF
sm3_api.py填充、迭代入口、直接输出杂凑值SM3 类,update(), digest(), hexdigest()
sm3_hmac.py基于SM3的HMAC实现,用于密钥校验hmac_sm3(key, msg)

下面给出sm3_api.py的骨架(完整代码见随课资源):

class SM3:
    def __init__(self):
        self._V = IV[:]            # 初始向量
        self._unprocessed = b''    # 未满512位的缓存
        self._total_bits = 0

    def update(self, data: bytes):
        """持续输入数据(支持分块喂入)"""
        self._unprocessed += data
        self._total_bits += len(data) * 8
        # 每次取满512位做CF
        while len(self._unprocessed) >= 64:
            block = self._unprocessed[:64]
            self._V = CF(self._V, block)
            self._unprocessed = self._unprocessed[64:]

    def digest(self) -> bytes:
        """计算最终杂凑值并返回32字节"""
        padded = sm3_pad(self._unprocessed, self._total_bits)
        # 最后可能剩余一组或多组512位
        for i in range(0, len(padded), 64):
            block = padded[i:i+64]
            self._V = CF(self._V, block)
        return struct.pack('>8I', *self._V)

    def hexdigest(self) -> str:
        return self.digest().hex()

模块化设计的另一个好处是:以后升级国密标准,只需要换core层的常量或轮函数,上层接口完全不用动。

4.3 单元测试:用标准测试向量卡边界

单元测试我一般分三个层次:

  • 白盒测试——直接测ROTL、P0、P1等基础函数,覆盖边界值(0x00000000、0xFFFFFFFF、全1移位等)。
  • 中间状态测试——确认填充后的分组长度正确,msg_expand输出的132个字是否满足SM3规范中的例子。
  • 黑盒测试——用国密标准里给的测试向量(空字符串、一个字符、长消息)比对最终杂凑值。

下面是一个用unittest写的示例:

import unittest
from sm3_api import SM3

class TestSM3(unittest.TestCase):
    # 国密标准测试向量
    VECTORS = [
        (b'', '66c7f0f4...'),          # 短消息hash(实际应为32字节hex)
        (b'abc', '66c7f0f4...'),        # 仅作示例,请替换为真实值
        (b'ab'*1000, 'xx...'),          # 长消息
    ]

    def test_short_message(self):
        sm3 = SM3()
        for msg, expected in self.VECTORS[:2]:
            with self.subTest(msg=msg):
                sm3.update(msg)
                result = sm3.hexdigest()
                self.assertEqual(result, expected)

    def test_basic_functions(self):
        """测试ROTL边界"""
        from sm3_core import ROTL
        self.assertEqual(ROTL(0x00000001, 31), 0x80000000, "左移31位溢出测试")
        self.assertEqual(ROTL(0xFFFFFFFF, 2), 0xFFFFFFFF, "全1循环移位不变")

if __name__ == '__main__':
    unittest.main()
个人经验:测试不能只跑标准向量。我曾经因为某次修改优化了P1的实现,但忘了一个括号的位置,导致长消息hash全错。后来我多补了一个随机消息与OpenSSL SM3输出比对的用例,才彻底抓出bug。

为了让你更直观地理解这三部分的关系,我画了一张结构图:

SM3 Python实现 · 模块与测试关系 sm3_core.py 常量 · ROTL · P0/P1 msg_expand · CF sm3_api.py 填充 · update() digest() · hexdigest() sm3_hmac.py hmac_sm3(key, msg) 用于密钥校验 单元测试 (unittest / pytest) 白盒测试·中间状态测试·黑盒测试(标准向量+随机比对) 关注:填充边界 · 长消息性能 与大端/小端字节序陷阱
单元测试跑通了吗?建议在每个模块的__init__.py里写一个def test_self()函数,导入时自动跑一遍自检——这个习惯帮我避免过好几次“改完A模块、B模块忘记同步改”的尴尬。