[译]最佳安全实践:在 Java 和 Android 中使用 AES 进行对称加密:第2部分:AES-CBC + HMAC

2,054 阅读10分钟

原文地址:Security Best Practices: Symmetric Encryption with AES in Java and Android: Part 2: AES-CBC + HMAC

本文是我上一篇文章:“最佳安全实践:在 Java 和 Android 中使用 AES 进行对称加密” 的续篇,在这篇文章中我总结了关于 AES 最为重要的事情并演示了如何通过 AES-GCM 来使用它。在阅读本文并深入下一个主题之前,我强烈建议你阅读它,因为它解释了最重要的基础知识。


本文讨论了以下可能发生的情况:你不能通过类似 Galois/Counter Mode (GCM) 的认证加密模式来使用高级加密标准(AES)?你当前使用的平台不支持它,或者你必须兼容老版本或其它第三方协议?无论你放弃 GCM 的原因是什么,你都不应该放弃它所具有的安全属性:

  • 保密性:没有密钥的人无法阅读该消息
  • 完整性:没有人会修改消息内容
  • 真实性:可以对消息的发送者进行验证

选择非认证加密,比如块模式密码分组链接(CBC),不幸的是,由于具备很好的延展性,它缺少后两个安全属性。如何解决这个问题?正如我在上一篇文章中所说的那样,一种可能的解决方案是将加密原语组合在一起以包含加密验证码(MAC)

加密验证码(MAC)

那么什么是 MAC,我们为什么要使用它呢?MAC 类似于散列函数,这意味着它将消息作为输入并生成一个所谓的简短标记。为了确保并非任何人都可以为任意消息创建标记,MAC 函数需要一个密钥来进行计算。与使用非对称加密的签名相比,MAC 可使用相同的密钥来进行标记生成和认证。

例如,如果双方安全地交换了 MAC 密钥,并且每条消息都附加了认证标记,那么它们都可以检查消息是否是由另一方创建的,并且在传输过程中没有被更改。攻击者需要保密的 MAC 密钥来伪造身份进行标记验证。

最广泛使用的 MAC 类型之一是 散列消息密钥验证码(HMAC),它包含一个哈希加密函数,该函数通常是 SHA256。由于我不会详细介绍其算法,因此我建议你阅读相关 RFC。当然还有如 CBC-MAC 等其他可用于对称加密的类型。几乎所有的加密框架都至少包含一个 HMAC 实现,包括通过 Mac 实现的 JCA/JCE

使用加密的 MAC:架构

那么正确应用 MAC 的方法是什么呢?根据安全研究院 Hugo Krawcyzk 的说法,这里有三种基本选项

  • MAC-then-Encrypt:基于明文生成 MAC,然后将其追加到明文中后再进行加密(在 SSL 中使用)
  • Encrypt-then-MAC:基于密文和初始向量生成 MAC,然后将其追加到密文中(在 IPsec 中使用)
  • Encrypt-and-MAC: 基于明文生成 MAC、然后将其追加到密文中(在 SSH 中使用)

每一个选项都有它自己的属性,我建议你通过这篇文章来获取每个选项的完整参数。总而言之,大部分 研究员 推荐使用 Encrypt-then-MAC(EtM)。由于 MAC 可以防止不正确消息的解密,它可以防止选择密文攻击。此外也由于 MAC 在密文中运行,它不能泄漏有关明文的任何信息。然而它的缺点是,因为 IV 和标记中必须包含可能的协议/算法版本或类型,因此实施起来稍微有些困难。重要的是在验证 MAC 之前永远不要进行任何加密操作,否则你可能受到 padding-oracle 攻击Moxie 称之为末日原则)。

Encrypt-then-Mac 架构

Encrypt-then-Mac 架构

附录:CGM 和 Encrypt-then-MAC 通常情况下它们的安全强度可能类似,CGM 有以下优点:

  • 简单易用而不易出错
  • 更快,因为它只需要一次通过整个信息

它的缺点是只能允许 96 位初始向量(对于 128 位),HMAC 理论上比 GCM 的内部 MAC 算法 GHASH(128 位标记大小对 256 位及以上)更强。GCM 无法进行 IV + 密钥重用。相关详细讨论,请查阅此处

使用加密的 MAC:验证密钥

我们必须解决的最后一个问题是:我们应该从哪里获得用于 MAC 计算的密钥?如果使用的是强密钥(即足够随机且可以安全地切换),那么使用与加密相同的密钥(当使用 HMAC 时)似乎没有已知问题。但最佳实践是使用密钥派生函数(KDF)派生出 2 个子密钥以防范未来可能发现的任何问题。这可以像计算主密钥上的 SHA256 并将其拆分为两个 16 字节块一样简单。 但是我更喜欢标准化的协议,比如基于 HMAC 的 Extract-and-Expand 密钥派生函数,它直接支持此场景而不需要字节调整。

2 个子密钥的派生

2 个子密钥的派生

在 Java 和 Android 中使用 EtM 实现 AES-CBC

理论已经足够了,现在让我们开始编码!在接下来的例子中,我将使用 AES-CBC,这是一个看似保守的决定。这样做的原因是,应该保证几乎每个 JREAndroid 版本都可以使用它。如前所述,我们将使用带有 HMAC 的 Encrypt-then-Mac 方案。这里唯一的外部依赖是 HKDF。这段代码基本上是我在上一篇文章中描述的 GCM 示例的一个映射。

加密

简单起见,我们使用随机生成的 128 位密钥。当你传递 128、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 环境中都可用,因此它可能是你唯一的选择。请考虑以下事项:

  • 使用 16 字节随机初始化向量(使用强 PRNG
  • 使用 128 位以上的 MAC 长度(HMAC-SHA256 输出 256 位)
  • 使用 Encrypt-then-Mac
  • 使用 KDF 派生出 2 个子密钥
  • 解密之前进行验证(末日原则
  • 通过使用恒定时间等于实现来防止定时攻击
  • 使用 128 位加密密钥长度(你会没事的!)
  • 将所有内容整合到一条消息中

参考资料:

进一步阅读:

最佳安全实践:在 Java 和 Android 中使用 AES 进行对称加密