为什么使用 Java Cipher 要指定转换模式?

原创文章,如需转载,请注明来自:https://bigzuo.github.io/

前提

写过 Java 程序的同学一定熟悉类似下面的代码:用 Java 加密、解密一些数据。这段代码是如此通用,以至于几乎在每个 Java 项目中都能发现它的身影。但是几乎大部分见过甚至是亲自写过这段代码的人也不一定清楚它的具体含义和运行方式,当有一天发现它运行和预期不一致时,也只是在网上 copy 另外一段类似的代码解决问题。这也是我之前处理这段代码的方式,直到最近遇到一个诡异的问题,才下决心搞清楚它的具体含义。

1
2
3
4
5
6
7
public static byte[] publicDecrypt(PublicKey publicKey, byte[] encryptData) throws Exception {
byte[] output;
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, publicKey);
output = cipher.doFinal(encryptData);
return output;
}

我自己遇到的问题是:我在应用程序中使用上面的代码加密一些数据,运行时间超过一年,一直运行正常。后来服务器升级了JDK版本,从JDK7升级到了JDK8(具体小版本不清楚,且后来发现异常和 JDK 版本升级无关)。后来应用实例在重启时,偶尔会有个别应用实例在重启后对相同的内容的加密结果和其他实例的结果不一致,并且其他应用实例在解密其加密结果时,会报如下异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
javax.crypto.BadPaddingException: Decryption error
at sun.security.rsa.RSAPadding.unpadV15(RSAPadding.java:380)
at sun.security.rsa.RSAPadding.unpad(RSAPadding.java:291)
at com.sun.crypto.provider.RSACipher.doFinal(RSACipher.java:356)
at com.sun.crypto.provider.RSACipher.engineDoFinal(RSACipher.java:389)
at javax.crypto.Cipher.doFinal(Cipher.java:2165)
at com.paic.palife.common.util.encry.RSAUtils.publicDecrypt(RSAUtils.java:206)
at com.paic.palife.common.util.encry.RSAUtils.publicDecrypt(RSAUtils.java:221)
at com.paic.palife.common.util.encry.la.LASecurityUtils.verifySignature(LASecurityUtils.java:90)
at com.paic.palife.common.util.encry.la.LASecurityUtils.verifySignatureForParameters(LASecurityUtils.java:124)
at com.pajk.fingw.service.validate.impl.WealthNewNotifyValidateServiceImpl.verifyRefundNotify(WealthNewNotifyValidateServiceImpl.java:425)
at com.pajk.fingw.service.validate.impl.WealthNewNotifyValidateServiceImpl.refundNotifyValidate(WealthNewNotifyValidateServiceImpl.java:219)
at com.pajk.fingw.service.validate.impl.WealthNewNotifyValidateServiceImpl$$FastClassBySpringCGLIB$$2fc73ad4.invoke(<generated>)
at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:204)

解决方式也很简单,只通过添加几个字符串即可:

1
2
3
4
5
6
7
public static byte[] publicDecrypt(PublicKey publicKey, byte[] encryptData) throws Exception {
byte[] output;
Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); //指定转换模式即可
cipher.init(Cipher.DECRYPT_MODE, publicKey);
output = cipher.doFinal(encryptData);
return output;
}

在解决问题的过程中,自己就记录了对 Java cipher 的学习过程,方便后续参考。

What is Java Cipher?

在计算机系统中,Java Cryptography Architecture (JCA) 是一个使用 Java 编程语言处理加解密相关操作的框架。它是 Java 安全 API 的一部分,最早是在 JDK1.1 版本的java.security包中引入的。JCA 基于”provider“架构,并且包含一系列不同作用的 API,比如加密、秘钥生成与管理、安全随机数生成、证书验证等等。这些 API 为开发人员提供了在应用代码中集成安全操作的简易方式。

Java Cryptography Extension (JCE) 是 Java 平台的一个官方标准扩展,也是 JCA 体系的一部分。JCE 为加密、密码生成和管理、消息认证码等操作提供了一个框架和具体实现。其实 Java 平台本身就包含摘要生成、数字签名等操作的接口和具体实现,JCE 提供了一个更丰富的补充。而javax.crypto.Cipher 类则是 JCE 扩展的核心。

Cipher 实例化

我们可以通过调用静态getInstance方法,传入具体的转换模式名称,就可以实例化一个 Cipher 对象。下面是实例化 Cipher 的示例代码:

1
2
3
4
5
6
7
public class Encryptor {
public byte[] encryptMessage(byte[] message, byte[] keyBytes)
throws InvalidKeyException, NoSuchPaddingException, NoSuchAlgorithmException {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
//...
}
}

转换模式(transformation)的具体含义

转换模式(transformation)是 Cipher 实例化的一个核心参数。transformation 参数的格式是:算法/工作模式/填充模式(algorithm/mode/padding),如上述示例中AES/ECB/PKCS5Padding

算法(Algorithm)

算法指的就是具体使用到的加解密算法的名称,且必须为英文字符串,如”AES”, “RSA”, “SHA-256” 等。

工作模式(Mode)

工作模式主要是指分组密码工作模式。分组密码工作模式允许使用同一组分组密码秘钥对于多于一块的数据进行加密,并保证其安全性。简单说就是将需要加密的原始信息分成固定长度的数据块,然后用分组密码对这些数据块进行加密。使用分组加密一般有如下场景:

  • 当需要加密的明文长度比较大,比如文件内容,由于硬件或者性能原因所以需要分组加密;
  • 多次使用相同的密钥对多个分组加密,会引发一些安全问题;

分组密码工作模式本质上是一项增强密码算法或者使算法适应具体应用的技术,例如将分组密码应用于数据块组成的序列或者数据流。目前主要包括下面五种由NIST定义的工作模式:

模式 名称 描述 典型应用
电子密码本(ECB) Electronic CodeBook 用相同的密钥分别对明文分组独立加密 单个数据的安全传输(例如一个加密密钥)
密码分组链接(CBC) Cipher Block Chaining 加密算法的输入是上一个密文组合下一个明文组的异或 面向分组的通用传输或者认证
密文反馈(CFB) Cipher FeedBack 一次处理s位,上一块密文作为加密算法的输入,产生的伪随机数输出与明文异或作为下一单元的密文 面向分组的通用传输或者认证
输出反馈(OFB) Output FeedBack 与CFB类似,只是加密算法的输入是上一次加密的输出,并且使用整个分组 噪声信道上的数据流的传输(如卫星通信)
计数器(CTR) Counter 每个明文分组都与一个经过加密的计数器相异或。对每个后续分组计数器递增 面向分组的通用传输或者用于高速需求

填充模式(Padding)

分组密码工作模式只能加密长度等于密码分组长度的单块数据,所以通常来讲,最后一块数据也需要使用合适填充方式将数据扩展到匹配密码块大小的长度。所以填充是指在加密之前,在原始信息的开头、结尾或者中间添加特定格式的数据,使被加密信息满足固定加密长度的操作。例如我们约定块的长度为128,但是需要加密的原文长度为129,那么需要分成两个加密块,第二个加密块需要填充127长度的数据,填充模式决定怎么填充数据。

对数据在加密时进行填充、解密时去除填充则是通信双方需要重点考虑的因素。对原文进行填充,主要基于以下原因:

  • 首先,考虑安全性。由于对原始数据进行了填充,使原文能够“伪装”在填充后的数据中,使得攻击者很难找到真正的原文位置。
  • 其次,由于块加密算法要求原文数据长度为固定块大小的整数倍,如果加密原文不满足这个条件,则需要在加密前填充原文数据至固定块大小的整数倍。
  • 另外,填充也为发送方与接收方提供了一种标准的形式以约束加密原文的大小。只有加解密双方知道填充方式,才可知道如何准确移去填充的数据并进行解密。

常用的填充方式至少有5种,不同编程语言实现加解密时用到的填充多数来自于这些方式或它们的变种方式。以下五种填充模式摘抄自参考资料的论文:

1.填充数据为填充字节序列的长度

这种填充方式中,填充字符串由一个字节序列组成,每个字节填充该字节序列的长度。假定块长度为8,原文数据长度为9,则填充字节数 等于0x07;如果明文数据长度为8的整数倍,则填充字节数为0x08。填充字符串如下:

  • 原文数据1: FF FF FF FF FF FF FF FF FF
  • 填充后数据1:FF FF FF FF FF FF FF FF FF 07 07 07 07 07 07 07
  • ==========================================================
  • 原文数据2:FF FF FF FF FF FF FF FF
  • 填充后数据2:FF FF FF FF FF FF FF FF 08 08 08 08 08 08 08 08

2.填充数据为0x80后加0x00

这种填充方式中,填充字符串的第一个字节数是0x80,后面的每个字节是0x00。假定块长度为8,原文数据长度为9或者为8的整数倍,则 填充字符串如下:

  • 原文数据1: FF FF FF FF FF FF FF FF FF
  • 填充后数据1:FF FF FF FF FF FF FF FF FF 80 00 00 00 00 00 00
  • ==========================================================
  • 原文数据2:FF FF FF FF FF FF FF FF
  • 填充后数据2:FF FF FF FF FF FF FF FF 80 00 00 00 00 00 00 00

3.填充数据的最后一个字节为填充字节序列的长度

这种填充方式中,填充字符串的最后一个字节为该序列的长度,而前面的字节可以是0x00,也可以是随机的字节序列。假定块长度为8,原文数据长度为9或者为8的整数倍,则填充字符串如下:

  • 原文数据1:FF FF FF FF FF FF FF FF FF
  • 填充后数据1:FF FF FF FF FF FF FF FF FF 00 00 00 00 00 00 07或FF FF FF FF FF FF FF FF FF 0A B0 0C 08 05 09 07
  • ===============================================================================
  • 原文数据2:FF FF FF FF FF FF FF FF
  • 填充后数据2:FF FF FF FF FF FF FF FF 00 00 00 00 00 00 00 08或FF FF FF FF FF FF FF FF 80 06 AB EA 03 02 01 08

4.填充数据为空格

这种填充方式中,填充字符串的每个字节为空格对应的字节数0x20。假定块长度为8,原文数据长度为9或者为8的整数倍,则填充字符串如下:

  • 原文数据1: FF FF FF FF FF FF FF FF FF
  • 填充后数据1:FF FF FF FF FF FF FF FF FF 20 20 20 20 20 20 20
  • ===============================================================================
  • 原文数据2:FF FF FF FF FF FF FF FF
  • 填充后数据2:FF FF FF FF FF FF FF FF 20 20 20 20 20 20 20 20

5.填充数据为0x00

这种填充方式中,填充字符串的每个字节为0x00。假定块长度为8,原文数据长度为9或者8的整数倍,则填充字符串如下:

  • 原文数据1: FF FF FF FF FF FF FF FF FF
  • 填充后数据1:FF FF FF FF FF FF FF FF FF 00 00 00 00 00 00 00
  • ===============================================================================
  • 原文数据2:FF FF FF FF FF FF FF FF
  • 填充后数据2:FF FF FF FF FF FF FF FF 00 00 00 00 00 00 00 00

关于以上几种填充方式,需要补充说明的是:针对填充数据为0x00空格的场景,如果原始数据尾部本身就包含0x00空格,系统是无法区分哪些是填充的部分,哪些属于明文信息本身,所以0x00空格填充模式是不可逆的。这种填充方式经常使用在消息内容的长度可以通过带外传输或者消息内容本身不包含0x00空格的场景。

在了解了 transformation 含义后,从 Oracle Java 官网可以查到 JDK7 中 SunJCE Provider 支持的算法列表如下:

Algorithm Name Modes Paddings
AES ECB, CBC, PCBC, CTR, CTS, CFB, CFB8..CFB128, OFB, OFB8..OFB128 NOPADDING, PKCS5PADDING, ISO10126PADDING
AESWrap ECB NOPADDING
ARCFOUR ECB NOPADDING
Blowfish, DES, DESede, RC2 ECB, CBC, PCBC, CTR, CTS, CFB, CFB8..CFB64, OFB, OFB8..OFB64 NOPADDING, PKCS5PADDING, ISO10126PADDING
DESedeWrap CBC NOPADDING
PBEWithMD5AndDES, PBEWithMD5AndTripleDES1PBEWithSHA1AndDESede, PBEWithSHA1AndRC2_40 CBC PKCS5Padding
RSA ECB NOPADDING, PKCS1PADDING, OAEPWITHMD5ANDMGF1PADDING, OAEPWITHSHA1ANDMGF1PADDING, OAEPWITHSHA-1ANDMGF1PADDING, OAEPWITHSHA-256ANDMGF1PADDING, OAEPWITHSHA-384ANDMGF1PADDING, OAEPWITHSHA-512ANDMGF1PADDING

需要注意的是,并不是所有的加密算法和加密模式 Java 原生都支持。比如你可能需要安装外部的Bouncy Castle provider 来实例化一个满足特定加密模式和填充模式的 Cipher

而自己这次遇到的问题原因就是在实例化 Cipher 时没有指定完整的转换模式名称,只写算法名称:Cipher.getInstance("RSA")。如果没有按照algorithm/mode/padding格式填写 transformation 参数,Java 平台会使用提供者提供的默认值。默认值会根据不同版本、不同提供者而不同,如果加解密双方都没有指定具体的 transformation 参数,并且 Java 平台使用的默认值又不同,则就出现了异常。

Cipher APIs

Cipher 的7个属性

Modifier and Type Field and Description
static int **DECRYPT_MODE**用于解密模式下的 Cipher 初始化。
static int **ENCRYPT_MODE**用于加密模式下的 Cipher 初始化。
static int **PRIVATE_KEY**用于说明解包装模式下密钥是私钥。
static int **PUBLIC_KEY**用于说明解包装模式下密钥是公钥。
static int **SECRET_KEY**用于说明解包装模式下密钥是密钥。
static int **UNWRAP_MODE**用于解包装密钥模式下的 Cipher 初始化。
static int **WRAP_MODE**用于包装密钥模式下的 Cipher 初始化。

SECRET_KEY 模式一般用于不区分公私钥的对称加密场景。

Cipher 一般工作流程

创建一个 Cipher 实例

1
Cipher cipher = Cipher.getInstance("AES");

getInstance()方法用于创建一个 Cipher 实例,这也是我们在使用 Cipher 时第一步要做的操作。上面一行代码就创建了一个使用 AES 加密算法的 Cipher 实例。getInstance()方法有几个不同的实现,但是最核心的参数都是transformation。一般情况下,我们应该按照algorithm/mode/padding格式填写转换模式全称,如AES/ECB/PKCS5Padding

Cipher 初始化

在我们使用 Cipher 实例进行加解密操作之前,我们需要调用init()方法进行初始化。init()方法一般有两个参数:

  • 加密/解密的模式
  • 加密/解密的密钥,或者是包含密钥的证书

Cipher 初始化为加密模式代码示例:

1
2
Key key = ... // get / create symmetric encryption key
cipher.init(Cipher.ENCRYPT_MODE, key);

Cipher 初始化为解密模式代码示例:

1
2
Key key = ... // get / create symmetric encryption key
cipher.init(Cipher.DECRYPT_MODE, key);

加密和解密数据

在初始化之后,我们就可以使用 Cipher 实例进行加解密操作了。大多数情况下,我们会使用到下面两个方法进行加解密:

  • update(),主要用于部分加密或者部分解密,至于加密或是解密取决于Cipher初始化时候的opmode。
  • doFinal(),主要功能是结束单部分或者多部分加密或者解密操作。

上面两个方法都有多种不同的实现,但是功能都大同小异,感兴趣的可以查询官网 API 说明。这里只介绍最普遍的用法:

如果你只是加密单个不大的数据块,那么可以使用doFinal()方法:

1
2
byte[] plainText  = "abcdefghijklmnopqrstuvwxyz".getBytes("UTF-8");
byte[] cipherText = cipher.doFinal(plainText);

同样,你可以使用类似的代码进行解密,不过尤其要注意的是解密前要使用解密模式初始化 Cipher。

1
byte[] plainText = cipher.doFinal(cipherText);

如果我们需要加/解密由多个数据块组成的信息,比如一个大文件,这个时候我们就需要调用update()方法分别对每一块数据进行加密,最后再调用doFinal()方法对最后一个数据块加密,比如如下代码示例:

1
2
3
4
5
6
7
byte[] data1 = "abcdefghijklmnopqrstuvwxyz".getBytes("UTF-8");
byte[] data2 = "zyxwvutsrqponmlkjihgfedcba".getBytes("UTF-8");
byte[] data3 = "01234567890123456789012345".getBytes("UTF-8");

byte[] cipherText1 = cipher.update(data1);
byte[] cipherText2 = cipher.update(data2);
byte[] cipherText3 = cipher.doFinal(data3);

如上所述,doFinal()方法主要功能是结束单部分或者多部分加密或者解密操作。一般情况下,如果只加密单块数据,或者加密信息长度较短,无需分块,可以直接调用doFinal()方法进行加解密。如果进行多部分加密、或者加解密信息较大,这个时候就需要多次调用update()方法进行分块加解密,最后再调用doFinal()方法对最后一块内容加解密,进而结束整个加解密操作。因为对于很多加密算法,对所加密的数据长度都有固定要求,最后一块数据可能不满足加密长度。比如一个加密算法每次只能加密长度为8的信息,对于一个长度23的信息,调用两次update()方法后,最后剩余的数据长度是7,调用doFinal()方法就会根据 Cipher 实例化时指定的padding 模式填充到8的长度再进行加解密。

Cipher 实例重用

创建一个 Cipher 实例是一个特别消耗性能的操作,因此 Cipher 类设计时就考虑了可重用性。在正常情况下,调用doFinal()方法完成加解密操作时,Cipher 会重置成初始化即cipher.init()时的状态,可以继续进行下一轮加解密操作,这个特性可以有效降低每次创建 Cipher 实例的性能开销。

Cipher 实例重用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
Cipher cipher = Cipher.getInstance("AES");

Key key = ... // get / create symmetric encryption key
cipher.init(Cipher.ENCRYPT_MODE, key);

byte[] data1 = "abcdefghijklmnopqrstuvwxyz".getBytes("UTF-8");
byte[] data2 = "zyxwvutsrqponmlkjihgfedcba".getBytes("UTF-8");

byte[] cipherText1 = cipher.update(data1);
byte[] cipherText2 = cipher.doFinal(data2);

byte[] data3 = "01234567890123456789012345".getBytes("UTF-8");
byte[] cipherText3 = cipher.doFinal(data3);

Cipher 包装和解包装密钥

在 Cipher 的 API 中,有wrap()unwrap()两个方法,这两个方法做的其实是一个互逆的操作:

  • wrap方法的作用是把原始的密钥通过某种加密算法包装为加密后的密钥,这样就可以避免在传递密钥的时候泄漏了密钥的明文。
  • unwrap方法的作用是把包装(加密)后的密钥解包装为原始的密钥,得到密钥的明文。

这两个方法不是很常用,如下是使用的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public enum EncryptUtils {

/**
* 单例
*/
SINGLETON;

private static final String SECRECT = "passwrod";

public String wrap(String keyString) throws Exception {
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
//初始化密钥生成器,指定密钥长度为128,指定随机源的种子为指定的密钥(这里是"passward")
SecureRandom secureRandom = SecureRandom.getInstance("SHA1PRNG");
secureRandom.setSeed(SECRECT.getBytes("UTF-8"));
keyGenerator.init(128, secureRandom);
SecretKey secretKey = keyGenerator.generateKey();
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getEncoded(), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.WRAP_MODE, secretKeySpec);
SecretKeySpec key = new SecretKeySpec(keyString.getBytes("UTF-8"), "AES");
byte[] bytes = cipher.wrap(key);
return Hex.encodeHexString(bytes);
}

public String unwrap(String keyString) throws Exception {
byte[] rawKey = Hex.decodeHex(keyString);
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
//初始化密钥生成器,指定密钥长度为128,指定随机源的种子为指定的密钥(这里是"passward")
SecureRandom secureRandom = SecureRandom.getInstance("SHA1PRNG");
secureRandom.setSeed(SECRECT.getBytes("UTF-8"));
keyGenerator.init(128, secureRandom);
SecretKey secretKey = keyGenerator.generateKey();
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getEncoded(), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.UNWRAP_MODE, secretKeySpec);
SecretKey key = (SecretKey) cipher.unwrap(rawKey, "AES", Cipher.SECRET_KEY);
return new String(key.getEncoded());
}

public static void main(String[] args) throws Exception {
String wrapKey = EncryptUtils.SINGLETON.wrap("doge");
System.out.println(wrapKey);
System.out.println(EncryptUtils.SINGLETON.unwrap(wrapKey));
}

需要说明的是,我在写本文时,大量的参考了JDK安全模块JCE核心Cipher使用详解这篇非常优秀的博文,但是这篇博文里面有些小瑕疵:如果直接拿里面的示例运行,如果你不是 Windows 系统,则很有可能报如下错误:

1
2
3
4
5
6
Exception in thread "main" java.security.InvalidKeyException: The wrapped key is not padded correctly
at com.sun.crypto.provider.CipherCore.unwrap(CipherCore.java:1121)
at com.sun.crypto.provider.AESCipher.engineUnwrap(AESCipher.java:561)
at javax.crypto.Cipher.unwrap(Cipher.java:2549)
at com.personal.demo.cipher.EncryptUtils.unwrap(EncryptUtils.java:52)
at com.personal.demo.cipher.EncryptUtils.main(EncryptUtils.java:59)

这是因为原博文示例中密钥的生成使用了SecureRandom方法,但是这个方法在不同的平台表现并不一样。在 Windows 平台,它使用SHA1PRNG算法生成随机数,如果种子一样,那么生成的随机数也一定一样。但是在 Mac 平台,它使用NativePRNG算法生成随机数,就算种子一样,每次生成的随机数也不一样。所以,这就导致加解密时使用的密钥不一样,进而引起上述异常。使用SecureRandom.getInstance("SHA1PRNG")指定具体算法即可解决问题。

总结

在了解了 Java Cipher 原理及 transformation 参数说明后,我们后面再写 Java 加解密相关的代码时,就会根据需求自定义代码的实现,再也无需在网上不明所以的拷贝。并且以后遇到不同平台、不同语言用同一种加密算法实现的加解密操作不能互相加解密时,也能很快根据 transformation 参数信息排查问题原因。

参考资料

JDK安全模块JCE核心Cipher使用详解

Windows and Mac not giving the same results

WIKI: Java Cryptography Architecture

WIKI: Padding (cryptography)

Using Padding in Encryption

Java Cipher