理论概述
hash算法,被音译为哈希算法,按照意思翻译就是散列算法。
理论基础描述就是,将不固定长度的输入转成固定长度的输出;输入的也许是我们可以直观理解的内容,但是输出的结果是完全打散的、没有直接含义的内容。
哈希算法是一类算法的总称,并不是具体哪一个算法,只要是符合hash算法的理论的,都可以成为是hash算法。
哈希算法种类
- MD4
MD4是MIT的Rivest在1990年设计,MD是信息摘要 Message Digest 的缩写。它是基于32位操作数的位操作来实现的。
- MD5
MD5(Message Digest 5)是一种被广泛使用的Hash函数,可以生成128位(16字节)的Hash值,用于确保信息传输完整性。MD5由美国密码学家罗纳德·李维斯特(Ronald Linn Rivest)设计,于1992年公开,用以取代MD4算法。MD5比MD4更加安全,但过程更加复杂,计算速度要慢一点。MD5已于2004年被成功碰撞,其安全性已不足应用于商业场景。 - SHA1
SHA(Secure Hash Algorithm)家族中第一代哈希算法,SHA1在许多安全协议中广为使用,包括TLS和SSL。2017年2月,Google宣布已攻破了SHA1,并准备在其Chrome浏览器产品中逐渐降低SHA1证书的安全指数,逐步停止对使用SHA1哈希算法证书的支持。 - SHA2
SHA(Secure Hash Algorithm)家族中第二代哈希算法,这是SHA算法家族的第二代,支持了更长的摘要信息输出,主要有SHA224、SHA256、SHA384和SHA512,数字后缀表示它们生成的哈希摘要结果长度。 - SHA3
SHA(Secure Hash Algorithm)家族中第三代哈希算法,之前名为Keccak256算法(读作“ketchak”,被采纳为标准之后改叫SHA3,但在最终SHA3标准改了keccak256中的填充算法,所以两者实际计算的结果是不同的),SHA3并不是要取代SHA2,因为目前SHA2并没有出现明显的弱点,由于对MD5、SHA0和SHA1出现成功的破解,NIST感觉需要一个与之前算法不同的,可替换的加密Hash算法,也就是现在的SHA3。 - RIPEMD-160
RIPEMD-160(RACE Integrity Primitives Evaluation Message Digest-160)是一个160位加密哈希函数。它旨在替代128位哈希函数MD4、MD5和RIPEMD-128。 - SM3
SM3(Shang Mi 3)是国家密码局指定的国密版本的哈希算法,此算法对输入长度小于2的64次方的比特消息,经过填充和迭代压缩,生成长度为256比特的哈希值。
伪代码示例
String param1 = "a";String param2 = "aaaaa";String param3 = "aaaaa1";String hashRes1 = hash(param1); // a12c565e5665fString hashRes2 = hash(param2); // 78cb656a56e51String hashRes3 = hash(param3); // 0c9b998a8778f
三个参数,长度不同,经过hash计算后,得到了相同长度的结果值,并且结果完全没有相似性。即便param2和param3只相差一个“1”,但是得到的结果也是完全不同。
原理简单阐述
首先有一个数据来源,数据来源的信息并不是一个固定的范围,有可能很小,有可能非常大,具体需要看使用场景,如用户系统中的用户信息;再有一个用来产出hash字符串结果的结合,这类以16进制的数字为例;
将输入的字符内容经过运算后,对应到输出的字符集上,得到固定长度的hash结果。
总之,hash运算的过程就是将无限不固定的内容信息输入,得到由固定字符范围的字符组成的hash结果。
这里有一个结论就是:输入的范围理论上是大于可以输出的范围的,从数学角度就是从16个字符中,随机做可放回抽取32次,一共有16的32次方,这个数量其实是非常庞大的。
我们将例子进行精简:
加设我们的hash算法比较简单,可使用的输出字符只有1、2、3这三个数字,输出的长度为3,排列组合一共有:
C3、1 C3、1C3、1 = 27种
那么输入的情况呢?
并不是固定的,用户的信息如果只包含姓名,一个系统中可以有成千上万的用户名,即便不重名,经过hash计算后的结果,重复的概率也是非常大的。当然,在实际开发中不会使用这种简单不安全的hash算法,而是使用hash结果不可能重复的hash算法。
HASH算法的意义
哈希算法得到的结果是不可逆的,如果用来进行加密,貌似说不通,我讲一个数据进行hash计算之后,得到的结果也翻译不了,也看不懂是什么?是不是就不能用来加密呢?
我们应该正确的理解加密,单纯从加密角度讲,hash计算后就是加密了,加密过程并不包含解密,也没有说一定要可以解密。所以加密就是负责将一个数据转成不可直接使用的另一个数据,让其失去原有的功能特性。
1、文件校验
我们在下载软件安装包的时候,经常可以看到有一些数据串:
这里的MD5数据串,其实就是将文件转成字节数组后通过MD5哈希算法得到的哈希结果值,可以用来验证这个文件是否被改动过,哪怕只改动了一个字节,得到的hash结果也是完全不同的。
软件官网提供了这个MD5值之后,让用户下载后,通过一些MD5的计算软件来计算下载的系统镜像的MD5值,是否和网站提供的MD5值相同,如果不同,那就是下载的文件受损。
同样的,在软件开发中,可以用来检查一些文件是否被非法篡改过,保证文件的使用安全。
在当下如火如荼发展中的区块链技术,内部就是用了这样的技术,用来验证文件的有效性。
2、数字签名
数字签名也是用来验证数据安全的。
在网站开发中,经常通过互联网请求一些数据,比如你登录了淘宝,要购买东西,登录淘宝之后,在你的本地浏览器展示的淘宝网站上就有了你的个人账号信息,登录的时候淘宝会使用你的账号信息(账号、昵称、手机号等)计算出一个hash值(这里称作是sign签名),然后在你每次请求数据的时候,淘宝接收到你的请求信息,就会进行验证。
你的请求信息:sign、账号、手机号等
淘宝接收到信息后:使用账号、手机号等再次计算一次hash签名值,然后和你传过来的进行比较,如果相同则认为是合法的账号,如果不同说明请求的参数有问题。
3、账号密码
在真实的开发中,系统设置的用户密码,在存储的时候,存储的都是明文密码加密后的内容。
例如,有一个账号的密码是123456,系统后台接收到这个密码后并不会直接存储,而是将明文密码计算一个hash值,将这个hash值进行存储,这样谁都不能知道存储的这个密码到底什么。
用户登录的时候,输入密码123456,后台接收到这个密码之后,按照同样的hash计算逻辑,得到一个hash值,使用这个hash值和用户注册账号时设置的存储了的密码hash值进行比较,如果相同,则说明密码输入正确,可以登陆。
如果输入了1234567,得到的hash值和之前存储的123456得到的hahs值肯定是一样的,那么就是错误密码。
这里只是举了结果简单常用的场景,哈希算法的使用非常广泛,在后续的开发中会用到。
hash算法基本特点
1、输入内容长度不固定,输出结果长度固定
2、输出结果有可能相同(即存在结果碰撞,或称为hash碰撞,现有算法中几率很小)
3、不可逆性(不可逆不代表不可以破解)
4、输入敏感(两个输入相差甚微,输出结果毫无相似性)
5、统一输入,hash结果幂等
1、输入输出
public static void main(String[] args) {// 输入长度不同String input1 = "1";String input2 = "asduash7dy8asyduha";String input3 = "scdcfsdfscdsfaef78rherrfe7rfergverrhervherehvyevherhsrfverhfg8efr";// hash计算String hashRes1 = hash(input1);String hashRes2 = hash(input2);String hashRes3 = hash(input3);// 得到相同长度的结果 32个字符长度System.out.println( hashRes1 ); // C4CA4238A0B923820DCC509A6F75849BSystem.out.println( hashRes2 ); // 611DA83FB7B01B40DC698BFDC095F15ASystem.out.println( hashRes3 ); // DE3190321556251ED1E68B38F4701582}
输入内容长度不固定,但是输出内容的长度固定。
2、输入结果不同,输出结果可能相同
这种情况我们称之为hash碰撞。就是不同的内容经过hash计算后得到的结果是相同的。
单纯的文本内容使用高级的hash算法很难得到相同的hash结果,这里不举例子。
3、不可逆性
不可逆指的是,通过一个hash值,不能反向计算出原来的内容是什么。
而我们平时说的破解密码,其实都是试出来的。
比如你的密码忘记了,只记得是6位数的密码,那么怎么试出来?肯定是在一堆可能的字符中选出6个进行随机排列,尝试那个是对的。有很多密码破解软件,就是这样,在一堆可能的字符中,组成几位到几位长度的密码进行尝试。
而直接通过hash值去计算原来的数据,是不可能的。除非你的hash算法不够那么复杂,并且将算法过程暴露出去了,他人按照你的hash计算过程逆向计算,得到一个可能性的结果。
但是现在的hash算法即便暴露的计算过程,也是无法逆向得到原数据的,更何况,不同的输入数据可以得到相同的hash结果,那么一个hash结果,从理论上讲可以得到无数个不同的原数据,自然也就无法逆运算了。
4、输入敏感
hash算法对输入数据是敏感的,输入的数据无论相差多小,即便是长度10000的两个数据,只有一个位置不相同,hash计算得到的结果也应该是完全不相同、不相似的。hash的算法特点就是:输入差之毫厘,输出谬以千里。
5、统一输入,结果幂等
这个很好理解,相同的输入信息,多次计算hash值,得到的结果永远是不变的。
常用HASH算法
MD5
md5在开发中经常被使用,并且在java中有自带的md5算法:
import java.security.MessageDigest;// 对密码进行md5加密private static void pwdMD5(String pwd) throws NoSuchAlgorithmException {// 得到md5算法的对象// 这里也可以传入 "SHA"或"SHA1",得到的最后结果的长度不一样MessageDigest md5 = MessageDigest.getInstance("MD5");// 得到长度为16的byte数组byte[] digest = md5.digest(pwd.getBytes());// 得到长度为32的字符串String hashRes = bytes2str(digest);System.out.println(hashRes);}// 将字节数组转成字长度为32的字符串static char[] hex = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'};private static String bytes2str(byte []bytes){int len = bytes.length;StringBuffer result = new StringBuffer();// 每次循环累加两个字符for (int i = 0; i < len; i++) {// -128 ~ 127byte byte0 = bytes[i];// 无符号右移4位,再和15做与运算:只保留二进制的后四位,范围就是0~15result.append(hex[byte0 >>> 4 & 0xf]);// 只做与运算result.append(hex[byte0 & 0xf]);}return result.toString();}
通过上面的例子可以将明文密码转换成加密的hash串。
密码加密升级
问题
在前面讲到,通过hash算法对密码进行加密,将hash值存储后,用于后续的验证;这里有一个问题:两个账户的密码相同,生成的hash值也是相同的,很多时候一个系统的不同账户密码有很大几率是相同的,在存储之后,看到的hash值也是一样的,如果知道了一个账户的密码,在存储的密码中找到了相同的hash,这样一样会造成密码泄露,如何解决?
为密码增加“盐”
根据hash算法的特性,在原有的内容上稍微改动一点,hash值得结果就会发生非常大的变化,那么我们可以在原有密码的基础上增加一些新的内容,生成的hash值结果就不同了。
如果增加相同的内容,那得到的hash结果还是一样的,例如:
密码“123456”,得到的hash值是:e10adc3949ba59abbe56e057f20f883e
为密码增加一个后缀:“123456abc”;
得到新的hash值:df10ef8509dc176d733d59549e7dbfaf
如果都是增加后缀“abc”,其实并没有什么意义,两个相同的密码最后得到的hash值还是相同的;那么就需要为每个密码增加不同的后缀,那就需要增加随机的字符串,这样相同的密码得到的结果就不同了,例如:
账户1 密码:“123456”增加后缀“ssa5s567”,得到hash值:6c1d8a14bd29d8d6c446d639f2424b04
账户2密码:“123456”增加后缀“78ddh4ses”得到hash值:81c5b848c197fcd0027b9b353e78e7b8
这样相同的原始密码,得到的最终hash值就不同了,即便是知道了一个账户的密码是123456,因为hash值不同了,所以无法对应到密码。
我们称后来追加的这个随机字符串叫做“盐”,通常使用slat进行命名,这是软件开发中的一贯称呼,成文加盐,密码盐。
账户登录时如何验证呢?
从注册到验证的过程:
1、用户提供:账户名、密码
2、系统:密码+盐 —> hash值
3、系统:存储hash值、盐
4、用户登录:账户名、密码
5、系统:密码+根据账户查询出来盐 —> hash值;
6、系统:计算出来的hash 、根据账户查询出来的hash值进行比较
7、hash值相同则正确,不同则密码错误
用户注册
用户登录
普通加salt的MD5加密
// 对原来的MD5进行升级,加入随机字符串(盐)private static String myUpgradeMD5(String pwd) throws NoSuchAlgorithmException {// 生成随机字符串(盐)String randomStr = randomStr(8);// 得到最终的String hashPwd = myUpgradeMD5(pwd, randomStr);// 统一存储加密后密码和盐// savePwd(hashRes, randomStr);return hashPwd;}// 为密码生成盐一起hashprivate static String myUpgradeMD5(String pwd, String salt) throws NoSuchAlgorithmException {// 简单拼接随机字符串pwd += salt;// 得到md5算法的对象MessageDigest md5 = MessageDigest.getInstance("MD5");// 得到长度为16的byte数组byte[] digest = md5.digest(pwd.getBytes());// 得到长度为32的字符串String hashRes = new BigInteger(1, digest).toString(16);// bytes2str(digest);return hashRes;}// 生成随机字符串private static String randomStr(int len) {String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";Random random = new Random();StringBuffer sb = new StringBuffer();for(int i=0; i < len; i++){int number = random.nextInt(62);sb.append(str.charAt(number));}return sb.toString();}
现有的API—-HmacMD5
HmacMD5也是md5算法,是加强版的md5,可以使用盐来增强md5的加密性。
import javax.crypto.KeyGenerator;import javax.crypto.Mac;import javax.crypto.SecretKey;import java.io.UnsupportedEncodingException;import java.math.BigInteger;import java.security.InvalidKeyException;import java.security.NoSuchAlgorithmException;private static String hmacMD5(String pwd) throws NoSuchAlgorithmException, InvalidKeyException, UnsupportedEncodingException {//KeyGenerator keyGen = KeyGenerator.getInstance("HmacMD5");SecretKey key = keyGen.generateKey();// 打印随机生成的key:byte[] skey = key.getEncoded();System.out.println(new BigInteger(1, skey).toString(16));Mac mac = Mac.getInstance("HmacMD5");mac.init(key);mac.update(pwd.getBytes("UTF-8"));byte[] result = mac.doFinal();return new BigInteger(1, result).toString(16);}
这里使用的并不是随机字符串,而是随机生成的字节数组,理论都是一样的,如果使用了随机字节数组生成的话,转成字符串后存储更方便。
注意:byte[] skey = key.getEncoded();
这其实就是key调用了一个编码方法,得到了一个字节数组,就如同我们前面讲到的字符串编码一样。
