本文是我上一篇文章:“最佳安全实践:在 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 架构
附录: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 个子密钥的派生
在 Java 和 Android 中使用 EtM 实现 AES-CBC
理论已经足够了,现在让我们开始编码!在接下来的例子中,我将使用 AES-CBC,这是一个看似保守的决定。这样做的原因是,应该保证几乎每个 JRE 和 Android 版本都可以使用它。如前所述,我们将使用带有 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 位加密密钥长度(你会没事的!)
- 将所有内容整合到一条消息中