本文是我上一篇文章:“最佳安全实践:在 Java和 Android 中使用 AES 进行对称加密” 的续篇,在这篇文章中我总结了关于 AES 最为重要的事情并演示了如何经过 AES-GCM 来使用它。在阅读本文并深刻下一个主题以前,我强烈建议你阅读它,由于它解释了最重要的基础知识。php
本文讨论了如下可能发生的状况:你不能经过相似 Galois/Counter Mode (GCM) 的认证加密模式来使用高级加密标准(AES)?你当前使用的平台不支持它,或者你必须兼容老版本或其它第三方协议?不管你放弃 GCM 的缘由是什么,你都不该该放弃它所具备的安全属性:html
选择非认证加密,好比块模式密码分组连接(CBC),不幸的是,因为具有很好的延展性,它缺乏后两个安全属性。如何解决这个问题?正如我在上一篇文章中所说的那样,一种可能的解决方案是将加密原语组合在一块儿以包含加密验证码(MAC)。java
那么什么是 MAC,咱们为何要使用它呢?MAC 相似于散列函数,这意味着它将消息做为输入并生成一个所谓的简短标记。为了确保并不是任何人均可觉得任意消息建立标记,MAC 函数须要一个密钥来进行计算。与使用非对称加密的签名相比,MAC 可以使用相同的密钥来进行标记生成和认证。android
例如,若是双方安全地交换了 MAC 密钥,而且每条消息都附加了认证标记,那么它们均可以检查消息是不是由另外一方建立的,而且在传输过程当中没有被更改。攻击者须要保密的 MAC 密钥来伪造身份进行标记验证。git
最普遍使用的 MAC 类型之一是 散列消息密钥验证码(HMAC),它包含一个哈希加密函数,该函数一般是 SHA256。因为我不会详细介绍其算法,所以我建议你阅读相关 RFC。固然还有如 CBC-MAC 等其余可用于对称加密的类型。几乎全部的加密框架都至少包含一个 HMAC 实现,包括经过 Mac 实现的 JCA/JCE。github
那么正确应用 MAC 的方法是什么呢?根据安全研究院 Hugo Krawcyzk 的说法,这里有三种基本选项:算法
每个选项都有它本身的属性,我建议你经过这篇文章来获取每一个选项的完整参数。总而言之,大部分 研究员 推荐使用 Encrypt-then-MAC(EtM)。因为 MAC 能够防止不正确消息的解密,它能够防止选择密文攻击。此外也因为 MAC 在密文中运行,它不能泄漏有关明文的任何信息。然而它的缺点是,由于 IV 和标记中必须包含可能的协议/算法版本或类型,所以实施起来稍微有些困难。重要的是在验证 MAC 以前永远不要进行任何加密操做,不然你可能受到 padding-oracle 攻击(Moxie 称之为末日原则)。apache
Encrypt-then-Mac 架构segmentfault
附录:CGM 和 Encrypt-then-MAC 一般状况下它们的安全强度可能相似,CGM 有如下优势:api
它的缺点是只能容许 96 位初始向量(对于 128 位),HMAC 理论上比 GCM 的内部 MAC 算法 GHASH(128 位标记大小对 256 位及以上)更强。GCM 没法进行 IV + 密钥重用。相关详细讨论,请查阅此处。
咱们必须解决的最后一个问题是:咱们应该从哪里得到用于 MAC 计算的密钥?若是使用的是强密钥(即足够随机且能够安全地切换),那么使用与加密相同的密钥(当使用 HMAC 时)彷佛没有已知问题。但最佳实践是使用密钥派生函数(KDF)派生出 2 个子密钥以防范将来可能发现的任何问题。这能够像计算主密钥上的 SHA256 并将其拆分为两个 16 字节块同样简单。 可是我更喜欢标准化的协议,好比基于 HMAC 的 Extract-and-Expand 密钥派生函数,它直接支持此场景而不须要字节调整。
2 个子密钥的派生
理论已经足够了,如今让咱们开始编码!在接下来的例子中,我将使用 AES-CBC,这是一个看似保守的决定。这样作的缘由是,应该保证几乎每一个 JRE 和 Android 版本均可以使用它。如前所述,咱们将使用带有 HMAC 的 Encrypt-then-Mac 方案。这里惟一的外部依赖是 HKDF。这段代码基本上是我在上一篇文章中描述的 GCM 示例的一个映射。
简单起见,咱们使用随机生成的 128 位密钥。当你传递 12八、192 或 256 位长度的密钥时,Java 将自动选择正确的模式。但请注意,256 位加密一般须要在 JRE 中安装 无政策限制权限文件(OpenJDK 和 Android 无需安装)。若是你不肯定要使用的密钥大小,请在个人上一篇文章中阅读关于该主题的相关段落。
SecureRandom secureRandom = new SecureRandom(); byte[] key = new byte[16]; secureRandom.nextBytes(key);
而后咱们须要建立咱们的初始化向量。对于 CBC,应该使用 16 个字节长的初始向量(IV)。请注意,始终使用像 SecureRandom 这样的强伪随机数生成器(PRNG)。
byte[] iv = new byte[16]; secureRandom.nextBytes(iv);
重用 IV 不像 GCM 那样具备灾难性,但最好仍是避免使用。在这里能够看到可能的攻击。
下一步,咱们将派生出加密和身份验证所需的 2 个子密钥。咱们将在配置 HMAC-SHA256(使用此库)中使用 HKDF,因为它使用起来简单直接。咱们使用 HKDF 中的 info
参数来生成两个 16 字节子密钥,从而对它们进行区分。
// import at.favre.lib.crypto.HKDF; byte[] encKey = HKDF.fromHmacSha256().expand(key, "encKey".getBytes(StandardCharsets.UTF_8), 16); byte[] authKey = HKDF.fromHmacSha256().expand(key, "authKey".getBytes(StandardCharsets.UTF_8), 32); //HMAC-SHA256 key is 32 byte
接下来,咱们将初始化密码并加密咱们的明文。因为 CBC 的行为相似于块模式,所以咱们须要一个填充模式用于填充不彻底符合 16 字节块大小的信息。因为对使用的填充方案彷佛没有安全隐患,咱们选择了支持最普遍的:PKCS#7。
注意: 因为历史缘由,咱们必须将密码套件设置为 PKCS5
。除了被定义为了避免同的块尺寸,二者几乎彻底相同;一般状况下 PKCS#5 与 AES 并不兼容,但因为定义可追溯到使用了 8 字节块的 3DES,咱们坚持使用它。若是你的 JCE 提供程序接受 AES/CBC/PKCS7Padding
,那么使用此定义更好,如此你的代码将更容易被理解。
final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); //actually uses PKCS#7 cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(encKey, "AES"), new IvParameterSpec(iv)); byte[] cipherText = cipher.doFinal(plainText);
接下来,咱们须要准备 MAC 并添加主要数据来进行身份验证。
SecretKey macKey = new SecretKeySpec(authKey, "HmacSHA256"); Mac hmac = Mac.getInstance("HmacSHA256"); hmac.init(macKey); hmac.update(iv); hmac.update(cipherText);
若是你想要验证其余元数据(好比协议版本),你还能够将其添加到 mac 生成过程当中。这与将关联数据添加到通过身份验证的加密算法的概念相同。
if (associatedData != null) { hmac.update(associatedData); }
而后计算 mac:
byte[] mac = hmac.doFinal();
最后将全部信息序列化为单个消息:
ByteBuffer byteBuffer = ByteBuffer.allocate(1 + iv.length + 1 + mac.length + cipherText.length); byteBuffer.put((byte) iv.length); byteBuffer.put(iv); byteBuffer.put((byte) mac.length); byteBuffer.put(mac); byteBuffer.put(cipherText); byte[] cipherMessage = byteBuffer.array();
这基本上就是加密。将构建消息、IV、IV 的长度以及 mac 的长度、mac 和加密数据附加到单个字节数组。
若是你须要字符串表示,能够选用 Base64 对其进行编码。Android 中有该编码的标准实现,JDK 仅从版本 8 开始支持(若是可能,我将避免使用 Apache Commons Codec,由于它很慢且实现混乱)。
因为 Java 是一种自动内存管理语言,所以最佳作法是尽量快地从内存中擦除加密密钥或 IV 等敏感数据。咱们没法保证如下内容可以按照预期工做,但在大多数状况下应该如此:
Arrays.fill(authKey, (byte) 0); Arrays.fill(encKey, (byte) 0);
注意不要覆盖还在其余地方使用的数据。
解密和反向加密相似:首先解构消息。
ByteBuffer byteBuffer = ByteBuffer.wrap(cipherMessage); int ivLength = (byteBuffer.get()); if (ivLength != 16) { // check input parameter throw new IllegalArgumentException("invalid iv length"); } byte[] iv = new byte[ivLength]; byteBuffer.get(iv); int macLength = (byteBuffer.get()); if (macLength != 32) { // check input parameter throw new IllegalArgumentException("invalid mac length"); } byte[] mac = new byte[macLength]; byteBuffer.get(mac); byte[] cipherText = new byte[byteBuffer.remaining()]; byteBuffer.get(cipherText);
仔细验证输入参数以防止拒绝服务攻击,如 IV 或 mac 长度,由于攻击者可能会更改相关值。
而后导出解密和身份验证所需的密钥。
// import at.favre.lib.crypto.HKDF; byte[] encKey = HKDF.fromHmacSha256().expand(key, "encKey".getBytes(StandardCharsets.UTF_8), 16); byte[] authKey = HKDF.fromHmacSha256().expand(key, "authKey".getBytes(StandardCharsets.UTF_8), 32);
在咱们解密任何东西以前,咱们将验证 MAC。首先咱们像以前同样计算 MAC;不要忘记以前添加的相关数据。
SecretKey macKey = new SecretKeySpec(authKey, "HmacSHA256"); Mac hmac = Mac.getInstance("HmacSHA256"); hmac.init(macKey); hmac.update(iv); hmac.update(cipherText); if (associatedData != null) { hmac.update(associatedData); } byte[] refMac = hmac.doFinal();
比较 mac 时,咱们须要一个恒定的时间比较函数来避免旁道攻击;阅读此文了解为何这很重要。幸运的是咱们可使用 MessageDigest.isEquals()(旧的 bug 已在 Java 6u17 中修复):
if (!MessageDigest.isEqual(refMac, mac)) { throw new SecurityException("could not authenticate"); }
做为最后一步,咱们最终能够解密咱们的消息。
final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(encKey, "AES"), new IvParameterSpec(iv)); byte[] plainText = cipher.doFinal(cipherText);
以上即是全部内容,若是你想查看一个完整的例子,请查看我托管到 Github 中的一个使用 AES-CBC 的项目 Armadillo。若是你遇到了什么问题,也能够在 Gist 中找到这个确切的示例。
咱们演示了使用密码分组连接(CBC)的 AES 和使用 HMAC 的 Encrypt-then-MAC 架构提供了咱们但愿从加密协议中看到的全部理想的安全属性:保密性、完整性和真实性。
能够看出,仅仅使用了 GCM,协议就变得复杂了。可是,这些原语一般在全部 Java/Android 环境中均可用,所以它多是你惟一的选择。请考虑如下事项: