前言:
都已經2016年了,本來不打算寫這篇的,但偶然發現網路上有多個中文部落格,甚至是論壇上分享或是討論 Java 的 AES 的程式,都沒有討論一些問題,深怕一堆人看到這種範例程式就放到你開發的系統上。另外,為了簡化程式來說明,以下的程式並不考慮Exception處理方式。內容:
先來看網路上常見的程式的寫法:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | class AES_DEFAULT { public static byte[] Encrypt(SecretKey secretKey, String msg) throws Exception { Cipher cipher = Cipher.getInstance("AES"); //: 等同 AES/ECB/PKCS5Padding cipher.init(Cipher.ENCRYPT_MODE, secretKey); System.out.println("AES_DEFAULT IV:"+cipher.getIV()); System.out.println("AES_DEFAULT Algoritm:"+cipher.getAlgorithm()); byte[] byteCipherText = cipher.doFinal(msg.getBytes()); System.out.println("加密結果的Base64編碼:" + Base64.getEncoder().encodeToString(byteCipherText)); return byteCipherText; } public static byte[] Decrypt(SecretKey secretKey, byte[] cipherText) throws Exception { Cipher cipher = Cipher.getInstance("AES"); cipher.init(Cipher.DECRYPT_MODE, secretKey); byte[] decryptedText = cipher.doFinal(cipherText); String strDecryptedText = new String(decryptedText); System.out.println("解密結果:" + strDecryptedText); return decryptedText; } public static void main(String args[]) throws Exception{ KeyGenerator keyGen = KeyGenerator.getInstance("AES"); keyGen.init(128,new SecureRandom( ) ); SecretKey secretKey = keyGen.generateKey(); byte[] iv = new byte[16]; SecureRandom prng = new SecureRandom(); prng.nextBytes(iv); byte[] cipher = AES_DEFAULT.Encrypt(secretKey, "I am PlainText!!"); AES_DEFAULT.Decrypt(secretKey, cipher); } } |
- 第 1 個問題在第 4 行 這個用法所使用的 Cipher mode 是 ECB,也就是比較不安全的方式。原因是 ECB 對於相同的資料加密後的結果會是一樣的,有興趣可以參考(Wiki上的那張企鵝圖)。 如果你的應用是每次加密時 secret key 都是重新產生的,而且需要被加密的資料每次都完全不同的時候,各自的資料內容本身也是異質性相當高,如果採用這種做法也沒有太大的問題,但還是不建議。因為以加密的應用來說,常見的對象就是檔案或是運用在傳輸加密。而這兩種方式,大部分都會有相同的資料。以檔案來說,相同類型的檔案你用binary編輯器打開檔案,你就可以觀察到檔案的前面都會有雷同的資料。通訊協定更是如此,例如:HTTP通訊協定。
- 第 2 個問題在第 25 行 secret key 長度的問題,不應該使用 128 bits 長度,強度太弱。
- 第 3 個問題在第 8 行的 msg.getBytes( ),不同的作業系統所使用的預設 charset 可能是不同的。這樣的做法,有可能會造成不同平台在加密時的行為不如你所預期,也就是解密後的內容可能跟你當初要加密的資料不同。
比較建議的寫法是採用下面這種:
Important warring: You should select the correct cipher mode, for example: CCM or GCM mode. (Update 2022/03/04) The reason is the CBC mode is vulnerable to padding oracle attacks.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | class AES_CBC_PKCS5PADDING { public static byte[] Encrypt(SecretKey secretKey, byte[] iv, String msg) throws Exception{ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING"); cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv)); System.out.println("AES_CBC_PKCS5PADDING IV:"+cipher.getIV()); System.out.println("AES_CBC_PKCS5PADDING Algoritm:"+cipher.getAlgorithm()); byte[] byteCipherText = cipher.doFinal(msg.getBytes("UTF-8")); System.out.println("加密結果的Base64編碼:" + Base64.getEncoder().encodeToString(byteCipherText)); return byteCipherText; } public static void Decrypt(SecretKey secretKey, byte[] cipherText, byte[] iv) throws Exception{ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING"); cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv)); byte[] decryptedText = cipher.doFinal(cipherText); String strDecryptedText = new String(decryptedText); System.out.println("解密結果:" + strDecryptedText); } public static void main(String args[]) throws Exception{ KeyGenerator keyGen = KeyGenerator.getInstance("AES"); keyGen.init(256,new SecureRandom( ) ); SecretKey secretKey = keyGen.generateKey(); byte[] iv = new byte[16]; SecureRandom prng = new SecureRandom(); prng.nextBytes(iv); byte[] cipher = AES_CBC_PKCS5PADDING.Encrypt(secretKey, iv, "I am PlainText!!"); AES_CBC_PKCS5PADDING.Decrypt(secretKey, cipher, iv); } } |
此處,還是要強調 AES的加密 Secret Key 的長度建議至少要 256 bit 以上,用同一把 Secret Key 做加密時候,應該都要產生新的 IV 來加密。可以參考下面這種寫法來產生所需要的 AES Secret Key 和 IV。另外,IV 在解密時候,也要用當初加密使用的相同IV才可以。
KeyGenerator keyGen = KeyGenerator.getInstance("AES"); keyGen.init(256,new SecureRandom( ) ); SecretKey secretKey = keyGen.generateKey(); byte[] iv = new byte[16]; SecureRandom prng = new SecureRandom(); prng.nextBytes(iv);
另外,由於 Oracle 官方預設標準的Java執行環境沒法支援產生 AES 256 bits 長度的 secret key。所以,你會遇到下面這種錯訊息:
1 2 3 4 5 6 | Exception in thread "main" java.security.InvalidKeyException: Illegal key size or default parameters at javax.crypto.Cipher.checkCryptoPerm(Cipher.java:1026) at javax.crypto.Cipher.implInit(Cipher.java:801) at javax.crypto.Cipher.chooseProvider(Cipher.java:864) at javax.crypto.Cipher.init(Cipher.java:1249) at javax.crypto.Cipher.init(Cipher.java:1186) |
這表示你的執行環境需要安裝 Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files。這裡要特別注意,你務必要安裝跟執行環境JVM相同版本的JCE Policy,否則可能會遇到一些怪異的現象。(Android執行環境不在此限)
最後,常問遇到的問題就是:
- 如果檔案很大的Stream類型的檔案(如:Video or Audio)要如何做到加密?
- 想要隨意位置讀取已經加密的內容要怎麼做?
建議的做法:
- 第 1 個問題,很簡單請看清楚 Cipher 的 API doc,採用多次呼叫 update 的方法,最後再呼叫 doFinal 方法即可。
- 第 2 個問題,只要將 Cipher mode 改為 CTR 即可,另外如果是要隨機存取某個 Block 的資料,必須要自己重新計算那個 Block 開始的 IV。
請參考類似下面的寫法:
(Note: This sample code does not testing well, please don't adopt it on production system.)
(Note: This sample code does not testing well, please don't adopt it on production system.)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 | class AES_CTR_PKCS5PADDING { private static final int BLOCK_SIZE = 16; public static void Encrypt(SecretKey secretKey, byte[] iv, File plainTextFile, File encryptedFile) throws Exception{ Cipher cipher = Cipher.getInstance("AES/CTR/PKCS5PADDING"); cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv)); System.out.println("AES_CTR_PKCS5PADDING IV:"+cipher.getIV()); System.out.println("AES_CTR_PKCS5PADDING Algoritm:"+cipher.getAlgorithm()); byte buf[] = new byte[4096]; try (InputStream in = new FileInputStream(plainTextFile); OutputStream out = new FileOutputStream(encryptedFile);){ int readBytes = in.read(buf); while(readBytes > 0){ byte[] cipherBytes = cipher.update(buf, 0 , readBytes); out.write(cipherBytes); readBytes = in.read(buf); } cipher.doFinal(); } } public static void Decrypt(SecretKey secretKey, byte[] iv, File cipherTextFile, File decryptedFile) throws Exception{ Cipher cipher = Cipher.getInstance("AES/CTR/PKCS5PADDING"); cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv)); if(!decryptedFile.exists()){ decryptedFile.createNewFile(); //: Here, it may be fail if ... } byte buf[] = new byte[4096]; try (InputStream in = new FileInputStream(cipherTextFile); OutputStream out = new FileOutputStream(decryptedFile);){ int readBytes = in.read(buf); while(readBytes > 0){ byte[] decryptedBytes = cipher.update(buf, 0 , readBytes); out.write(decryptedBytes); readBytes = in.read(buf); } cipher.doFinal(); } } public static byte[] DecryptPartial(SecretKey secretKey, byte[] iv, File cipherTextFile, int blockIndex, int blockCount ) throws Exception{ final int offset = blockIndex * BLOCK_SIZE; final int bufSize = blockCount * BLOCK_SIZE; Cipher cipher = Cipher.getInstance("AES/CTR/PKCS5PADDING"); cipher.init(Cipher.DECRYPT_MODE, secretKey, calculateIVForBlock(new IvParameterSpec(iv), blockIndex )); byte[] decryptedBytes = new byte[bufSize]; try (FileInputStream in = new FileInputStream(cipherTextFile)){ byte inputBuf[] = new byte[bufSize]; in.skip(offset); int readBytes = in.read(inputBuf); decryptedBytes = cipher.update(inputBuf, 0, readBytes); } return decryptedBytes; } private static IvParameterSpec calculateIVForBlock(final IvParameterSpec iv, final long blockIndex) { final BigInteger biginIV = new BigInteger(1, iv.getIV()); final BigInteger blockIV = biginIV.add(BigInteger.valueOf(blockIndex)); final byte[] blockIVBytes = blockIV.toByteArray(); // Normalize the blockIVBytes as 16 bytes for IV if(blockIVBytes.length == BLOCK_SIZE){ return new IvParameterSpec(blockIVBytes); } if(blockIVBytes.length > BLOCK_SIZE ){ // For example: if the blockIVBytes length is 18, blockIVBytes is [0],[1],...[16],[17] // We have to remove [0],[1] , so we change the offset = 2 int offset = blockIVBytes.length - BLOCK_SIZE; return new IvParameterSpec(blockIVBytes, offset, BLOCK_SIZE); } else{ // For example: if the blockIVBytes length is 14, blockIVBytes is [0],[1],...[12],[13] // We have to insert 2 bytes at head final byte[] newBlockIV = new byte[BLOCK_SIZE]; //: default set to 0 for 16 bytes int offset = blockIVBytes.length - BLOCK_SIZE; System.arraycopy(blockIVBytes, 0, newBlockIV, offset, blockIVBytes.length); return new IvParameterSpec(newBlockIV); } } private static void createTestFile(String path) throws Exception{ File test = new File(path); try(FileOutputStream out = new FileOutputStream(test)){ StringBuffer buf = new StringBuffer(16); int blockCount = 100000; for(int i = 0 ; i < blockCount ; i ++){ buf.append(i); int size = buf.length(); for(int j = 0; j < (14-size); j++ ){ buf.append('#'); } out.write(buf.toString().getBytes()); out.write("\r\n".getBytes()); buf.delete(0, 16); } } } public static void main(String args[]) throws Exception{ KeyGenerator keyGen = KeyGenerator.getInstance("AES"); keyGen.init(256,new SecureRandom( ) ); SecretKey secretKey = keyGen.generateKey(); byte[] iv = new byte[16]; SecureRandom prng = new SecureRandom(); prng.nextBytes(iv); { String originalFile = "~/PlainText.txt"; String encryptedFile = "~/CipherText.enc"; String deryptedFile = "~/Decrypted.txt"; AES_CTR_PKCS5PADDING.createTestFile(originalFile); //: Create Testing Data AES_CTR_PKCS5PADDING.Encrypt(secretKey, iv, new File(originalFile), new File(encryptedFile)); AES_CTR_PKCS5PADDING.Decrypt(secretKey, iv, new File(encryptedFile), new File(deryptedFile)); byte[] ret = AES_CTR_PKCS5PADDING.DecryptPartial(secretKey, iv, new File(encryptedFile), 100, 10); System.out.println(new String(ret)); } } |
最後:
這邊我沒有提到另外一種 GCM 的 Cipher Mode,原則上,如果你沒有 Authentication 的需要時候,就不需要用到 GCM。
Reference:
* https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Electronic_Codebook_.28ECB.29* https://docs.oracle.com/javase/8/docs/technotes/guides/security/SunProviders.html
果然是良心的文章,感謝分享
回覆刪除想請問一下,文章裡有兩處:
回覆刪除byte[] iv = new byte[256 / 8]
上面的 256 是不是筆誤?
謝謝。
yes, IV應該是16.
刪除