[譯]最佳安全實踐:在 Java 和 Android 中使用 AES 進行對稱加密:第2部分:AES-CBC + HMAC

淚已無痕發表於2019-01-11

原文地址: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 進行對稱加密

相關文章