概述
编程开发中,像用户登录注册这种功能很常见,那么对于用户密码处理,我们该选择什么样的加密算法呢?在这种场景下,算法需要满足下面两个条件:
- 算法需不可逆,这样才能有效防止密码泄露。
- 算法需相对慢,可以动态调整计算成本,缓慢是应对暴力破解有效方式。
目前来看有这么几个算法 PBKDF2、 BCrypt 和 SCrypt 可以满足。我们先看下旧的密码加密方式。
旧的加密
过去密码加密常用MD5或者SHA。MD5是早期设计的加密哈希,它生成哈希速度很快,随着计算机能力的增强,出现了被破解的情况,所以又有了一些长度增大的哈希函数,如:SHA-1,SHA-256等。下面是它们的一些比较:
-
MD5:速度快生成短哈希(16 字节)。意外碰撞的概率约为:\(1.47 \times 10^{-29}\) 。
-
SHA1:比 md5 慢 20%,生成的哈希比 MD5 长一点(20 字节)。意外碰撞的概率约为:\(1 \times 10^{-45}\)。
-
SHA256:最慢,通常比 md5 慢 60%,并且生成的哈希长(32 字节)。意外碰撞的概率约为:\(4.3 \times 10^{-60}\) 。
为了确保安全你可能会选择目前长度最长的哈希SHA-512,但硬件能力在增强,或许有一天又会发现新的漏洞,研究人员又推出较新的版本,新版本的长度也会越来越长,而且他们也可能会发布底层算法,所以我们应该另外寻找更合适的算法。
加盐操作
密码安全,除了要选择足够可靠的加密算法外,输入数据的强度也要提升,因为密码是人设置的,其字符长度组合强度不可能一致,如果直接进行哈希存储往往会提升爆破的概率,这时我们需要加盐。
加盐是密码学中经常提到的概念,其实就是随机数据。下面是一个 java 中生成盐的例子:
public static byte[] generateSalt() {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[16];
random.nextBytes(salt);
return salt;
}
SHA-512 加盐哈希密码
public static String sha512(String rawPassword, byte[] salt) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-512");
// 加点盐
md.update(salt);
return Hex.encodeHexString(md.digest(rawPassword.getBytes(StandardCharsets.UTF_8)));
} catch (GeneralSecurityException ex) {
throw new IllegalStateException("Could not create hash", ex);
}
}
PBKDF2
PBKDF1和PBKDF2是一个密钥派生函数,其作用就是根据指定的密码短语生成加密密钥。之前在 常见加密算法 提到过。它虽然不是加密哈希函数,但它仍然适用密码存储场景,因为它有足够的安全性,PBKDF2 函数计算如下:
- \(PRF\) 是伪随机函数两个参数,输出固定的长度(例如,HMAC);
- \(Password\) 是生成派生密钥的主密码;
- \(Salt\) 是加密盐;
- \(Iterations\) 是迭代次数,次数越多;
- \(HashWidth\) 是派生密钥的长度;
- \(DK\) 是生成的派生密钥。
PRF(HMAC)大致迭代过程,第一次时将 Password 作为密钥和Salt传入,然后再将输出结果作为输入重复完成后面迭代。
HMAC:基于哈希的消息认证码,可以使用共享密钥提供身份验证。比如HMAC-SHA256,输入需要认证的消息和密钥进行计算,然后输出sha256的哈希值。
PBKDF2不同于MD和SHA哈希函数,它通过增加迭代次数提升了破解难度,并且还可以根据情况进行配置,这使得它具有滑动计算成本。
对于MD5和SHA,攻击者每秒可以猜测数十亿个密码。而使用 PBKDF2,攻击者每秒只能进行几千次猜测(或更少,取决于配置),所以它适用于抗击暴力攻击。
2021 年,OWASP 建议对 PBKDF2-HMAC-SHA256 使用 310000 次迭代,对 PBKDF2-HMAC-SHA512 使用 120000 次迭代
public static String pbkdf2Encode(String rawPassword, byte[] salt) {
try {
int iterations = 310000;
int hashWidth = 256;
PBEKeySpec spec = new PBEKeySpec(rawPassword.toCharArray(), salt, iterations, hashWidth);
SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
return Base64.getEncoder().encodeToString(skf.generateSecret(spec).getEncoded());
} catch (GeneralSecurityException ex) {
throw new IllegalStateException("Could not create hash", ex);
}
}
Bcrypt
简介
bcrypt 是基于 eksblowfish 算法设计的加密哈希函数,它最大的特点是:可以动态调整工作因子(迭代次数)来调整计算速度,因此就算以后计算机能力不断增加,它仍然可以抵抗暴力攻击。
关于eksblowfish算法,它是采用分组加密模式,并且支持动态设定密钥计算成本(迭代次数)。算法的详细介绍可查看下面文章:
https://www.usenix.org/legacy/publications/library/proceedings/usenix99/full_papers/provos/provos_html/node4.html
结构
bcrypt 函数输入的密码字符串不超过 72 个字节、包含算法标识符、一个计算成本和一个 16 字节(128 位)的盐值。通过输入计算得到 24字节(192位)哈希,最终输出格式如下:
$2a$12$DQoa2eT/aXFPgIoGwfllHuj4wEA3F71WWT7E/Trez331HGDUSRvXi
\__/\/ \____________________/\_____________________________/
Alg Cost Salt Hash
$2a$
: bcrypt 算法标识符或叫版本;12
: 工作因子 (2^12 表示 4096 次迭代)DQoa2eT/aXFPgIoGwfllHu
: base64 的盐值;j4wEA3F71WWT7E/Trez331HGDUSRvXi
: 计算后的 Base64 哈希值(24 字节)。
bcrypt 版本
$2a$
: 规定哈希字符串必须是 UTF-8 编码,必须包含空终止符。$2y$
: 该版本为修复 2011年6月 PHP 在 bcrypt 实现中的一个错误。$2b$
: 该版本为修复 2014年2月 OpenBSD 在 bcrypt 实现中的一个错误。
2014年2月 在 OpenBSD 的 bcrypt 实现中发现,它使用一个无符号的 8 位值来保存密码的长度。对于长度超过255字节的密码,密码将在72或长度模256 中的较小者处被截断,而不是被截断为72字节。例如:260 字节的密码将被截断为4个字节,而不是截断为 72 个字节。
实践
bcrypt 关键在于设定合适的工作因子,理想的工作因子没有特定的法则,主要还是取决于服务器的性能和应用程序上的用户数量,一般在安全性和应用性能之间权衡设定。
假如你的因子设置比较高,虽然可以保证攻击者难以破解哈希,但是登录验证也会变慢,严重影响用户体验,而且也可能被攻击者通过大量登录尝试耗尽服务器的 CPU 来执行拒绝服务攻击。一般来说计算哈希的时间不应该超过一秒。
我们使用 spring security BCryptPasswordEncoder
看下不同因子下产生哈希的时间,我电脑配置如下:
处理器:2.2 GHz 四核Intel Core i7
内存:16 GB 1600 MHz DDR3
显卡:Intel Iris Pro 1536 MB
Map encoderMap = new LinkedHashMap<>();
for (int i = 8; i <= 21; i++) {
encoderMap.put(i, new BCryptPasswordEncoder(i));
}
String plainTextPassword = "huhdfJ*!4";
for (int i : encoderMap.keySet()) {
BCryptPasswordEncoder encoder = encoderMap.get(i);
long start = System.currentTimeMillis();
encoder.encode(plainTextPassword);
long end = System.currentTimeMillis();
System.out.println(String.format("bcrypt | cost: %d, time : %dms", i, end - start));
}
bcrypt | cost: 8, time : 39ms
bcrypt | cost: 9, time : 45ms
bcrypt | cost: 10, time : 89ms
bcrypt | cost: 11, time : 195ms
bcrypt | cost: 12, time : 376ms
bcrypt | cost: 13, time : 720ms
bcrypt | cost: 14, time : 1430ms
bcrypt | cost: 15, time : 2809ms
bcrypt | cost: 16, time : 5351ms
bcrypt | cost: 17, time : 10737ms
bcrypt | cost: 18, time : 21417ms
bcrypt | cost: 19, time : 43789ms
bcrypt | cost: 20, time : 88723ms
bcrypt | cost: 21, time : 176704ms
拟合得到以下公式:
BCryptPasswordEncoder
因子范围在 4-31 ,默认是 10,我们根据公式推导一下 31时需要多长时间。
/**
* @param strength the log rounds to use, between 4 and 31
*/
public BCryptPasswordEncoder(int strength) {
this(strength, null);
}
工作因子 31
时大概需要 284
天,所以我们知道使用 bcrypt 可以很容易的扩展哈希计算过程以适应更快的硬件,为我们留出很大的回旋余地,以防止攻击者从未来的技术改进中受益。
SCrypt
SCrypt 比上面提到的算法出来较晚,是Colin Percival于 2009 年 3 月创建的基于密码的密钥派生函数。关于该算法我们需要明白下面两点:
- 该算法专门设计用于通过需要大量内存来执行大规模自定义硬件攻击,成本高昂。
- 它属于密钥派生函数和上面提到 PBKDF2 属于同一类别。
Spring security 也实现该算法 SCryptPasswordEncoder
,输入参数如下:
- CpuCost: 算法的 cpu 成本。 必须是大于 1 的 2 的幂。默认当前为 16,384 或 2^14)
- MemoryCost: 算法的内存成本。默认当前为 8。
- Parallelization: 算法的并行化当前默认为 1。请注意,该实现当前不利用并行化。
- KeyLength: 算法的密钥长度。 当前默认值为 32。
- SaltLength: 盐长度。 当前默认值为 64。
不过也有人提到,并不建议在生产系统中使用它来存储密码,他的结论是首先 SCrypt 设计目的是密钥派生函数而不是加密哈希,另外它实现上也并不那么完美。详细可查看下面文章。
https://blog.ircmaxell.com/2014/03/why-i-dont-recommend-scrypt.html
结论
我会推荐使用 bcrypt。为什么是 bcrypt 呢?
密码存储这种场景下,将密码哈希处理是最好的方式,第一它本身就是加密哈希函数,其次按照摩尔定律的定义,集成系统上每平方英寸的晶体管数量大约每 18 个月翻一番。在 2 年内,我们可以增加它的工作因子以适应任何变化。
当然这并不是说其它算法不够安全,你仍然可以选择其它算法。建议优先使用 bcrypt,其次是密钥派生类(PBKDF2 和 SCrypt),最后是哈希+盐(SHA256(salt))。