SM3 消息预处理:填充方式与长度编码

哈希算法处理消息时,总是按固定块大小来的。SM3 的块大小是 512 位(64 字节)。但实际消息长度千变万化,必须先把消息 “修理” 成块长度的整数倍。这个过程就是消息预处理。我个人习惯把它拆成两步:填充位 + 长度编码。今天咱们就详细拆解这一步。

为什么要填充?

你想想看,SM3 的压缩函数一次吃 512 位。如果消息长度不是 512 的整数倍,最后一块就没法处理。更关键的是,哈希算法需要保证唯一性:哪怕只差一个比特,摘要也必须完全不同。所以填充规则必须是确定且可逆的。说白了,就是收件人拿到摘要后,能精确还原出原始消息的比特长度。

我记得刚接触 SM3 时,看到填充步骤觉得挺啰嗦的。后来做国密改造项目,发现不少团队在长度编码上栽过跟头——Byte Order 搞反了,摘要全错。嗯,今天咱们就把这个坑填平。

填充的三大步骤

SM3 的填充分三步,按顺序执行:

  1. 补一个 “1” 比特——在消息末尾追加一个比特 1。
  2. 补若干个 “0” 比特——一直补到消息长度(含第一步)模 512 等于 448。
  3. 附加 64 位长度编码——用大端(Big-Endian)格式写入原始消息的比特长度。

这样一来,最终的消息长度正好是 512 的整数倍。示意图如下:

SM3 消息填充流程 原始消息(L bit) 补 1 比特 补 0 至模512余448 64位长度编码 最终消息(512*k bits) 填充后长度 L+1+K+64 ≡ 0 (mod 512) K 是补 0 的比特数

补 1 与补 0 的细节

补 1 总是只有 1 个比特。补 0 的数量呢?得算一下。

给定原始消息比特长度 L,第一步补 1 后长度 L+1。第二步需要补 K 个 0,使得:

(L + 1 + K) ≡ 448 (mod 512)

所以 K = (448 - (L + 1)) mod 512。取值范围 0 到 511。注意:如果 L+1 正好等于 448,K=0?不对,因为模运算要取正余数。实际表达式是:

K = (447 - L) mod 512

我在项目里写过一个自测用例:空字符串(L=0)。这时 K = (447 - 0) mod 512 = 447。也就是说,空字符串先补一个 1,再补 447 个 0,最后加 64 位长度 0x0000000000000000。最后得到 1+447+64=512 位,正好一个块。

小提示: 空字符串填充后,前 448 位中只有开头一个 1,其余全是 0。这个用例可以作为验证填充函数的快速测试。

64 位长度编码

这是最容易踩坑的地方。长度编码的规则:

  • 记录原始消息的比特长度,不是字节长度,也不是填充后的长度。
  • 使用 64 位无符号整数,大端字节序(Big-Endian)。
  • 如果原始消息长度超过 2⁶⁴ 比特,只保留低 64 位(实际上 SM3 几乎不会处理那么大的消息)。

大端编码是什么意思?高字节在前,低字节在后。比如长度 0x1234567890ABCDEF,比特从左到右就是 0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF。

我曾经踩过的坑: 有一次用 Python 的 struct.pack('>Q', length) 居然因为 length 传成了字节长度,结果摘要全错,排查了两天。请务必确认长度单位是比特,不是字节。

一个完整的填充示例

假设原始消息是 "abc",ASCII 编码 3 字节,即 24 比特。

  1. 补 1:末尾追加 1 比特,现在是 25 比特。
  2. 补 0:需要 K 满足 (25+K) ≡ 448 (mod 512) → K = (448-25) mod 512 = 423。
  3. 把 24 比特长度写成 64 位大端:0x00000000 00000018。
  4. 最终消息块由三部分组成:原消息(24 比特) + 1 比特 1 + 423 个 0 + 64 比特长度 = 512 比特。

下面用表格拆解一下各个部分:

段 内容(十六进制/描述) 比特数
原始消息 61 62 63 24
补 1 80(最后一个字节的 8 比特) 1
补 0 00 00 ... 00(共 423 /8 = 52.875 字节,需要注意最后一字节不完整) 423
长度编码 00 00 00 00 00 00 00 18 64

实际编程中,建议以字节为单位处理:先把 1 比特补位拆成 0x80(如果最后一个字节不满 8 比特则独立处理),再补 0x00 字节,最后追加 8 字节长度大端。我之前写过一段 Python 代码,贴出来给你参考:

def sm3_padding(message: bytes) -> bytes:
    # 1. 补 1 比特:0x80
    padded = message + b'\x80'
    
    # 2. 补 0 至 (len(padded) 模 64) == 56
    #    因为 64 字节 = 512 比特,448 比特 = 56 字节
    while (len(padded) % 64) != 56:
        padded += b'\x00'
    
    # 3. 追加 64 位长度编码(大端)
    bit_length = len(message) * 8   # 比特长度
    padded += bit_length.to_bytes(8, byteorder='big')
    
    return padded
关键检查点: 填充后的长度必须是 512 比特的整数倍。运行 len(padded) % 64 == 0,如果通过,填充逻辑才算正确。

注意事项

  • 消息长度超过 2⁶⁴ 比特? SM3 规格说明取模 2⁶⁴,但实际项目中几乎不可能遇到。
  • 大端 vs 小端: SM3 整个算法都基于大端设计,包括字(32 位)和长度。一旦混用小端,摘要立马错误。
  • 空字符串的填充: 是测试填充逻辑的极佳用例,建议每个实现都跑一下。

掌握填充方式和长度编码,SM3 压缩函数的输入就有了保证。下一部分我们就能正式进入迭代压缩的核心了。嗯,这条知识线脉络清楚,咱们一步步来。