ps:非密码学,因此对算法细节不做研究。
整体使用和问题的解决一共找资料耗时10h+[大部分时间去研究16进制字符串了],特别是AES的java使用,网上资料对策不太适用
1.简单介绍Bcrypt[单向hash]
- 基于Blowfish密码,1999年在USENIX被提出
- 一般用于前端登录加密,然后后端将加密串与数据库select出来的密码进行匹配。
- 是单向hash算法,无法逆向解码,只能单向加密后发送给controller,controller调用数据库返回的密码然后匹配两者是否相同。
- 可以抵御彩虹表攻击,彩虹表是使用撞库技术,也就是穷举去收集和存储所有对应的密码和加密,输入加密后的串就自动返回对应的原密码。一般的md5技术会很容易被破解。可以抵御的原因是算法迭代次数多,加密速度慢【远慢于md5】
有文章指出bcrypt一个密码出来的时间比较长,需要0.3秒,而MD5只需要一微秒(百万分之一秒),一个40秒可以穷举得到明文的MD5,在bcrypt需要12年,时间成本高 ——引用自网络
- 最终加密的hash结果结构是
[Alg][cost][22 character salt][31 character hash]
【算法版本】【迭代次数】【加密所需要的盐】【hash散列码】
Alg算法版本:是官方指定的
Cost迭代次数:默认10次,而我们最高可以指定到12,是最高安全级别
Salt盐:一个128bits的随机字符串,一共22个字符
Hash散列码:明文密码password和Salt盐进行的循环加盐hash算法
$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
\__/\/ \____________________/\_____________________________/
Alg Cost Salt Hash
【图源于网络】
- 使用简单,但不可逆向解密,只能match与明文对比返回true或者false
- spring security的crypto项目中也有Bcrypt的加密方法和验证匹配方法
2.登录服务:使用Bcrypt加密和验证
2.1 Vue前端使用Bcrypt加密
ps:演示使用vue cli2,如果是普通的vue,除了导入方法和异步请求方法不同,其他都一样。
- 先把我们需要的包用npm拉到自己的项目中
npm install bcryptjs
- 导入包变量
//在main.js
import bcrypt form 'bcryptjs'
Vue.use(bcrypt)
//下面图中的...的警告不用管,是vsc的检验机制问题
//如果在mianjs导入后变量不生效,报错未定义变量
//那就在对应的vue组件也要导入
import bcrypt from 'bcryptjs'
- 在自己提交请求的方法中使用【下面展示异步发送登录post请求】
login() {
//使用刚刚导入的包变量bcrypt注册一个盐,默认是10,12是最高安全级别【循环迭代次数】
var salt = bcrypt.genSaltSync(11)
//对我们的data中的password变量进行加密覆盖,把字符串和盐放进去混合hash即可
this.password = bcrypt.hashSync(this.password,salt)
//到这里我们的password就已经被加密好了,可以console.log输出看看
//现在我们后端只要成功接收就可以拿来和数据库中的密码匹配看看是否密码正确了
this.$http.post("/xxx/login",
{account: this.account,
password: this.password})
.then(function(res){
//此处处理后端返回结果
})
},
- 请求参数的加密结果查看:
2.2 SpringBoot后端使用Bcrypt验证密码
- 导入Bcrypt依赖
<!--bcrypt加密工具-->
<dependency>
<groupId>at.favre.lib</groupId>
<artifactId>bcrypt</artifactId>
<version>0.9.0</version>
</dependency>
- 登录请求最初来到我们的controller,我们把信息交给service服务层处理即可
@Override
//LoginInfo是我定义的pojo持久层类,也就是用来存放登录信息的实体类而已
//info包括getAccount和getPassword方法获取前端登录信息
public int login(LoginInfo info) {
//我们调用dao层去通过账号在数据库中找到用户的密码
String password = userDao.queryPasswordByAccount(info.getAccount());
//获取一个Bcrypt类Verifyer验证器
BCrypt.Verifyer verifyer = BCrypt.verifyer();
//提供我们的加密数据【需要字符数组】和我们数据库中获取的密码个验证器匹配
//返回类型要求是BCrypt的result类型,点进去看源码可以看到result有verified属性判断是否匹配
BCrypt.Result result = verifyer.verify(password.toCharArray(),info.getPassword().toCharArray());
//若成功匹配,result的verified属性为true
if(result.verified){
return 1;
}
return 0;
}
-
前端查看是否匹配成功,并验证错误密码是否返回失败
-
成功匹配
-
匹配失败
-
3.简单介绍AES[对称加密]
-
AES属于对称秘钥加密算法,2001年发布,替代了原来的DES【比DES更加高级】
科普:对称加密就是前端和后端秘钥一致。非对称加密指约定两个秘钥,前端携带一个秘钥加密,后端用另一个秘钥解密,安全系数提高。
-
简单来说,AES是通过对我们需要加密的信息和我们自定义提供的秘钥进行混合加密或解密,只要把相同的秘钥提供给后端,后端就能解密
-
AES通过秘钥加解密,支持128位,192位,256位秘钥长度,也就是平时说的AES128,AES192,AES256,秘钥越长效率越低,保密性越强。【一般使用128位,一个字符8bit位,也就是一般的秘钥是16个字符】
-
AES重要的3个参数:
- 填充:算法加密是基于将明文拆成以128bit为单位的数据块分别加密,如果明文并非128bit的倍数【非16个字符的倍数字符】,就会有不成块的数据,这在AES加密中不允许存在,就需要进行填充,因为我们输入的明文并不是每次能16个字符或者以上的。比如密码。
- NoPadding:不做任何填充,但是要求明文必须是16字节【一个字符一字节,一个字节8bit】的整数倍
- PKCS5Padding(默认):如果明文快少于16个字节,在明文块末尾补足相应数量的字符,而且每个字节的值等于缺少的字符数,方便解码的时候取出填充
- ISO10126Padding:如果明文块少于16个字节,在明文块末尾补足相应数量的字节,最后一关字符值等于缺少的字符数,其他字符随机【不推荐】
- 模式:共五种
- CBC : 电码本
- ECB : 密码分组链接模式Cipher Block Chaining【推荐】
- CTR : 计算器模式Counter
- CFB : 密码反馈模式Cipher FeedBack
- OFB : 输出反馈模式
- 偏移量:对加密数据进行一定的偏移,提高安全性,如果加密文被获取,没有偏移量被破解出来也是一个没用的数据。一般企业会有自己的偏移量。
- 填充:算法加密是基于将明文拆成以128bit为单位的数据块分别加密,如果明文并非128bit的倍数【非16个字符的倍数字符】,就会有不成块的数据,这在AES加密中不允许存在,就需要进行填充,因为我们输入的明文并不是每次能16个字符或者以上的。比如密码。
-
个人认为科普十分全面的博客【对理解有一定帮助,但并没有解决我自己的项目的问题】
AES(对称加密算法)的JS实现和JAVA实现 - 简书 (jianshu.com)
4.注册服务:使用AES加密和解密信息
4.1 Vue前端使用AES加密
- 获取能使用AES的包到自己项目中
npm install --save crypto-js
//crypto包有很多加密算法可以使用,AES就是其中之一
- 导入包变量
//在main.js
import crypto from 'crypto-js'
Vue.use(crypto)
//下面图中的...的警告不用管,是vsc的检验机制问题
//如果在mianjs导入后变量不生效,报错未定义变量
//那就在对应的vue组件也要导入
import cryptoJs from 'crypto-js'
【在对应vue组件的js区导入】
- 加密我们的变量参数并放入post请求体
import cryptoJs from 'crypto-js'
export default {
xxx...省略一些代码
register() {
//随便约定的秘钥,后端使用也是要用这个
const key = '1111122222333334'
//调用我们刚刚导入的cryptoJs包变量,指定AES加密算法,encrypt加密,参数是我们的明文+秘钥
//cryptoJs.enc.Utf8.parse()该操作是用来确保我们加密前的数据正确
var secret = cryptoJs.AES.encrypt(cryptoJs.enc.Utf8.parse(this.password), cryptoJs.enc.Utf8.parse(key), {
//这里的iv属性是用来定义偏移量的,这里暂不演示偏移量的使用
//iv: cryptoJs.enc.Utf8.parse(iv),
//使用经典的密码分组链接ECB模式
mode: cryptoJs.mode.ECB,
//注意后端是PKCS5Padding,对应前端就是PKCS7,不要换成5,这两个才是对应的,因为使用的代码不一样所以版本不一样,前端的AES是用js写的,后端使用java写的。
padding: cryptoJs.pad.Pkcs7
//保证我们加密后secret变量是字符串变量
}).toString()
//控制台输出看看是否已经被加密
console.log(secret)
//将加密后的信息发
this.$http.post("/xxx/register", {
username: this.username,
phone: this.phone,
email: this.email,
password: secret
}).then(function (res) {
})
}
- 查看加密结果
4.2 SpringBoot后端使用AES解密
- 导入依赖
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.14</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.5</version>
</dependency>
- 待会要使用的包,一定要注意不要导错包了
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigInteger;
//
import org.apache.commons.codec.binary.Base64;
- 登录请求首先进入controller,controller调用service层进行处理, 分两步,解密和dao层
注意:下面代码的秘钥KEY和算法模式ALGORITHMESTR已经提前定义
//密钥 前后台一致16位
private static final String KEY = "1111122222333334";
//参数分别代表 算法名称/加密模式/数据填充方式
private static final String ALGORITHMESTR = "AES/ECB/PKCS5Padding";
@Override
//info是我们自定义的接收前端参数的pojo持久层,实体类
public int register(RegisterInfo info) throws Exception {
//检查前端发送的加密文是否正常编码
//8ZxOqdlC....0e6MPA==
System.out.println("前端获取:"+info.getPassword());
//这里是测试后端加密,查看加密后的字符串和前端加密是否一致
//实际使用可以忽略
KeyGenerator enKgen = KeyGenerator.getInstance("AES");
enKgen.init(128);
Cipher enCipher = Cipher.getInstance(ALGORITHMESTR);
enCipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(KEY.getBytes(), "AES"));
byte[] encodeTest = enCipher.doFinal("你前端输入的密码,手打一遍".getBytes("utf-8"));
//这里输出是乱码的,说明encode得到的byte和decode得到的code是不一样的
System.out.println(new String(encodeTest));
//直接输出byte[]转字符串会乱码,说明这里得到的byte[]结构不是我们日常用的byte数组
//下面测试乱码原因,通过AES得到的串到底用的是什么编码成什么结构的byte[]
//8ZxOqdlCF....a0e6MPA==
System.out.println("获取encodeTest的base64形态:"+Base64.encodeBase64String(encodeTest));
//385a784f71646c43....594f48613065364d50413d3d
System.out.println("获取前端的encodeTest进制数字版:"+new BigInteger(1, info.getPassword().getBytes()).toString(16));
//f19c4ea9d9421718....ad1ee8c3c
System.out.println("获取encodeTest的16进制数字版:"+new BigInteger(1, encodeTest).toString(16));
//结论:通过AES加密doFinal后得到的byte[]数组编译加密成Base64就能得到正常我们使用的串
//因此我们在decode解密的时候,要把前端返回的正常串通过base64加密编码,才能交给java的AES处理
//测试解密结束
//decode解密
//让程序自动创建keyGenerator对象,并告诉其用AES处理
KeyGenerator kgen = KeyGenerator.getInstance("AES");
//告诉秘钥生成器处理模式是128bit,也就是16位
kgen.init(128);
//拿到秘钥,KEY是我们刚刚定义的
SecretKeySpec key = new SecretKeySpec(KEY.getBytes(),"AES");
//创建密码器[算法名/加密模式/数据填充模式]
Cipher cipher = Cipher.getInstance(ALGORITHMESTR);
//表示密码器要使用key进行解码
cipher.init(Cipher.DECRYPT_MODE, key);
//根据刚刚加密得到的结果,我们需要把info.getPassword字符串通过base64编码成cipher能识别的byte数组
byte[] result = cipher.doFinal(Base64.decodeBase64(info.getPassword()));
//测试通过,前后端AES一致,后端能转AES码[需要转为string]
System.out.println("decode:"+new String(result));
//调用dao类注册用户
User user = new User(0,info.getUsername(),info.getPhone(),info.getEmail(),new String(result),"","");
user.setCreate_time(String.valueOf(new Date().getTime()));
user.setAuthentication("common");
return userDao.registerUser(user);
}
-
结果查看和结论:
-
输入1928376455
-
后端加密和解密结果
很明显,前端获取了
-
结论:
- 1928376455的加密文:0TjNNAi1KHVaD0iVOCxWMw==;
- 后端对1928376455加密后的加密文也是0TjNNAi1KHVaD0iVOCxWMw==,但是无法直接使用和输出,会乱码;
- 最后测试发现我们用base64解码可以将乱码的后端加密文输出0TjNNAi1KHVaD0iVOCxWMw==;
- 也就是说明cipher只能处理base64加密后的串,所以我们在decode解密的时候不能直接输入前端获取的加密文,而是要先Base64.decodeBase64(info.getPassword())后输入base64加密编码的byte[]数组。
5.总结在研究过程中的异常和解决方法
- 通过控制台输出,确保每次加密前的数据都是正确的
- 通过控制台输出,确保每次解密前的数据都是正确的
- 通过控制台输出,确保每次解密前的数据格式和加密时候的数据格式一致【二进制还是十六进制】
5.1 [已解决]Input length must be multiple of 16 when decrypting with padded cipher
意思就是你输入的交给cipher密码器处理的byte数组必须是128位,也就是16位的编码,就是cipher无法处理你提交的byte[]数组,刚刚我们测试了,java后端的AES加密后的byte[]不是普通的byte[],也就是说cipher处理的byte[]不是普通的byte[]。
本人方法,已解决
我们通过Base64加密解密测试发现,cipher处理的byte[]数组是经过Base64处理过的,那么我们在decode解码的时候也传入一个base64处理过[编码]的byte[]数组就行了。
将:byte[] result = cipher.doFinal("前端获取的串".getBytes());
修改为:byte[] result = cipher.doFinal(Base64.decodeBase64("前端获取的串"));
确认已解决。
网络方法一:二进制转十六进制,但是亲测无效,不建议使用,可以测试
https://blog.youkuaiyun.com/fragrant_no1/article/details/84402147
网络方法二:转义与编码,不适用,因为我们前端获取的信息是正确无误的
https://www.cnblogs.com/goloving/p/15574685.html
5.2 Given final block not properly padded. Such issues can arise if a bad key is used during decryption.
意思是数据块不能正确填充,处理前端传来的加密文过程可能是因为秘钥错误导致。但是我们的秘钥明文是没错的,这就说明我们的秘钥明文转为SecretKeySpec对象的时候错误了。
- 检查KeyGenerator是否指定为AES算法
- 检查KeyGenerator是否指定128bit
- 检查KeyGenerator初始化init时候是否选择随机加密。 如下:
kgen.init(128, new SecureRandom(password.getBytes()));
SecretKey secretKey = kgen.generateKey();
byte[] enCodeFormat = secretKey.getEncoded();
SecretKeySpec key = new SecretKeySpec(enCodeFormat, "AES");
如果是,删去new SecureRandom(password.getBytes())随机加密,我们直接在第四行创建SecretKeySpec的时候传入我们自己的KEY:new SecretKeySpec(KEY, “AES”);