大家好,欢迎来到IT知识分享网。
应该怎样保存用户密码?
首先,MD5 其实不是真正的加密算法。所谓加密算法,是可以使用密钥把明文加密为密文,随后还可以使用密钥解密出明文,是双向的。
使用 MD5 运算后得到的都是固定长度的摘要信息或指纹信息,无法再解密为原始数据。所以,MD5 是单向的。最重要的是,仅仅使用 MD5 对密码进行摘要,并不安全。
比如,使用如下代码在保持用户信息时,对密码进行了 MD5 计算:
UserData userData = new UserData(); userData.setId(1L); userData.setName(name); //密码字段使用MD5哈希后保存 userData.setPassword(DigestUtils.md5Hex(password)); return userRepository.save(userData);
通过输出,可以看到密码是 32 位的 MD5:
"password": "325a2cc052914ceeb8c19016c091d2ac"
到某 MD5 激活成功教程网站上输入这个 MD5,不到 1 秒就得到了原始密码:
其实你可以想一下,虽然 MD5 不可解密,但是我们可以构建一个超大的数据库,把所有 20 位以内的数字和字母组合的密码全部计算一遍 MD5 存进去,需要解密的时候搜索一下 MD5 就可以得到原始值了。这就是字典表。
目前,有些 MD5 解密网站使用的是彩虹表,是一种使用时间空间平衡的技术,即可以使用更大的空间来降低激活成功教程时间,也可以使用更长的激活成功教程时间来换取更小的空间。
此外,你可能会觉得多次 MD5 比较安全,其实并不是这样。比如,如下代码使用两次 MD5 进行摘要:
userData.setPassword(DigestUtils.md5Hex(DigestUtils.md5Hex( password)));
得到下面的 MD5:
"password": "ebbca84993fe002bac3a54e90d677d09"
也可以激活成功教程出密码,并且激活成功教程网站还告知我们这是两次 MD5 算法:
所以直接保存 MD5 后的密码是不安全的。一些同学可能会说,还需要加盐。是的,但是加盐如果不当,还是非常不安全,比较重要的有两点。
第一,不能在代码中写死盐,且盐需要有一定的长度,比如这样:
userData.setPassword(DigestUtils.md5Hex("salt" + password));
得到了如下 MD5:
"password": "58b1d63ed8492fd6ba6b93a"
对于这样一串 MD5,虽然激活成功教程网站上找不到原始密码,但是黑客可以自己注册一个账号,使用一个简单的密码,比如 1:
"password": "55f312f84e7785aa1efa552acbf251db"
然后,再去激活成功教程网站试一下这个 MD5,就可以得到原始密码是 salt,也就知道了盐值是 salt:
其实,知道盐是什么没什么关系,关键的是我们是在代码里写死了盐,并且盐很短、所有用户都是这个盐。这么做有三个问题:
- 因为盐太短、太简单了,如果用户原始密码也很简单,那么整个拼起来的密码也很短,这样一般的 MD5 激活成功教程网站都可以直接解密这个 MD5,除去盐就知道原始密码了。
- 相同的盐,意味着使用相同密码的用户 MD5 值是一样的,知道了一个用户的密码就可能知道了多个。
- 我们也可以使用这个盐来构建一张彩虹表,虽然会花不少代价,但是一旦构建完成,所有人的密码都可以被激活成功教程。
所以,最好是每一个密码都有独立的盐,并且盐要长一点,比如超过 20 位。
第二,虽然说每个人的盐最好不同,但我也不建议将一部分用户数据作为盐。比如,使用用户名作为盐:
userData.setPassword(DigestUtils.md5Hex(name + password));
如果世界上所有的系统都是按照这个方案来保存密码,那么 root、admin 这样的用户使用再复杂的密码也总有一天会被激活成功教程,因为黑客们完全可以针对这些常用用户名来做彩虹表。所以,盐最好是随机的值,并且是全球唯一的,意味着全球不可能有现成的彩虹表给你用。
正确的做法是,使用全球唯一的、和用户无关的、足够长的随机值作为盐。比如,可以使用 UUID 作为盐,把盐一起保存到数据库中:
userData.setSalt(UUID.randomUUID().toString()); userData.setPassword(DigestUtils.md5Hex(userData.getSalt() + password));
并且每次用户修改密码的时候都重新计算盐,重新保存新的密码。你可能会问,盐保存在数据库中,那被拖库了不是就可以看到了吗?难道不应该加密保存吗?
在我看来,盐没有必要加密保存。盐的作用是,防止通过彩虹表快速实现密码“解密”,如果用户的盐都是唯一的,那么生成一次彩虹表只可能拿到一个用户的密码,这样黑客的动力会小很多。
更好的做法是,不要使用像 MD5 这样快速的摘要算法,而是使用慢一点的算法。推荐使用 BCryptPasswordEncoder,也就是BCrypt来进行密码哈希。BCrypt 是为保存密码设计的算法,相比 MD5 要慢很多。
写段代码来测试一下 MD5,以及使用不同代价因子的 BCrypt,看看哈希一次密码的耗时。
private static BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); @GetMapping("performance") public void performance() { StopWatch stopWatch = new StopWatch(); String password = "Abcd1234"; stopWatch.start("MD5"); //MD5 DigestUtils.md5Hex(password); stopWatch.stop(); stopWatch.start("BCrypt(10)"); //代价因子为10的BCrypt String hash1 = BCrypt.gensalt(10); BCrypt.hashpw(password, hash1); System.out.println(hash1); stopWatch.stop(); stopWatch.start("BCrypt(12)"); //代价因子为12的BCrypt String hash2 = BCrypt.gensalt(12); BCrypt.hashpw(password, hash2); System.out.println(hash2); stopWatch.stop(); stopWatch.start("BCrypt(14)"); //代价因子为14的BCrypt String hash3 = BCrypt.gensalt(14); BCrypt.hashpw(password, hash3); System.out.println(hash3); stopWatch.stop(); log.info("{}", stopWatch.prettyPrint()); }
可以看到,MD5 只需要 0.8 毫秒,而三次 BCrypt 哈希(代价因子分别设置为 10、12 和 14)耗时分别是 82 毫秒、312 毫秒和 1.2 秒:
我们写一段代码观察下,BCryptPasswordEncoder 生成的密码哈希的规律:
@GetMapping("better") public UserData better(@RequestParam(value = "name", defaultValue = "zhuye") String name, @RequestParam(value = "password", defaultValue = "Abcd1234") String password) { UserData userData = new UserData(); userData.setId(1L); userData.setName(name); //保存哈希后的密码 userData.setPassword(passwordEncoder.encode(password)); userRepository.save(userData); //判断密码是否匹配 log.info("match ? {}", passwordEncoder.matches(password, userData.getPassword())); return userData; }
我们可以发现三点规律。
第一,我们调用 encode、matches 方法进行哈希、做密码比对的时候,不需要传入盐。BCrypt 把盐作为了算法的一部分,强制我们遵循安全保存密码的最佳实践。
第二,生成的盐和哈希后的密码拼在了一起:$是字段分隔符,其中第一个$后的 2a 代表算法版本,第二个$后的 10 是代价因子(默认是 10,代表 2 的 10 次方次哈希),第三个$后的 22 个字符是盐,再后面是摘要。所以说,我们不需要使用单独的数据库字段来保存盐。
"password": "$2a$10$wPWdQwfQO2lMxqSIb6iCROXv7lKnQq5XdMO96iCYCj7boK9pk6QPC" //格式为:$<ver>$<cost>$<salt><digest>
第三,代价因子的值越大,BCrypt 哈希的耗时越久。因此,对于代价因子的值,更建议的实践是,根据用户的忍耐程度和硬件,设置一个尽可能大的值。
应该怎么保存姓名和身份证?
我们把姓名和身份证,叫做二要素。
重点看对称加密算法。对称加密常用的加密算法,有 DES、3DES 和 AES。
虽然,现在仍有许多老项目使用了 DES 算法,但我不推荐使用。在 1999 年的 DES 挑战赛 3 中,DES 密码激活成功教程耗时不到一天,而现在 DES 密码激活成功教程更快,使用 DES 来加密数据非常不安全。因此,在业务代码中要避免使用 DES 加密。
而 3DES 算法,是使用不同的密钥进行三次 DES 串联调用,虽然解决了 DES 不够安全的问题,但是比 AES 慢,也不太推荐。AES 是当前公认的比较安全,兼顾性能的对称加密算法。不过严格来说,AES 并不是实际的算法名称,而是算法标准。2000 年,NIST 选拔出 Rijndael 算法作为 AES 的标准。
AES 有一个重要的特点就是分组加密体制,一次只能处理 128 位的明文,然后生成 128 位的密文。如果要加密很长的明文,那么就需要迭代处理,而迭代方式就叫做模式。网上很多使用 AES 来加密的代码,使用的是最简单的 ECB 模式(也叫电子密码本模式),其基本结构如下:
可以看到,这种结构有两个风险:明文和密文是一一对应的,如果明文中有重复的分组,那么密文中可以观察到重复,掌握密文的规律;因为每一个分组是独立加密和解密的 ,如果密文分组的顺序,也可以反过来操纵明文,那么就可以实现不解密密文的情况下,来修改明文。
我们写一段代码来测试下。在下面的代码中,我们使用 ECB 模式测试:
加密一段包含 16 个字符的字符串,得到密文 A;然后把这段字符串复制一份成为一个 32 个字符的字符串,再进行加密得到密文 B。我们验证下密文 B 是不是重复了一遍的密文 A。
模拟银行转账的场景,假设整个数据由发送方账号、接收方账号、金额三个字段构成。我们尝试改变密文中数据的顺序来操纵明文。
private static final String KEY = "secretkey"; //密钥 //测试ECB模式 @GetMapping("ecb") public void ecb() throws Exception { Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding"); test(cipher, null); } //获取加密秘钥帮助方法 private static SecretKeySpec setKey(String secret) { return new SecretKeySpec(secret.getBytes(), "AES"); } //测试逻辑 private static void test(Cipher cipher, AlgorithmParameterSpec parameterSpec) throws Exception { //初始化Cipher cipher.init(Cipher.ENCRYPT_MODE, setKey(KEY), parameterSpec); //加密测试文本 System.out.println("一次:" + Hex.encodeHexString(cipher.doFinal("abcdefghijklmnop".getBytes()))); //加密重复一次的测试文本 System.out.println("两次:" + Hex.encodeHexString(cipher.doFinal("abcdefghijklmnopabcdefghijklmnop".getBytes()))); //下面测试是否可以通过操纵密文来操纵明文 //发送方账号 byte[] sender = "12345".getBytes(); //接收方账号 byte[] receiver = "34567".getBytes(); //转账金额 byte[] money = "00000000".getBytes(); //加密发送方账号 System.out.println("发送方账号:" + Hex.encodeHexString(cipher.doFinal(sender))); //加密接收方账号 System.out.println("接收方账号:" + Hex.encodeHexString(cipher.doFinal(receiver))); //加密金额 System.out.println("金额:" + Hex.encodeHexString(cipher.doFinal(money))); //加密完整的转账信息 byte[] result = cipher.doFinal(ByteUtils.concatAll(sender, receiver, money)); System.out.println("完整数据:" + Hex.encodeHexString(result)); //用于操纵密文的临时字节数组 byte[] hack = new byte[result.length]; //把密文前两段交换 System.arraycopy(result, 16, hack, 0, 16); System.arraycopy(result, 0, hack, 16, 16); System.arraycopy(result, 32, hack, 32, 16); cipher.init(Cipher.DECRYPT_MODE, setKey(KEY), parameterSpec); //尝试解密 System.out.println("原始明文:" + new String(ByteUtils.concatAll(sender, receiver, money))); System.out.println("操纵密文:" + new String(cipher.doFinal(hack))); }
输出如下:
可以看到:
- 两个相同明文分组产生的密文,就是两个相同的密文分组叠在一起。
- 在不知道密钥的情况下,我们操纵密文实现了对明文数据的修改,对调了发送方账号和接收方账号。
所以说,ECB 模式虽然简单,但是不安全,不推荐使用。我们再看一下另一种常用的加密模式,CBC 模式。
CBC 模式,在解密或解密之前引入了 XOR 运算,第一个分组使用外部提供的初始化向量 IV,从第二个分组开始使用前一个分组的数据,这样即使明文是一样的,加密后的密文也是不同的,并且分组的顺序不能任意调换。这就解决了 ECB 模式的缺陷:
private static final String initVector = "abcdefghijklmnop"; //初始化向量 @GetMapping("cbc") public void cbc() throws Exception { Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); IvParameterSpec iv = new IvParameterSpec(initVector.getBytes("UTF-8")); test(cipher, iv); }
可以看到,相同的明文字符串复制一遍得到的密文并不是重复两个密文分组,并且调换密文分组的顺序无法操纵明文:
对于敏感数据保存,除了选择 AES+ 合适模式进行加密外,我还推荐以下几个实践:
- 不要在代码中写死一个固定的密钥和初始化向量,最好和之前提到的盐一样,是唯一、独立并且每次都变化的。
- 在加密后,加密服务密钥和初始化向量保存到数据库中,返回加密 ID 作为本次加密的标识。
- 应用解密时,需要提供加密 ID、密文和加密时的 AAD 来解密。加密服务使用加密 ID,从数据库查询出密钥和初始化向量。
这段逻辑的实现代码比较长, 上注解
@Service public class CipherService { //密钥长度 public static final int AES_KEY_SIZE = 256; //初始化向量长度 public static final int GCM_IV_LENGTH = 12; //GCM身份认证Tag长度 public static final int GCM_TAG_LENGTH = 16; @Autowired private CipherRepository cipherRepository; //内部加密方法 public static byte[] doEncrypt(byte[] plaintext, SecretKey key, byte[] iv, byte[] aad) throws Exception { //加密算法 Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); //Key规范 SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), "AES"); //GCM参数规范 GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv); //加密模式 cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmParameterSpec); //设置aad if (aad != null) cipher.updateAAD(aad); //加密 byte[] cipherText = cipher.doFinal(plaintext); return cipherText; } //内部解密方法 public static String doDecrypt(byte[] cipherText, SecretKey key, byte[] iv, byte[] aad) throws Exception { //加密算法 Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); //Key规范 SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), "AES"); //GCM参数规范 GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv); //解密模式 cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmParameterSpec); //设置aad if (aad != null) cipher.updateAAD(aad); //解密 byte[] decryptedText = cipher.doFinal(cipherText); return new String(decryptedText); } //加密入口 public CipherResult encrypt(String data, String aad) throws Exception { //加密结果 CipherResult encryptResult = new CipherResult(); //密钥生成器 KeyGenerator keyGenerator = KeyGenerator.getInstance("AES"); //生成密钥 keyGenerator.init(AES_KEY_SIZE); SecretKey key = keyGenerator.generateKey(); //IV数据 byte[] iv = new byte[GCM_IV_LENGTH]; //随机生成IV SecureRandom random = new SecureRandom(); random.nextBytes(iv); //处理aad byte[] aaddata = null; if (!StringUtils.isEmpty(aad)) aaddata = aad.getBytes(); //获得密文 encryptResult.setCipherText(Base64.getEncoder().encodeToString(doEncrypt(data.getBytes(), key, iv, aaddata))); //加密上下文数据 CipherData cipherData = new CipherData(); //保存IV cipherData.setIv(Base64.getEncoder().encodeToString(iv)); //保存密钥 cipherData.setSecureKey(Base64.getEncoder().encodeToString(key.getEncoded())); cipherRepository.save(cipherData); //返回本地加密ID encryptResult.setId(cipherData.getId()); return encryptResult; } //解密入口 public String decrypt(long cipherId, String cipherText, String aad) throws Exception { //使用加密ID找到加密上下文数据 CipherData cipherData = cipherRepository.findById(cipherId).orElseThrow(() -> new IllegalArgumentException("invlaid cipherId")); //加载密钥 byte[] decodedKey = Base64.getDecoder().decode(cipherData.getSecureKey()); //初始化密钥 SecretKey originalKey = new SecretKeySpec(decodedKey, 0, decodedKey.length, "AES"); //加载IV byte[] decodedIv = Base64.getDecoder().decode(cipherData.getIv()); //处理aad byte[] aaddata = null; if (!StringUtils.isEmpty(aad)) aaddata = aad.getBytes(); //解密 return doDecrypt(Base64.getDecoder().decode(cipherText.getBytes()), originalKey, decodedIv, aaddata); } }
第四步,分别实现加密和解密接口用于测试。
我们可以让用户选择,如果需要保护二要素的话,就自己输入一个查询密码作为 AAD。系统需要读取用户敏感信息的时候,还需要用户提供这个密码,否则无法解密。这样一来,即使黑客拿到了用户数据库的密文、加密服务的密钥和 IV,也会因为缺少 AAD 无法解密:
@Autowired private CipherService cipherService; //加密 @GetMapping("right") public UserData right(@RequestParam(value = "name", defaultValue = "朱晔") String name, @RequestParam(value = "idcard", defaultValue = "0001234") String idCard, @RequestParam(value = "aad", required = false)String aad) throws Exception { UserData userData = new UserData(); userData.setId(1L); //脱敏姓名 userData.setName(chineseName(name)); //脱敏身份证 userData.setIdcard(idCard(idCard)); //加密姓名 CipherResult cipherResultName = cipherService.encrypt(name,aad); userData.setNameCipherId(cipherResultName.getId()); userData.setNameCipherText(cipherResultName.getCipherText()); //加密身份证 CipherResult cipherResultIdCard = cipherService.encrypt(idCard,aad); userData.setIdcardCipherId(cipherResultIdCard.getId()); userData.setIdcardCipherText(cipherResultIdCard.getCipherText()); return userRepository.save(userData); } //解密 @GetMapping("read") public void read(@RequestParam(value = "aad", required = false)String aad) throws Exception { //查询用户信息 UserData userData = userRepository.findById(1L).get(); //使用AAD来解密姓名和身份证 log.info("name : {} idcard : {}", cipherService.decrypt(userData.getNameCipherId(), userData.getNameCipherText(),aad), cipherService.decrypt(userData.getIdcardCipherId(), userData.getIdcardCipherText(),aad)); } //脱敏身份证 private static String idCard(String idCard) { String num = StringUtils.right(idCard, 4); return StringUtils.leftPad(num, StringUtils.length(idCard), "*"); } //脱敏姓名 public static String chineseName(String chineseName) { String name = StringUtils.left(chineseName, 1); return StringUtils.rightPad(name, StringUtils.length(chineseName), "*");
访问加密接口获得如下结果,可以看到数据库表中只有脱敏数据和密文:
{"id":1,"name":"朱*","idcard":"1234","idcardCipherId":26346,"idcardCipherText":"t/wIh1XTj00wJP1Lt3aGzSvn9GcqQWEwthN58KKU4KZ4Tw==","nameCipherId":26347,"nameCipherText":"+gHrk1mWmveBMVUo+CYon8Zjj9QAtw=="}
访问解密接口,可以看到解密成功了:
[21:46:00.079] [http-nio-45678-exec-6] [INFO ] [o.g.t.c.s.s.StoreIdCardController:102 ] - name : 朱晔 idcard : 0001234
如果 AAD 输入不对,会得到如下异常:
javax.crypto.AEADBadTagException: Tag mismatch! at com.sun.crypto.provider.GaloisCounterMode.decryptFinal(GaloisCounterMode.java:578) at com.sun.crypto.provider.CipherCore.finalNoPadding(CipherCore.java:1116) at com.sun.crypto.provider.CipherCore.fillOutputBuffer(CipherCore.java:1053) at com.sun.crypto.provider.CipherCore.doFinal(CipherCore.java:853) at com.sun.crypto.provider.AESCipher.engineDoFinal(AESCipher.java:446) at javax.crypto.Cipher.doFinal(Cipher.java:2164)
经过这样的设计,二要素就比较安全了。黑客要查询用户二要素的话,需要同时拿到密文、IV+ 密钥、AAD。而这三者可能由三方掌管,要全部拿到比较困难。
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://haidsoft.com/125143.html