关于密码保存的一些事
前言
登录功能是绝大部分系统都绕不过去的一个问题,而只要涉及到登录就会涉及到经典的用户名密码登录场景。系统登录作为用户使用系统功能的第一道校验,用户需要输入正确的用户名密码才能被允许访问系统,系统除了是登录的校验方,一般来说也自然承担着用户名、密码数据的保存任务。本文会对常见的密码保存方式进行介绍,总结比较推荐的密码加密方式,并针对密码加密的常见问题进行解释,希望对各位读者有所帮助。
名词解释
-
拖库(Drag Attack)
拖库本来是数据领域的术语,原意是指从数据库中导出数据。后期因为黑客攻击手段的不断升级,拖库也被用来描述通过非授权获得数据库访问或数据库所在操作系统的权限,批量下载数据库中数据或数据库数据文件的恶意行为,本文中使用的拖库均是指这种行为。黑客用于拖库的常见手段是
SQL
注入和文件上传漏洞,常见的防范方案是代码层面的防止SQL
注入、启用数据库防火墙以及数据库加密等。 -
双因子认证(Two-factor authentication,2FA)
双因子验证(2FA):有时又被称作两步验证或者双因素验证,是一种安全验证过程。在这一验证过程中,需要用户提供两种不同的认证因素来证明自己的身份,从而起到更好地保护用户证书和用户可访问的资源。
-
哈希碰撞
Hash碰撞是指两个不同的输入值,经过哈希函数的处理后,得到相同的输出值,这种情况被称之为哈希碰撞。
一、常见的密码存储方式
(一)明文保存
在比较早期互联网时代,一些企业在搭建Web应用时,会出于省事的考虑直接将用户的密码明文保存到数据库表中,登录检查的时候时候直接拿用户登录时填写的密码和数据库存储的密码进行比对,若一致则判断认证成功。
// 用户认证的伪代码
public boolean authenticate(String username,String password){
User user = UserDao.queryUserByUsername(username);
...
if(user.getPassword().equals(password)){
return true;
}
return false;
}
明文保存的优点是简单,但缺点更加明显,一旦被系统被黑客拖库,黑客几乎不需要花费任何成本就能得到包括用户密码在内的所有敏感数据。当早期的系统领教到明文保存带来的教训后,就纷纷开始使用各种算法来对用户密码进行加密了,具体的加密手段我们可以在下面的小节中看到。
(二)对称加密算法
对称加密算法的过程和大多数算法一样,都是:明文+秘钥=密文,对称算法最大的特点是还可以根据密文+秘钥
来反向解密出明文,常见的加密算法有AES
、3DES
等。客观来说,以AES
为代表的对称加密算法确实有效防止用户密码的泄露(微信小程序加密传输就是采用的这个加密算法),对称加密使用的秘钥长度越长,破解的成本就越高。
但对称加密虽然优秀,但用于密码加密还是存在一定的“硬伤”,一个是可以通过秘钥反向解密出明文,一旦秘钥泄露就存在着明文密码暴露的风险;另外一个是秘钥的存储问题,由于秘钥的特殊性,如何安全的存储也是一件麻烦的事情。
// 用户认证的伪代码
public boolean authenticate(String username,String password){
User user = UserDao.queryUserByUsername(username);
...
String encryptPwd = AESUtils.encrypt(password);
if(user.getPassword().equals(encryptPwd)){
return true;
}
return false;
}
(三)MD5加密保存
MD5是典型的单向加密算法,通过明文+秘钥得到密文后,无法通过密文+秘钥来反向得到明文,和常见的加密算法不同,MD5加密有着长度固定(无论原始明文多长,加密后的字符串长度都是一致的)、不可逆、抗修改性(对原文做一丁点儿改动,MD5值就会有巨大的变动)等特点。由于MD5在密码加密上的特殊优势,很多系统都采用了MD5算法来进行密码的加密,但和MD5算法的蜜月期只持续到2004年。2004年的国际密码学会议上,王小云教授公布了MD5存在的漏洞以及破解方式,部分公司就开始弃用MD5算法改用更加安全的SHA系列算法来进行密码加密。等到彩虹表开始出现,单纯的MD5加密的保护性就更低了,所以现在已经很少系统会单纯选择MD5来进行密码加密了。
// 使用MD5算法进行用户认证的伪代码
public boolean authenticate(String username,String password){
User user = UserDao.queryUserByUsername(username);
...
String encryptPwd = this.md5Encrypt(password);
if(user.getPassword().equals(encryptPwd)){
return true;
}
return false;
}
private String md5Encrypt(String input){
MessageDigest digest = MessageDigest.getInstance("MD5");
byte[] hash = digest.digest(input.getBytes());
StringBuilder hexString = new StringBuilder();
for (byte aByte : hash) {
String hex = String.format("%02x", aByte);
hexString.append(hex);
}
return hexString.toString();
}
下面分享一下破解MD5算法的基本原理,有兴趣的读者可以看一下
破解方式一:哈希碰撞 MD5(V1) = MD5(V2)
(这也是王小云教授所使用的破解方式)
已知MD5算法本质上是一个根据原文加密成固定字符串的函数,那么理论上只要我的数据样本足够大就可能出现两个原文不同但是密文一致的情况,比如原文“123456”经过MD5加密后的结果为“AAABBBCCCDDD”,而原文“654321”经过MD5加密后得到的密文也是“AAABBBCCCDDD”,也就是说出现了哈希碰撞。那么MD5 用于数字签名就可能存在严重问题,因为无法保障原始消息没有被篡改。
虽然哈希碰撞的存在使得破解密码成为了可能,但是理论上MD5算法的哈希碰撞的理论值是280次,对算力的要求特别高,黑客破解的成本很大。而王小云则是改良了散列冲撞算法,使得哈希碰撞只需要大约269次计算就能找出来,破解成本的降低使得MD5算法的保障性也随之下降。需要注意的是,哈希碰撞的存在只是说降低了破解密码的成本,并不是说完全就攻破了MD5算法。
破解方式二:彩虹表(相比对方式一更强的破解方式)
针对密码破解,有一种经典的破解方式:字典式攻击,攻击者预先收集好一些常见的弱口令或者目标系统经常出现的组合口令形成字典,然后逐个用户名进行登录尝试,假如说字典中包含了“123456”这个数据,且用户恰好是“123456”的弱密码,那么就可能被这类攻击乘虚而入。彩虹表的存在和字典表有点类似,彩虹表是一个用于加密散列函数逆运算的预先计算好的表, 可以简单理解为彩虹表是一个P(MD5密文)=明文
的函数,攻击者输入MD5密文之后就能计算出明文出来,一般主流的彩虹表都在100G以上。
这里需要注意,彩虹表和字典表只是原理相似,背后的实现复杂度完全不是一个等级,字典表只是简单的收集弱口令去尝试登录行为而已,而彩虹表可不只是用来破解弱密码,它厉害的地方在于通过底层的算法(结合了暴力破解和字典攻击)大大降低了暴力破解的成本。正是因为彩虹表的兴起,导致了MD5算法破解的成本降低,现在很少有公司会选择单纯的MD5来进行密码加密了。
PS:关于彩虹表的原理可以参考这篇文章: 彻底搞懂彩虹表的实现原理
(四)MD5算法加盐(Salt)
在第三小节中,我们知道由于彩虹表的存在,如果系统使用MD5加密密码,黑客在完成拖库后可以轻松反向破解出明文,这无疑会造成用户密码的泄露。根据彩虹表破解的原理,我们可以在用户注册时,在原密码上加入一段固定长度的随机字符串(这个随机字符串我们也称之为Slat,即盐值)形成新的密码,再对新的密码进行MD5加密后保存到数据库中。那么即使后面数据库发生泄露,黑客通过彩虹表破解出来的密码也不是用户真正的原始密码,而是我们二次加密后的明文。
// 用户注册伪代码
public void enroll(String username,String password){
User user = new User();
...
// 给每个用户生成随机盐
String salt = generateSalt();
String encryptPwd = this.md5Encrypt(password + salt);
user.setPassword(encryptPwd);
User user = UserDao.save(user);
}
// 使用MD5算法进行用户认证的伪代码
public boolean authenticate(String username,String password){
User user = UserDao.queryUserByUsername(username);
...
String encryptPwd = this.md5Encrypt(password);
if(user.getPassword().equals(encryptPwd)){
return true;
}
return false;
}
// 获取随机盐
public static String generateSalt() {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[50];
random.nextBytes(salt);
StringBuilder sb = new StringBuilder();
for (byte b : salt) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
// 加密
private String md5Encrypt(String input){
MessageDigest digest = MessageDigest.getInstance("MD5");
byte[] hash = digest.digest(input.getBytes());
StringBuilder hexString = new StringBuilder();
for (byte aByte : hash) {
String hex = String.format("%02x", aByte);
hexString.append(hex);
}
return hexString.toString();
}
使用MD5+盐
加密这种做法是当前少部分项目还有在使用的做法,为了提高密码破解的难度,通过给每个用户生成专属的随机盐(有些系统还会选择在字符串的中间插入盐值来提升解密的难度),客观来说这些做法确实是有效的,对于加了“固定盐”的HASH算法,需要保护“盐”不能泄露,这就会遇到“保护对称密钥”一样的问题,一旦“盐”泄露,根据“盐”重新建立彩虹表可以进行破解,对于多次HASH,也只是增加了破解的时间,并没有本质上的提升。
(五)SHA256+盐
SHA-256
相对于MD5
来说更安全、更强大,是目前常用的密码学哈希算法之一,由美国国家安全局(NSA)设计并于2001年发布,也是当前部分系统的首选的加密算法,在不考虑彩虹表的前提下,SHA256基本上已经够用了(目前没有已知的有效攻击方法能够破解SHA-256)。出于安全考虑,大部分系统即使采用了SHA256
算法来进行加密,也还是会再使用盐值来做二次加密来进一步提升密码的安全性。
// 用户注册伪代码
public void enroll(String username,String password){
User user = new User();
...
// 给每个用户生成随机盐
String salt = generateSalt();
String encryptPwd = this.sha256Encrypt(password + salt);
user.setPassword(encryptPwd);
User user = UserDao.save(user);
}
// 使用SHA256算法进行用户认证的伪代码
public boolean authenticate(String username,String password){
User user = UserDao.queryUserByUsername(username);
...
String encryptPwd = this.sha256Encrypt(password);
if(user.getPassword().equals(encryptPwd)){
return true;
}
return false;
}
// 获取随机盐
public static String generateSalt() {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[50];
random.nextBytes(salt);
StringBuilder sb = new StringBuilder();
for (byte b : salt) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
// 加密
private String sha256Encrypt(String input){
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes());
StringBuilder hexString = new StringBuilder();
for (byte aByte : hash) {
String hex = String.format("%02x", aByte);
hexString.append(hex);
}
return hexString.toString();
}
简单理解的话,就是SHA256+盐
比MD5+盐
要强,强无数倍。
(六)PBKDF2算法
PBKDF2(Password-Based Key Derivation Function 2)是一种基于密码的密钥派生函数。它的主要目的是从给定的密码和盐(salt)生成一个强大的密钥,用于加密或验证过程中的密钥派生。PBKDF2的设计目标是增加破解密码的难度,并提供更高的安全性。它使用一个伪随机函数(通常是HMAC-SHA1、HMAC-SHA256等哈希函数)来执行派生过程。
PBKDF2算法的原理是在HASH算法基础上增加随机盐,并进行多次HASH运算,随机盐使得彩虹表的建表难度大幅增加,而多次HASH也使得建表和破解的难度都大幅增加,可以认为这种算法本质的策略是提升破解的时间成本,通过多次散列函数使得恶意攻击者的每次试探成本都变得高昂。这也是不少新系统目前的加密算法上的首选。
// 用户注册伪代码
public void enroll(String username,String password){
User user = new User();
...
// 给每个用户生成随机盐
String salt = generateSalt();
String encryptPwd = this.pbkdf2Encrypt(password , salt.getBytes(),310000);
user.setPassword(encryptPwd);
User user = UserDao.save(user);
}
// 使用PBKDF2算法进行用户认证的伪代码
public boolean authenticate(String username,String password){
User user = UserDao.queryUserByUsername(username);
...
String encryptPwd = this.pbkdf2Encrypt(password,user.getSalt().getBytes(),310000);
if(user.getPassword().equals(encryptPwd)){
return true;
}
return false;
}
// 获取随机盐
public static String generateSalt() {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[50];
random.nextBytes(salt);
StringBuilder sb = new StringBuilder();
for (byte b : salt) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
// 加密
private String pbkdf2Encrypt(String input,byte[] salt,int iterations){
PBEKeySpec spec = new PBEKeySpec(input.toCharArray(), salt, iterations, 512);
SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] hash = skf.generateSecret(spec).getEncoded()
StringBuilder hexString = new StringBuilder();
for (byte aByte : hash) {
String hex = String.format("%02x", aByte);
hexString.append(hex);
}
return hexString.toString();
}
(七)新生代的强者——Argon2算法
Argon2是一种最新的密码哈希函数,于2015年获得了密码学竞赛(Password Hashing Competition)的胜利,并被选为密码存储和哈希领域的最佳选择。它是一种内存硬算法,旨在提供更高的安全性和抗攻击性。Argon2算法的原理和其实和PBKDF2算法类似,加密的核心在于使用随机盐+多次迭代,相比于后者来说,Argon2采用了内存硬算法和并行计算等技术,有着更高的抗攻击性能,包括抵抗暴力破解、碰撞攻击和侧信道攻击等。但Argon2在使用上目前相对要繁琐一些,需要单独引入第三方的依赖,官方JDK没有提供比较好的支持手段。
需要注意的一点是,Argon2算法和Bcrypt之类的现代哈希算法会自动对密码加Salt,不再需要手动去生成和维护盐值!下面是使用Argon2算法的用例:
步骤一:引入第三方依赖
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15on</artifactId>
<version>1.70</version>
</dependency>
<dependency>
<groupId>de.mkammerer</groupId>
<artifactId>argon2-jvm</artifactId>
<version>2.11</version>
</dependency>
步骤二:使用Argon2进行加解密
// 用户注册伪代码
public void enroll(String username,String password){
User user = new User();
...
String encryptPwd = this.argon2Encrypt(password);
user.setPassword(encryptPwd);
User user = UserDao.save(user);
}
// 使用Argon2算法进行用户认证的伪代码
public boolean authenticate(String username,String password){
User user = UserDao.queryUserByUsername(username);
...
String encryptPwd = this.pbkdf2Encrypt(password,user.getSalt().getBytes(),310000);
return this.passwordRight(user.getPassword(),encryptPwd);
}
// 加密
private String argon2Encrypt(String input){
// 创建Argon2实例
Argon2 argon2 = Argon2Factory.create();
try {
// 使用Argon2对密码进行加密
return argon2.hash(10, 65536, 1, input.toCharArray());
} finally {
// 清理Argon2实例
argon2.wipeArray(input.toCharArray());
}
}
private boolean passwordRight(String input,String encryptPwd){
// 创建Argon2实例
Argon2 argon2 = Argon2Factory.create();
try {
return argon2.verify(encryptPwd, input.toCharArray());
} finally {
// 清理Argon2实例
argon2.wipeArray(input.toCharArray());
}
}
小结
加密方式 | 安全性 | 优点 | 缺点 | 推荐指数 |
---|---|---|---|---|
明文保存 | 低 | 实现简单 | 一旦被拖库,用户密码将被直接暴露 | ☆ |
对称加密算法<br />(AES、3DES) | 中 | 原理简单,实现方便<br />已经有相对成熟的java依赖 | 1. 需要手动维护对称加密的秘钥,一旦秘钥泄露,用户密码原文还是会暴露<br />2. 可以根据秘钥倒推回明文,本身就有潜在的密码泄露风险<br />(从安全的角度考虑,系统本身也不应该有可以拿到用户明文密码的渠道) | ☆☆☆ |
MD5 | 偏低 | 算法特点天然适合用于密码加密和文件加密,且也有比较成熟的java依赖 | MD5算法已经比较老了,哈希碰撞和彩虹表技术的兴起让MD5的安全性大大降低,不太适合用于敏感数据的加密 | ☆☆ |
MD5+Salt | 中 | 有了盐值的存在,可以一定程度上干扰彩虹表生效,安全性要强一点 | 还是需要考虑盐值的保存问题,一旦泄露还是容易被反推出明文密码 | ☆☆☆ |
SHA256+Salt | 高 | 安全性比MD5+Salt 更强,也有较好的java依赖支持 |
也有盐值保存的问题,但因为SHA256算法的优越性,即使暴露盐值,反推原文的难度也不小。 | ☆☆☆☆ |
PBKDF2 | 很高 | 本质上是对指定哈希函数进行迭代来提升算力成本,系统可以根据实际需要来提升破解所需的算力。<br />java有较好的支持,可以指定哈希函数这点相对也比较灵活 | 也存在盐值保存问题,但是只要算法迭代次数够多,盐值即使暴露,反推明文难度还是很大。 | ☆☆☆☆☆ |
Argon2 | 很高 | 安全性很强,支持指定迭代次数,算是目前最佳的加密算法之一了。而且不需要手动维护盐值 | 需要单独引入第三方库,而且目前java对这块的支持还不是很完善 | ☆☆☆☆☆ |
二、一些常见问题的解释
(一)为什么不推荐组合哈希函数
使用组合哈希函数。人们经常不由自主地认为将不同的哈希函数组合起来结果会更加安全,例如:md5(sha1(password))
、md5(md5(salt) + md5(password))
等。实际上这样做几乎没有好处,仅仅造成了函数之间互相影响的问题,甚至有时候会变得更加不安全。永远不要尝试发明自己的加密方法,只需只用已经被设计好的标准算法。
需要肯定的是,使用组合哈希函数在攻击者在不知道加密算法前提下是无法发动攻击的,但是不要忘了柯克霍夫原则Kerckhoffs’s principle, 攻击者通常很容易就能拿到源码(尤其是那些免费或开源的软件)。通过系统中取出的一些密码-哈希值对应关系,很容易反向推导出加密算法。破解组合哈希函数确实需要更多时间,但也只是受了一点可以确知的因素影响。更好的办法是使用一个很难被并行计算出结果的迭代算法,然后增加适当的盐值防止彩虹表攻击。
(二)系统被黑客拿到数据库权限后,黑客直接通过改表数据就能入侵系统,为什么还要关注密码问题
事实上,如果拖库的事实已经发生,(业务数据没有额外加密的话)大概率业务数据也是会被直接泄露的,我们之所以关注密码问题,是因为防止黑客在本系统中破解出用户的明文密码后,拿这些密码反向再去窃取用户在不同应用中的数据,因为很多用户会习惯于在不同的应用中使用相同的密码,所以这种手段确实有一定几率能成功。我们关注用户的密码有一部分原因其实是保障用户数据在泄露的情况下,用户的密码仍然是安全的。
(三)是否应该强制用户使用复杂的密码?
这其实要看具体的业务场景,一般来说企业内部系统可以考虑强制使用复杂的密码,如果系统是面向广大用户的话,强制要求复杂密码可能会引起用户的反感,且容易引起用户的遗忘。定期要求用户修改密码也是保障系统安全的有效手段,但客观来说如果是系统是的使用群体很大的话,强制用户改密码可能会造成用户的流失,如果系统是对内部使用的话,那么可以考虑推行。
(四)推荐使用哪种方式来实现密码加密
推荐PBKDF2算法和Argon2算法,如果是java项目的话,推荐使用PBKDF2(如果系统对安全要求不高的话,SHA256+Salt
这种方式也是一种可行的选择)
(五)上述算法可以用来防止暴力破解和字典破解吗?
并不能,暴力破解和字典破解其实是属于另外一个角度的密码攻击,攻击者不需要事先知道用户的任何信息,只在系统正常的登录入口中不断的尝试登录用户账户。客观来说这种方法并不高效,针对这种攻击方式,我们可以通过IP黑名单机制、设置密码最大尝试次数、避免用户设置过于简单的弱口令等手段来进行限制。
三、一些后话
需要注意的是,随着系统对安全实时性的要求越来越高以及移动端应用的普及,现在其实一些系统已经不再采用用户名+密码
这种方式来登录,而是使用是 用户名+手机动态验证码
或者app扫码登录
的方式来完成登录动作,这样的好处是所有的登录行为都需要经过用户移动端认证,即使用户密码暴露也不会造成信息泄露。动态认证的缺点是高度信任用户的移动端行为,如果用户手机丢失容易存在风险,所以一般情况下系统还会兼着进行用户习惯、IP属地这些信息来进行风险评估。
还有部分系统会选择使用双因子认证,在登录使用用户名+密码
完成认证后,还会有一轮动态验证口令来二次认证(动态验证口令可以是手机验证码,也可以是第三方应用的令牌等),只有完成二次认证后才能登录成功。避免用户因为密码泄露的原因导致系统数据泄露。
但总的来说,密码登录在较长一段时期内还是会存在于历史舞台上的,了解和认识密码加密的原因以及措施,对于开发人员来说还是十分有必要的。
选择合适的加密算法,与时俱进,让你的系统更加的健壮 : )