加解密算法分析
日常开发中,无论你是使用什么语言,都应该遇到过使用加解密的使用场景,比如接口数据需要加密传给前端保证数据传输的安全;HTTPS使用证书的方式首先进行非对称加密,将客户端的私匙传递给服务端,然后双方后面的通信都使用该私匙进行对称加密传输;使用MD5进行文件一致性校验,等等很多的场景都使用到了加解密技术。
很多时候我们对于什么时候要使用什么样的加解密方式是很懵的。因为可用的加解密方案实在是太多,大家对加解密技术的类型可能不是很清楚,今天这篇文章就来梳理一下目前主流的加解密技术,本篇文档只针对算法做科普性说明,不涉及具体算法分析。日常使用的加解密大致可以分为以下四类:
- 散列函数(也称信息摘要)算法
- 对称加密算法
- 非对称加密算法
- 组合加密技术
1. 散列函数算法
听名字似乎不是一种加密算法,类似于给一个对象计算出hash值。所以这种算法一般用于数据特征提取。常用的散列函数包括:MD5、SHA1、SHA2(包括SHA128、SHA256等)散列函数的应用很广,散列函数有个特点,它是一种单向加密算法,只能加密、无法解密。
1.1 MD5
先来看MD5算法,MD5算法是广为使用的数据特征提取算法,最常见的就是我们在下载一些软件,网站都会提供MD5值给你进行校验,你可以通过MD5值是否一致来检查当前文件是否被别人篡改。MD5算法具有以下特点:
- 任意长度的数据得到的MD5值长度都是相等的;
- 对原数据进行任一点修改,得到的MD5值就会有很大的变化;
- 散列函数的不可逆性,即已知原数据,无法通过特征值反向获取原数据。(
需要说明的是2004年的国际密码讨论年会(CRYPTO)尾声,王小云及其研究同事展示了MD5、SHA-0及其他相关杂凑函数的杂凑冲撞。也就是说,她找出了第一个 两个值不同,但 MD5 值相同的碰撞的例子。这个应该不能称之为破解
)
1.2 MD5用途:
- 防篡改。上面说过用于文件完整性校验。
- 用于不想让别人看到明文的地方。比如用户密码入库,可以将用户密码使用MD5加密存储,下次用户输入密码登录只用将他的输入进行MD5加密与数据库的值判断是否一致即可,这样就有效防止密码泄露的风险。
- 用于文件秒传。比如百度云的文件秒传功能可以用这种方式来实现。在你点击上传的时候,前端同学会先计算文件的MD5值然后与服务端比对是否存在,如果有就会告诉你文件上传成功,即完成所谓的秒传。
在JDK中提供了MD5的实现:java.security
包中有个类MessageDigest
,MessageDigest 类为应用程序提供信息摘要算法的功能,如 MD5 或 SHA 算法。信息摘要是安全的单向哈希函数,它接收任意大小的数据,输出固定长度的哈希值。
MessageDigest 对象使用getInstance
函数初始化,该对象通过使用 update 方法处理数据。任何时候都可以调用 reset 方法重置摘要。一旦所有需要更新的数据都已经被更新了,应该调用 digest 方法之一完成哈希计算。
对于给定数量的更新数据,digest 方法只能被调用一次。digest 被调用后,MessageDigest 对象被重新设置成其初始状态。
下面的例子展示了使用JDK自带的MessageDigest类使用MD5算法。同时也展示了如果使用了update
方法后没有调用digest
方法,则会累计当前所有的update中的值在下一次调用digest方法的时候一并输出:
package other;
import java.security.MessageDigest;
/**
* @author: rickiyang
* @date: 2019/9/13
* @description:
*/
public class MD5Test {
static char[] hex = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};
public static void main(String[] args) {
try {
//申明使用MD5算法
MessageDigest md5 = MessageDigest.getInstance("MD5");
md5.update("a".getBytes());//
System.out.println("md5(a)=" + byte2str(md5.digest()));
md5.update("a".getBytes());
md5.update("bc".getBytes());
System.out.println("md5(abc)=" + byte2str(md5.digest()));
//你会发现上面的md5值与下面的一样
md5.update("abc".getBytes());
System.out.println("md5(abc)=" + byte2str(md5.digest()));
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 将字节数组转换成十六进制字符串
*
* @param bytes
* @return
*/
private static String byte2str(byte[] bytes) {
int len = bytes.length;
StringBuffer result = new StringBuffer();
for (int i = 0; i < len; i++) {
byte byte0 = bytes[i];
result.append(hex[byte0 >>> 4 & 0xf]);
result.append(hex[byte0 & 0xf]);
}
return result.toString();
}
}
输出:
md5(a)=0CC175B9C0F1B6A831C399E269772661
md5(abc)=900150983CD24FB0D6963F7D28E17F72
md5(abc)=900150983CD24FB0D6963F7D28E17F72
2.1 SHA系列算法
Secure Hash Algorithm,是一种与MD5同源的数据加密算法。SHA算法能计算出一个数位信息所对应到的,长度固定的字串,又称信息摘要。而且如果输入信息有任何的不同,输出的对应摘要不同的机率非常高。因此SHA算法也是FIPS所认证的五种安全杂凑算法之一。原因有两点:一是由信息摘要反推原输入信息,从计算理论上来说是极为困难的;二是,想要找到两组不同的输入信息发生信息摘要碰撞的几率,从计算理论上来说是非常小的。任何对输入信息的变动,都有很高的几率导致的信息摘要大相径庭。
SHA实际上是一系列算法的统称,分别包括:SHA-1、SHA-224、SHA-256、SHA-384以及SHA-512。后面4中统称为SHA-2,事实上SHA-224是SHA-256的缩减版,SHA-384是SHA-512的缩减版。各中SHA算法的数据比较如下表,其中的长度单位均为位:
类别 | SHA-1 | SHA-224 | SHA-256 | SHA-384 | SHA-512 |
---|---|---|---|---|---|
消息摘要长度 | 160 | 224 | 256 | 384 | 512 |
消息长度 | 小于264位 | 小于264位 | 小于264位 | 小于2128位 | 小于2128位 |
分组长度 | 512 | 512 | 512 | 1024 | 1024 |
计算字长度 | 32 | 32 | 32 | 64 | 64 |
计算步骤数 | 80 | 64 | 64 | 80 | 80 |
SHA-1算法输入报文的最大长度不超过264位,产生的输出是一个160位的报文摘要。输入是按512 位的分组进行处理的。SHA-1是不可逆的、防冲突,并具有良好的雪崩效应。
上面提到的MessageDigest
类同时也支持SHA系列算法,使用方式与MD5一样,注意SHA不同的类型:
MessageDigest md = MessageDigest.getInstance("SHA");
MessageDigest md = MessageDigest.getInstance("SHA-224");
MessageDigest md = MessageDigest.getInstance("SHA-384");
2. 对称加密算法
所谓的对称加密,意味着加密者和解密者需要同时持有一份相同的密匙,加密者用密匙加密,解密者用密匙解密即可。
常用的对称加密算法包括DES算法、AES算法等。 由于对称加密需要一个秘钥,而秘钥在加密者与解密者之间传输又很难保证安全性,所以目前用对称加密算法的话主要是用在加密者解密者相同,或者加密者解密者相对固定的场景。
对称算法又可分为两类:
第一种是一次只对明文中的单个位(有时对字节)运算的算法称为序列算法或序列密码;
另一种算法是对明文的一组位进行运算,这些位组称为分组,相应的算法称为分组算法或分组密码。现代计算机密码算法的典型分组长度为64位――这个长度既考虑到分析破译密码的难度,又考虑到使用的方便性。
2.1 BASE64算法
我们很熟悉的BASE64算法就是一个没有秘密的对称加密算法。因为他的加密解密算法都是公开的,所以加密数据是没有任何秘密可言,典型的防菜鸟不防程序员的算法。
BASE64算法作用:
-
用于简单的数据加密传输;
-
用于数据传输过程中的转码,解决中文问题和特殊符号在网络传输中的乱码现象。
网络传输过程中如果双方使用的编解码字符集方式不一致,对于中文可能会出现乱码;与此类似,网络上传输的字符并不全是可打印的字符,比如二进制文件、图片等。Base64的出现就是为了解决此问题,它是基于64个可打印的字符来表示二进制的数据的一种方法。
BASE64原理
BASE64的原理比较简单,每当我们使用BASE64时都会先定义一个类似这样的数组:
['A', 'B', 'C', ... 'a', 'b', 'c', ... '0', '1', ... '+', '/']
上面就是BASE64的索引表,字符选用了"A-Z、a-z、0-9、+、/" 64个可打印字符,这是标准的BASE64协议规定。在日常使用中我们还会看到“=”或“==”号出现在BASE64的编码结果中,“=”在此是作为填充字符出现。
JDK提供了BASE64的实现:BASE64Encoder
,我们可以直接使用:
//使用base64加密
BASE64Encoder encoder = new BASE64Encoder();
String encrypt = encoder.encode(str.getBytes());
//使用base64解密
BASE64Decoder decoder = new BASE64Decoder();
String decrypt = new String(decoder.decodeBuffer(encryptStr));
2.2 DES
DES (Data Encryption Standard),在很长时间内,许多人心目中“密码生成”与DES一直是个同义词。
DES是一个分组加密算法,典型的DES以64位为分组对数据加密,加密和解密用的是同一个算法。它的密钥长度是56位(因为每个第8 位都用作奇偶校验),密钥可以是任意的56位的数,而且可以任意时候改变。
DES加密过程大致如下:
- 首先需要从用户处获取一个64位长的密码口令,然后通过等分、移位、选取和迭代形成一套16个加密密钥,分别供每一轮运算中使用;
- 然后将64位的明文分组M进行操作,M经过一个初始置换IP,置换成m0。将m0明文分成左半部分和右半部分m0 = (L0,R0),各32位长。然后进行16轮完全相同的运算(迭代),这些运算被称为函数f,在每一轮运算过程中数据与相应的密钥结合;
- 在每一轮迭代中密钥位移位,然后再从密钥的56位中选出48位。通过一个扩展置换将数据的右半部分扩展成48位,并通过一个异或操作替代成新的48位数据,再将其压缩置换成32位。这四步运算构成了函数f。然后,通过另一个异或运算,函数f的输出与左半部分结合,其结果成为新的右半部分,原来的右半部分成为新的左半部分。将该操作重复16次;
- 经过16轮迭代后,左,右半部分合在一起经过一个末置换(数据整理),这样就完成了加密过程。
对于DES解密的过程大家猛然一想应该是使用跟加密过程相反的算法,事实上解密和加密使用的是一样的算法,有区别的地方在于加密和解密在使用密匙的时候次序是相反的。比如加密的时候是K0,K1,K2......K15,那么解密使用密匙的次序就是倒过来的。之所以能用相同的算法去解密,这跟DES特意设计的加密算法有关,感兴趣的同学可以深入分析。
2.3 AES
高级加密标准(AES,Advanced Encryption Standard),与DES一样,使用AES加密函数和密匙来对明文进行加密,区别就是使用的加密函数不同。
上面说过DES的密钥长度是56比特,因此算法的理论安全强度是2^56。但以目前计算机硬件的制作水准和升级情况,破解DES可能只是山脉问题,最终NIST(美国国家标准技术研究所(National Institute of Standards and Technology))选择了分组长度为128位的Rijndael算法作为AES算法。
AES为分组密码,分组密码也就是把明文分成一组一组的,每组长度相等,每次加密一组数据,直到加密完整个明文。在AES标准规范中,分组长度只能是128位,也就是说,每个分组为16个字节(每个字节8位)。密钥的长度可以使用128位、192位或256位。密钥的长度不同,推荐加密轮数也不同,如下表所示:
AES | 密钥长度(32位比特字) | 分组长度(32位比特字) | 加密轮数 |
---|---|---|---|
AES-128 | 4 | 4 | 10 |
AES-192 | 6 | 4 | 12 |
AES-256 | 8 | 4 | 14 |
3. 非对称加密
非对称加密算法的特点是,秘钥一次会生成一对,其中一份秘钥由自己保存,不能公开出去,称为“私钥”,另外一份是可以公开出去的,称为“公钥”。
将原文用公钥进行加密,得到的密文只有用对应私钥才可以解密得到原文;
将原文用私钥加密得到的密文,也只有用对应的公钥才能解密得到原文;
因为加密和解密使用的是两个不同
的密钥,所以这种算法叫作非对称加密算法
。
与对称加密算法的对比
- 优点:其安全性更好,对称加密的通信双方使用相同的秘钥,如果一方的秘钥遭泄露,那么整个通信就会被破解。而非对称加密使用一对秘钥,一个用来加密,一个用来解密,而且公钥是公开的,秘钥是自己保存的,不需要像对称加密那样在通信之前要先同步秘钥。
- 缺点:非对称加密的缺点是加密和解密花费时间长、速度慢,只适合对少量数据进行加密。
在非对称加密中使用的主要算法有:RSA、Elgamal、ESA、背包算法、Rabin、D-H、ECC(椭圆曲线加密算法)等。不同算法的实现机制不同。
非对称加密工作原理
下面我们就看一下非对称加密的工作原理。
- 乙方生成一对密钥(公钥和私钥)并将公钥向其它方公开。
- 得到该公钥的甲方使用该密钥对机密信息进行加密后再发送给乙方。
- 乙方再用自己保存的另一把专用密钥(私钥)对加密后的信息进行解密。乙方只能用其专用密钥(私钥)解密由对应的公钥加密后的信息。
- 在传输过程中,即使攻击者截获了传输的密文,并得到了乙的公钥,也无法破解密文,因为只有乙的私钥才能解密密文。同样,如果乙要回复加密信息给甲,那么需要甲先公布甲的公钥给乙用于加密,甲自己保存甲的私钥用于解密。
非对称加密鼻祖:RSA
RSA算法基于一个十分简单的数论事实:将两个大质数(素数)相乘十分容易,但是想要对其乘积进行因式分解却极其困难,因此可以将乘积公开作为加密密钥。比如:取两个简单的质数:67,73,得到两者乘积很简单4891;但是要想对4891进行因式分解,其工作量成几何增加。
应用场景:
HTTPS请求的SSL层。
在JDK中也提供了RSA的实现,下面给出示例:
/**
* 创建密匙对
*
* @return
*/
private KeyPair genKeyPair() {
//创建 RSA Key 的生产者。
KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
//利用用户密码作为随机数初始化出 1024 比特 Key 的生产者。
//SecureRandom 是生成安全随机数序列,password.getBytes() 是种子,只要种子相同,序列就一样。
keyPairGen.initialize(1024, new SecureRandom("password".getBytes()));
//创建密钥对
return keyPairGen.generateKeyPair();
}
/**
* 生成公匙
*
* @return
*/
public PublicKey genPublicKey() {
try {
//创建密钥对
KeyPair keyPair = genKeyPair();
//生成公钥
PublicKey publicKey = keyPair.getPublic();
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKey.getEncoded());
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
publicKey = keyFactory.generatePublic(keySpec);
return publicKey;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 生成私匙
*
* @return
*/
public PrivateKey genPrivateKey() {
try {
//创建密钥对
KeyPair keyPair = genKeyPair();
//生成私匙
PrivateKey privateKey = keyPair.getPrivate();
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(privateKey.getEncoded());
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePrivate(keySpec);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 公钥加密
*
* @param data
* @param publicKey
* @return
* @throws Exception
*/
public static byte[] encryptByPublicKey(byte[] data, String publicKey)
throws Exception {
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(publicKey.getBytes());
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
Key publicK = keyFactory.generatePublic(x509KeySpec);
// 对数据加密
Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
cipher.init(Cipher.ENCRYPT_MODE, publicK);
int inputLen = data.length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
int offSet = 0;
byte[] cache;
int i = 0;
// 对数据分段加密
while (inputLen - offSet > 0) {
if (inputLen - offSet > 117) {
cache = cipher.doFinal(data, offSet, 117);
} else {
cache = cipher.doFinal(data, offSet, inputLen - offSet);
}
out.write(cache, 0, cache.length);
i++;
offSet = i * 117;
}
byte[] encryptedData = out.toByteArray();
out.close();
return encryptedData;
}
/**
* 私钥解密
*
* @param encryptedData
* @param privateKey
* @return
* @throws Exception
*/
public static byte[] decryptByPrivateKey(byte[] encryptedData,
String privateKey) throws Exception {
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(privateKey.getBytes());
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
Key privateK = keyFactory.generatePrivate(pkcs8KeySpec);
Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
cipher.init(Cipher.DECRYPT_MODE, privateK);
int inputLen = encryptedData.length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
int offSet = 0;
byte[] cache;
int i = 0;
// 对数据分段解密
while (inputLen - offSet > 0) {
if (inputLen - offSet > 118) {
cache = cipher.doFinal(encryptedData, offSet, 118);
} else {
cache = cipher.doFinal(encryptedData, offSet, inputLen - offSet);
}
out.write(cache, 0, cache.length);
i++;
offSet = i * 118;
}
byte[] decryptedData = out.toByteArray();
out.close();
return decryptedData;
}
/**
* 私钥加密
*
* @param data
* @param privateKey
* @return
* @throws Exception
*/
public static byte[] encryptByPrivateKey(byte[] data, String privateKey)
throws Exception {
PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(publicKey.getBytes());
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
Key privateK = keyFactory.generatePrivate(pkcs8KeySpec);
Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
cipher.init(Cipher.ENCRYPT_MODE, privateK);
int inputLen = data.length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
int offSet = 0;
byte[] cache;
int i = 0;
// 对数据分段加密
while (inputLen - offSet > 0) {
if (inputLen - offSet > 117) {
cache = cipher.doFinal(data, offSet, 117);
} else {
cache = cipher.doFinal(data, offSet, inputLen - offSet);
}
out.write(cache, 0, cache.length);
i++;
offSet = i * 117;
}
byte[] encryptedData = out.toByteArray();
out.close();
return encryptedData;
}
/**
* 公钥解密
*
* @param encryptedData
* @param publicKey
* @return
* @throws Exception
*/
public static byte[] decryptByPublicKey(byte[] encryptedData,
String publicKey) throws Exception {
X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(publicKey.getBytes());
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
Key publicK = keyFactory.generatePublic(x509KeySpec);
Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
cipher.init(Cipher.DECRYPT_MODE, publicK);
int inputLen = encryptedData.length;
ByteArrayOutputStream out = new ByteArrayOutputStream();
int offSet = 0;
byte[] cache;
int i = 0;
// 对数据分段解密
while (inputLen - offSet > 0) {
if (inputLen - offSet > 118) {
cache = cipher.doFinal(encryptedData, offSet, 118);
} else {
cache = cipher.doFinal(encryptedData, offSet, inputLen - offSet);
}
out.write(cache, 0, cache.length);
i++;
offSet = i * 118;
}
byte[] decryptedData = out.toByteArray();
out.close();
return decryptedData;
}
4. 组合加密
上面介绍的3种加密技术,每一种都有自己的特点,比如散列技术用于特征值提取,对称加密速度虽快但是有私匙泄露的危险,非对称加密虽然安全但是速度却慢。基于这些情况,现在的加密技术更加趋向于将这些加密的方案组合起来使用,基于此来研发新的加密算法。
MAC(Message Authentication Code,消息认证码算法)是含有密钥散列函数算法,兼容了MD和SHA算法的特性,并在此基础上加上了密钥。因此MAC算法也经常被称作HMAC算法。MAC(Message Authentication Code,消息认证码算法)是含有密钥散列函数算法,HMAC加密可以理解为加盐的散列算法,此处的“盐”就相当于HMAC算法的秘钥。
HMAC算法的实现过程需要一个加密用的散列函数(表示为H)和一个密钥。
经过MAC算法得到的摘要值也可以使用十六进制编码表示,其摘要值得长度与实现算法的摘要值长度相同。例如 HmacSHA算法得到的摘要长度就是SHA1算法得到的摘要长度,都是160位二进制数,换算成十六进制的编码为40位。
MAC算法的实现:
算法 | 摘要长度 | 备注 |
---|---|---|
HmacMD5 | 128 | JAVA6实现 |
HmacSHA1 | 160 | JAVA6实现 |
HmacSHA256 | 256 | JAVA6实现 |
HmacSHA384 | 384 | JAVA6实现 |
HmacSHA512 | 512 | JAVA6实现 |
HmacMD2 | 128 | BouncyCastle实现 |
HmacMD4 | 128 | BouncyCastle实现 |
HmacSHA224 | 224 | BouncyCastle实现 |
过程如下:
- 在密钥key后面添加0来创建一个长为B(64字节)的字符串(str);
- 将上一步生成的字符串(str) 与ipad(0x36)做异或运算,形成结果字符串(istr);
- 将数据流data附加到第二步的结果字符串(istr)的末尾;
- 做md5运算于第三步生成的数据流(istr);
- 将第一步生成的字符串(str) 与opad(0x5c)做异或运算,形成结果字符串(ostr),再将第四步的结果(istr) 附加到第五步的结果字符串(ostr)的末尾做md5运算于第6步生成的数据流(ostr),最终输出结果(out)
注意:如果第一步中,key的长度klen大于64字节,则先进行md5运算,使其长度klen = 16字节。
JDK中的实现:
public static void jdkHmacMD5() {
try {
// 初始化KeyGenerator
KeyGenerator keyGenerator = KeyGenerator.getInstance("HmacMD5");
// 产生密钥
SecretKey secretKey = keyGenerator.generateKey();
// 获取密钥
byte[] key = secretKey.getEncoded();
// byte[] key = Hex.decodeHex(new char[]{'1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e'});
// 还原密钥
SecretKey restoreSecretKey = new SecretKeySpec(key, "HmacMD5");
// 实例化MAC
Mac mac = Mac.getInstance(restoreSecretKey.getAlgorithm());
// 初始化MAC
mac.init(restoreSecretKey);
// 执行摘要
byte[] hmacMD5Bytes = mac.doFinal("data".getBytes());
System.out.println("jdk hmacMD5:" + new String(hmacMD5Bytes));
} catch (Exception e) {
e.printStackTrace();
}
}