实现一下SHA1算法
文章目录
由于工作中经常会接触到数字签名,摘要算法等知识。现在准备实现一个最基础的摘要算法: SHA1。
算法的文档在RFC3174。这一份 standard 里面定义了很多规范,我们一个一个看。
输入内容长度限制和输出内容的长度
When a message of any length < 2^64 bits is input, the SHA-1 produces a 160-bit output called a message digest.
输入的内容转成二进制之后,长度必须小于 2^64 位。由于这是一个 Hash 算法,输出的长度是固定,SHA1 的输出长度是 160 位的二进制。
输入和输出
对于使用方,我们一般是输入一段字符串给 SHA1,结果是得到一串固定长度的十六进制数字组成的字符串。 由于输入的字符串是带字符编码的,所以我们需要先根据编码转成字节数组,再交给 SHA1 处理。 从 SHA1 的视角看,只认字节,它会对输入的字节数组进行一系列的处理,输出一个 20 个字节的数组,一共 160 位。
最后,通过这 20 个字节的数组,每个字节转换成十六进制的字符串,拼接进来,就是最终的结果。
public String digest(String text) {
byte[] result = hash(text.getBytes(StandardCharsets.UTF_8));
StringBuilder stringBuilder = new StringBuilder();
for (byte b : result) {
stringBuilder.append(Integer.toHexString(b & 0xff));
}
return stringBuilder.toString();
}
Padding 算法
SHA1 算法中 512 位称为一个 block,padding 算法就是为了将输入的数据补全为 512 位的 n 倍。
比如:输入 a, 一共 1 个字节,1 * 8 = 8 位,不足 512 位,所以后面要补 512 - 8 = 504 位
至于要补什么内容,规则是这样的:
- 先在内容的后面补一个 1,这个 1 是指二进制的 1,补的是 1 位,所以转成字节就是 1000 0000, 十六进制就是 0x80
- 再在末尾的 64 位补上输入内容的长度
以刚刚 a 的例子来说,a 对应的 ascii 码为 97,转成二进制为 01100001, 所以初始内容为 01100001,
后面补上二进制 1 之后,为 01100001 10000000,
长度是 8 位,转换为二进制就是 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00001000
最后的结果就是一个 512 位的二进制,01100001 10000000 … 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00001000
中间的…都是补 0.
有 2 个特殊 case:
- 如果内容刚好是 512 位的倍数,需要 padding 吗?答案是需要的,需要再往后面补多 512 位
- 如果内容的长度还差 1 ~ 9 位才到达 512 位的倍数,怎么办?答案是,需要往后面再补多 512 位
实现的代码如下:
long bits = originalBytes.length * 8;
int newLength = ((originalBytes.length / 64) + 1) * 64;
if ((newLength - originalBytes.length) < 9) {
newLength += 64;
}
byte[] bytes = new byte[newLength];
System.arraycopy(originalBytes, 0, bytes, 0, originalBytes.length);
bytes[originalBytes.length] = (byte) 0x80;
for (int i = bytes.length - 8; i < bytes.length; ++i) {
bytes[i] = (byte) ((bits >>> (56 - (i - (bytes.length - 8)) * 8)) & 0xff);
}
处理算法
文档中有 2 种方法,我选择的是算法一。
先定义几个算法中会用到的函数:
- S^n(X), 对 X 进行循环左移 n 位的操作
S^n(X) = (X << n) OR (X >> 32-n)
- f(t;B,C,D), 根据 t 的不同取值,对 B, C, D 进行相应位运算
f(t;B,C,D) = (B AND C) OR ((NOT B) AND D) ( 0 <= t <= 19)
f(t;B,C,D) = B XOR C XOR D (20 <= t <= 39)
f(t;B,C,D) = (B AND C) OR (B AND D) OR (C AND D) (40 <= t <= 59)
f(t;B,C,D) = B XOR C XOR D (60 <= t <= 79)
- K(t), 根据不同的 t,返回相应的魔法数字
K(t) = 5A827999 ( 0 <= t <= 19)
K(t) = 6ED9EBA1 (20 <= t <= 39)
K(t) = 8F1BBCDC (40 <= t <= 59)
K(t) = CA62C1D6 (60 <= t <= 79)
算法描述是这样的:
对 Padding 过后的数据,每 512 位执行下面的操作:
- 初始化 5 个魔法数字: H0 = 67452301, H1 = EFCDAB89, H2 = 98BADCFE, H3 = 10325476, H4 = C3D2E1F0
- 将 512 位分成 16 份,每份包含 1 个字(word),即 32 位,记为 W[0] ~ W[15]
- 对于 t = 16 到 t = 79, 执行下面的操作
- W(t) = S^1(W(t-3) XOR W(t-8) XOR W(t-14) XOR W(t-16)),
- 令 A = H0, B = H1, C = H2, D = H3, E = H4
- 对于 t = 0 到 t = 79, 执行下面的操作
- TEMP = S^5(A) + f(t;B,C,D) + E + W(t) + K(t);
- E = D; D = C; C = S^30(B); B = A; A = TEMP;
- 令 H0 = H0 + A, H1 = H1 + B, H2 = H2 + C, H3 = H3 + D, H4 = H4 + E
最终的结果是 H0 H1 H2 H3 H4 拼成 5 个字(word),一共 160 位。
全部的代码实现如下:
public byte[] hash(byte[] originalBytes) {
long bits = originalBytes.length * 8;
int newLength = ((originalBytes.length / 64) + 1) * 64;
if ((newLength - originalBytes.length) < 9) {
newLength += 64;
}
byte[] bytes = new byte[newLength];
System.arraycopy(originalBytes, 0, bytes, 0, originalBytes.length);
bytes[originalBytes.length] = (byte) 0x80;
for (int i = bytes.length - 8; i < bytes.length; ++i) {
bytes[i] = (byte) ((bits >>> (56 - (i - (bytes.length - 8)) * 8)) & 0xff);
}
int[] hash = new int[]{0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0};
int[] words = new int[80];
for (int i = 0; i < bytes.length; ) {
for (int t = 0; t < 16; ++t) {
words[t] = (bytes[i] << 24) | ((bytes[i + 1] << 16) & 0xff0000) +
((bytes[i + 2] << 8) & 0xff00) + (bytes[i + 3] & 0xff);
i += 4;
}
for (int t = 16; t < 80; ++t) {
words[t] = circularLeftShift(1, words[t - 3] ^ words[t - 8] ^ words[t - 14] ^ words[t - 16]);
}
int[] newHash = Arrays.copyOf(hash, hash.length);
for (int t = 0; t < 80; ++t) {
int temp = circularLeftShift(5, newHash[0]) + func(t, newHash[1], newHash[2], newHash[3]) +
newHash[4] + words[t] + key(t);
newHash[4] = newHash[3];
newHash[3] = newHash[2];
newHash[2] = circularLeftShift(30, newHash[1]);
newHash[1] = newHash[0];
newHash[0] = temp;
}
for (int j = 0; j < hash.length; ++j) {
hash[j] += newHash[j];
}
}
byte[] result = new byte[hash.length * 4];
int i = 0;
for (int h : hash) {
result[i++] = (byte) ((h >>> 24) & 0xff);
result[i++] = (byte) ((h >>> 16) & 0xff);
result[i++] = (byte) ((h >>> 8) & 0xff);
result[i++] = (byte) (h & 0xff);
}
return result;
}
@Override
public String digest(String text) {
byte[] result = hash(text.getBytes(StandardCharsets.UTF_8));
StringBuilder stringBuilder = new StringBuilder();
for (byte b : result) {
stringBuilder.append(Integer.toHexString(b & 0xff));
}
return stringBuilder.toString();
}
private int circularLeftShift(int n, int x) {
return (x << n) | (x >>> (32 - n));
}
private int func(int t, int b, int c, int d) {
if (t >= 0 && t < 20) {
return (b & c) | ((~b) & d);
} else if (t < 40) {
return b ^ c ^ d;
} else if (t < 60) {
return (b & c) | (b & d) | (c & d);
} else {
return b ^ c ^ d;
}
}
private int key(int t) {
if (t >= 0 && t < 20) {
return 0x5A827999;
} else if (t < 40) {
return 0x6ED9EBA1;
} else if (t < 60) {
return 0x8F1BBCDC;
} else {
return 0xCA62C1D6;
}
}
加到 GUI 工具
实现了算法之后,加到我以前写的一个 GUI 工具上面去,效果是这样的:
源码
https://github.com/zhengyhn/crypto-tool
总结
跟着文档写其实不难,难在阅读理解,我在实现过程中被 int 和 byte 之间互转的问题卡了很久,实际上还是基础不扎实,最后看了 open jdk 的实现以及 debug 它和我的的实现的不同才最终解决所有问题。
不过 SHA1 现在已经不推荐使用了,因为出现了相同数据产生同样输出的碰撞情况,更推荐使用 SHA2 和 SHA256 等算法。不过,这依旧是哈希算法中最基础的算法,对我很有帮助。