1. 项目概述:为什么需要一份完整的AES加解密指南?
在Java开发中,数据安全是个绕不开的话题。无论是用户密码、配置文件,还是需要传输的敏感业务数据,加密都是第一道防线。AES(高级加密标准)作为目前全球公认最安全、最高效的对称加密算法,无疑是首选。但说实话,网上关于Java AES加解密的资料多如牛毛,质量却参差不齐。很多文章要么只给个文本加密的片段,要么代码里藏着安全漏洞(比如用了不安全的ECB模式),要么对文件加密这种常见需求一笔带过。更别提那些把密钥硬编码在代码里,或者错误处理一塌糊涂的“教学”了。
我自己在项目里踩过不少坑,从简单的配置文件加密,到处理大文件的流式加密,再到与Android、C#等其他平台对接时的编码对齐问题,每一个环节都可能让你调试到怀疑人生。所以,我决定整理这份指南。它不仅仅是一份代码清单,更是一个从原理到实践,从文本到文件,从基础使用到安全进阶的完整解决方案。无论你是刚接触安全的新手,还是需要处理具体加密需求的老手,都能在这里找到清晰、可运行且安全的代码示例和背后的思考逻辑。我们的目标很简单:让你拿到就能用,用了不出错。
2. AES核心原理与Java实现选型
在动手写代码之前,花几分钟理解AES的核心和Java里的“玩法”是绝对值得的。这能帮你避开很多初级错误。
2.1 AES算法快速理解
你可以把AES想象成一个极其复杂且精密的“数字打乱机”。它把明文(你的原始数据)和密钥一起送进去,经过多轮固定的“搅拌”操作(包括字节替换、行移位、列混合和轮密钥加),输出谁也看不懂的密文。解密则是这个过程的精确逆操作。
有几个关键概念决定了这个“打乱机”的工作方式:
- 密钥长度:AES支持128位、192位和256位。位数越长越安全,但计算也稍慢。目前128位对于绝大多数场景已足够安全,256位则用于更高安全要求。Java默认支持所有长度。
- 工作模式:这是决定如何用这个“打乱机”处理超过一个“数据块”的长数据。
- ECB:最不安全的模式!每个数据块独立加密,相同的明文块会得到相同的密文块,容易暴露数据模式。绝对不要用于需要保密性的数据!
- CBC:最常用的模式。它引入了一个“初始化向量”,让每个块的加密都依赖于前一个块,消除了ECB的模式问题。这是本指南主要使用的模式。
- 其他模式:如CTR、GCM等,GCM还能同时提供加密和完整性认证,更先进,但实现稍复杂。
- 填充方式:AES一次处理一个128位(16字节)的块。如果你的数据不是16字节的整数倍,就需要填充。
PKCS5Padding(在Java里叫PKCS5Padding,但对应PKCS#7标准)是最常用的。
在Java中,我们通过Cipher这个核心类来完成加解密的所有操作。你需要告诉它:用AES算法、CBC模式、PKCS5填充。
2.2 密钥的生成与管理:安全的第一道门
密钥管理是加密系统最脆弱的一环。很多漏洞都源于密钥处理不当。
1. 如何生成一个安全的密钥?对于AES,你需要一个指定长度的随机字节序列。KeyGenerator类是标准做法:
import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import java.security.NoSuchAlgorithmException; public class KeyGenDemo { public static SecretKey generateAESKey(int keySize) throws NoSuchAlgorithmException { KeyGenerator keyGen = KeyGenerator.getInstance("AES"); keyGen.init(keySize); // 可以是 128, 192, 256 return keyGen.generateKey(); } }生成的SecretKey对象包含了密钥材料。你可以通过getEncoded()方法获取原始的字节数组,以便存储或传输。
2. 从密码派生的密钥更常见的场景是,用户输入一个密码(如“MySuperSecretPass”),我们需要从中派生出固定长度的密钥。这里千万不能简单地对密码字符串取哈希!正确的方法是使用PBKDF2WithHmacSHA256这类基于密码的密钥派生函数。
import javax.crypto.SecretKey; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; import java.security.spec.KeySpec; import java.util.Base64; public class PasswordBasedKeyDemo { public static SecretKey getKeyFromPassword(String password, String salt) throws Exception { // 1. 定义派生参数:迭代次数越多,暴力破解越难,但也越慢。推荐10000次以上。 int iterationCount = 65536; int keyLength = 256; // 生成256位的密钥材料 // 2. 将密码和盐转换为字符数组和字节数组 char[] passwordChars = password.toCharArray(); byte[] saltBytes = salt.getBytes(); // 3. 使用PBKDF2算法派生密钥 SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); KeySpec spec = new PBEKeySpec(passwordChars, saltBytes, iterationCount, keyLength); byte[] keyMaterial = factory.generateSecret(spec).getEncoded(); // 4. 将派生出的密钥材料包装成AES密钥 return new SecretKeySpec(keyMaterial, "AES"); } }注意:盐值必须是随机且唯一的,通常和密文一起存储。它确保了即使用户密码相同,派生出的密钥也不同,防止预计算攻击(如彩虹表)。
3. 密钥到底存哪里?这是灵魂拷问。硬编码在代码里是自杀行为。对于服务端应用:
- 配置文件:至少要对配置文件中的密钥进行加密,或者将密钥放在环境变量中。
- 密钥管理服务:如HashiCorp Vault、AWS KMS、阿里云KMS等,这是生产环境的最佳实践。应用在启动时从KMS获取密钥,内存中使用,永不落盘。
- 硬件安全模块:最高安全等级的需求。
对于本指南的示例,我们会在代码中生成或使用固定的密钥字符串,但你必须清楚,这只是为了演示。在实际项目中,请务必采用上述安全方式管理密钥。
2.3 初始化向量的正确使用
CBC模式需要一个初始化向量。它不需要保密,但必须不可预测,并且对于同一个密钥,每次加密都应该使用不同的IV。最佳实践是:每次加密时,随机生成一个16字节的IV,并将其预先到密文前面。解密时,先从密文头部取出这16个字节作为IV。
import java.security.SecureRandom; public class IVDemo { public static byte[] generateRandomIV() { byte[] iv = new byte[16]; // AES块大小是16字节 new SecureRandom().nextBytes(iv); // 使用密码学安全的随机数生成器 return iv; } }这样,即使你用同一个密钥加密两份相同的明文,由于IV不同,产生的密文也完全不同,安全性大大增强。
3. 文本字符串的AES加解密实现
文本加密是最基础的需求,比如加密存储在数据库中的用户手机号、加密传输的JSON字符串等。这里的关键在于处理好字符串、字节数组和Base64编码之间的转换。
3.1 核心工具类封装
我们先构建一个健壮、可复用的工具类。它要处理异常,安全地生成IV,并清晰地分离加密和解密逻辑。
import javax.crypto.Cipher; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Base64; public class AESTextCryptor { private static final String ALGORITHM = "AES"; private static final String TRANSFORMATION_CBC = "AES/CBC/PKCS5Padding"; // 你也可以选择更现代的GCM模式,它不需要单独填充 // private static final String TRANSFORMATION_GCM = "AES/GCM/NoPadding"; private static final int IV_LENGTH = 16; // AES块大小,单位字节 /** * 使用CBC模式加密文本 * @param plaintext 明文 * @param secretKey 密钥 * @return Base64编码的字符串,格式为: Base64(IV) + ":" + Base64(CipherText) * @throws Exception 加密过程中的任何异常 */ public static String encryptCBC(String plaintext, SecretKey secretKey) throws Exception { // 1. 生成随机IV byte[] iv = new byte[IV_LENGTH]; java.security.SecureRandom.getInstanceStrong().nextBytes(iv); IvParameterSpec ivSpec = new IvParameterSpec(iv); // 2. 初始化Cipher为加密模式 Cipher cipher = Cipher.getInstance(TRANSFORMATION_CBC); cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec); // 3. 执行加密 byte[] plaintextBytes = plaintext.getBytes(StandardCharsets.UTF_8); byte[] ciphertextBytes = cipher.doFinal(plaintextBytes); // 4. 将IV和密文一起编码并返回。IV不需要保密,但必须传给解密方。 String ivBase64 = Base64.getEncoder().encodeToString(iv); String ciphertextBase64 = Base64.getEncoder().encodeToString(ciphertextBytes); return ivBase64 + ":" + ciphertextBase64; } /** * 使用CBC模式解密文本 * @param encryptedText 加密后的字符串(encryptCBC方法的输出格式) * @param secretKey 密钥(必须与加密时相同) * @return 解密后的明文 * @throws Exception 解密过程中的任何异常(如密钥错误、数据被篡改) */ public static String decryptCBC(String encryptedText, SecretKey secretKey) throws Exception { // 1. 拆分出IV和密文 String[] parts = encryptedText.split(":"); if (parts.length != 2) { throw new IllegalArgumentException("Invalid encrypted text format"); } byte[] iv = Base64.getDecoder().decode(parts[0]); byte[] ciphertextBytes = Base64.getDecoder().decode(parts[1]); // 2. 使用IV初始化Cipher为解密模式 IvParameterSpec ivSpec = new IvParameterSpec(iv); Cipher cipher = Cipher.getInstance(TRANSFORMATION_CBC); cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec); // 3. 执行解密 byte[] plaintextBytes = cipher.doFinal(ciphertextBytes); return new String(plaintextBytes, StandardCharsets.UTF_8); } /** * 从一个Base64编码的字符串恢复SecretKey对象 * 适用于密钥已安全存储为字符串的场景 */ public static SecretKey loadKeyFromBase64(String base64Key) { byte[] keyBytes = Base64.getDecoder().decode(base64Key); return new SecretKeySpec(keyBytes, ALGORITHM); } }3.2 使用示例与深度解析
现在,让我们看看如何实际使用这个工具类,并理解每一步的用意。
public class TextCryptExample { public static void main(String[] args) { try { // 模拟一个密钥(生产环境请从安全的地方获取) String base64EncodedKey = "K7QvMpXq3t6w9y$B&E)H@McQfTjWnZr4"; // 这只是一个示例字符串,实际应是256位密钥的Base64 // 更真实的做法:生成一个新密钥 KeyGenerator keyGen = KeyGenerator.getInstance("AES"); keyGen.init(256); SecretKey secretKey = keyGen.generateKey(); String base64EncodedKey = Base64.getEncoder().encodeToString(secretKey.getEncoded()); System.out.println("Generated Key (Base64): " + base64EncodedKey); // 加载密钥 SecretKey key = AESTextCryptor.loadKeyFromBase64(base64EncodedKey); // 原始文本 String originalText = "这是一段需要加密的敏感信息,比如身份证号:110101199003077832"; // 加密 String encryptedText = AESTextCryptor.encryptCBC(originalText, key); System.out.println("加密结果: " + encryptedText); // 输出类似:sN2f7xLk9pP1rT4oAzq6Mw==:9Hj5KpLm3NqBvX7cRfT2wE1zY8uGt0oP... // 解密 String decryptedText = AESTextCryptor.decryptCBC(encryptedText, key); System.out.println("解密结果: " + decryptedText); // 验证 System.out.println("解密是否成功: " + originalText.equals(decryptedText)); } catch (Exception e) { e.printStackTrace(); // 在实际应用中,应根据异常类型进行更精细的处理,如无效密钥、错误数据格式等 } } }关键点解析与避坑指南:
- 字符编码是魔鬼:
getBytes()和new String()必须明确指定字符集,我们统一使用StandardCharsets.UTF_8。如果不指定,它会使用平台默认编码,在跨系统(如Linux部署,Windows开发)时会导致解密出乱码。 - 异常处理要具体:
Cipher.doFinal()可能抛出多种异常:BadPaddingException(通常意味着密钥错误或数据被篡改)、IllegalBlockSizeException等。在生产代码中,不应该简单地catch (Exception e),而应该根据不同的异常类型给用户或日志更明确的提示,但注意不要泄露过多系统信息(如具体的密钥错误)。 - IV的存储与传输:我们设计用
:将IV和密文拼接在一起。这个分隔符要确保不会在Base64编码中出现。:是一个安全的选择。另一种常见做法是将IV和密文直接拼接成字节数组IV + CipherText,然后对整个结果做Base64。两种方式都可以,关键是加解密双方要约定一致。 - 关于Base64:Java 8及以上推荐使用
java.util.Base64。注意编码器有getEncoder()(标准)和getUrlEncoder()(URL安全,将+和/替换为-和_)两种,根据你的使用场景选择。如果密文需要放在URL或文件名里,务必使用URL安全的编码。
4. 大文件的AES流式加解密实战
加密文本是一回事,加密动辄几百MB甚至几个GB的大文件则是另一回事。你不能把整个文件读进内存再加密,那会耗尽资源。解决方案是流式加密:一边读,一边加密,一边写。
4.1 为什么需要流式加密?
假设你要加密一个2GB的视频文件。如果采用Cipher.doFinal(byte[])一次性处理,JVM需要至少分配2GB的连续内存来存放文件数据,加密过程中还会产生额外的内存开销,极易导致OutOfMemoryError。流式加密使用固定大小的缓冲区(例如8KB),内存占用恒定,无论文件多大都能处理。
4.2 文件加密工具类实现
我们将使用CipherInputStream和CipherOutputStream,它们是Java为这种场景提供的完美工具。
import javax.crypto.Cipher; import javax.crypto.CipherInputStream; import javax.crypto.CipherOutputStream; import javax.crypto.SecretKey; import javax.crypto.spec.IvParameterSpec; import java.io.*; import java.security.SecureRandom; public class AESFileCryptor { private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding"; private static final int IV_LENGTH = 16; private static final int BUFFER_SIZE = 8192; // 8KB缓冲区,可根据实际情况调整 /** * 加密文件 * @param inputFile 待加密的原始文件 * @param outputFile 加密后的输出文件 * @param secretKey 密钥 * @throws Exception 加密过程中的IO或加密异常 */ public static void encryptFile(File inputFile, File outputFile, SecretKey secretKey) throws Exception { // 1. 生成随机IV byte[] iv = new byte[IV_LENGTH]; SecureRandom secureRandom = SecureRandom.getInstanceStrong(); secureRandom.nextBytes(iv); IvParameterSpec ivSpec = new IvParameterSpec(iv); // 2. 初始化Cipher Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec); // 3. 将IV写入输出文件的开头 try (FileOutputStream fos = new FileOutputStream(outputFile); BufferedOutputStream bos = new BufferedOutputStream(fos)) { bos.write(iv); // 先写IV // 4. 用CipherOutputStream包装输出流,所有写入的数据都会被自动加密 try (CipherOutputStream cos = new CipherOutputStream(bos, cipher); FileInputStream fis = new FileInputStream(inputFile); BufferedInputStream bis = new BufferedInputStream(fis)) { byte[] buffer = new byte[BUFFER_SIZE]; int bytesRead; while ((bytesRead = bis.read(buffer)) != -1) { cos.write(buffer, 0, bytesRead); // 写入即加密 } } // CipherOutputStream关闭时会自动调用doFinal,处理最后的填充块 } System.out.println("文件加密完成: " + outputFile.getAbsolutePath()); } /** * 解密文件 * @param inputFile 待解密的加密文件(文件开头包含IV) * @param outputFile 解密后的原始文件 * @param secretKey 密钥(必须与加密时相同) * @throws Exception 解密过程中的IO或解密异常 */ public static void decryptFile(File inputFile, File outputFile, SecretKey secretKey) throws Exception { try (FileInputStream fis = new FileInputStream(inputFile); BufferedInputStream bis = new BufferedInputStream(fis)) { // 1. 从加密文件开头读取IV byte[] iv = new byte[IV_LENGTH]; int ivBytesRead = bis.read(iv); if (ivBytesRead != IV_LENGTH) { throw new IOException("加密文件已损坏或格式不正确,无法读取完整的IV"); } IvParameterSpec ivSpec = new IvParameterSpec(iv); // 2. 初始化Cipher Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec); // 3. 用CipherInputStream包装输入流,所有读取的数据都会被自动解密 try (CipherInputStream cis = new CipherInputStream(bis, cipher); FileOutputStream fos = new FileOutputStream(outputFile); BufferedOutputStream bos = new BufferedOutputStream(fos)) { byte[] buffer = new byte[BUFFER_SIZE]; int bytesRead; while ((bytesRead = cis.read(buffer)) != -1) { bos.write(buffer, 0, bytesRead); } } } System.out.println("文件解密完成: " + outputFile.getAbsolutePath()); } }4.3 实战演示与性能优化
让我们写一个完整的例子来测试文件加密解密,并讨论一些进阶话题。
public class FileCryptExample { public static void main(String[] args) { // 准备测试文件(可以是一个图片、视频或任何文件) File originalFile = new File("test_data/original_document.pdf"); File encryptedFile = new File("test_data/encrypted_document.aes"); File decryptedFile = new File("test_data/decrypted_document.pdf"); try { // 生成或加载密钥(复用之前的文本加密密钥也可以) SecretKey secretKey = AESTextCryptor.loadKeyFromBase64("你的Base64密钥字符串"); // 记录加密开始时间 long startTime = System.currentTimeMillis(); // 执行加密 AESFileCryptor.encryptFile(originalFile, encryptedFile, secretKey); long encryptTime = System.currentTimeMillis() - startTime; System.out.println("加密耗时: " + encryptTime + " ms"); // 执行解密 startTime = System.currentTimeMillis(); AESFileCryptor.decryptFile(encryptedFile, decryptedFile, secretKey); long decryptTime = System.currentTimeMillis() - startTime; System.out.println("解密耗时: " + decryptTime + " ms"); // 验证文件完整性(比较MD5或SHA-256哈希) String originalHash = calculateFileHash(originalFile, "SHA-256"); String decryptedHash = calculateFileHash(decryptedFile, "SHA-256"); System.out.println("文件完整性校验: " + (originalHash.equals(decryptedHash) ? "成功" : "失败")); } catch (Exception e) { e.printStackTrace(); } } // 一个简单的文件哈希计算工具方法(用于完整性校验) private static String calculateFileHash(File file, String algorithm) throws Exception { MessageDigest digest = MessageDigest.getInstance(algorithm); try (FileInputStream fis = new FileInputStream(file); BufferedInputStream bis = new BufferedInputStream(fis)) { byte[] buffer = new byte[8192]; int count; while ((count = bis.read(buffer)) > 0) { digest.update(buffer, 0, count); } } byte[] hashBytes = digest.digest(); // 将字节数组转换为十六进制字符串 StringBuilder sb = new StringBuilder(); for (byte b : hashBytes) { sb.append(String.format("%02x", b)); } return sb.toString(); } }性能与内存优化要点:
- 缓冲区大小:
BUFFER_SIZE设置为8192字节(8KB)是一个经验值,在大多数磁盘I/O和加密计算之间取得了良好平衡。你可以根据实际文件大小和性能测试进行调整。太小的缓冲区会增加I/O次数,太大的缓冲区可能不会带来明显收益。 - 使用缓冲流:我们嵌套使用了
BufferedInputStream和BufferedOutputStream。它们内部维护了一个缓冲区,可以减少对底层操作系统读写调用的次数,显著提升大文件处理的效率。即使CipherInputStream本身也有缓冲,显式使用缓冲流仍是好习惯。 - 资源管理:使用
try-with-resources语句确保所有流(FileInputStream,CipherInputStream等)都能被正确关闭,即使在发生异常的情况下。这对于释放文件句柄和系统资源至关重要。 - 完整性校验:加解密本身不保证文件在传输或存储过程中未被篡改。对于极高安全要求,应考虑在加密前计算文件的哈希值(如SHA-256)并安全存储,解密后再计算对比。或者直接使用能提供认证加密的模式,如GCM。
5. 进阶话题:GCM模式与认证加密
CBC模式提供了机密性,但它不能保证密文的完整性。攻击者可能篡改密文,导致解密出一堆乱码(通过填充错误暴露),或者在某些精心构造的攻击下,甚至能部分修改明文。GCM模式解决了这个问题。
5.1 GCM模式原理简介
GCM全称Galois/Counter Mode。它有两个核心优势:
- 认证加密:它在加密的同时,会生成一个“认证标签”。解密时,会先验证这个标签。如果密文在传输过程中被任何方式修改(哪怕一个比特),验证都会失败,
Cipher.doFinal()会抛出AEADBadTagException。这同时保证了数据的机密性和完整性。 - 并行计算:在某些硬件上比CBC模式更快。
使用GCM时,你需要提供一个IV(在GCM中常称为Nonce)和一个可选的“关联数据”。关联数据是不需要加密但需要完整性保护的信息,比如数据包的头部。
5.2 Java实现GCM加密解密
修改我们的工具类,增加GCM支持:
public class AESGCMCryptor { private static final String TRANSFORMATION_GCM = "AES/GCM/NoPadding"; // GCM不需要填充 private static final int GCM_IV_LENGTH = 12; // 对于GCM,推荐使用12字节的Nonce private static final int GCM_TAG_LENGTH = 128; // 认证标签长度,单位比特 public static String encryptGCM(String plaintext, SecretKey secretKey) throws Exception { byte[] iv = new byte[GCM_IV_LENGTH]; SecureRandom.getInstanceStrong().nextBytes(iv); // 创建GCMParameterSpec,指定标签长度和IV GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); Cipher cipher = Cipher.getInstance(TRANSFORMATION_GCM); cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec); // 如果需要,可以添加关联数据 (AAD) // byte[] aad = "SomeAssociatedData".getBytes(StandardCharsets.UTF_8); // cipher.updateAAD(aad); byte[] ciphertextBytes = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); // 同样,将IV和密文拼接返回 String ivBase64 = Base64.getEncoder().encodeToString(iv); String ciphertextBase64 = Base64.getEncoder().encodeToString(ciphertextBytes); return ivBase64 + ":" + ciphertextBase64; } public static String decryptGCM(String encryptedText, SecretKey secretKey) throws Exception { String[] parts = encryptedText.split(":"); if (parts.length != 2) { throw new IllegalArgumentException("Invalid encrypted text format"); } byte[] iv = Base64.getDecoder().decode(parts[0]); byte[] ciphertextBytes = Base64.getDecoder().decode(parts[1]); GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); Cipher cipher = Cipher.getInstance(TRANSFORMATION_GCM); cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec); // 如果加密时添加了AAD,解密时必须用相同的AAD初始化 // cipher.updateAAD(aad); byte[] plaintextBytes = cipher.doFinal(ciphertextBytes); return new String(plaintextBytes, StandardCharsets.UTF_8); } }重要提示:GCM模式下的IV(Nonce)有一个绝对禁忌:同一个密钥,绝对不能重复使用同一个Nonce!否则会严重破坏安全性。因此,务必使用密码学安全的随机数生成器来生成Nonce。
6. 跨平台与实战问题排查
你的Java程序加密的数据,很可能需要被其他系统解密,比如Python后端、Android App、或者C#客户端。这时,对齐参数就变得至关重要。
6.1 跨平台加解密参数对齐清单
确保以下所有参数在加解密双方完全一致:
- 算法:
AES - 密钥:
- 长度:128/192/256位。
- 材料:密钥的原始字节必须一致。通常通过Base64或Hex字符串来安全传递密钥字节。
- 工作模式:
CBC,GCM等。 - 填充模式:
PKCS5Padding(在Java中) /PKCS7Padding(在其他平台,如C#、Python的cryptography库中)。注意:PKCS#5和PKCS#7在AES的上下文中本质相同,但不同平台的叫法可能不同。 - 初始化向量:
- 长度:CBC模式必须是16字节。GCM模式推荐12字节。
- 值:必须相同。通常由加密方生成,并随密文一起传递给解密方。
- 字符编码:处理文本时,双方必须使用相同的字符编码(强烈推荐UTF-8)。
- 数据格式:IV和密文的拼接方式(如
Base64(IV):Base64(CipherText))或拼接顺序(IV + CipherText)必须一致。
6.2 常见错误与调试实录
以下是我在联调和排查问题时总结的“血泪史”:
问题1:解密时抛出javax.crypto.BadPaddingException: Given final block not properly padded
- 可能原因1(最常见):密钥错误。用于解密的密钥和加密时的密钥不匹配。请仔细检查密钥的生成、存储和传递过程。一个字节的差异都会导致此错误。
- 可能原因2:IV错误或不一致。解密时使用的IV和加密时不同。检查IV的传递和解析逻辑。
- 可能原因3:数据被截断或损坏。密文在传输或存储过程中丢失了部分字节。确保传输是二进制安全的(例如,使用Base64编码进行文本传输)。
- 可能原因4:跨平台填充不一致。确保对方平台使用的填充方案是PKCS#7。
问题2:解密出的中文是乱码
- 几乎可以确定是字符编码问题。确保在加密端
String.getBytes(“UTF-8”),在解密端new String(bytes, “UTF-8”)。不要依赖平台默认编码。
问题3:Android/其他平台能解密,Java程序不能,或者反过来
- 按6.1的清单逐项核对。写一个简单的测试:用Java生成一个已知的明文、密钥和IV,加密后得到密文。把这个密钥、IV和密文(三者都是Base64或Hex格式)提供给对方平台,让他们用相同的参数解密。如果失败,就能定位到是哪个参数不一致。
- 重点关注密钥的生成方式。如果对方使用密码派生密钥,请确认双方的盐值、迭代次数、密钥长度、哈希算法是否完全一致。
问题4:文件解密后大小不对或无法打开
- 检查IV的处理:确认加密时是否将IV写在了文件开头,解密时是否先读取了正确长度的IV。
- 检查流是否正常关闭:
CipherOutputStream在关闭时会写入最后的填充块。如果流没有正常关闭,文件尾部可能不完整。 - 验证文件哈希:像上面的示例一样,计算并对比原始文件和解密后文件的哈希值,这是验证完整性的最可靠方法。
一个实用的调试技巧:在开发阶段,为你的加密函数增加详细的日志,输出密钥长度、IV、算法转换字符串等核心参数。同时,将第一次加密的密钥、IV、明文、密文(都转为Hex或Base64)记录下来。当出现问题时,先用这些固定的值进行单元测试,可以快速排除环境或动态数据带来的干扰。