多线程计算Hash结果不一致问题排查

1,066 阅读8分钟

问题描述

项目中有一个文件下载的任务,为提升并行效率,考虑使用线程池并行下载文件。在下载完成后,为校验文件准确性,需要计算文件的Hash值。但是发现相同的文件在串行和并行情况下,计算得到的Hash值并不一样,进而导致文件校验时认为下载的文件非法。

还原现场

Hash的计算使用 java.security.MessageDigest。使用时想当然的使用了如下的写法:

private static final String ALGORITHM = "SHA-1";
private static final MessageDigest digester;

static {
    try {
        digester = MessageDigest.getInstance(ALGORITHM);
    } catch (NoSuchAlgorithmException e) {
        // won't happen
        throw new BtException("Unexpected error", e);
    }
}

/**
    * Digest bytes into SHA1
    *
    * @param bytes Binary data
    * @return Binary data digested using SHA1
    */
public static byte[] digest(byte[] bytes) {
    return digester.digest(bytes);
}

一般串行情况下,以上方法并没有什么问题,但是并行处理后,会引起digest不一致。将以上代码调整为如下,多线程情况下仍可准确计算Hash。

private static final String ALGORITHM = "SHA-1";

/**
    * Digest bytes into SHA1
    *
    * @param bytes Binary data
    * @return Binary data digested using SHA1
    */
public static byte[] digest(byte[] bytes) {
    MessageDigest digester = MessageDigest.getInstance(ALGORITHM);
    return digester.digest(bytes);
}

原因分析

在遇到以上问题后,首先怀疑是多线程情况下MessageDigest并不是线程安全的原因。 通过分析MessageDigest源码,可以印证我们的猜测。

/**
    * Returns a MessageDigest object that implements the specified digest
    * algorithm.
    *
    * <p> This method traverses the list of registered security Providers,
    * starting with the most preferred Provider.
    * A new MessageDigest object encapsulating the
    * MessageDigestSpi implementation from the first
    * Provider that supports the specified algorithm is returned.
    *
    * <p> Note that the list of registered providers may be retrieved via
    * the {@link Security#getProviders() Security.getProviders()} method.
    *
    * @param algorithm the name of the algorithm requested.
    * See the MessageDigest section in the <a href=
    * "{@docRoot}/../technotes/guides/security/StandardNames.html#MessageDigest">
    * Java Cryptography Architecture Standard Algorithm Name Documentation</a>
    * for information about standard algorithm names.
    *
    * @return a Message Digest object that implements the specified algorithm.
    *
    * @exception NoSuchAlgorithmException if no Provider supports a
    *          MessageDigestSpi implementation for the
    *          specified algorithm.
    *
    * @see Provider
    */
public static MessageDigest getInstance(String algorithm) throws NoSuchAlgorithmException {

更深入思考

这次问题的出现是使用MessageDigest时产生的,那么有个问题,MessageDigest到底是做什么的呢?我们常说的加密算法又是这么实现的呢?本小节让我们来详细介绍下。

Message-Digest Algorithm

Message-Digest Algorithm:信息摘要算法,最著名的Message-Digest Algorithm就要算MD5了。本文也着重介绍MD5的相关内容。

维基百科对MD5有如下的定义:

MD5讯息摘要演算法(英语:MD5 Message-Digest Algorithm),一种被广泛使用的密码杂凑函数,可以产生出一个128位元(16个字元(BYTES))的散列值(hash value),用于确保信息传输完整一致。MD5由美国密码学家罗纳德·李维斯特(Ronald Linn Rivest)设计,于1992年公开,用以取代MD4演算法。这套演算法的程序在 RFC 1321 中被加以规范。

通过查阅 RFC 1321 可以看出,MD5执行的大体过程包括:

  1. Append Padding Bits
  2. Append Length
  3. Initialize MD Buffer
  4. Process Message in 16-Word Blocks
  5. Output

处理原文

首先,我们计算出原文长度(bit)对512求余的结果,如果不等于448,就需要填充原文使得原文对512求余的结果等于448。填充的方法是第一位填充1,其余位填充0。填充完后,信息的长度就是512*N+448。

之后,用剩余的位置(512-448=64位)记录原文的真正长度,把长度的二进制值补在最后。这样处理后的信息长度就是512*(N+1)。

设置初始值

MD5的哈希结果长度为128位,按每32位分成一组共4组。这4组结果是由4个初始值A、B、C、D经过不断演变得到。MD5的官方实现中,A、B、C、D的初始值如下(16进制):

word A: 01 23 45 67
word B: 89 ab cd ef
word C: fe dc ba 98
word D: 76 54 32 10

循环加工

流程图如下:

640.png

图中,ABCD就是哈希值的四个分组,每一次循环都会让旧的ABCD产生新的ABCD。一共进行多少次循环呢?由处理后的原文长度决定。 假设经过第一步处理原文之后的长度为M,则主循环次数L=M÷512,每个主循环中包含512÷32*4=64次子循环,分为4组16次。上面这张图表达的是一次子循环的流程。 其中F是一个非线性函数。MD5用到的非线性函数有下面四种,4组循环中依次使用FGHI。 伪代码实现:

F(X,Y,Z) = XY v not(X) Z
G(X,Y,Z) = XZ v Y not(Z)
H(X,Y,Z) = X xor Y xor Z
I(X,Y,Z) = Y xor (X v not(Z))

一般编程语言实现:

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))

其中Mi为原文的一个分组,长度为32bits,每次循环所用到的分组编号不同,会交替使用到M0到M15中的数据。 而Ki则是一个32bits的常量,具体的计算公式如下: Ki=floor(2^64*sin(i)) i的范围是1~64,单位是弧度 而 <<<s 则代表移位操作,注意:这里的移位是循环移位!而移多少位,则如下:

[7,12,17,22 ]
[5,9,14,20]
[4,11,16,23]
[6,10,15,21]

第一组16次循环中依次使用【7,12,17,22】,使用4次,以此类推。所以经过一次循环后可以发现(其中=左边为新,等号右边全部使用旧的abcd):

a=d
b=(F(b,c,d)+Mi+Ki)<<<s+a
c=a
d=c

而一次主循环的4组16次子循环依次如下: 第一轮

FF(ab,c,d,M0,70xd76aa478)
FF(d,ab,c,M1,120xe8c7b756)
FF(c,d,ab,M2,170x242070db)
FF(b,c,d,a,M3,220xc1bdceee)
FF(ab,c,d,M4,70xf57c0faf)
FF(d,ab,c,M5,120x4787c62a)
FF(c,d,ab,M6,170xa8304613)
FF(b,c,d,a,M7,220xfd469501)
FF(ab,c,d,M8,70x698098d8)
FF(d,ab,c,M9,120x8b44f7af)
FF(c,d,ab,M10,170xffff5bb1)
FF(b,c,d,a,M11,220x895cd7be)
FF(ab,c,d,M12,70x6b901122)
FF(d,ab,c,M13,120xfd987193)
FF(c,d,ab,M14,170xa679438e)
FF(b,c,d,a,M15,220x49b40821)

第二轮

GG(ab,c,d,M1,50xf61e2562)
GG(d,ab,c,M6,90xc040b340)
GG(c,d,ab,M11,140x265e5a51)
GG(b,c,d,a,M0,200xe9b6c7aa )
GG(ab,c,d,M5,50xd62f105d)
GG(d,ab,c,M10,90x02441453)
GG(c,d,ab,M15,140xd8a1e681)
GG(b,c,d,a,M4,200xe7d3fbc8)
GG(ab,c,d,M9,50x21e1cde6)
GG(d,ab,c,M14,90xc33707d6)
GG(c,d,ab,M3,140xf4d50d87)
GG(b,c,d,a,M8,200x455a14ed)
GG(ab,c,d,M13,50xa9e3e905)
GG(d,ab,c,M2,90xfcefa3f8)
GG(c,d,ab,M7,140x676f02d9)
GG(b,c,d,a,M12,200x8d2a4c8a)

第三轮

HH(ab,c,d,M5,40xfffa3942)
HH(d,ab,c,M8,110x8771f681)
HH(c,d,ab,M11,160x6d9d6122)
HH(b,c,d,a,M14,230xfde5380c)
HH(ab,c,d,M1,40xa4beea44)
HH(d,ab,c,M4,110x4bdecfa9)
HH(c,d,ab,M7,160xf6bb4b60)
HH(b,c,d,a,M10,230xbebfbc70)
HH(ab,c,d,M13,40x289b7ec6)
HH(d,ab,c,M0,110xeaa127fa)
HH(c,d,ab,M3,160xd4ef3085)
HH(b,c,d,a,M6,230x04881d05)
HH(ab,c,d,M9,40xd9d4d039)
HH(d,ab,c,M12,110xe6db99e5)
HH(c,d,ab,M15,160x1fa27cf8)
HH(b,c,d,a,M2,230xc4ac5665)

第四轮

II(ab,c,d,M0,60xf4292244)
II(d,ab,c,M7,100x432aff97)
II(c,d,ab,M14,150xab9423a7)
II(b,c,d,a,M5,210xfc93a039)
II(ab,c,d,M12,60x655b59c3)
II(d,ab,c,M3,100x8f0ccc92)
II(c,d,ab,M10,150xffeff47d)
II(b,c,d,a,M1,210x85845dd1)
II(ab,c,d,M8,60x6fa87e4f)
II(d,ab,c,M15,100xfe2ce6e0)
II(c,d,ab,M6,150xa3014314)
II(b,c,d,a,M13,210x4e0811a1)
II(ab,c,d,M4,60xf7537e82)
II(d,ab,c,M11,100xbd3af235)
II(c,d,ab,M2,150x2ad7d2bb)
II(b,c,d,a,M9,210xeb86d391)

其中FF(a,b,c,d,Mi,s,Ki)表示a=b+((F(b,c,d)+Mi+Ki)<<<s),其余类似。

拼接结果

最终经过L次主循环之后,将每次主循环之后的结果拼接在一起便得到了MD5的最终结果。

Secure Hash Algorithm

Secure Hash Algorithm:安全散列算法,此算法包含诸多中实现,比较著名的有SHA-1SHA-2SHA-256

SHA-1(英语:Secure Hash Algorithm 1,中文名:安全散列算法1)是一种密码散列函数,美国国家安全局设计,并由美国国家标准技术研究所(NIST)发布为联邦资料处理标准(FIPS)[3]。SHA-1可以生成一个被称为消息摘要的160位(20字节)散列值,散列值通常的呈现形式为40个十六进制数。

SHA-2,名称来自于安全散列演算法2(英语:Secure Hash Algorithm 2)的缩写,一种密码杂凑函数演算法标准,由美国国家安全局研发[3],由美国国家标准与技术研究院(NIST)在2001年发布。属于SHA演算法之一,是SHA-1的后继者。其下又可再分为六个不同的演算法标准,包括了:SHA-224、SHA-256、SHA-384、SHA-512、SHA-512/224、SHA-512/256。

SHA-256 算法,简单地说,分为两大步骤。第一步就是数据补齐,第二步就是 SHA-256 的 Hash 算法。这里仅仅列举出两个核心步骤,详细的算法过程可以参考SHA-256 算法简介

数据补齐

所谓数据补齐,是将原来的数据长度补上一定的数据,使之总长度变成64字节(也就是512比特)的整数倍。数据补齐在诸多的加密算法中都是第一步要做的事情。

SHA-256的Hash算法

SHA-256 算法的示意,分为如下几个步骤。

  1. 首先选取1个256比特的字符串作为种子数据
  2. 然后将补齐后的数据按照512比特分为若干块
  3. 第一块与种子数据一通搅拌,作为临时输出
  4. 后续的每一块都与前一个搅拌的临时输出再一通搅拌
  5. 直到最后一块与前一个搅拌的临时输出再一通搅拌后,这个搅拌的结果,就是 SHA-256 的输出——1个256比特的字符串。

SHA-1与MD5的比较

因为二者均由MD4导出,SHA-1和MD5彼此很相似。相应的,他们的强度和其他特性也是相似,但还有以下几点不同:

  1. 对强行攻击的安全性:最显著和最重要的区别是SHA-1摘要比MD5摘要长32 位。使用强行技术,产生任何一个报文使其摘要等于给定报摘要的难度对MD5是2^128数量级的操作,而对SHA-1则是2^160数量级的操作。这样,SHA-1对强行攻击有更大的强度。
  2. 对密码分析的安全性:由于MD5的设计,易受密码分析的攻击,SHA-1显得不易受这样的攻击。
  3. 速度:在相同的硬件上,SHA-1 的运行速度比 MD5 慢。

Digest Stream

实际上在使用时,当遇到大文件需要计算Hash,可以使用MessageDigest提供的update方法,流式的计算Hash,避免大文件引起的Hash计算阻塞系统。具体方法:

MessageDigest messageDigest = MessageDigest.getInstance("SHA-1");
messageDigest.update(key);
byte[] bytes = messageDigest.digest(); 

对于流式计算,实际上JDK本身提供更为灵活的类,即DigestInputStreamDigestOutputStream。举个例子:

File file = new File(fileName);
MessageDigest digest = MessageDigest.getInstance("SHA-1");
DigestOutputStream digestOutputStream = new DigestOutputStream(new FileOutputStream(file), digest);

通过以下MessageDigest的注释说明可以更好的理解MessageDigest的使用规则。

MessageDigest.png

最后,通过这个问题的排查,再次印证了,绝大部分情况下最好的说明文档就是优秀的注释。

参考文献