今天遇到新同事想要开发环境某个密码,一时想不起来,于是给了他一个工具类,输入明文入参,生成新的加密密码,然后直接替换原来密码。这小伙子挺谨慎的,替换前问了一嘴,怎么没有盐(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
比比看,相同明文情况下,两次加密的内容哪里不一样:
- 数据库密文:$2a$10$nfOGMHlh8CAl03Wzwb/mXOFoXixtgEvBjRhHECc.JERN9gL5nzseC
- 新生成密文:$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");
}
}
}
总结起来,就干了三件事:
- 从salt字符串中判断格式,并提取出real_salt、rounds、minor等加密需要的入参
- 执行真正的加密方法,获取到password的base64密文hasded
- 把加密方式、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和加密参数肯定是不一样的,这样来暴力撞库的话,是不会成功,还需提高修行。