上个学期在学数据库的时候,大作业是用Java Web+MySQL实现一个简易的系统,其中老师就提到了MD5算法,用来将用户提交的密码进行加密后放在数据库中,以防被泄露。在网页上进行表单校验时,也是将数据库中用户的散列值与用户提交的密码进行映射后得到的散列值进行比对。java.security中提供了一个MessageDigest类,提供了很多此类密码散列函数,如MD5和SHA(安全散列算法 Secure Hash Algorithm)等,所以只需要调用即可,不用亲自实现。
这个暑假小学期学区块链也用到了散列算法,在比特币系统中使用的是SHA-256算法,安全性更高,为了理解这些散列算法,我选择其中相对比较简单的MD5来学习一下。
MD5算法(Message Digest Algorithm)是一种被广泛使用的密码散列函数,其输入是任意长度的字符串,输出是这个字符串的128位(16字节)散列值(hash value)。
散列算法的本质是定义了两个集合之间的某个映射:φ:A→B
其中A是全部字符串的集合,B是全部128位二进制数的集合
显然A是无限集而B是有限集,所以理论上在A中存在两个元素,它们映射到B中的同一值,也即它们发生了碰撞,但在现实中实际使用时是用不到这么多输入的,所以碰撞的概率很低,可以忽略不计。
值得注意的是,MD5算法如今已经不再认为是一个安全的算法。有的网站提供MD5的破解方法,使用的原理是彩虹表,也就是将各个可能的输入与其对应的散列值存储起来,其存储了相当多的数据,对于某个特定的散列值,在数据库中查找其对应的原字符串即可,也就是穷举法。尽管这需要很大的数据量,但由于很多密码具有一定的相似性,比如生日、电话等等,所以很多使用MD5算法加密的密文可以被破解。
一个可以破解MD5的网站:http://www.cmd5.com/default.aspx
一块MD5算法的执行由一个64次的循环完成,分为4组,每组16次,图中每个方框为1组,每个T[i]对应一次。
填充完成后,将每块(512位)分为16组,每组32位,存在一个长度为16的int数组message中。
举例:如果我的输入转化了一个长度为128的位串,那么我在它后面添加1个’1’和319个’0’,此时位串的长度为448。然后原输入位串的长度是128,表示为16进制是00 00 A0 00,将它以小端方式添加到串的后面,也就是在串的后面添加00 A0 00 00,得到初始化后的位串。
至此,我们得到了一个长度为512倍数的位串。
初始化缓冲器
初始化方法:我们的目标是在128位的缓冲器中存入如下数据:
0x01234567 89ABCDEF FEDCBA98 76543210
因为我们的数据在内存中是按小端方式存放的,所以应将A初始化为0x67452301,B初始化为0xEFCDAB89(long),C初始化为0x98BADCFE(long),D初始化为0x10325476(long)。
初始化T数组
T数组是一个长度为64的int数组,其目的是为了生成一些分布比较均匀的数,在实现时使用到了正弦函数。伪代码如下:
for i from 0 to 63
T[i] := floor(abs(sin(i + 1)) × 2^32)
执行函数
规则:在图中每个方框中执行它们对应的四个函数F,G,H,I,它们的输入都是B,C,D,定义如下:
F ( B , C , D ) = ( B ∧ C ) ∨ ( ¬ B ∧ D ) F(B,C,D) = (B \wedge C) \vee (\neg B \wedge D) F(B,C,D)=(B∧C)∨(¬B∧D)
G ( B , C , D ) = ( B ∧ D ) ∨ ( C ∧ ¬ D ) G(B,C,D) = (B \wedge D) \vee ( C \wedge \neg D) G(B,C,D)=(B∧D)∨(C∧¬D)
H ( B , C , D ) = B ⊕ C ⊕ D H(B,C,D) = B \oplus C \oplus D H(B,C,D)=B⊕C⊕D
I ( B , C , D ) = C ⊕ ( B ∨ ¬ D ) I(B,C,D) = C \oplus (B \vee \neg D) I(B,C,D)=C⊕(B∨¬D)
换位
执行完第一个方框中的F函数后,得到一个结果f = F(B,C,D),现进行如下运算:
B + ( (A + f + message[x] + T[y]) <<< s )
其中x,y和s的具体选取是由一个数组定义好的,详见下面的源代码。
进行完上面的运算后,将B的值放入C中,C的值放入D中,D的值放入A中,然后将上述运算的结果放入B中,完成换位。具体操作如图所示:
执行完第五步后,在转回到第4步,进入到下一个方框中执行G函数。以此类推,直到所有函数都执行完,我们将寄存器A,B,C,D中的值组合起来,得到一个128位的Message Digest。如果MD函数的输入位串长度大于512,则将这四个寄存器的值作为下一次的输入,再执行一次上述步骤,直到将全部的位串块(512位)执行完为止;否则就将这128位的Message Digest作为输出,程序结束。
下面是这个算法的一个Java实现,代码不是我写的,是在外网上找到的,并没有调用任何的Java库,也就是说用C语言以同样的方法也能完成该算法。代码写的相当简洁,使用了很多巧妙的位运算,可以看出写这个代码的人功力非常深厚。理解这份代码可能需要一些时间,我为这份代码添加了一份注释以帮助理解和辅助记忆。
源代码:`
public class MD5 {
//小端方式写入数据,使得数据在内存中看起来是规律的
private static final int INIT_A = 0x67452301;
private static final int INIT_B = (int)0xEFCDAB89L;
private static final int INIT_C = (int)0x98BADCFEL;
private static final int INIT_D = 0x10325476;
//这个数组对应的是上文中循环左移的s位
private static final int[] SHIFT_AMTS = {
7, 12, 17, 22,
5, 9, 14, 20,
4, 11, 16, 23,
6, 10, 15, 21
};
//对应上文中生成的T数组
private static final int[] TABLE_T = new int[64];
static
{
for (int i = 0; i < 64; i++)
TABLE_T[i] = (int)(long)((1L << 32) * Math.abs(Math.sin(i + 1)));
}
public static byte[] computeMD5(byte[] message)
{
int messageLenBytes = message.length;
/*
*+8byte就是+64位,将结果右移6位也就是除以64,因为每64byte对应512bits,即一块。
*此步求出填充后位串的总块数
*/
int numBlocks = ((messageLenBytes + 8) >>> 6) + 1;
int totalLen = numBlocks << 6; //填充后的总字节数
//初始化填充的内容:1个1和若干个0
byte[] paddingBytes = new byte[totalLen - messageLenBytes];
paddingBytes[0] = (byte)0x80;
//初始化填充的内容:最后64位
long messageLenBits = (long)messageLenBytes << 3;
for (int i = 0; i < 8; i++)
{
/*
*每次将一个64位的long类型数据转为byte相当于截取其最低的8位
*然后将这个数右移8位,下次再截取8位,也就是原数最低的16位
*/
paddingBytes[paddingBytes.length - 8 + i] = (byte)messageLenBits;
messageLenBits >>>= 8;
}
int a = INIT_A;
int b = INIT_B;
int c = INIT_C;
int d = INIT_D;
int[] buffer = new int[16]; //这里的buffer相当于前文的message数组
for (int i = 0; i < numBlocks; i ++)
{
int index = i << 6;
/*
*将一个32位的int分为4个8位的byte
*对于每个buffer[i],执行循环中的四步将其初始化
*每一步得到最终32位中的8位数据
*最后用或运算将这些8位的中间结果连接起来
*/
for (int j = 0; j < 64; j++, index++)
buffer[j >>> 2] = ((int)((index < messageLenBytes) ? message[index] : paddingBytes[index - messageLenBytes]) << 24) | (buffer[j >>> 2] >>> 8);
int originalA = a;
int originalB = b;
int originalC = c;
int originalD = d;
for (int j = 0; j < 64; j++)
{
//这个循环总共执行64次,每16次为一组(一个方框),div16用来选择不同方框中的函数FGHI
int div16 = j >>> 4;
int f = 0;
int bufferIndex = j;
switch (div16)
{
//第一组不用选择bufferindex,按顺序读取buffer数组即可
case 0:
f = (b & c) | (~b & d);
break;
//第二组,bufferIndex用来选取数组中的特定元素
case 1:
f = (b & d) | (c & ~d);
bufferIndex = (bufferIndex * 5 + 1) & 0x0F;
break;
case 2:
f = b ^ c ^ d;
bufferIndex = (bufferIndex * 3 + 5) & 0x0F;
break;
case 3:
f = c ^ (b | ~d);
bufferIndex = (bufferIndex * 7) & 0x0F;
break;
}
/*
*循环左移,rotateLeft函数中的第二个参数用来指定左移的位数
*这里按照如下规则定义左移位数s
*/
int temp = b + Integer.rotateLeft(a + f + buffer[bufferIndex] + TABLE_T[j], SHIFT_AMTS[(div16 << 2) | (j & 3)]);
//换位
a = d;
d = c;
c = b;
b = temp;
}
a += originalA;
b += originalB;
c += originalC;
d += originalD;
}
byte[] md5 = new byte[16];
int count = 0;
for (int i = 0; i < 4; i++)
{
int n = (i == 0) ? a : ((i == 1) ? b : ((i == 2) ? c : d));
for (int j = 0; j < 4; j++)
{
md5[count++] = (byte)n;
n >>>= 8;
}
}
return md5;
}
public static String toHexString(byte[] b)
{
StringBuilder sb = new StringBuilder();
for (int i = 0; i < b.length; i++)
{
sb.append(String.format("%02X", b[i] & 0xFF));
}
return sb.toString();
}
public static void main(String[] args)
{
String[] testStrings = { "", "a", "abc", "message digest", "abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", "12345678901234567890123456789012345678901234567890123456789012345678901234567890" };
for (String s : testStrings)
System.out.println("0x" + toHexString(computeMD5(s.getBytes())) + " <== \"" + s + "\"");
System.out.println(0x67452301);
return;
}
}
我最近发现很多以前想明白的东西因为没有及时记录下来就会很快忘记,如果将自己的思考过程记录下来可能会记得更牢固些,所以开通了这个博客。
这是我第一次写博客,发现真正想把一件事表达清楚还是比较困难的。