在写这篇文章之前我翻译过Jakob Jenkov个人博客中讲解JCE的部分,但是我觉得这篇文章虽然很详细,但是重点不突出,同时也是为了加深自己的印象,所以自己又总结了一篇关于 JCE 的博客,也是参考了之前的那篇译文,重点关注加密算法中的消息摘要、对称加密算法、非对称加密算法和数字签名,以及它们之间的联系。
JCE(Java Cryptography Extension)是 Java 加密扩展,是 Java 平台的一个标准扩展,用于提供加密算法和密钥交换协议的实现。
JCE 提供了加解密的统一接口,但是具体实现由 Provider 来提供,JDK 默认自带了很多的 Provider,你也可以添加第三方的 Provider 来使用默认 Provider 不具备的加解密算法。下面的例子打印了 JDK 自带的 Provider:
@Test
public void test05(){
Provider[] providers = Security.getProviders();
System.out.print("{ ");
for (Provider provider : providers) {
String name = provider.getName();
System.out.print(name + " ");
}
System.out.println("}");
// 添加 Bouncy Castle 作为 Provider
Security.addProvider(new BouncyCastleProvider());
providers = Security.getProviders();
System.out.print("{ ");
for (Provider provider : providers) {
String name = provider.getName();
System.out.print(name + " ");
}
System.out.println("}");
}
{ SUN SunRsaSign SunEC SunJSSE SunJCE SunJGSS SunSASL XMLDSig SunPCSC SunMSCAPI }
{ SUN SunRsaSign SunEC SunJSSE SunJCE SunJGSS SunSASL XMLDSig SunPCSC SunMSCAPI BC }
消息摘要
消息摘要是一种不可逆的加密算法,即无法通过密文得到明文,在实际的使用场景中,它通常会和可逆的加密算法搭配使用(数字签名),常见的消息摘要算法有:
- MD2
- MD5
- SHA-1
- SHA-256
- SHA-384
- SHA-512
在 Java 中消息摘要的实现类是 MessageDigest,下面是 MessageDigest 的使用方式:
@Test
public void test06() throws NoSuchAlgorithmException {
// 获取使用 MD5 算法的 MessageDigest 实例
MessageDigest md5Instance = MessageDigest.getInstance("MD5");
byte[] plainByte = "Hello World!".getBytes();
byte[] digest = md5Instance.digest(plainByte);
String encode = Base64.getEncoder().encodeToString(digest);
System.out.println(encode);
}
值得注意的是,如果使用同一个 MessageDigest 实例对同一个字节数组进行多次加密得到的密文是一样的。
如果你有多个字节数组,你可以先多次调用 update() 方法,最后调用 digest() 方法,该方法会返回合并后的加密数据:
@Test
public void test07() throws NoSuchAlgorithmException {
// 获取使用 MD5 算法的 MessageDigest 实例
MessageDigest md5Instance = MessageDigest.getInstance("MD5");
byte[] plainByte1 = "Hello World!".getBytes();
byte[] plainByte2 = "This is a test method!".getBytes();
md5Instance.update(plainByte1);
md5Instance.update(plainByte2);
byte[] digest = md5Instance.digest();
String encode = Base64.getEncoder().encodeToString(digest);
System.out.println(encode);
}
对称加密算法
对称加密算法是先对于非对称加密算法而言的,对称加密算法使用同一个密钥来进行加密和解密,常见的对称加密算法有:
- DES
- 3DES
- AES
- RC4
- RC5
- IDEA
对于对称加密算法来说,密钥是非常重要的,在 Java 中,要想使用对称加密算法,你需要以下几步:
生成密钥
对称加密算法的密钥通过 KeyGenerator 类来生成,需要经过 KeyGenerator 实例化和初始化,最后生成密钥三个步骤,生成的密钥可以通过 SecretKey#GetEncoded() 方法保存到磁盘上。
/**
* 生成 256 位的 AES 加密算法的密钥
*/
public SecretKey generateAesKey() throws Exception {
// 1.创建 KeyGenerator 实例
KeyGenerator aesKeyGenerator = KeyGenerator.getInstance("AES");
// 2.初始化,传入的参数是密钥的大小,单位是字节,密钥越大越安全
aesKeyGenerator.init(256);
// 3.生成密钥
SecretKey aesSecretKey = aesKeyGenerator.generateKey();
return aesSecretKey;
}
@Test
public void test08() throws NoSuchAlgorithmException {
SecretKey secretKey = generateAesKey();
String algorithm = secretKey.getAlgorithm();
byte[] keyByte = secretKey.getEncoded();
// base64 编码后的密钥可以保存在磁盘上,或者分发给其他的应用程序
String secretKeyStr = Base64.getEncoder().encodeToString(keyByte);
System.out.println("algorithm: " + algorithm);
System.out.println("secretKeyStr: " + secretKeyStr);
}
你可以通过 Base64 编码,将密钥以文本格式保存在磁盘文件中,然后分发到其他的系统,比如以下示例将密钥保存到工作目录下的 key.txt 文件中:
@Test
public void test09() throws Exception {
SecretKey secretKey = generateAesKey();
byte[] keyByte = secretKey.getEncoded();
String secretKeyStr = Base64.getEncoder().encodeToString(keyByte);
Path path = Paths.get("key.txt");
Files.deleteIfExists(path);
Files.createFile(path);
Files.write(path, secretKeyStr.getBytes());
}
如果你要从 key.txt 文件中读取密钥并生成 SecretKey,你需要使用 SecretKeySpec,运行下面的例子,你会发现输出 ture,则表明两个密钥是同一个密钥。
@Test
public void test09() throws Exception {
SecretKey secretKey = generateAesKey();
byte[] keyByte = secretKey.getEncoded();
String secretKeyStr = Base64.getEncoder().encodeToString(keyByte);
Path path = Paths.get("key.txt");
Files.deleteIfExists(path);
Files.createFile(path);
Files.write(path, secretKeyStr.getBytes());
// 从磁盘读取密钥
Path keyPath = Paths.get("key.txt");
byte[] keyBytes = Files.readAllBytes(keyPath);
byte[] secretKeyFromDisk = Base64.getDecoder().decode(keyBytes);
// 使用密钥数据生成 SecretKey 对象
SecretKey secretKeyNew = new SecretKeySpec(secretKeyFromDisk, "AES");
System.out.println(secretKeyNew.equals(secretKey));
}
Cipher 加解密
Cipher 在 Java 中代表加密算法,对称加密和非对称加密都要用到 Cipher 来加解密数据,在实例化 Cipher 的时候需要指定三个部分,分别是加密算法、加密模式和填充方案,后两个部分是可选的。JCE 提供了多种加密模式和填充方案。
以下是一些常见的加密模式和填充方案:
加密模式:
- ECB(Electronic Codebook):电子密码本模式,每个块独立加密,相同的明文块加密后始终产生相同的密文块,不需要指定初始化向量
- CBC(CIpher Block Chaining):密码分组链接模式,每个块的加密依赖于前一个块的密文,增加了安全性,需要指定初始化向量
- CFB(Output Feedback):输出反馈模式,将前一个加密块作为密钥来加密一个不断变化的位流,适用于流数据,需要指定初始化向量
- CTR(Counter):计数器模式,每个块的加密依赖于一个计数器值,适用于并行处理和流数据,需要指定初始化向量
- GCM(Galois/Counter Mode):在 CTR 模式的基础上,添加了 Galios 认证码,提供了加密和身份验证,需要指定初始化向量
填充方案:
- NoPadding:不填充,要求明文长度必须是块长度的整数倍
- PKCS5Padding/PKCS7Padding:PKCS5 和 PKCS7 填充,将剩余的字节用填充值填充到块长度
- ISO10126Padding:使用随机数据填充,最后一个字节表示填充的字节数
- ZeroBytePadding:使用零字节填充,填充的字节都是 0
- ISO7816d4Padding:与 ISO10126Padding 类似,但最后一个字节表示填充的字节数,其他字节都是 0
如下是使用 AES 加密算法、CBC 加密模式和 PKCS5Padding 填充方案的 Cipher 实例:
Cipher aesCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
在初始化 Cipher 时我们除了需要指定算法的的模式(加密模式/解密模式)和密钥,对于部分加密模式(如 CBC),你还需要指定初始化向量(Initialization Vector,IV)。如下所示:
@Test
public void test12() throws Exception {
// 密钥
SecretKey secretKey = generateAesKey();
// 随机生成 16 字节大小的初始化向量
SecureRandom random = new SecureRandom();
byte[] iv = new byte[16];
random.nextBytes(iv);
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
// AES 加密算法,CBC 加密模式,PKCS5Padding 填充方案
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
// 初始化
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivParameterSpec);
}
以下示例演示了使用 AES 加密算法、CBC 加密模式和 PKCS5Padding 填充方案的 Cipher 实例来加解密数据:
@Test
public void test11() throws Exception {
// 注意加密和解密要使用相同的密钥
SecretKey secretKey = generateAesKey();
// 初始化向量
// 随机生成 16 字节大小的初始化向量
SecureRandom random = new SecureRandom();
byte[] iv = new byte[16];
random.nextBytes(iv);
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
// 加密数据
// 1.创建 Cipher 实例,使用 AES 加密算法、CBC 加密模式和 PKCS5Padding 填充方案
Cipher encryptCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
// 2.初始化,Cipher.ENCRYPT_MODE 指 Cipher 用来加密
encryptCipher.init(Cipher.ENCRYPT_MODE, secretKey, ivParameterSpec);
// 3.加密
String plainStr = "Hello World!";
byte[] encryptBytes = encryptCipher.doFinal(plainStr.getBytes());
String encryptStr = Base64.getEncoder().encodeToString(encryptBytes);
System.out.println("plainStr: " + plainStr);
System.out.println("encryptStr: " + encryptStr);
// 解密数据
// 2.初始化,复用之前的加密时的 Cipher 实例,Cipher.DECRYPT_MODE 指 Cipher 用来解密
encryptCipher.init(Cipher.DECRYPT_MODE, secretKey, ivParameterSpec);
// 3.解密
byte[] decryptBytes = encryptCipher.doFinal(encryptBytes);
String decryptStr = new String(decryptBytes);
System.out.println("decryptStr: " + decryptStr);
}
和 MessageDigest 类似,Cipher 也支持加密一个或多个数据块,加密多个数据块的时候,先多次调用 update() 方法,最后调用 doFinal() 方法。
@Test
public void test14() throws Exception {
// 密钥
byte[] keyData = "mysecretkey12345".getBytes();
SecretKey secretKey = new SecretKeySpec(keyData, "AES");
// 随机生成 16 字节大小的初始化向量
SecureRandom random = new SecureRandom();
byte[] iv = new byte[16];
random.nextBytes(iv);
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
// 初始化Cipher对象
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivParameterSpec);
// 要加密的数据块
byte[] block1 = "Hello World!".getBytes();
byte[] block2 = "Hello World!Hello World!Hello World!".getBytes();
byte[] block3 = "This is a test method!".getBytes();
// 加密第一个数据块
byte[] encryptedBlock1 = cipher.update(block1);
// 加密第二个数据块
byte[] encryptedBlock2 = cipher.update(block2);
// 加密第三个数据块
byte[] encryptedBlock3 = cipher.doFinal(block3);
// 输出加密结果
System.out.println("Block 1 byte size: " + encryptedBlock1.length + " ;Encrypted Block 1: " + Base64.getEncoder().encodeToString(encryptedBlock1));
System.out.println("Block 2 byte size: " + encryptedBlock2.length + " ;Encrypted Block 2: " + Base64.getEncoder().encodeToString(encryptedBlock2));
System.out.println("Block 3 byte size: " + encryptedBlock3.length + " ;Encrypted Block 3: " + Base64.getEncoder().encodeToString(encryptedBlock3));
// 合并加密后的数据块
byte[] encryptedData = new byte[encryptedBlock1.length + encryptedBlock2.length + encryptedBlock3.length];
System.arraycopy(encryptedBlock1, 0, encryptedData, 0, encryptedBlock1.length);
System.arraycopy(encryptedBlock2, 0, encryptedData, encryptedBlock1.length, encryptedBlock2.length);
System.arraycopy(encryptedBlock2, 0, encryptedData, encryptedBlock1.length + encryptedBlock2.length, encryptedBlock3.length);
System.out.println("Merge byte size: " + encryptedData.length + " ;Merge Encrypted Block: " + Base64.getEncoder().encodeToString(encryptedData));
}
Block 1 byte size: 0 ;Encrypted Block 1:
Block 2 byte size: 48 ;Encrypted Block 2: mTlr4G6XNSIBG6D4TNlEVARc7djp/1UZAydxWvvIKcy9jX59hKgiwgA7Y1Y74PTr
Block 3 byte size: 32 ;Encrypted Block 3: frJHmhhSHNq2vS7pvJofi7yTj+qi5sYM7GppEHd3gQM=
Merge byte size: 80 ;Merge Encrypted Block: mTlr4G6XNSIBG6D4TNlEVARc7djp/1UZAydxWvvIKcy9jX59hKgiwgA7Y1Y74PTrmTlr4G6XNSIBG6D4TNlEVARc7djp/1UZAydxWvvIKcw=
Cipher 不管是 update() 还是 doFinal() 都会返回密文数据块,用户需要手动将这些密文数据块合并成最终的密文数据块,你可能注意到了第一个加密数据块返回的字节数组大小是 0,这是因为在 CBC 模式下,update() 方法会对输入的字节数据进行加密,但不会处理填充,如果输入的数据块大小不符合加密算法的块大小,update() 方法会等待足够的数据,然后再加密,doFinal() 方法会将输入的字节数组进行填充,最后返回加密后的完整结果,所以 doFinal() 在最后调用。
非对称加密算法
非对称加密算法指的是用一个密钥对来加密/解密数据,这个密钥对有两个密钥,分别是私钥和公钥,你可以用私钥来加密,然后用公钥来解密,也可以用公钥来加密,然后用私钥来解密。
常见的非对称加密算法有:
- RSA:最常用的非对称加密算法,可以用于加密和数字签名
- DSA:主要用于数据签名
- ECDSA:基于椭圆曲线的数字签名算法,相比 RSA 更高效
- DH:用于密钥交换,而不是加密数据本身
- ECDH:基于椭圆曲线的密钥交换算法,比 DH 更高效
和对称加密一样,要想使用非对称加密算法加解密数据,你首先要先创建密钥,只是对于非对称加密算法来说,要创建密钥对。
@Test
public void test15() throws Exception {
// 生成 RSA 密钥对
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(1024);
KeyPair keyPair = keyPairGenerator.generateKeyPair();
// 使用 RSA 公钥加密明文数据
Cipher rsaCipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
rsaCipher.init(Cipher.ENCRYPT_MODE, keyPair.getPublic());
byte[] block1 = "Hello World!".getBytes();
byte[] encryptedData = rsaCipher.doFinal(block1);
// 使用 RSA 私钥解密数据
rsaCipher.init(Cipher.DECRYPT_MODE, keyPair.getPrivate());
byte[] decryptedData = rsaCipher.doFinal(encryptedData);
System.out.println("Encrypted Data: " + Base64.getEncoder().encodeToString(encryptedData));
System.out.println("Decrypted Data: " + new String(decryptedData));
}
非对称加密算法的私钥和公钥也可以像对称加密算法的密钥一样进行传输。
数字签名
数字签名区别于普通的加密算法,它是由消息摘要算法+非对称加密算法组合而成的,而且数字签名的主要目的是确保数据的完整性和身份验证,而不是数据的保密性。
数字签名的工作流程可以参考阮一峰老师的这篇文章,里面非常形象地介绍了数字签名和数字证书之间的关系,以及 HTTPS 是如何使用数字签名的。
常见的数字签名算法有:
- MD5withRSA:使用 MD5 哈希算法进行签名,RSA 非对称加密算法
- SHA1withRSA:使用 SHA-1 哈希算法进行签名,RSA 非对称加密算法
- SHA256withRSA:使用 SHA-256 哈希算法进行签名,RSA 非对称加密算法
- SHA1withDSA:使用 SHA-1 哈希算法进行签名,DSA(数字签名算法)
- SHA256withECDSA:使用 SHA-256 哈希算法进行签名,ECDSA(椭圆曲线数字签名算法)
其中最常用的是和 RSA 相关的签名算法。
数字签名算法在 Java 中的实现是 Signature,下面示例展示了使用 MD5WithRSA 数字签名算法签名和验证的流程:
@Test
public void test16() throws Exception {
// 生成 RSA 密钥对
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(1024);
KeyPair keyPair = keyPairGenerator.generateKeyPair();
// 待签名的数据
String plainTxt = "Hello World!!!";
// 实例化 Signature,MD5 + RSA
Signature signature = Signature.getInstance("MD5WithRSA");
// 使用私钥初始化为签名模式
signature.initSign(keyPair.getPrivate());
signature.update(plainTxt.getBytes());
// 签名
byte[] signBytes = signature.sign();
// 使用私钥初始化为验证模式
signature.initVerify(keyPair.getPublic());
signature.update(plainTxt.getBytes());
// 验证
boolean isVerify = signature.verify(signBytes);
System.out.println("Signature verified: " + isVerify);
}