BCryptPassword加密及匹配原理探究

1,546 阅读3分钟

今天遇到新同事想要开发环境某个密码,一时想不起来,于是给了他一个工具类,输入明文入参,生成新的加密密码,然后直接替换原来密码。这小伙子挺谨慎的,替换前问了一嘴,怎么没有盐(salt),不是不安全吗呢。我乐呵了,你猜?

其实我们是用Spring Securtiy的BCryptPassword做的加密,Spring Security提供了一个 BCryptPasswordEncoder工具类,帮我们加密及匹配。该工具会自动生成盐,且同一个明文每次生成的密文都不一样,这样就能防止一般的撞库攻击了。但是当用户登录的时候,输入的是非加密数据,如何与数据库的密文匹配呢?这里主要就加密及匹配的用法及原理展开聊聊。

一、加密及匹配用法

  • 已知数据库密文:$2a$10$nfOGMHlh8CAl03Wzwb/mXOFoXixtgEvBjRhHECc.JERN9gL5nzseC
  • 对应的明文为:123qwe$%^ 问题:当对这个明文用BCryptPasswordEncoder再加密一次时,输出内容会等于数据库密文吗? 请看示例代码:
String plainPwdStr = "123qwe$%^";
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String bcryptEncodedPwd = encoder.encode(plainPwdStr);
System.out.println("bcryptEncodedPwd内容为:" + bcryptEncodedPwd);
// 以下为控制台输出
// bcryptEncodedPwd内容为:$2a$10$/8NWWhwtgAYWiR0NjmxYUetZjlUSWxEPCEKKIxlrDyiM6ciPRvyne

比比看,相同明文情况下,两次加密的内容哪里不一样:

  1. 数据库密文:$2a$10$nfOGMHlh8CAl03Wzwb/mXOFoXixtgEvBjRhHECc.JERN9gL5nzseC
  2. 新生成密文:$2a$10/8NWWhwtgAYWiR0NjmxYUetZjlUSWxEPCEKKIxlrDyiM6ciPRvyne 好像除了开头几个字符\2a$10$是一样的,后面基本不一样,那这两个完整字符串能在匹配的时候判断为true吗? 先看看匹配的结果代码:
String dbPwd = "$2a$10$nfOGMHlh8CAl03Wzwb/mXOFoXixtgEvBjRhHECc.JERN9gL5nzseC";
String newPwd = "$2a$10$/8NWWhwtgAYWiR0NjmxYUetZjlUSWxEPCEKKIxlrDyiM6ciPRvyne";
boolean result = encoder.matches(dbPwd, newPwd);
System.out.println(result);
// 输出: false

所以,其实两次加密的密文是无法匹配,那要如何匹配呢?仔细看看matches方法,其实第一个参数是原始明文字符:

// rawPassword为加密前的明文字符串
matches(CharSequence rawPassword, String encodedPassword)

再拿明文字符匹配试试:

String dbPwd = "$2a$10$nfOGMHlh8CAl03Wzwb/mXOFoXixtgEvBjRhHECc.JERN9gL5nzseC";
String plainPwdStr = "123qwe$%^";
boolean result = encoder.matches(plainPwdStr, dbPwd);
System.out.println(result);
// 输出:true

到此基本清楚,数据库存的是加密密文,程序里用encoder.matches(plainPwdStr, dbPwd)进行匹配,符合后返回ture。

二、加密原理

从BCryptPasswordEncoder.encode(CharSequence rawPassword)方法源码看起:

// 入参:明文字符串
public String encode(CharSequence rawPassword) {
    String salt;
    if (this.random != null) {
        salt = BCrypt.gensalt(this.version.getVersion(), this.strength, this.random);
    } else {
        // 这里会生成一个salt,其中version是在构造函数里默认的,值为"$2a"
        salt = BCrypt.gensalt(this.version.getVersion(), this.strength);
    }
    // 调用hashpw方法进行详细加密
    return BCrypt.hashpw(rawPassword.toString(), salt);
}

public static String gensalt(String prefix, int log_rounds) throws IllegalArgumentException {
    //从 new SecureRandom()看,这里肯定是生成一个随机数,就不继续深入讲了,有兴趣的同学自己继续
    return gensalt(prefix, log_rounds, new SecureRandom());
}

public static String hashpw(String password, String salt) {
    byte[] passwordb = password.getBytes(StandardCharsets.UTF_8);
    // 继续调用
    return hashpw(passwordb, salt);
}

在详细展开hashpw方法之前,可以看到生产密文还是有salt的,只不过是构造函数里提供了初始值,加密的时候生产了一个随机的salt,而且每次都不一样的,所以这个是加密算法每次都不一样的秘密。继续看看hashpw(passwordb, salt)方法:

public static String hashpw(byte[] passwordb, String salt) {
    char minor = 0;
    StringBuilder rs = new StringBuilder();
    // salt为空是不行滴
    if (salt == null) {
        throw new IllegalArgumentException("salt cannot be null");
    } else {
        int saltLength = salt.length();
        // salt长度<28也是不行滴
        if (saltLength < 28) {
            throw new IllegalArgumentException("Invalid salt");
        // 如果是salt是以$2开头的,咱就真正开始干活了
        } else if (salt.charAt(0) == '$' && salt.charAt(1) == '2') {
            byte off;
            if (salt.charAt(2) == '$') {
                off = 3;
            } else {
                minor = salt.charAt(2);
                // 如果第3个字符不是 a|x|y|b 其中之一 或者 第4个字符不是$ 那也是不行的
                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");
            } else if (off == 4 && saltLength < 29) {
                throw new IllegalArgumentException("Invalid salt");
            } else {
                // 从第5个字符到第6个字符获取rounds
                int rounds = Integer.parseInt(salt.substring(off, off + 2));
                // 从第8个字符开始获取22个长度的real_salt
                String real_salt = salt.substring(off + 3, off + 25);
                // 对real_sale进行base64编码
                byte[] saltb = decode_base64(real_salt, 16);
                if (minor >= 'a') {
                    // 对传入的passwordb进行扩容一格长度
                    passwordb = Arrays.copyOf(passwordb, passwordb.length + 1);
                }

                BCrypt B = new BCrypt();
                // 传入passwordb,saltb,rounds等参数进行真正的对password进行加密
                byte[] hashed = B.crypt_raw(passwordb, saltb, rounds, minor == 'x', minor == 'a' ? 65536 : 0);
                // 对输出StringBuilder rs进行格式拼接 
                rs.append("$2");
                if (minor >= 'a') {
                    rs.append(minor);
                }

                rs.append("$");
                if (rounds < 10) {
                    rs.append("0");
                }

                rs.append(rounds);
                rs.append("$");
                // 解码base64 saltb, 并拼接到rs
                encode_base64(saltb, saltb.length, rs);
                // 解码base64 hashed, 并拼接到rs
                encode_base64(hashed, bf_crypt_ciphertext.length * 4 - 1, rs);
                // 输出最终加密返回值
                return rs.toString();
            }
        } else {
            throw new IllegalArgumentException("Invalid salt version");
        }
    }
}

总结起来,就干了三件事:

  1. 从salt字符串中判断格式,并提取出real_salt、rounds、minor等加密需要的入参
  2. 执行真正的加密方法,获取到password的base64密文hasded
  3. 把加密方式、real_salt、hasded解码并构造成返回参数返回

三、匹配原理

那么明文和密文是如何进行匹配的呢,下面探究下matches(CharSequence rawPassword, String encodedPassword)方法实现原理:

// rawPassword: 明文字符; encodedPassword:密文
public boolean matches(CharSequence rawPassword, String encodedPassword) {
    if (encodedPassword != null && encodedPassword.length() != 0) {
        /* 加密格式判断,不符合就拜拜
        * BCRYPT_PATTERN = Pattern.compile("\A\$2(a|y|b)?\$(\d\d)\$[./0-9A-Za-z]{53}")
        */ 
        if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
            this.logger.warn("Encoded password does not look like BCrypt");
            return false;
        } else {
            // 进入匹配 go go go
            return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
        }
    } else {
        this.logger.warn("Empty encoded password");
        return false;
    }
}

public static boolean checkpw(String plaintext, String hashed) {
    // 对plaintext进行hashpw构建新密文,然后新密文与hashed密文进行比较
    return equalsNoEarlyReturn(hashed, hashpw(plaintext, hashed));
}

// salt为原有密文
public static String hashpw(String password, String salt) {
    byte[] passwordb = password.getBytes(StandardCharsets.UTF_8);
    /* 重点:这里回到了我们上一步探究过的加密方法了,核心如下:
     * 1)明文是相同的
     * 2)slat是原来的密文,但是real_salt和加密方式都可以从中提取
     * 这样得到结果自然应该是一样的
     */ 
    return hashpw(passwordb, salt);
}

// 比较两个密文,如果相同就返回true
static boolean equalsNoEarlyReturn(String a, String b) {
    return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
}

总结,其实匹配的时候取了个巧,原有的加密密文上保留了salt和加密参数,所以可以保证在用该方法进行匹配时,能与原有密文相符合。如果黑客仅仅是根据相同明文自行生成一个密文,那么salt和加密参数肯定是不一样的,这样来暴力撞库的话,是不会成功,还需提高修行。