之前的工作中,频繁用到 MD5 值...
然后就一直在思考 MD5 值是如何算出来的,可惜没有太多时间,现在有时间了,深入研究一下。
前言
在正式介绍 MD5 算法之前,我们需要先知道它是什么。按照百度百科的解释,MD5 算法是一种信息摘要算法(MD5 Message-Digest Algorithm),本质上就是一种密码散列函数(所以它是一种哈希算法,不是加密算法),可以产生出一个 128 位(16 字节)的散列值(Hash Value),而这个散列值则可以用来确保信息传输的完整一致性,比如,对下载完成的文件进行 MD5 校验。从发展历史上来看,MD5 实际上是从 MD4 改进而来的,主要增加了算法的复杂度。
原理
MD5 算法的原理可以简单概括为:MD5 算法以 512 位分组处理输入信息,且每一分组又被划分为 16 个 32 位子分组,经过一系列计算后,得到四个 32 位数,将这四个 32 位数拼接在一起得到一个 128 位散列值。
假设输入信息的长度(比特数)为 $b$ 位,$b$ 可以为任意非负整数,则计算其 MD5 值需要经过以下步骤:
补位
由于 MD5 算法是以 512 位为一组进行信息处理,那么输入信息的长度就必须要是 512 的整数倍,长度不够就需要补位。设补位后的信息长度为 $LEN$,则 $LEN \% 512 = 448(bits)$,也即 $LEN = K * 512 + 56(byte)$。当输入信息已经满足 $LEN \% 512 = 448$ 时,仍然需要补位,且此时完整补 512 位。具体补位操作:第一位补 1,后面全部补 0。
尾部写入信息长度
前面提到的输入信息的长度 $b$ 会被表示为一个无符号 64 位整数,并写入到补位后的结果后面,当 $b > 2^{64}$ 时,$b$ 的高位会被截去。这样,补位后的数据块就为:$448 + 64 = 512$,正好满足计算需要。
初始化缓存变量
MD5 使用四个固定的“魔法数字”作为初始值(小端序,十六进制数):
1 | A = 0x67452301 |
单块四轮运算
单块大小为 512 位,再拆成 16 个 32 位小块,使用四个混淆函数进行 4 轮循环运算,每轮 16 次,共 64 次。
四个混淆函数:
$$
\begin{align}
F(x, y, z) &= (x \& y) | (\neg x \& z) \\
G(x, y, z) &= (x \& z) | (y \& \neg z) \\
H(x, y, z) &= (x \oplus y \oplus z) \\
I(x, y, z) &= y \oplus (x | \neg z) \\
\end{align}
$$
每一次计算都会结合子块数据、固定正弦常数表、固定循环左移位数表来计算 A、B、C、D 的值,计算完成之后,再交换位置。
累加计算结果
每处理完一个 512 位分组,将临时计算的 A、B、C、D 累加到初始的缓存变量中,而非覆盖。
拼接最终结果
把最终 A、B、C、D 四个 32 位数按小端序拼接,合成 128 位结果。
小结
经过前面的介绍,我们了解了 MD5 算法的执行过程,以及它的一些特点,比如:
- MD5 算法理论支持任意比特长度输入,实际工程中输入均为字节对齐(即 8 的整数倍)。
- MD5 算法的最小计算单元是 512 位长的信息块,最终经过补位、尾部长度填充后的数据比特位长度一定是 512 的整数倍。
- MD5 算法每一步的计算过程是固定的,只是混淆函数和参与计算的常数不一致。
- MD5 算法中的字节序都是小端序。
实现
现在,我们考虑如何实现这个算法。有了上面的了解,我们发现这个算法并没有复杂的操作过程,只是在重复的进行计算,所以我们可以用 C 语言来实现它。
混淆函数和常量表
先从简单的入手,把固定的东西完成:
1 | // 循环左移函数 |
MD5 结构体
接着,我们考虑定义 MD5 结构体来保存计算过程中得到的一些中间值:
1 | // MD5 上下文结构体 |
功能函数
然后就是围绕 MD5 结构体的一系列功能函数。
md5_init
首先,我们需要将 MD5 上下文结构体初始化:
1 | void md5_init(MD5_CTX *ctx) { |
md5_update
接着,我们将输入信息写入到 MD5 上下文结构体中,每当结构体中缓存满了(达到 512 位),就可以将这一块进行 MD5 计算;当数据不满 512 位时,先写入缓存,补位后在进行计算:
1 | void md5_update(MD5_CTX *ctx, const uint8_t *input, size_t input_len) { |
这里,可能有人会疑惑为什么补位操作放在计算操作之后了。按照之前的分析,应该是先补位,然后再计算吧?实际上,这里之所以先计算,是因为输入信息长度大于等于 512 位,满足 MD5 计算要求,所以可以直接计算。而且即便是把补位操作放在计算之后,也不会对最终结果有任何影响。
md5_transform
现在,我们考虑如何计算 512 位数据。按照之前的分析,512 位会分为 16 个 32 位子块,每个子块会单独参与到计算中。在每轮运算中,需要先算出混淆函数的结果,然后与 A、正弦常数和单个 32 位子块累加在一起,左移指定位数后,轮换 A、B、C、D 的值。计算完成后,将 A、B、C、D 的值累加到 MD5 上下文结构体中。
1 | static void md5_transform(MD5_CTX *ctx, const uint8_t data[64]) { |
md5_final
最后,我们还需要考虑补位及最后一块数据块的计算。首先,补位的问题按照前面的补位规则进行实现即可;最后一块数据块的计算也比较直观,调用md5_update函数就可以完成补位数据写入和计算的操作,最后将得到的结果,写入到output中即可:
1 | void md5_final(MD5_CTX *ctx, uint8_t output[16]) { |
注意:最后 8 字节写入的比特位数是原始消息的比特位数,补位数据的比特位数不算在内!
这里,我们还可以发现,尽管 MD5 算法理论上补位的最小单位是比特,但由于计算机最小的读写单位是字节,所以补位的数据一定是 8 的倍数。
md5_to_hex
为了方便测试和观察,再实现一个将 MD5 值转换为十六进制字符串的函数:
1 | void md5_to_hex(const uint8_t digest[16], char hex_str[33]) { |
测试
实现完成后,再用一个简单的单元测试函数测试一下正确性:
1 | void unit_test() { |
总结
到这里,本次 MD5 算法之旅就算结束了。在本篇文章中,做了两件事:
- 分析和探究了 MD5 算法的执行过程。
- 使用 C 语言实现了 MD5 算法,并进行了简单测试,测试结果符合预期。
整体代码量不算大,算上注释只有二百来行:
1 |
|