[譯] 最佳安全實踐:在 Java 和 Android 中使用 AES 進行對稱加密

淚已無痕發表於2019-03-02

原文地址:Security Best Practices: Symmetric Encryption with AES in Java and Android

最佳安全實踐:在 Java 和 Android 中使用 AES 進行對稱加密

我將在本文中為大家介紹高階加密標準(AES),常見塊模式,為什麼需要填充和初始化向量以及如何保護資料不被篡改。最後,我將為大家展示如何使用 Java 輕鬆實現此功能,從而避免大多數安全問題。

[譯] 最佳安全實踐:在 Java 和 Android 中使用 AES 進行對稱加密

為什麼每一個軟體工程師都需要知道 AES

AES,又稱 Rijndael 加密演算法,在 2000 年被 NIST 選中以用來替換過時的資料加密標準(DES)。AES 是一種分組密碼,這意味著加密發生在固定長度的位元組上。在我們的例子中,演算法定義塊長度為 128 位。AES 支援 128,192 和 256 位的金鑰長度。

每個塊都經歷多輪轉換。我將在這裡省略演算法的細節,對演算法感興趣的讀者可以參考維基百科中有關 AES 的文章。這裡需要指出的是塊大小受轉換輪次的重複次數影響(128 位金鑰是 10 個週期,256 位為 14 個週期),而金鑰長度並不影響它的大小。

一直到 2009 年 5 月,唯一一次成功釋出,針對完整 AES 的攻擊是對某些特定實現的旁道攻擊。(資源

想要加密多個塊?

AES 只會加密 128 位資料,如果我們想要加密整個訊息,我們需要選擇一種塊模式,利用該模式可以將多個塊加密為一個密文。最簡單的塊模式是電子密碼本或 ECB。它將在每個區塊中使用相同的未更改的鍵:

[譯] 最佳安全實踐:在 Java 和 Android 中使用 AES 進行對稱加密

圖片來自維基百科

這將是特別糟糕的,因為相同的明文會被加密成相同的密文。

[譯] 最佳安全實踐:在 Java 和 Android 中使用 AES 進行對稱加密

使用 ECB 塊模式加密的圖片顯示原始圖案(自己嘗試一下

請記住,除非你只加密小於 128 位的資料,否則永遠不要選擇該模式。不幸的是,它仍然被經常誤用,因為它不需要你提供初始向量(稍後會詳細介紹),因此開發人員似乎更容易處理。

必須使用塊模式處理的一種情況:如果最後一個塊的大小不足 128 位會發生什麼?這就是填充發揮作用的地方,即填充塊的缺失位。最簡單的方式是用零填充缺失位。在 AES 中選擇填充幾乎沒有任何安全隱患

密碼分組連結(CBC)

那麼有什麼方案可以替代 ECB 呢?例如 CBC,在該模式中,用當前的明文塊和前一個密文塊進行異或在該方法中,每個密文塊都依賴於它前面的所有明文塊。使用與之前相同的圖片,加密結果將是與噪聲資料無法區分的隨機資料:

[譯] 最佳安全實踐:在 Java 和 Android 中使用 AES 進行對稱加密

使用 CBC 塊模式加密的圖片看起來是隨機的

那如何處理第一個塊呢?最簡單的方法是使用一個完整的填充塊(比如用零填充),但這樣每次加密相同金鑰和明文都會產生一樣的密文。此外,如果你為不同的明文重用相同的金鑰,那麼恢復金鑰將會更加容易。更好的方法是使用隨機初始化向量(IV)。這對於隨機資料來說只是一個奇特的詞,大約是一個塊(128 位)大小。將它想象成一個加密的 salt,也就是說,IV 是可以公開的,隨機的且只能使用一次。但請注意,因為 CBC 將密文異或而不是前一個明文的明文,因此 IV 不僅僅會阻止第一個塊的解密。

在傳輸或保持資料時,通常只將 IV 新增到實際的密碼訊息中。如果你對如何正確使用 AES-CBC 感興趣,請閱讀本系列的第 2 部分

記數模式(CTR)

另外一種選擇是使用 CTR 模式。這種模式很有意思,因為它會將密碼轉換為密碼流,這意味著不需要進行填充。在其基本形式中,所有塊的編號為 0 到 n。現在每個塊都將使用金鑰、IV(此處也稱為 nonce)和計數器的值來進行加密。

[譯] 最佳安全實踐:在 Java 和 Android 中使用 AES 進行對稱加密

圖片來自維基百科

與 CBC 不同,它的優點是可以進行並行加密並且所有塊都依賴於 IV,而不僅僅是第一個。一個很嚴重的警告是,IV 永遠不能被相同的金鑰重用,因為攻擊者可以從中輕鬆計算出你所使用的金鑰。

我可以確保沒有人能夠修改我的訊息嗎?

事實:加密不會自動防止資料修改。這實際上是一種非常常見的攻擊。有關該問題更全面的討論,請閱讀此文

那麼我們又能做些什麼呢?我們只需將加密驗證碼(MAC)新增到加密郵件中。MAC 類似於數字簽名,不同之處在於驗證和驗證金鑰實際上是相同的。這種方法有不同的變化大多數研究人員推薦的模式叫做 Encrypt-then-Mac 。也就是說,在加密之後,在密文上計算並附加 MAC。你通常會使用基於雜湊的訊息身份驗證程式碼(HMAC)作為 MAC 的型別。

現在它開始變得複雜了。為了完整性/真實性我們必須選擇 MAC 演算法,選擇加密標籤模式,計算 mac 並附加它。因為整個訊息必須處理兩次,所以該操作執行速度緩慢。反向操作必須與前面一致,但用於解密和驗證。

使用 GCM 進行認證加密

如果有模式可以處理所有的身份驗證,那不是很好嗎?幸運的是有一種稱為認證加密的加密方式,它同時為資料的機密性、完整性和真實性提供了保證。支援此功能最流行的塊模式之一為 Galois/Counter Mode or GCM(比如它可以使用 TLS v1.2 中的密碼元件)。

GCM 基於 CTR 模式,它還在加密期間順序計算身份驗證標記。然後該標記通常會附加到密文中。它的大小是一個重要的安全屬性,因此它的長度至少是 128 位。

它還可以驗證未包括在明文中的附加資訊。該資料稱為關聯資料。這為什麼有用呢?例如,加密資料具有元屬性,即用於檢查是否必須重新載入內容的建立日期。攻擊者可以輕鬆更改建立日期,但如果將其新增為關聯資料, CGM 將驗證此資訊並識別出更改。

激烈的討論:使用多長的金鑰?

直覺會說:越大越好 - 很明顯,強制 256 位隨機值比 128 位更難。根據我們目前的理解,強制通過 128 位長位元組的所有值都需要天文數量的能量,對於任何在合理時間內的人來說都是不現實的(看著你,NSA)。因此,決定基本上在無限和無限時間 2¹²⁸ 之間。

AES 實際上有三種不同的金鑰大小,因為它被選為美國聯邦政府的標註加密演算法以用於聯邦政府「包括軍方」控制的各個領域。(...)因此,精明的軍事首腦提出了應該有三個“安全級別”的想法,以便使用重量級方法加密最重要的祕密,但較低價值的資料可以用更實用,更輕量級的演算法加密。(...)因此,NIST 決定正式遵守規定(要求三個關鍵尺寸),但也要做前瞻性的事(最低階別必須通過可遇見的技術不可攻破)(來源)。

論點如下:AES 加密訊息可能不會被暴力破壞金鑰破壞,而是通過其他較便宜的攻擊(當前未知)。這些攻擊對於 128 位金鑰模式和 256 位模式一樣有害,因此在這種情況下選擇更大的金鑰大小也無濟於事。

所以基本上 128 位金鑰對於大多數用例來說都足夠安全,但量子計算機保護除外。同樣使用比 256 位更快的 128 位加密。128 位金鑰的金鑰強度似乎可以更好的防止相關金鑰攻擊(但這與大多數實際用途無關)。

旁註:旁道攻擊

旁道攻擊是利用特定於某些實現的問題的攻擊。加密密碼方案本身不能有效地保護它們。簡單的 AES 實現可能容易發生計時快取攻擊其他攻擊

作為一個非常基本的例子:一個容易發生定時攻擊的簡單演算法是一個比較兩個祕密位元組陣列的 equals() 方法。如果 equals() 有一個快速返回,意味著在第一對不匹配的位元組結束迴圈之後,攻擊者可以測量 equals() 完成所需要的時間,並且可以一個位元組一個位元組的猜測,直到全部匹配為止。

[譯] 最佳安全實踐:在 Java 和 Android 中使用 AES 進行對稱加密

使用快速返回可能受到定時攻擊的程式碼

在這種情況下,一個修復方法是使用恆定時間等於。請注意,在類似於 JVM 等解釋語言中編寫常量時間程式碼往往並非易事。

針對 AES 的定時和快取攻擊不僅僅是理論上的,甚至可以通過網路進行實施。雖然防止旁道攻擊主要是實施加密原語的開發人員關注的問題,但瞭解編碼實踐可能對整個例程的安全性有害是明智的。最一般的主題是,可觀察到的與時間相關的行為不應該依賴於私密資料。此外,你應該仔細考慮要選擇的實現方案。例如,使用帶有 OpenJDK 的 Java 8+ 和預設的 JCA 提供程式應該在內部使用 Intel 的 AES-NI 指令集,該指令集通過恆定時間和在硬體中實現(同時仍具有良好的效能)來防止大多數時序和快取攻擊。Android 使用它的 AndroidOpenSSLProvider,內部可能會在硬體中使用 AES(ARM TrustZone),具體取決於 SoC。但我不相信它具有與 Intels pedant 相同的防護。但即使你改進硬體,也可以使用其他攻擊向量,例如功率分析。存在專門用於防止大多數這些問題的專用硬體,即硬體安全模組(HSM)。不幸的是,這些裝置的成本通常高達數千美元(有趣的是:你的基於晶片的信用卡也是 HSM)。

在 Java 和 Android 中實現 AES-GCM

最後它變得實用了。現在 Java 擁有我們需要的所有工具,但加密 API 可能不是最直接的。細心的開發人員也可能不確定要使用的長度/大小/預設值。注意:如果沒有說明,所有內容都同樣適用於 Java 和 Android

在我們的示例中,我們使用隨機生成的 128 位金鑰。傳遞 192 和 256 位長度的金鑰時,Java 會自動選擇正確的模式。但請注意,256 位加密通常需要在 JRE 中安裝 無政策限制許可權檔案(Android 中無需安裝)。

SecureRandom secureRandom = new SecureRandom();
byte[] key = new byte[16];
secureRandom.nextBytes(key);
SecretKey secretKey = SecretKeySpec(key, “AES”);
複製程式碼

然後我們必須建立我們的初始化向量。對於 CGM,NIST 建議使用 12 位元組(非16位元組!)隨機字陣列,因為它更快,更安全。請注意始終使用像 SecureRandom 這樣的強偽隨機數生成器(RNG)

byte[] iv = new byte[12]; //NEVER REUSE THIS IV WITH SAME KEY
secureRandom.nextBytes(iv);
複製程式碼

然後初始化你的密碼。AES-GCM 模式應該適用於大多數現代 JRE 和 Android v2.3 以上版本雖然僅在 SDK 21+ 上可以完全正常執行)。如果碰巧不可用,請安裝像 BouncyCastle 這樣的自定義加密提供程式,但通常首選預設提供程式。我們選擇 128 位大小的認證標籤。

final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec parameterSpec = new GCMParameterSpec(128, iv); //128 bit auth tag length
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 僅從版本 8 開始(如果可能,我會避免使用 Apache Commons Codec,因為它很慢且實現混亂)。

這基本上就是加密。為了構造訊息,IV 長度,IV,加密資料和認證標籤被附加到單個位元組陣列。(在 Java 中,身份驗證標記會自動附加到訊息中,無法使用標準加密 API 自行處理)。

最佳事件是儘可能快地從記憶體中擦除加密金鑰或 IV 等敏感資料。由於 Java 是一種具有自動記憶體管理的語言,因此我們無法保證以下內容能夠預期工作,但在大多數情況下應該如此:

Arrays.fill(key,(byte) 0); //overwrite the content of key with zeros
複製程式碼

注意不要覆蓋仍在其他地方使用的資料。

現在到解密部分,它的工作原理類似加密,首先解構訊息:

ByteBuffer byteBuffer = ByteBuffer.wrap(cipherMessage);
int ivLength = byteBuffer.getInt();
if(ivLength < 12 || ivLength >= 16) { // check input parameter
    throw new IllegalArgumentException("invalid iv length");
}
byte[] iv = new byte[ivLength];
byteBuffer.get(iv);
byte[] cipherText = new byte[byteBuffer.remaining()];
byteBuffer.get(cipherText);
複製程式碼

小心驗證輸入引數,比如 IV 長度,因為攻擊者可能會將長度值更改為如 2³¹,它會分配 2 GiB記憶體並可能很快填滿你的堆,使得拒絕服務攻擊變得微不足道。

初始化密碼並新增可選的關聯資料並解密:

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 中的一個使用 AES-GCM 的專案 Armadillo

總結

我們需要三個屬性來保護我們的資料:

  • 保密性:防止竊聽者發現明文訊息或有關明文訊息的資訊的能力。
  • 完整性:防止攻擊者在合法使用者未注意的情況下修改訊息的能力。
  • 真實性:證明訊息是由特定方生成並防止偽造新訊息的能力。 這通常通過訊息驗證程式碼(MAC)提供。注意,真實性也意味著完整性。

具有 Galois/Counter(GCM)塊模式的 AES 提供所有這些屬性,並且相當容易使用,並且在大多數 Java/Android環境中都可用。 請考慮以下事項:

  • 使用永遠不會與相同金鑰一起使用的 12 位元組初始化向量(使用像 SecureRandom 這樣的強偽隨機數生成器)。
  • 使用 128 位身份驗證標記長度。
  • 使用 128 位金鑰長度(你會沒事的!)。
  • 將所有內容整合到一條訊息中。

進一步閱讀:

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

參考資料:

相關文章