原文作者:Patrick Favre-Bulle
在本文里面,我会介绍 AES(Advanced Encryption Standard,高级加密标准)、常见的块加密模式,并说明填充以及初始向量的必要性,以及能保护数据不被篡改的方法。最后我会展示用 Java 实现这些东西,来规避大多数安全问题的一种轻松方法。
AES,原本叫 Rijndael,于 2000 年被 NIST 选中来取代过时的 DES(Data Encryption Standard,数据加密标准)。AES 是一种分块加密技术,其基本的加密流程是在一组固定长度的比特上进行的。本文示例部分的算法所定义的块长是 128 位。AES 支持的密钥长度有 128 / 192 / 256 位。
在加密的时候,每个块都会进行多轮转换。这具体的转换细节可以参考维基百科上的 AES 条目,这里就略过不提了。关键在于,密钥的长度不会影响块的长度,但会影响转换的重复轮数(128 位密钥是 10 轮、256 位是 14 轮)。
针对完整的 AES 的唯一已发布的成功攻击直到 2009 年 5 月才出现。那就是对 AES 的某些特定实现进行的旁路攻击。(消息来源见此)
如上所述,AES 本身只能加密 128 位的数据。如果我们要加密一长串消息,那就需要选择一种块模式,使得我们能基于这个模式把数据分块加密,然后汇总成单个密文。最简单的块模式是 ECB(Electronic Codebook,电子密码本)。它会使用同一个密钥来加密所有的块,如下图所示:
这种模式非常不靠谱。毕竟,内容相同的明文块,用同一个密钥加密,就会得到相同的密文块。
请记住,除非是只加密小于 128 位(分块长度)的数据,否则千万不要采取这种模式。遗憾的是,因为它并不用我们提供初始向量(后面会介绍),开发人员处理起来 “似乎” 更方便,这种模式还是会被经常误用。
在使用块模式的时候还需要考虑一件事:如果最后一个块长度不足 128 位时怎么办?这时候就要用到填充了。顾名思义,就是填充最后一个块来补足 128 位。最简单的模式是用 0 来填充最后一个块。在 AES 里面,填充格式的选择是几乎不会产生安全隐患的。
那么 ECB 有哪些替代方案呢?CBC 便是一个。它会把当前明文块跟前一个密文块进行异或运算。这样,每个明文块的加密都会受到之前加密过的明文块的影响。加密跟之前相同的图像,就会产生看起来比较随机的结果,而原本的图案是认不出来的。
那么第一个块又要跟谁异或呢?最简单的方法是跟一个固定的块(比如全 0 的块)异或。但是这样的话,用相同密钥加密相同明文又会产生相同的密文。而且,如果我们给不同的明文重用了同一密钥,那么密钥破解起来就会更为容易。更好的方法是使用随机的初始向量(IV,Initialization vector)。这个术语其实只是对一个块(128 位)大小的随机数据的一种形容。它就像加密算法里面用到的盐一样。也就是说,IV 可以是公开的,而且应该是随机生成、只用一次的。不过也要注意,因为 CBC 会将加密产生的密文跟前一块密文做异或运算,若丢失了 IV,那第一个,乃至后面的块都会解密不出来。
在实际传输并保存加密数据的时候,初始向量一般会附在加密数据前面。
另一种方案是 CTR 模式。这种块模式很有意思,因为它将块加密变成了流加密,不再需要进行填充。在其基本形式里面,所有的块会有一个从 0 到 n 的编号。然后每个块都会使用密钥、IV(也叫 nonce)还有编号进行加密。
这一模式有着 CBC 所没有的优点,那就是加密可以并行执行,因为所有的块只依赖于 IV,并不会依赖于前一个密文块。不过在使用这一模式时必须时刻注意到:在同一密钥下,IV 绝不能重用。否则,攻击者就可以从中轻松的把密钥破解出来。
现实很骨感:加密并不会自然地避免篡改。其实这也对应着一类很常见的攻击(Padding oracle attack / BEAST attack / Lucky Thirteen attack)。这里有对此问题的全面讨论。
所以我们能做些什么呢?我们能做的其实也只有在加密信息中加一个消息鉴别码(MAC)了。MAC 跟数字签名很像,而不同在 MAC 只会用到一个密钥。MAC 这一方法有很多种变体,而多数研究者都推荐一种叫 Encrypt-then-MAC 的模式,也就是在加密之后对密文计算 MAC 然后附带到密文上。我们常常会用到带密钥的散列函数(HMAC)这种类型的 MAC。
现在这就变得复杂了。为了保证完整性 / 真实性,我们必须选用 MAC 算法,选用加密标签模式,然后计算 MAC 并附带上去。可是这过程就变慢了,毕竟我们需要在加密的时候把消息处理两遍,在解密的时候也要处理两遍(分别是解密和验证)。
如果有一种模式能为我们处理所有的认证步骤,岂不是很棒?正好,有一种叫做认证加密(Authenticated Encryption)的模式就能同时确保数据的保密性、完整性及真实性。有种非常流行的块模式就为这一模式提供了支持,那就是 Galois / Counter Mode,简称 GCM(GCM 在很多地方都有所支持,比如在 TLS v1.2 里面就以一个加密套件的形式为其提供了支持)。
GCM 基本上就是 CTR 模式,只不过它还会在加密时按序地计算出一个认证标签,然后将这个认证标签附到密文后面。这一标记的长度跟安全性有所挂钩,因此它应该至少有 128 位。
认证一些别的不包括在明文里面的信息也是可以的。这部分数据就叫做关联数据。这有什么用呢?比方说,若加密数据包含了用于检查重新应该重新加密一遍内容的创建日期这样的元属性,那么攻击者可以很轻松地修改这个创建日期。不过,如果这些元属性被加到了关联数据里面,那么 GCM 就可以验证这部分的信息,并从中识别出修改的痕迹。
我们的直觉会认为:越长越好 - 显然,暴力枚举 256 位的随机值会比破解 128 位的要难。根据我们目前的理解,暴力枚举 128 位长的字的所有取值将需要天文数字级别的能量,对想在合理时间内完成破解的人来说是很不现实的。因此,选 256 位还是 128 位基本就是无穷大和无穷大再乘以 2^{128} 之间的选择。
AES 实际上提供了三种不同的密钥长度,因为它被选为了美国联邦政府的算法方案,会用到受美国联邦政府 [包括军方] 控制的各个领域。…… 因此,精明的军方提出了应该有三个 “安全级别” 的想法,以便对最重要的秘密能用最高级的方法加密,而对战术价值较低的数据可以用相对更实用但或许安全性更弱的算法来加密。…… 于是,NIST 决定正式地遵循这个规定(要求提供三种密钥长度),但也做出了明智的行为(最低级别的加密必须在可预见的技术水平上牢不可破)(原文来源)
其中的论点在于:经由 AES 加密过的信息应该不会被暴力枚举密钥所破解,而相对更有可能被其他成本较低的攻击所破解(现在还没发现有这样的攻击)。无论密钥长度是 128 位还是 256 位,后者这种攻击都会具有相同的破坏性,此时选用更长的密钥实际上也没有用处。
因此,128 位的密钥基本对多数使用场景来说都是足够安全的,但是若考虑到引入量子计算机的破解算法就不行了。另外,128 位的密钥使用起来也比 256 位的密钥要快。
终于能说点实例了。现在的 Java 已经有了我们所需的全部工具,不过加密算法的 API 或许并不太简单易用。有安全意识的开发者往往会陷入对密钥长度 / 分块长度 / 其他一些设置的选择的沉思里面。(注意:如果没有特别指出,这里所指的环境就是 Java 和 Android)
在本文的示例里面我们会使用一个随机生成的 128 位密钥。在我们使用 192 位或 256 位长的密钥时,Java 也会自动地选用正确的模式来进行操作。然而还是要注意,使用 256 位密钥的加密通常需要我们把 JCE(Java 密码扩展包)安装到 JRE 里面(不过在 Android 环境下就不用)。
SecureRandom secureRandom = new SecureRandom();
byte[] key = new byte[16];
secureRandom.nextBytes(key);
SecretKey secretKey = SecretKeySpec(key, "AES");
然后我们就要生成一个初始向量了。就 GCM 而言,NIST 推荐使用一个 12 字节(而非 16 字节!)的随机字节数组,因为它能更快生成,并且也更安全。在生成关乎安全的随机结果的时候,千万要注意,应该使用像 SecureRandom
这样的密码学意义上安全的伪随机数生成器(PRNG)。
byte[] iv = new byte[12]; // 使用同一密钥时初始向量绝不能重用
secureRandom.nextBytes(iv);
然后再初始化我们所用的加密算法。AES-GCM 模式应该在现今的 JRE 还有 v2.3 版本以上的 Android 上得到了支持。如果它没被支持,那也可以安装一个像 BouncyCastle 这样的第三方的加密算法扩展包。当然,我们最好还是使用默认提供的加密算法比较好。在这里我们设置认证标签的长度为 128 位。
final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec parameterSpec = new GCMParameterSpec(128, iv); // 128 位长的认证标签
cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);
关联数据想加的话也可以加(比如元数据)。
if (associatedData != null) {
cipher.updateAAD(associatedData);
}
然后开始进行加密。若要加密一大块数据,不妨使用一下 CipherInputStream,用它就不会出现大块数据被加载到堆上的情形了。
byte[] cipherText = cipher.doFinal(plainText);
把初始向量还有密文合在一起。
ByteBuffer byteBuffer = ByteBuffer.allocate(4 + iv.length + cipherText.length);
byteBuffer.putInt(iv.length);
byteBuffer.put(iv);
byteBuffer.put(cipherText);
byte[] cipherMessage = byteBuffer.array();
若要把加密数据用可打印字符显示,也可以把这段数据编码成 Base64 的格式。Android 默认提供了这种编码方式的标准实现,不过 JDK 在 Java 8 之后才提供了支持。这里也提一个替代方案,即 Apache Commons Codec。
如此便完成了加密的过程。我们依次确认并生成了明文、IV 长度、IV、密文还有认证标签,并把 IV、密文以及认证标签都放到了一个字节数组里面(Java 会自动地将认证标签附加到信息里面,这个过程就标准的加密 API 而言是对我们透明的)。
我们最好应该尽快地把像初始向量和密钥这样的敏感数据从内存里面清走。不过 Java 是一种有自动内存管理机制的语言,我们并不能保证下面这段代码一定会奏效,不过在大多数情况下还是靠谱的:
Arrays.fill(key,(byte) 0); // 用全 0 来覆盖一段字节数据
注意不要覆盖掉一些我们还会在别的地方用到的数据。
现在进行解密部分:这部分跟加密很像;首先把 IV 和消息分开:
ByteBuffer byteBuffer = ByteBuffer.wrap(cipherMessage);
int ivLength = byteBuffer.getInt();
byte[] iv = new byte[ivLength];
byteBuffer.get(iv);
byte[] cipherText = new byte[byteBuffer.remaining()];
byteBuffer.get(cipherText);
初始化加密算法,若有关联数据就加进来,然后再进行解密:
final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, iv));
if (associatedData != null) {
cipher.updateAAD(associatedData);
}
byte[] plainText= cipher.doFinal(cipherText);
这就行了!若要查看完整示例的话,不妨在我的 Github 项目 Armadillo 上看看我对 AES-GCM 的用法。
总的来说,数据安全性的保护包括了下面三点:
AES 加上 Galosis / Counter Mode(GCM)块模式能提供全部三个方面的保护,并且使用起来也比较简单,在 Java / Android 环境里面也得到了支持。我们只需要注意下面几点:
SecureRandom
这样的在密码学意义上安全的伪随机数生成器)