1. 概述

加密一般分为对称加密(symmetric-key encryption)和非对称加密(asymmetric-key encryption)。对称加密又分为分组加密和序列密码。

对称分组/分块加密(block cipher)在数据加密中起着重要作用,它使得可以使用同一密钥进行加密和解密。 高级加密标准 (AES)是一种广泛使用的对称分组加密算法。

此教程中, 我们将使用JDK内置的密码框架(JCA)来实现AES的加密和解密。 2到4节主要介绍AES的基础知识,第5节通过实战示例演示如何在Java中完成AES的加密与解密,着急的朋友可以直接跳到第5节。

2. AES 算法简介

AES算法是一种迭代的对称分组加密,支持用128,192或256位(bit)的密钥,加密和解密长度为128位的区块。下图展示了AES算法:

High Level AES Algorithm

如果要加密的数据不满足128位区块大小要求,则必须对其进行填充。

3. AES 加密模式

AES 算法有6种加密模式:

  1. ECB (Electronic Code Book, 电话本)模式
  2. CBC (Cipher Block Chaining, 密码分组链接)模式
  3. CFB (Cipher FeedBack, 加密反馈)模式
  4. OFB (Output FeedBack, 输出反馈)模式
  5. CTR (Counter, 计数)模式
  6. GCM (Galois/Counter Mode)模式

应用不同的加密模式可以增强加密算法的效果 。此外,加密模式可以将分组密码转换为流密码,每种模式都有其优势和劣势, 让我们快速回顾一下:

3.1. ECB (电子密码本)

ECB模式是最早采用和最简单的模式。将明文分为大小为128位的块,然后每个块都使用相同的密钥和算法进行加密。 因此,对于相同的块它会得到相同的结果。 这是此模式的主要缺点,不建议用于加密。 它需要填充数据。

3.2. CBC (密码块链接)

为了克服ECB的弱点,CBC模式使用初始化向量(IV)来增强加密。 首先,CBC将明文块与IV进行异或(xor)后,将结果加密到密文块。 在下一个块中,它将使用上一个加密结果与明文块进行异或,重复该步骤直到最后一个块。

此模式下, 加密不支持并行计算, 但解密支持并行计算。它同样需要填充数据。

3.3. CFB (密文反馈)

CFB模式类似于CBC,可以将块密码变为自同步的流密码。首先,它加密初始化向量 (IV),然后将其与明文块进行异或运算以获得密文。然后,CFB 模式会加密这个加密结果,再与明文进行异或运算。

在此模式下,解密可以并行化,但加密不能并行化。

3.4. OFB (输出反馈)

OFB 可以将块密码变成同步的流密码。首先,它加密 IV。然后使用加密结果对明文进行异或运算以获取密文。

它不需要填充数据,也不会受到噪声块的影响。

3.5. CTR (计数器模式)

CTR 使用计数器的值作为 IV。它与 OFB 非常相似,但每次加密时都使用计数器而不是 IV。

CTR有两个优点,包括加密/解密并行化,并且一个块中的噪声不会影响其他块。

3.6. GCM

GCM 是 CTR 模式的扩展。GCM 备受关注,并受到 NIST 的推荐。GCM 模型输出密文和身份验证标签。与算法的其他操作模式相比,此模式的主要优势在于效率。

在本教程中,我们主要学习使用广泛的 AES/CBC/PKCS5Padding 算法,它用在很多的项目上。

3.7. 加密后的数据大小

前面我们提到,AES的块大小为128位或16个字节。 AES不会更改大小,并且密文大小或等于明文大小。 另外,在ECB和CBC模式下,我们应该使用类似于PKCS 5的填充算法。因此,加密后的数据大小为:

ciphertext_size (bytes) = cleartext_size + (16 - (cleartext_size % 16))

为了将IV与密文存储在一起,我们需要再增加16个字节。

4. AES 参数

在AES算法中,我们需要三个参数:输入数据(待加密和解密的数据),密钥和IV。 ECB模式下不使用IV。

4.1. 加密的数据类型

需要加密的数据类型可以是字符串,文件,对象等。

4.2. 密钥

在AES中生成密钥的方法有两种:从随机数生成或从给定的密码中导出密钥。 第一种方法中,应该使用像SecureRandom类这样的加密安全(伪)随机数生成器生成密钥。

为了生成密钥,我们可以使用KeyGenerator类。 下面我们定义一种生成大小为n(128、192、256)位的AES密钥的方法:

public static SecretKey generateKey(int n) throws NoSuchAlgorithmException {
    KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
    keyGenerator.init(n);
    SecretKey key = keyGenerator.generateKey();
    return key;
}

在第二种方法中,可以使用基于密码的密钥派生算法(如PBKDF2)从给定的密码导出AES密钥。我们还需要加盐(salt)将密码转换为密钥。 salt也是一个随机值。

我们可以将SecretKeyFactory类与PBKDF2WithHmacSHA256算法一起使用,以根据给定的密码生成密钥。

下面定义一个方法,该方法将给定的密码通过65,536次迭代生成长度位256位的AES密钥:

public static SecretKey getKeyFromPassword(String password, String salt)
    throws NoSuchAlgorithmException, InvalidKeySpecException {

    SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
    KeySpec spec = new PBEKeySpec(password.toCharArray(), salt.getBytes(), 65536, 256);
    SecretKey secret = new SecretKeySpec(factory.generateSecret(spec)
        .getEncoded(), "AES");
    return secret;
}

4.3. 初始化向量 (IV)

IV的值是随机的,其大小与加密块相同。 我们可以使用SecureRandom类生成随机IV。

下面定义生成 IV的方法:

public static IvParameterSpec generateIv() {
    byte[] iv = new byte[16];
    new SecureRandom().nextBytes(iv);
    return new IvParameterSpec(iv);
}

5. AES加密与解密示例

在介绍完AES基础知识后,开始进入实战环境。我们将分别演示对字符串的加密与解密,以及文件、和Object的加解密。

5.1. 字符串的加密与解密

JDK 中已集成了AES算法,所以实现起来比较简单:

    public static String encrypt(String algorithm, String input, SecretKey key,
        IvParameterSpec iv) throws NoSuchPaddingException, NoSuchAlgorithmException,
        InvalidAlgorithmParameterException, InvalidKeyException,
        BadPaddingException, IllegalBlockSizeException {
    
        Cipher cipher = Cipher.getInstance(algorithm);
        cipher.init(Cipher.ENCRYPT_MODE, key, iv);
        byte[] cipherText = cipher.doFinal(input.getBytes());
        // 将byte[]转为Base64字符串,也可使用16进制表示
        return Base64.getEncoder()
            .encodeToString(cipherText);
    }

encrypt 方法接受4个参数,第一个是加密算法,第二个是需要加密的字符串。另外两个参数:密钥和IV的生成已经在4.2和4.3节中实现了。

首先我们通过getInstance()静态方法获取Cipher类实例,然后调用init()方法,传入密钥,IV和加密模式这3个参数。最后,我们通过调用doFinal()方法对输入的字符串进行加密

下面是解密过程,很类似。注意cipher.init() 传入的是 DECRYPT_MODE 表示解密:

public static String decrypt(String algorithm, String cipherText, SecretKey key,
    IvParameterSpec iv) throws NoSuchPaddingException, NoSuchAlgorithmException,
    InvalidAlgorithmParameterException, InvalidKeyException,
    BadPaddingException, IllegalBlockSizeException {

    Cipher cipher = Cipher.getInstance(algorithm);
    cipher.init(Cipher.DECRYPT_MODE, key, iv);
    byte[] plainText = cipher.doFinal(Base64.getDecoder()
        .decode(cipherText));
    return new String(plainText);
}

下面编写测试代码,验证解密与解密:

@Test
void givenString_whenEncrypt_thenSuccess()
    throws NoSuchAlgorithmException, IllegalBlockSizeException, InvalidKeyException,
    BadPaddingException, InvalidAlgorithmParameterException, NoSuchPaddingException {

    // 待加密的字符串
    String input = "baeldung";
    // 生成密钥,generateKey() 实现见 4.2 节
    SecretKey key = AESUtil.generateKey(128);
    // 生成 IV,generateIv() 实现见 4.3 节
    IvParameterSpec ivParameterSpec = AESUtil.generateIv();
    String algorithm = "AES/CBC/PKCS5Padding";
    // 执行加密
    String cipherText = AESUtil.encrypt(algorithm, input, key, ivParameterSpec);
    // 执行解密
    String plainText = AESUtil.decrypt(algorithm, cipherText, key, ivParameterSpec);
    // 验证结果是否正确
    Assertions.assertEquals(input, plainText);
}

5.2. 文件的加密与解密

现在,让我们使用AES算法实现对文件进行加密。步骤同字符串加密相同,但是我们需要一些IO类来处理文件:

public static void encryptFile(String algorithm, SecretKey key, IvParameterSpec iv,
    File inputFile, File outputFile) throws IOException, NoSuchPaddingException,
    NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException,
    BadPaddingException, IllegalBlockSizeException {

    Cipher cipher = Cipher.getInstance(algorithm);
    cipher.init(Cipher.ENCRYPT_MODE, key, iv);
    // 获取文件流
    FileInputStream inputStream = new FileInputStream(inputFile);
    FileOutputStream outputStream = new FileOutputStream(outputFile);
    byte[] buffer = new byte[64];
    int bytesRead;
    // 循环读取输入流,将加密后的byte[]写入到输出流
    while ((bytesRead = inputStream.read(buffer)) != -1) {
        byte[] output = cipher.update(buffer, 0, bytesRead);
        if (output != null) {
            outputStream.write(output);
        }
    }
    byte[] outputBytes = cipher.doFinal();
    if (outputBytes != null) {
        outputStream.write(outputBytes);
    }
    inputStream.close();
    outputStream.close();
}

需要注意的是,不建议直接将整个文件一次性导入内存(尤其当文件特别大时)。 而是,我们一次加密一个缓冲区。

如何解密文件,步骤同上。

下面我们编写测试代码,验证文件加密和解密是否正确。我们将从test resource目录中读取baeldung.txt文件,将其加密为一个名为baeldung.encrypted的文件,然后将该文件解密为一个新文件:

@Test
void givenFile_whenEncrypt_thenSuccess() 
    throws NoSuchAlgorithmException, IOException, IllegalBlockSizeException, 
    InvalidKeyException, BadPaddingException, InvalidAlgorithmParameterException, 
    NoSuchPaddingException {

    SecretKey key = AESUtil.generateKey(128);
    String algorithm = "AES/CBC/PKCS5Padding";
    IvParameterSpec ivParameterSpec = AESUtil.generateIv();
    Resource resource = new ClassPathResource("inputFile/baeldung.txt");
    File inputFile = resource.getFile();
    File encryptedFile = new File("classpath:baeldung.encrypted");
    File decryptedFile = new File("document.decrypted");
    AESUtil.encryptFile(algorithm, key, ivParameterSpec, inputFile, encryptedFile);
    AESUtil.decryptFile(
      algorithm, key, ivParameterSpec, encryptedFile, decryptedFile);
    assertThat(inputFile).hasSameTextualContentAs(decryptedFile);
}

5.3. 基于密码的加密

我们可以使用从给定密码导出的密钥进行AES加密和解密。

为了生成密钥,我们使用getKeyFromPassword()方法(实现见4.2节)。 加密和解密步骤同“5.1字符串类型”所讲的一样。 然后,我们可以使用实例化后的cipher和生成的密钥来执行加密。

测试示例:

@Test
void givenPassword_whenEncrypt_thenSuccess() 
    throws InvalidKeySpecException, NoSuchAlgorithmException, 
    IllegalBlockSizeException, InvalidKeyException, BadPaddingException, 
    InvalidAlgorithmParameterException, NoSuchPaddingException {

    // 待加密的字符串
    String plainText = "www.baeldung-cn.com";
    // 密码
    String password = "baeldung";
    // 加盐
    String salt = "12345678";
    // 生成 IV,generateIv() 实现见 4.3 节
    IvParameterSpec ivParameterSpec = AESUtil.generateIv();
    // 通过密码导出密钥,getKeyFromPassword() 的实现见4.2 节
    SecretKey key = AESUtil.getKeyFromPassword(password,salt);
    // 加密后的文本
    String cipherText = AESUtil.encryptPasswordBased(plainText, key, ivParameterSpec);
    // 解密
    String decryptedCipherText = AESUtil.decryptPasswordBased(
      cipherText, key, ivParameterSpec);
    Assertions.assertEquals(plainText, decryptedCipherText);
}

5.4. Object 的加密与解密

我们还可以实现对Java对象的加密与解密, 我们需要使用 SealedObject 类。该对象需要支持串行化( Serializable). 下面我们定义一个 Student 类:

public class Student implements Serializable {
    private String name;
    private int age;

    // 省略setters 和 getters 方法
}

下面加密 Student 对象 :

public static SealedObject encryptObject(String algorithm, Serializable object,
    SecretKey key, IvParameterSpec iv) throws NoSuchPaddingException,
    NoSuchAlgorithmException, InvalidAlgorithmParameterException, 
    InvalidKeyException, IOException, IllegalBlockSizeException {

    Cipher cipher = Cipher.getInstance(algorithm);
    cipher.init(Cipher.ENCRYPT_MODE, key, iv);
    SealedObject sealedObject = new SealedObject(object, cipher);
    return sealedObject;
}

下面是解密:

public static Serializable decryptObject(String algorithm, SealedObject sealedObject,
    SecretKey key, IvParameterSpec iv) throws NoSuchPaddingException,
    NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException,
    ClassNotFoundException, BadPaddingException, IllegalBlockSizeException,
    IOException {

    Cipher cipher = Cipher.getInstance(algorithm);
    cipher.init(Cipher.DECRYPT_MODE, key, iv);
    Serializable unsealObject = (Serializable) sealedObject.getObject(cipher);
    return unsealObject;
}

编写测试代码:

@Test
void givenObject_whenEncrypt_thenSuccess() 
    throws NoSuchAlgorithmException, IllegalBlockSizeException, InvalidKeyException,
    InvalidAlgorithmParameterException, NoSuchPaddingException, IOException, 
    BadPaddingException, ClassNotFoundException {

    Student student = new Student("Baeldung", 20);
    SecretKey key = AESUtil.generateKey(128);
    IvParameterSpec ivParameterSpec = AESUtil.generateIv();
    String algorithm = "AES/CBC/PKCS5Padding";
    SealedObject sealedObject = AESUtil.encryptObject(
      algorithm, student, key, ivParameterSpec);
    Student object = (Student) AESUtil.decryptObject(
      algorithm, sealedObject, key, ivParameterSpec);
    assertThat(student).isEqualToComparingFieldByField(object);
}

6. 使用第三方实现

6.1 使用 Hutool

Hutool 是一个流行的国产Java工具库,提供了AES的封装。使用前先导入Maven依赖:


<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.26</version>
</dependency>

加密实现:

String content = "www.baeldung-cn.com";
// 随机生成密钥
byte[] key = SecureUtil.generateKey(SymmetricAlgorithm.AES.getValue()).getEncoded();
// 构建AES对象,默认是AES/ECB/PKCS5Padding
AES aes = SecureUtil.aes(key);
// 也可自定义参数
// AES aes = new AES(Mode.CBC, Padding.PKCS5Padding, "0CoJUm6Qyw8W8jud".getBytes(), "0102030405060708".getBytes());
// 加密
byte[] encrypt = aes.encrypt(content);
// 加密使用Base64表示
String encryptBase64 = Base64.getEncoder()
        .encodeToString(encrypt);

// 或者加密为16进制表示
String encryptHex = aes.encryptHex(content);
// 解密为字符串
String decryptStr = aes.decryptStr(encryptHex, CharsetUtil.CHARSET_UTF_8);

6.2 PKCS7Padding

JDK 默认模式是 AES/ECB/PKCS5Padding,不提供PKCS7Padding,如果需要使用PKCS7Padding,需要先引入第三方库:

<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcprov-jdk15to18</artifactId>
    <version>1.68</version>
</dependency>

然后就可以在hutool中使用:

AES aes = new AES("CBC", "PKCS7Padding",
  // 密钥,可以自定义
  "0123456789ABHAEQ".getBytes(),
  // iv加盐,按照实际需求添加
  "DYgjCEIMVrj2W9xN".getBytes());

// 加密为16进制表示
String encryptHex = aes.encryptHex(content);
// 解密
String decryptStr = aes.decryptStr(encryptHex);

7. 总结


总结,本文我们学习了如何在Java中使用AES算法来加密和解密,输入的数据类型可以是字符串,文件,Java对象等。 此外,我们还讨论了AES的几种加密模式以及加密后数据的大小。

和本站其他教程一样, 本文完整源代码存放在 GitHub.


» 下一篇: Java周报,362