bcrypt Hash 算法详解

2,334 阅读5分钟

一、什么是 Hash?

  Hash:(摘自百度百科)一般翻译做散列、杂凑,音译为哈希,是把任意长度的输入通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。

Hash 为什么不可逆?

  因为对不同的关键字可能得到同一散列地址,即 key1≠key2,而 f(key1)=f(key2),这种现象称碰撞。举个栗子:5^2=25、(-5)^2=25。由 25 推不出来原文,这就是最简单的不可逆,所以只能通过暴力破解一个一个的试。因为在加密的过程中丢失了一部分信息(在这个例子中是原文的正负号),因此一个 Hash 值是可以对应多个原文的。

二、为什么 MD5 并不安全?

  MD5 有很多种方法可以破解,不过需要明确一点,这里所谓的破解,并非把密文还原成原文。对于 MD5 的破解,实际上都属于碰撞。比如原文 A 通过 MD5 可以生成密文 Y,我们并不需要把 Y 还原成 A,只需要找到原文 B,生成同样的密文 Y 即可。

MD5 破解的方法有很多:

  • 暴力枚举法:简单粗暴地枚举出所有原文,并计算出它们的哈希值,看看哪个哈希值和给定的密文一致。(破解时间长)。
  • 字典法:黑客利用一个巨大的字典,存储尽可能多的原文和对应的哈希值。每次用给定的密文摘要查找字典,即可快速找到碰撞的结果。(存储空间大)。
  • 彩虹表法:组合了暴力枚举法和字典法,并在这两者之中取得一个折中,用我们可以承受的时间和存储空间进行破解.
  • 差分攻击:差分攻击是通过比较分析有特定区别的明文在通过加密后的变化传播情况来攻击密码算法的。

  虽然彩虹表有着非常惊人的破解效率,但我们仍然有办法防御彩虹表。最有效的方法就是加盐: 在密码学中加盐是指通过在密码任意固定位置插入特定的字符串,让散列后的结果和使用原始密码的散列结果不相符,这种过程称之为加盐。加盐后的密码经过哈希加密得到的哈希串与加盐前的哈希串完全不同,因此加盐可以大大降低密码泄露的概率。

如果加密算法和盐都泄露了,那针对性攻击依然是非常不安全的。因为同一个加密算法同一个盐加密后的字符串仍然还是一样的!那么有没有每次加密之后生成的密码都不一样的加密算法呢?有,这就是 bcrypt

三、BCrypt

bcrypt 有三个特点:

  • 每一次 Hash 出来的值不一样。
  • 计算非常缓慢。
  • 每次的 salt 是随机的生成的,不用担心 salt 会泄露。

一个 bcrypt hash 字符串应该像下面这样(摘自 Wikipedia):

$2<a/b/x/y>$[cost]$[22 character salt][31 character hash]

举个栗子:

$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
\__/\/ \____________________/\_____________________________/
Alg Cost      Salt                        Hash
  • 2a: 算法的标识符,代表是 bcrypt 的版本,有 2a、2y、2b 三个版本。
  • 10: 成本因子:代表轮询加密 2^10 = 1024 次。
  • N9qo8uLOickgx2ZMRZoMye: 16-byte (128-bit) 的 salt, 用 Radix-64 编码成为 22 个字符。
  • IjZAgcfl7p92ldGxad68LJZdL17lhWy: 24-byte (192-bit) 的 hash, 用 Radix-64 编码成为 31 个字符。

四、源码分析

1. 每次 Hash 生成新 salt 的源码:

public static String gensalt(String prefix, int log_rounds, SecureRandom random)
  throws IllegalArgumentException {
 StringBuilder rs = new StringBuilder();
 byte rnd[] = new byte[BCRYPT_SALT_LEN];

 // 判断版本号是否是 2a、2y、2b 中的一个。 
 if (!prefix.startsWith("$2") ||
   (prefix.charAt(2) != 'a' && prefix.charAt(2) != 'y' &&
     prefix.charAt(2) != 'b')) {
  throw new IllegalArgumentException ("Invalid prefix");
 }
 // 判断轮询加密的次数是否在 2^4 到 2^31 之间。
 if (log_rounds < 4 || log_rounds > 31) {
  throw new IllegalArgumentException ("Invalid log_rounds");
 }

 // 随机生成 salt。
 random.nextBytes(rnd);

 // salt 的拼接。
 rs.append("$2");
 rs.append(prefix.charAt(2));
 rs.append("$");
 if (log_rounds < 10)
  rs.append("0");
 rs.append(log_rounds);
 rs.append("$");
 // an encoded salt value
 encode_base64(rnd, rnd.length, rs);
 return rs.toString();
}

由此可见 bcrtpt 每一次 Hash 出来的密文不一样(因为 salt 完全是随机生成的)。计算非常缓慢(随着成本因子的变大,轮询加密的次数呈指数级提升)。每次的 salt 是随机的生成的,不用担心 salt 会泄露。

2. 校验源码分析:

bcrypt 每次生成的密文都不一样,那么它是如何进行校验的?

// 判空操作。
public boolean matches(CharSequence rawPassword, String encodedPassword) {
 if (encodedPassword == null || encodedPassword.length() == 0) {
  logger.warn("Empty encoded password");
  return false;
 }
// bcrypt 字符串匹配。
 if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
  logger.warn("Encoded password does not look like BCrypt");
  return false;
 }
// checkpw 检验是否匹配。
 return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
// 真正比较的是数据库里面存的 Hash 和 再次加密的 Hash,hashpw 方法用来加密。
public static boolean checkpw(String plaintext, String hashed) {
 return equalsNoEarlyReturn(hashed, hashpw(plaintext, hashed));
}
// 加密方法。这里传的参数 salt 并不是真正的 salt,而是存在数据库里面的 Hash。
public static String hashpw(byte passwordb[], String salt) {
 BCrypt B;
 String real_salt;
 byte saltb[], hashed[];
 char minor = (char) 0;
 int rounds, off;
 StringBuilder rs = new StringBuilder();

 if (salt == null) {
  throw new IllegalArgumentException("salt cannot be null");
 }

 int saltLength = salt.length();

 if (saltLength < 28) {
  throw new IllegalArgumentException("Invalid salt");
 }

 if (salt.charAt(0) != '$' || salt.charAt(1) != '2')
  throw new IllegalArgumentException ("Invalid salt version");
 if (salt.charAt(2) == '$')
  off = 3;
 else {
  minor = salt.charAt(2);
  if ((minor != 'a' && minor != 'x' && minor != 'y' && minor != 'b')
    || salt.charAt(3) != '$')
   throw new IllegalArgumentException ("Invalid salt revision");
  off = 4;
 }

 // 提取轮询加密的次数。
 if (salt.charAt(off + 2) > '$')
  throw new IllegalArgumentException ("Missing salt rounds");

 if (off == 4 && saltLength < 29) {
  throw new IllegalArgumentException("Invalid salt");
 }
 rounds = Integer.parseInt(salt.substring(off, off + 2));
 // 真正的 salt。
 real_salt = salt.substring(off + 3, off + 25);
 saltb = decode_base64(real_salt, BCRYPT_SALT_LEN);

 if (minor >= 'a')
  passwordb = Arrays.copyOf(passwordb, passwordb.length + 1);

 B = new BCrypt();
 hashed = B.crypt_raw(passwordb, saltb, rounds, minor == 'x', minor == 'a' ? 0x10000 : 0);
 // 拼接 Hash 字符串。
 rs.append("$2");
 if (minor >= 'a')
  rs.append(minor);
 rs.append("$");
 if (rounds < 10)
  rs.append("0");
 rs.append(rounds);
 rs.append("$");
 // 编码为固定长度。
 encode_base64(saltb, saltb.length, rs);
 encode_base64(hashed, bf_crypt_ciphertext.length * 4 - 1, rs);
 return rs.toString();
}

看到这里我们就明白了 bcrypt 的校验规则。 bcrypt 对同一个密码每次加密时使用的 salt 是不一样的, 因此每次生成的 Hash 也是不一样的,但是 Hash 中包含了 salt,在下次校验时,从 Hash 中取出 salt,salt 跟password 进行 Hash 得到密文。密文和保存在 DB 中的 Hash 是同样的原文和 salt 加密出来的,所以必定是相同的字符串。bcrypt 算法将 salt 随机并混入最终加密后的密码,验证时也无需单独提供之前的 salt。