一、前言
本文使用代码基于我写的存放于github的公开代码(点击前往仓库),欢迎前去查看是否有遗漏或者bug或者复制下来检验
因为RSA加密都是同一个路子,所以本文实验简单化——全采用公钥加密,私钥解密的方式(这种方式也是很常见的非对称加解密的操作方式)
本文只讨论最常见的 SunJCE version 1.8(其实就是java1.8自带的)和 BC version 1.7(引入依赖包) 两种库,其他的没了解过,不献丑。此外,不清楚是否有版本的影响,所以这里只讨论这两个库的这两个版本。
如果你还想了解更多JAVA实现PKI体系相关功能的例子,欢迎阅读我的github上的代码。
如果你想了解OpenSSL这个著名的工具如何通过命令行使用,欢迎阅读这篇文章:《OpenSSL命令行实例》
二、RSA加解密算法原理浅谈
RSA算法原理上,因为数据要对密钥的模取余数,所以要求的明文和密文都是:0 < 明文或密文大小 < 密钥的模大小,其实也对应了我们常说的“0 < 明文或密文的长度 < 密钥的模的长度”(长度相等时要额外多一步比较大小)
实际上一些加密工具之类的会对明文长度为0的时候进行特殊处理,另外,虽然最大可被加密的明文长度是密钥的长度,但是具体的实现工具或规范(如PKCS标准)都有进行限制(特别是当有填充时)。所以具体情况要看你用的工具/库的限制是怎样的。
密文长度通常情况下都是模的长度(这是标准规定的,可以在RFC文档里看到)
三、探寻Cipher实际会调用的Provider(算法提供者)
0、一个前提条件
即在类里面引入BC库,其实就是意味着你的安全算法引入了除了Sun以外的一个叫BC的提供者。(默认情况下,Sun的库的优先级是最高的,也就是会先检索sun库,然后再检索其他库)
如果不想用BC库,只想用Sun库,那么就不要加这一段代码。不然当Sun库不支持你指定的算法时,会自动调用BC库。
详情可见3.1里的示例。
注:通过这段代码,可以查看你当前的代码里有哪些算法提供者以及相关信息
System.out.println("-------当前有这些算法提供者-------");
for (Provider provider : Security.getProviders()) {
System.out.println("算法提供者名:" + provider.getName());
System.out.println("算法提供者的版本:" + provider.getVersion());
System.out.println("算法提供者的信息:" + provider.getInfo());
}
System.out.println("------------展示完毕------------");
这是我打印出来的结果:
可见大部分都是java默认的sun提供的,但是也有对用途进行了区分。
对于本文研究的Cipher加解密,只会用到其中的SunJCE和BC
1、不去指定时的调用情况
官网解释:获得Cipher实例时,如果默认的提供程序包提供了请求的转换实现,则会返回包含该实现的一个 Cipher
实例。如果默认的提供程序包中没有可用的转换,则将搜索其他的提供程序包。(这里的“转换”,就是你在getInstance时输入的那个算法字符串)
代码:
// alo表示使用的算法
Cipher cipher = Cipher.getInstance(alo);
// 初始化,指定使用加密模式,传入公钥
cipher.init(Cipher.ENCRYPT_MODE, key);
// 输出当前cipher使用的Provider
System.out.println("Provider: " + cipher.getProvider());
实际测试结果如图:
注:奇怪的是,RSA/ECB/OAEPWithSHA-1AndMGF1Padding 和 RSA/ECB/OAEPWithSHA-256AndMGF1Padding 在是Sun提供支持的时候,加密会报错,但是BC提供支持的时候就一切正常,加解密都没问题。
注:Cipher的getInstance() 实现逻辑如下:
特别地,如果getInstance有指定使用的provider,则会先判断输入的这个provider是否存在,不存在则报错:java.security.NoSuchProviderException: No such provider: ***。然后才是按照以下步骤进行(但是不会列举算法提供者列表,而是直接从指定的这个算法提供者获取服务实例,然后构造cipher对象的时候的处理也会比较不一样,这里不展开讨论)
①先解析提供的算法串变成一个List<Cipher.Transform>,其实里面还把输入的算法转成好几个书写格式进行保存,以适应不同算法提供者。每个Cipher.Transform都存储了你所写的算法可能对应的加密形式等信息。
②把List<Cipher.Transform>转成List<ServiceId>
③获取所有的算法提供者,也就是sun.security.jca.ProviderList
④利用ProviderList来把List<ServiceId>转成所有的实现的加解密服务实例列表List<Service>
⑤遍历List<Service>,先看算法提供者能不能用,然后看遍历服务支持的算法能不能支持这个指定的算法,再获取服务支持的填充模式代码,都不能支持输入的算法,就报错:java.security.NoSuchAlgorithmException: Cannot find any provider supporting ******
(填充模式代码不为0时才认为可行,否则继续寻找符合全部条件的服务实例。另外,模式填充代码为2时,此时不设置cipherSpi,即置为null,等初始化的时候设置)(暂时不是很清楚这个模式代码的几个数字的含义)
⑥初始化Cipher:Cipher(CipherSpi var1, Service var2, Iterator<Service> var3, String var4, List<Cipher.Transform> var5)
2、Cipher.getInstance时显式指定使用BC provider
代码:
// 全部强制使用BC库
Cipher cipher = Cipher.getInstance(alo, BouncyCastleProvider.PROVIDER_NAME);
// Cipher初始化,指定使用加密模式,并传入密钥
cipher.init(Cipher.ENCRYPT_MODE, key);
// 输出查看Cipher当前的Provider
System.out.println("Provider: " + cipher.getProvider());
实际测试结果:
3、显式指定provider
也可以显示指定SunJCE之类的provider,写法类似3.2,只是叫的名字不一样
// 输入“BC”表示强制使用BC库,输入“SunJCE”表示强制使用SunJCE
cipher = Cipher.getInstance(alo, provider);
4、当使用的算法提供者是SunJCE时请注意
当传入密钥为公钥时,RSACipher的模式其实只有加密和验签两种可能,不一定会是输入的那个模式。
当传入密钥为私钥时,RSACipher的模式其实只支持解密和签名两种,不一定会是输入指定的那个模式。
详细可见:com.sun.crypto.provider.RSACipher 的init方法
上面是常量,下面是方法实现
四、分组
1、使用SunJCE时分组必须使用ECB否则会报错
此时使用的服务是:type=Cipher,algorithm=RSA(本质上其实是com.sun.crypto.provider.RSACipher)
通过在Cipher的getInstance里打断点发现:
此时支持的信息:
可见,此时只支持ECB的分组方式,不支持其他分组方式。
2、补充说明
其实ECB是对称密码的一种分组模式,对非对称密钥没啥用,用None就行,例如RSA/None/PKCS1Padding,像RSA加密是不对明文数据进行分组的。
但是,实测发现,Sun库要求得带ECB,None的话得用BC库才支持(BC库也支持ECB的写法)
五、填充方式
1、来自国外老哥的说明
OAEP is less vulnerable to padding oracle attacks than PKCS#1 v1.5 padding. GCM is also protected against padding oracle attacks。
2、NoPadding
其实在理论的RSA密码学算法里,就是没有padding的。
正常情况下,NoPadding占用大小是0,也就是你提供的明文可以是当前密钥最大的支持长度。
如果你提供的明文长度小于密钥支持的最大长度,那么是不会进行额外的填充的。
但是算法实现标准里推荐是添加padding,主要是为了避免RSA算法上的一些漏洞,相关文章阅读指引:《为什么 RSA 加密填充至关重要》
3、PKCS1Padding
PKCS1Padding这种填充方式是要占用11的长度, 也就是明文最长只能是256-11=245了。
解密的时候就会把这些字符剔除
(SunJCE默认使用的RSACipher里,默认值就是使用这种填充方式)
相关标准需要查阅PKCS#1.5标准
相关规范细节可见:https://rfc2cn.com/rfc3447.html 的7.2.1
简单化描述:真正被加密的明文(EM)是这样的:
EM=0x00 | | 0x02 | | PS | | 0x00 | | M
其中,PS是至少8字节长度的随机字符串,M是要被加密的消息。
另外,因为有随机数据的引入,所以每次加密出来的结果会不大一样,这也提升了安全性。
六、加密长度限制
1、RSA加密实现不允许明文超过密钥长度
所谓密钥长度,其实也就是密钥模长度,单位是字节,也就是 byte。
也就是说,如果我们定义的密钥(如:java.security.KeyPairGenerator.initialize(int keySize) 来定义密钥长度)长度为 1024 (单位是位,也就是 bit)。
生成的密钥长度就是 1024位 /(8位/字节) = 128字节,那么我们需要加密的明文长度不能超过 128字节。相应的,2048bit长度的RSA密钥,对应的最大明文长度是256字节。
还有,当“明文数据”大于等于模的大小(一般是正好被加密的数据长度就是上限长度的时候),也会使得加密失败。特别地,padding的时候,因为数据都是加在开头,且第一位是0,所以理论上不可能使得加完padding后的数据大于模的大小,也就是有填充的加密方式,是不会出现这种情况的。只有在设置为NoPadding的时候才会出现这种情况。
SunJCE测试代码
当明文数据等于模大小的时候,就会开始报错:javax.crypto.IllegalBlockSizeException: Data must not be longer than 256 bytes
BC库测试代码
当明文数据等于模大小的时候,就会开始报错:java.lang.ArrayIndexOutOfBoundsException: too much data for RSA block
上述这些限制是由RSA的算法原理决定的。
注意,这里的1024、2048可以由解析密钥获得(就是二进制下的模modulus的长度):
2、Sun时的加密数据长度限制
此处以2048bit的密钥为例
当输入为RSA时,SunJCE的RSACipher会被设置为 paddingType=PKCS1Padding,oaepHashAlgorithm=“SHA-1” (这两个值其实是默认值)
3、BC时的加密数据长度限制
此处以2048bit的密钥为例
4、注意
① 输入RSA时的不同
Sun库在加解密时,输入RSA,等同于RSA/ECB/PKCS1Padding,因为此时使用了com.sun.crypto.provider.RSACipher的默认值,此时blockSize即最大分段长度为245(对于2048bit的密钥)
BC库在加解密时输入RSA,等同于NoPadding,此时blockSize即最大分段长度为256(对于2048bit的密钥)
② 另外,Sun在加解密时,如果用的是NoPadding,则会出现解密结果与原文对不上的情况。是为什么呢?(分段和不分段都有一样的问题)
不分段的测试代码举例
分段加密时,如果指定的转换为RSA/ECB/NoPadding,且使用的是SunJCE,最大输入长度是256,但是如果blockSize或者数据长度不是正好被模大小整除,则加密时会自动在长度不够的数据前面补0填充,这使得解密后的数据与原文对不上。
原因是SunJCE的RSACipher的解密是先创建大小为私钥模大小的byte[],然后往里面填入解密结果数据。而java的byte[]数组初始化会默认置0,所以如果结果长度小于模大小,解密结果数据会带上多余的0。
但是,实测发现,BC库在解密的时候,就不会有这个问题。
所以,如果加密时用SunJCE的RSA/ECB/NoPadding,解密时用BC的RSA/ECB/NoPadding 则结果就是正确的。
③BC库时,使用NoPadding,出现解密结果与原文对不上的特殊情况
出错情况代码示例如图
使用BC库加解密(填充方式选择NoPadding),在待加密的数据开头是至少有两个0时(十六进制文本),解密后会丢失最开始的两个0,目测是在内部处理时因为某种处理方式导致被删除掉了,于是解密结果与原文对不上。
神奇的是,这个特殊的内部处理,只在开头大于等于两个0(十六进制文本)的情况下出问题,如果是1个0或者开头压根就不是0的时候,就一切正常。
其实,解决办法也很简单,就是改为加解密都使用PKCS1Padding就没问题了
改用PKCS1Padding
而且,经过上万次各种各样的原文数据加解密测试,并未发现PKCS1有某种特殊条件下会出错的情况。
不过,话说回来,因为NoPadding这种出错的情况下原文是很特殊的,所以实际触发概率并不高,看是否可以接受这种出错,如果可以,不去理会其实也行。
七、解密长度限制
1、对长度有要求
其实也是标准规定的
例如2048长度的RSA只能解密256长度的数据,1024的RSA只能解密128长度的数据
2、实测使用Sun时
若指定使用RSA/ECB/NoPadding
对于2048长度的密钥,待解密的密文的长度不是256时报错:javax.crypto.IllegalBlockSizeException: Data must not be longer than 256 bytes
待解密的密文的长度小于256时能解密,不会报错,但是结果数据与原文对不上。(理论上来说这种密文本身就是残缺的,不是正常RSA加密生成的)
3、实测使用BC时
若指定使用RSA/ECB/NoPadding
对于2048长度的密钥,待解密的密文的长度大于256时报错:org.bouncycastle.crypto.DataLengthException: input too large for RSA cipher.
待解密的密文的长度小于256时能解密,不会报错,但是结果数据与原文对不上。(理论上来说这种密文本身就是残缺的,不是正常RSA加密生成的)
八、遗憾
很可惜,我试过好多BC里面的 Cipher 去打断点,但是都没有停在我打的断点上,所以也不清楚到底哪个方法才是BC的实现方法。所以,BC库的底层实现原理只有猜测和实践结论,没有更为具体的代码展示......