Shiro 中 SimpleHash MD5 的多次散列

in 技术 with 11 comments

Apache Shiro 是一个强大易用的 Java 安全框架,用以执行身份验证、授权、密码和会话管理,而且可以方便地被 Spring Boot 所集成。
大部分 Web 应用的用户密码一般通过散列算法 + 盐的形式持久化在数据库中。在使用 Shiro 进行身份验证时,可以在 Shiro 配置类中配置密码散列匹配器,来对数据库中保存的密码进行验证。下面是在 Shiro 中配置密码散列匹配器的例子:

@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher() {
  HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
  hashedCredentialsMatcher.setHashAlgorithmName("md5"); // 设置 MD5 散列算法
  hashedCredentialsMatcher.setHashIterations(2); // 散列迭代次数
  hashedCredentialsMatcher.setStoredCredentialsHexEncoded(true);
  return hashedCredentialsMatcher;
}

我前两天在学习 Shiro 的时候,看到有相当一部分的中文教程,都认为散列两次相当于md5(md5("")),我随手找了几篇秉此观点的文章:

其实这些教程都在误人子弟,散列两次并不等于md5(md5(""))。我们可以简单测试一下。

以「hello」为例,测试 MD5 两次散列结果

按照 散列两次相当于md5(md5("")) 的说法,对「hello」字符串进行 MD5 两次散列,程序运算结果如下(伪代码):

String hash = "hello";
Integer iteration = 2;
for (int i = 0; i < iteration; i++) {
  hash = md5(hash)
}
// 第一次 MD5 结果:5d41402abc4b2a76b9719d911017c592
// 第二次 MD5 结果:69a329523ce1ec88bf63061863d9cb14

在 Shiro 内置封装的 SimpleHash 中执行两次 MD5 散列,结果却是不同的:

String hash = "hello";
String salt = null;
Integer iteration = 2;
SimpleHash simpleHash = new SimpleHash("MD5", hash, salt, iteration);
simpleHash.getHash();
// 迭代两次的 MD5 散列结果:62109206880d38a4010a98e11243924a

可见 Shiro 中 SimpleHash 的多次 MD5 散列并不等于一层套一层的md5()
这里需要简单提一下 MD5 的散列过程。

MD5 散列算法原理

MD5 算法可看做一个「函数」,任意的二进制串都可以作为变量输入到此「函数」中,经过散列运算返回固定 128 位的二进制串(大整数),此大整数再经过 16 进制转换,最终得到 32 个字符的 MD5 值。

散列运算的大致过程如下:

  1. 二进制位补充
  2. 将位平均分成 16 组,每组 32 个位,初始化四个常数(默认值为标准幻数)进行四轮循环运算,每轮将运算结果依次更新到常数中
  3. 全部运算完毕后,将四个常数按照低内存到高内存排列,共 128 位
  4. 将结果进行 16 进制转换,得到最终 MD5 值

通过 SimpleHash 源码窥探多次散列过程

private void hash(ByteSource source, ByteSource salt, int hashIterations) throws CodecException, UnknownAlgorithmException {
  byte[] saltBytes = salt != null ? salt.getBytes() : null;
  byte[] hashedBytes = this.hash(source.getBytes(),
                      saltBytes, hashIterations); // 进行散列
  this.setBytes(hashedBytes);
}

protected byte[] hash(byte[] bytes, byte[] salt, int hashIterations) throws UnknownAlgorithmException {
  MessageDigest digest = this.getDigest(this.getAlgorithmName());
  if (salt != null) {
    digest.reset();
    digest.update(salt);
  }
  byte[] hashed = digest.digest(bytes);
  int iterations = hashIterations - 1;

  for(int i = 0; i < iterations; ++i) { // 根据迭代次数进行多次散列
    digest.reset();
    hashed = digest.digest(hashed);
  }
  return hashed;
}

看了源码一切就很明了了,SimpleHash 每次散列会得到 128 位的二进制串,多次散列会将得到的二进制串作为输入进行重新散列,而非对 16 进制转换后的 MD5 值进行重新散列。

结论

所以,Shiro SimpleHash 中的两次 MD5 散列,并不等于md5(md5(""))。他们的区别在于,前者会将得到的二进制串进行二次散列,后者将 MD5 运算(散列并将散列结果进行 16 进制转换)后得到的 32 位字符进行二次散列。

Responses
选择表情选择表情
  1. 一名智障

    Well, as you pointed out, the Double-Md5 in shiro will not be fully identical to md5(md5(" ")), whereas you haven't still tell us how this problem can be resolved.

    Reply
    1. @一名智障

      没有问题需要被解决,就是指出了部分教程里的错误。这些错误教程可能会误导开发者,导致使用 shiro 时不能正确地进行注册与登录认证。正确的用法是:如果 shiro 登录认证时使用了 SimpleHash 进行 md5 多次散列,那么在用户注册时,也需要使用 SimpleHash 对密码进行 md5 多次散列(而不是嵌套md5()的方式)并持久化到数据库中以保证散列结果的一致。

      Reply
      1. 一名智障
        @大袋鼠

        icon_sad.gif i.e 就是说,我重写了shiro默认的matcher,将二次迭代时的输入参数转换成一个hexstring就行了

        Reply
      2. 一名智障
        @大袋鼠

        Thanks for replying, I've resolved this problem from which double-md5 was incorrectly resulting, the only thing we need to do is just rewriting the MD5CredentialsMather and be sure it is fitting to what we desired.

        Reply
  2. 加油! icon_mrgreen.gif

    Reply
  3. 膜拜大佬了

    Reply
  4. 冯毅

    兄弟怎么联系你

    Reply
    1. @冯毅

      QQ:951231736

      Reply
  5. 学习了
    感谢分享

    Reply
  6. icon_smile.gif 舔舔

    Reply
  7. Lucida

    icon_cool.gif

    Reply