最近在学习MD5算法,在网上搜了很多介绍,但来来去去就那么几个版本,基本上都写得很难懂,看得也很痛苦。
通过查维基百科(http://zh.wikipedia.org/zh-cn/MD5), 百度百科(http://baike.baidu.com/view/706946.htm), 以及国内的一些文章和源代码( http://tech.ddvip.com/2006-11/116379689210766.html),自己硬着头皮做了一个自己的C#版的MD5算法实现,相对网上普便流传的那个C#代码版本进行了一些优化(主要是让代码显得更优雅了一些,至少俺个人是这么认为滴)。
大家可以在文章最后看到源代码,在这之前我先总结一些在做算法实现时的经验和遇到的一些问题。
本文假定阅读者已经对MD5算法有了初步的了解。如果读者对MD5还不怎么了解,可以点我前面提到的几个链接过去看看先,等看晕了再过来看这篇文章。(希望不会让读者更晕。)
关于补位
在对原始数组进行处理时,需要先对其进行补位,使原始数据的长度是64字节的倍数,在补位中,最后8个字节保存的是原始数据的长度值,这就是为什么在其他文章中会介绍在补位是要先将原始数据的长度增加到N,使N的长度 mod 64 = 56,其实是为了留最后8个字节用来保存原始数据长度的值。网上的一些文章在关于究竟应该补多少位时有一种说法是“要将原始数据补到长度N是n*64+56,其中n是正整数”。 这个"n是正整数“让我纠结了很久,其实n只要是>=0就OK了。
关于分组
网上的文章介绍MD5的核心是对数据进行分组,然后进行非线性运算迭代,不过说得很抽象,我看得很痛苦。现在想想,所谓分组是把补好位的数据按4个字节一个数据组,然后引入4轮非线性运算,每轮对每一个小组的数据(4个字节,所以是4个数据)进行16次迭代运算,所以4轮一共是64次运算。每64次运算处理16个数据组(一个64个字节)。这也是为什么一开始补位时要将原始数据的长度补成64的倍数。
在MD5算法中有几个关键的运算要关注:
- F(x,y,z)=(x & y) | ((~x) & z) G(x,y,z)=(x & z) | (y & (~z)) H(x,y,z)=x ^ y ^ z I(x,y,z)=y ^ (x | (~z))
这四个非线性函数会分别被用在每64次运算中的四轮非线性运算。在每次迭代中被处理的4个数据(来自于4个链接变量),后三个会被传到每一轮对应的函数中,再把处理结果结合其他一些数据相加后返回给第一个数据。至于具体来说返回给第一个数据的值是怎么来的,请看下面这个运算: - a = a + transform(b, c, d) + m + k;
这个运算出现在每64次运算中的4轮(每轮16次迭代)中的每一次迭代,transform在这里就代表每一轮中对应的非线性处理函数(F,G,H,I)。
m代表的是每一个分组(忘了吗,提醒一下,就是把原始数据按64的倍数细分成1到多个16组、每组4个字节的那个分组)。
k代表一个正弦补充值,在下一节中会提到。
在MD5算法中有几个数据需要关注:
- 四个链接变量: A = 0x67452301, B = 0xefcdab89, C = 0x98badcfe, D = 0x10325476; 这4个变量看起来有点眼花。由于Windows是基于Little Endian的,所以我们在定义这四个变量的值的时候要按它们在内存中的存储形式进行定义,实际上这四个变量的逻辑形式是: 0x01234567, 0x89abcdef, 0xfedcba98, 0x76543210。怎么样?是不是看出了规律?
- 四组位移环移偏移量:7, 12, 17, 22 | 5, 9, 14, 20 | 4, 11, 16, 23 | 6, 10, 15, 21。在每64个字节的处理中,有四轮运算,每一轮运算会使用一组偏移量。由于每一轮有16次迭代,所以每组偏移量中的4个值会被重复用4次。在每一轮中的每一次迭代中,有4个字节的数据会被处理,其中三个字节经过运算后会被累加到第四个字节中,而这第四个字节还要被进行一次向左环移(所谓环移就是被向左移到边界外面的bit又从右边移回来)。而这些数字的作用决定了向左环移多少位。
- 经过正弦计算的补充值:在a = a + transform(b, c, d) + m + k中,k就是那个正弦补充值,它的计算公式是floor(abs(sin(i)) × 2^32) ,其中i代表每64次运算中的次数(第i次,i=1 to 64)。
另外还有一些细节:
- 由于Windows是基于Little Endian,所以在给数据进行存取的时候要注意值的存储形式和逻辑形式。比如当所有的4轮运算迭代搞定后,我们需要将4个链接变量的最终值进行拼接,这里所谓拼接是指对其逻辑形式的值进行拼接,所以我们将不能简单的将4个链接变量的值转成16进制然后进行字符串相加,而是要对4个链接变量进行逐一的按位提取,然后每个变量从高位到低位进行拼接。
- 在进行数据分组的时候,由于是采用一个32位数进行4个字节的存储,所以这个32位数在声明时要用32位无符号数据类型,才能保证有32个位可以使用。如果使用带符号数据类型,那最高位会被用作符号位,这样我们能用的就只有31位了。
- 对于双字节数据(比如中文)的加密,要注意编码形式的一致性。由于MD5本身是对字节数据进行加密,不关心数据在high level层面的编码,所以编码问题需要使用者自己注意。
还需要进一步研究的问题 (欢迎牛人指点):
- 四个链接变量的值是怎么来的
- 四组位移环移偏移量是怎么来的
- 四个非线性运算函数是怎么来的
- 为什么要加上正弦补充值,这个值的公式是怎么来的
- 为什么经过MD5加密的数据能如此厉害,保证在“具有可行性的计算时间"内几乎不能被破解?
PS:通过MD5的学习,补习了很多实用的二进制数据操作技巧,发现很多二进制运算真的很神奇、很高效。
附上我的MD5 C#实现,加了一些注释,基本按照维基百科上MD5的算法描述进行实现,希望能给大家一些帮助。
{
// 定义四个链接变量,在Windows中由于是基于Little Endian,所以其实这四个变量的值本来是0x01234567, 0x89abcdef, 0xfedcba98, 0x76543210
uint A = 0x67452301 , B = 0xefcdab89 , C = 0x98badcfe , D = 0x10325476 ;
// 定义按位环移偏移量,每四个一组,每一组变量在分别在四轮变换中轮渡使用4次
int [] bitOffsets = new int [] { 7 , 12 , 17 , 22 , 5 , 9 , 14 , 20 , 4 , 11 , 16 , 23 , 6 , 10 , 15 , 21 };
ArrayList dataBytes = new ArrayList();
// 将输入的字符串按UTF8编码转换成字节串
byte [] textBytes = System.Text.Encoding.UTF8.GetBytes(text);
for ( int i = 0 ; i < textBytes.Length; i ++ )
{
dataBytes.Add(textBytes[i]);
}
// 补位:将字节串的长度补位成N,单位为byte,使N除于64余56,补位所用的数据由一个1和n个0组成。然后再将该字节串的长度按64位(8 bytes)数据
// 加在补位后的字节串中,最终将这个字节串变成长度为64(单位是byte)的倍数的串
// 补位开始
int onecount = 0 , zerocount = 0 ;
int mod = textBytes.Length % 64 ;
if (mod < 56 )
{
onecount = 1 ;
zerocount = 55 - mod;
}
else if (mod == 56 )
{
onecount = 0 ;
zerocount = 0 ;
}
else
{
onecount = 1 ;
zerocount = 55 - textBytes.Length % 64 + 64 ;
}
if (onecount == 1 )
{
dataBytes.Add(( byte ) 0x80 );
}
for ( int i = 0 ; i < zerocount; i ++ )
{
dataBytes.Add(( byte ) 0 );
}
// 补位结束
// 补位后再加上8 bytes的原始字节串长度数据
UInt64 length = (UInt64)text.Length * 8 ;
// 由于在Windows中是基于Little Endian,所以这里是按从低位到高位分别将长度数据加入到最终字节串
dataBytes.Add(( byte )(length & 0xff ));
dataBytes.Add(( byte )((length >> 8 ) & 0xff ));
dataBytes.Add(( byte )((length >> 16 ) & 0xff ));
dataBytes.Add(( byte )((length >> 24 ) & 0xff ));
dataBytes.Add(( byte )((length >> 36 ) & 0xff ));
dataBytes.Add(( byte )((length >> 40 ) & 0xff ));
dataBytes.Add(( byte )((length >> 48 ) & 0xff ));
dataBytes.Add(( byte )((length >> 56 ) & 0xff ));
// 将字节串进行分组,每四个bytes为一组(由一个32位数保存4个bytes),由于原字节串的长度总是64的倍数,所以这个分组数据的长度总是16的倍数
UInt32[] inputGroups = new UInt32[dataBytes.Count / 4 ];
for ( int i = 0 ; i < dataBytes.Count; i += 4 )
{
if (i == 14 )
{
int z = 1 ;
}
inputGroups[i / 4 ] = (UInt32)(( byte )dataBytes[i] | ((( byte )dataBytes[i + 1 ]) << 8 ) | ((( byte )dataBytes[i + 2 ]) << 16 ) | ((( byte )dataBytes[i + 3 ]) << 24 ));
}
// 开始一到多次四轮转换迭代,每次处理16个分组成员数据,每次处理完的结果由a,b,c,d保存,并将每次结果分别累加到A,B,C,D这四个链接变量中作为最终结果。
UInt32 a, b, c, d;
for ( int i = 0 ; i < inputGroups.Length; i += 16 )
{
a = A;
b = B;
c = C;
d = D;
// 从这里开始四轮转换迭代,每轮16次,所以一共64次
for ( int j = 0 ; j < 64 ; j ++ )
{
TransformDelegate transform;
int index = i;
// 第一轮用(x & y) | ((~x) & z),每次分组索引从0开始按1递增
// 第二轮用(x & z) | (y & (~z)),每次分组索引从1开始按5递增
// 第三轮用x ^ y ^ z,每次分组索引从5开始按3递增
// 第四轮用y ^ (x | (~z)),每次分组索引从0开始按7递增
if (j < 16 )
{
transform = delegate (UInt32 x, UInt32 y, UInt32 z)
{
return (x & y) | (( ~ x) & z);
};
index += j % 16 ;
}
else if (j < 32 )
{
transform = delegate (UInt32 x, UInt32 y, UInt32 z)
{
return (x & z) | (y & ( ~ z));
};
index += (j % 16 * 5 + 1 ) % 16 ;
}
else if (j < 48 )
{
transform = delegate (UInt32 x, UInt32 y, UInt32 z)
{
return x ^ y ^ z;
};
index += (j % 16 * 3 + 5 ) % 16 ;
}
else
{
transform = delegate (UInt32 x, UInt32 y, UInt32 z)
{
return y ^ (x | ( ~ z));
};
index += (j % 16 * 7 ) % 16 ;
}
// 确定了用哪种非线性函数运算,以及处理哪一个分组(index)后,开始进行迭代运算
int s = j / 16 * 4 + j % 16 % 4 ; // 根据当前是第几次(j)处理来确定用哪一个移位环移偏移量。(这是我针对网上广为流传的版本的一个改进,使用模运算,将原本长度为64的具有大量冗余数据的偏移量数组缩小为长度为16没有冗余数据的数组)
UInt32 k = (UInt32)(Math.Abs(Math.Pow( 2 , 32 ) * Math.Sin(j + 1 ))); // 取2的32次方和当前第几次(j+1)的正弦值的积的整数部分
UInt32 m = inputGroups[index]; // 取得当前要处理的分组数据
a = a + transform(b, c, d) + m + k;
a = a << bitOffsets[s] | a >> ( 32 - bitOffsets[s]); // 根据当前环移偏移量进行环移
a += b;
// 进行变量交换,以进行下一次处理。(这是我针对网上版本的另一个改进,通过4个变量迭代,以及利用匿名函数,省掉了重复写64个函数调用的代码,这种做法也是跟维基百科上对MD5算法的描述保持一致的)
UInt32 t;
t = d;
d = c;
c = b;
b = a;
a = t;
}
A += a;
B += b;
C += c;
D += d;
}
// 将最终得到的A,B,C,D的值进行拼接,要注意的是因为Windows是基于Little Endian,所以要将数据从逻辑高位到逻辑低位重新提取出来并进行拼接
byte [] output = new byte [ 16 ];
UInt32[] outputGroups = new UInt32[] { A, B, C, D };
for ( int i = 0 ; i < output.Length; i += 4 )
{
output[i] = ( byte )(outputGroups[i / 4 ] & 0xff );
output[i + 1 ] = ( byte )((outputGroups[i / 4 ] >> 8 ) & 0xff );
output[i + 2 ] = ( byte )((outputGroups[i / 4 ] >> 16 ) & 0xff );
output[i + 3 ] = ( byte )((outputGroups[i / 4 ] >> 24 ) & 0xff );
}
StringBuilder result = new StringBuilder();
for ( int i = 0 ; i < output.Length; i ++ )
{
result.Append(output[i].ToString( " x2 " ));
}
return result.ToString();
}
delegate UInt32 TransformDelegate(UInt32 x, UInt32 y, UInt32 z);