# 牛年自強計畫 Week 8 - SpringBoot - Encode & Encrypt & Hash 之於密碼 ## 【前言】 身為工程師,一定對於密碼加密、解密;密文、明文與金鑰的處理不陌生。特別是商用軟體、有會員制的服務,對於處理這些資訊的方式都下足了工夫,怕的就是個資被盜、資訊外洩。 這篇文章就來簡單聊聊,平常在JAVA中處理密碼加密的部分,我們可以怎麼做? 首先我們先搞懂Encode、Encrypt 和 Hash 有什麼不同? ## 【Encode】 對於工程師來說,編碼是完全不陌生的東西,編碼並不會改變資料本身,也不進行任何加密操作,僅僅是採用不同的方式去描述或轉述同一個資料而已。 這就像是數學的 **1** 與國文數字的 **壹**,同樣都表示為單位個數一個,僅是文字與符號差異而已,意思皆相同。 ### 【摩斯密碼】 其中最有名的便是摩斯密碼了,下圖取自[維基百科-摩斯密碼](https://en.wikipedia.org/wiki/Morse_code) ![](https://i.imgur.com/NJ0lgl4.png) 摩斯密碼最基本的組成是 **點(Dot)** 跟 **線(Dash)**,以及用來分隔的空格,以此組成一個詞彙、一段訊息等。 而實際應用中,字元與符號之間的轉換不一定跟圖片中相同,可以根據兩人之間的協議去更動,摩斯密碼因其簡單的原理且應用延伸十分廣泛,經常被應用於軍事防衛、航港環境、甚至科技電影的情節中。 ``` 舉例: .... . .-.. .-.. --- .-- --- .-. .-.. -.. 上述各符號以空格切分之後,可轉譯成 HELLOWORLD ``` **點(Dot)** 跟 **線(Dash)**、空格有可能被替換成手勢動作、聲音、時間經過等。 延伸類型過多,點到為止,有興趣的可自行深究。 ### 【URI】 工程常見的對於 URI 的 encode、decode 處理,這是因為 URI 無法支援多種符號所致,因此針對較為特殊的符號必須先將其轉為 URI 可接受的格式規範,待接收後再將其轉回,避免中間過程傳輸出錯 or 亂碼。 詳細可參考 [w3School HTML URL Encoding Reference](https://www.w3schools.com/tags/ref_urlencode.ASP) ```java= String url = "http://www.test.tw?parameter_1=check&parameter_2>2"; System.out.println("Origin Url: " + url); try{ String encodeURL = URLEncoder.encode(url, "UTF-8"); System.out.println("URL Encode: " + encodeURL); }catch(Exception e){ e.printStackTrace(); } ``` ``` Origin Url: http://www.test.tw?parameter_1=check&parameter_2>2 URL Encode: http%3A%2F%2Fwww.test.tw%3Fparameter_1%3Dcheck%26parameter_2%3E2 ``` ### 【Base64】 一種可將二進位字元轉成 ASCII 的方法,但並非加密的方法,因此其非常容易破解,基本用於將過度赤裸的訊息轉譯為無法一眼辨別程度的文字串,從而達到隱藏訊息的目的。常應用在MIME 電子郵件、隱藏 URL 參數等場景。 ```java= /*** Old Version Way ***/ String secretMessage = "Message for Base64 Encoding"; System.out.println("Origin Message: " + secretMessage); try{ BASE64Encoder encoder = new BASE64Encoder(); String encodedSecretMessage = encoder.encode(secretMessage.getBytes(StandardCharsets.UTF_8)); System.out.println("Message Encoded: " + encodedSecretMessage); BASE64Decoder decoder = new BASE64Decoder(); String decodedSecretMessage = new String(decoder.decodeBuffer(encodedSecretMessage), StandardCharsets.UTF_8); System.out.println("Message Decoded: " + decodedSecretMessage); }catch(Exception e){ e.printStackTrace(); } /*** New Version Way ***/ String secretMessage = "Message for Base64 Encoding"; System.out.println("Origin Message: " + secretMessage); try{ Base64.Encoder encoder = Base64.getEncoder(); String encodedSecretMessage = encoder.encodeToString(secretMessage.getBytes(StandardCharsets.UTF_8)); System.out.println("Message Encoded: " + encodedSecretMessage); Base64.Decoder decoder = Base64.getDecoder(); String decodedSecretMessage = new String(decoder.decode(encodedSecretMessage), StandardCharsets.UTF_8); System.out.println("Message Decoded: " + decodedSecretMessage); }catch(Exception e){ e.printStackTrace(); } ``` ``` Origin Message: Message for Base64 Encoding Message Encoded: TWVzc2FnZSBmb3IgQmFzZTY0IEVuY29kaW5n Message Decoded: Message for Base64 Encoding ``` :::success ## 【Encode 之於密碼】 基本上對於密碼的保護,不會用 Encode 的方式處理,但這樣的說法並不實際,還是有不少公司行號甚至小型個人企業的系統採用最簡單的 Base64 方式作密碼處理,這樣的保護力完全不夠,一旦發生盜用問題,法律層面、系統革新成本都將會造成一定程度的傷害,對於密碼的處理,最起碼要用 Hash 的方式並只作配對驗證而不儲存原值,這在下面會提到。 > 雖然有些小企業老闆會說:「等事情發生在說。」or「我們也不是大企業,不會被攻擊。」 > ORZ... ::: ## 【Hash】 接續介紹 Hash,是一種將目標文字轉為 **相同長度** 的 **不可逆** 的 **雜湊文字** 的技術。 由於其不可逆的方式,因此成為大部分中型企業軟體的密碼保護方法首選,重視資安的公司甚至會多加幾道功夫在密碼保護上。 常見的方式有: - **MD5** > 2015年被破解 - **SHA-0** > SHA-1 前身,發布後不久就被NSA撤回,2年後 SHA-1 問世 - **SHA-1** > 2017年被荷蘭密碼學研究小組CWI和Google破解 - **SHA-2** (SHA-256, SHA-512, etc...) > 隨著電腦發展出現越來越多漏洞,但 SHA-512 目前仍是 JAVA 中優秀的 Hash 方法 - **SHA-3** (SHA3-256, SHA3-512, etc...) > 因應 MD5 以及 SHA-0, SHA-1 被攻破,而發展出的 Hash 新方法 對照表: | Hash | Speed 處理速度 | Shortness 結果長度 | Duplicated 重複機率 | Security 安全性 | | ---- | ------------- | ---------------- | --------------------- | -------------- | | MD5 | 最快 | 16 bytes | 1.47 \* 10^-29^ | 低 | | SHA-1 | 比 MD5 慢 20% | 20 bytes | 1 \* 10^-45^ | 中 | | SHA-2 | 比 MD5 慢 60% | 32 bytes | 4.3 \* 10^-60^ | 高 | 參考資料來源: [Ariva - MD5: The broken algorithm](https://www.avira.com/en/blog/md5-the-broken-algorithm) 舉例: ``` password 經由 md5 轉換後,得到下面這段訊息 5f4dcc3b5aa765d61d8327deb882cf99 而這段訊息幾乎無法被反轉回去 ``` 其他近年著名的 Hash 方法還包含了 **PBKDF2**、**BCrypt** 和 **SCrypt** 等,詳細需讀者自行深研。在此不細說,不然就要寫好幾大篇文章了... ```java= /*** MD5 ***/ String message = "Message for Hash"; System.out.println("Origin Message: " + message); try { MessageDigest md = MessageDigest.getInstance("MD5"); md.update(message.getBytes(StandardCharsets.UTF_8)); byte[] digest = md.digest(); String hashMessage = DatatypeConverter.printHexBinary(digest).toUpperCase(); System.out.println("MD5 Hash Message: " + hashMessage); }catch(Exception e){ e.printStackTrace(); } /*** SHA-512 ***/ String message = "Message for Hash"; System.out.println("Origin Message: " + message); try { MessageDigest md = MessageDigest.getInstance("SHA-512"); md.update(message.getBytes(StandardCharsets.UTF_8)); byte[] digest = md.digest(); String hashMessage = DatatypeConverter.printHexBinary(digest).toUpperCase(); System.out.println("SHA-512 Hash Message: " + hashMessage); }catch(Exception e){ e.printStackTrace(); } /*** PBKDF2 ***/ String message = "Message for Hash"; byte [] salt = "message for Salt.".getBytes(StandardCharsets.UTF_8); System.out.println("Origin Message: " + message); System.out.println("Salt: " + salt); KeySpec spec = new PBEKeySpec(message.toCharArray(), salt, 65536, 128); try { SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); byte [] digest = factory.generateSecret(spec).getEncoded(); String hashMessage = DatatypeConverter.printHexBinary(digest).toUpperCase(); System.out.println("PBKDF2 Hash: " + hashMessage); }catch(Exception e){ e.printStackTrace(); } /*** Bcrypt ***/ String message = "Message for Hash"; String hash = BCrypt.hashpw(message, BCrypt.gensalt(11)); System.out.println("BCrypt Hash Message: " + hash); Boolean b = BCrypt.checkpw(message, hash); System.out.println(b); ``` ``` Origin Message: Message for Hash MD5 Hash Message: 0BE271FAD77B0F3B8A003536311AF785 SHA-512 Hash Message: FF1A6C369B0DB0E7130B8DF98E11636DAAF82385CC397BD5CBDB814296925FB9DD5B633B006829DFFBC2FEAEACCF0400A742A4684C859973965672806F8A2B5C Salt: [B@7ce6a65d PBKDF2 Hash: 410438F6CAA3C4C359D59B47ED1D2F3A BCrypt Hash Message: $2a$11$JFnPmb7t76Y/MPSxECbk9eSXWplrRATXqVOIiB86FfMGGMLtKe6w. ``` :::success ## 【Hash 之於密碼】 基本上不少中小企業以往都採用 MD5 的方式進行密碼的處理,可惜的是,作為著名例子的 MD5 方法,在近代超級運算電腦的窮舉淫威下,也幾乎被破解了。 破解方法並不是透過方法的反推,而是透過大量輸入不同的字詞後取得的結果去配對。但後續有許多承著 MD5 的原理繼續發展的方法誕生,MD5 可以說功成身退,為其在電腦發展史中添上了一筆。 然而這並不代表 MD5 就完全不能用了,畢竟窮舉的方式被破解,但另外一個前提是你要取得這段訊息才能作配對,因此原先就已經作多層密碼保護的公司,考慮到其改變處理法的成本效益,可能暫時會續用 MD5 一段時間才更換,或是直接採用類似於 MD5 的新方法代替。 使用 Hash 作為保護密碼的額外搭配方式之一,就是只保留轉換後的值,而不保留原值,這樣做法可以讓公司內部員工避嫌,減少內部盜用的可能,對於客戶端的保護也會更好一些。 ::: ## 【Encrypt】 接續介紹 Encrypt 即加密,將目標文字轉換為 **不盡相同長度** 的 **可逆** 的 **文字**。 加密的方法不盡相同,主要分作兩大類: - **對稱密鑰加密** - 傳送方與接收方擁有相同的密鑰,能針對同一份訊息進行加密與解密動作 - **非對稱金鑰加密** - 接收方有公開密鑰與私有密鑰,將公開密鑰交予傳送方給訊息進行加密後,被加密的訊息無法被公開密鑰解密,只有接收方的私有密鑰可以進行正確的解密 通常來說,**非對稱加密**的保護力比**對稱加密**還要好,但是針對巨量資料的處理上,耗費的時間卻可能是**對稱加密**的數倍,因此並非完全都只能使用一種加密方式,而是需要針對資料型態與數量去決定。 而無論是哪一種方法,皆會有一組(或多組)用於加密解密的 KEY 存在,而實際應用的加密方法又非常多種多樣,因此各挑一個代表分享。 > 口語上我們會將欲加密的資訊稱為**明文**,加密後的稱為**密文** ### 【AES】 對稱加密的經典代表,鑒於 DES 系列被證實為不安全加密方法後,AES 便逐步取代 DES 的使用。 根據密鑰長度不同,AES 會將二進位文檔切分為長度同密鑰的區塊,並將其進行加密數次再做結合: | Way | Key Length | Hash Length | | ---- | --------- | ----------- | | AES-128 | 16 | 32 | | AES-192 | 24 | 48 | | AES-256 | 32 | 64 | 是目前業界十分常用的加密方法,缺點就是 Key 如果遺失,那救回資料也是難如登天ORZ 以上是非常簡化的說法,實際上要理解絕對不容易。 詳細的 AES 加密邏輯推演,可參考 [iT邦幫忙-羊小咩著-Day 20. 對稱式加密演算法 - 大家都愛用的 AES](https://ithelp.ithome.com.tw/articles/10249488) 清楚、明白的優質好文章!! ```java= try { String secretMessage = "Message for Encrypt."; System.out.println("Origin Message: " + secretMessage); /*** Set Key ***/ KeyGenerator generator = KeyGenerator.getInstance("AES"); generator.init(256); Key key = generator.generateKey(); /*** do Encrypt ***/ Cipher encryptCipher = Cipher.getInstance("AES"); encryptCipher.init(Cipher.ENCRYPT_MODE, key); byte [] secretMessageBytes = secretMessage.getBytes(StandardCharsets.UTF_8); byte [] encryptedMessageBytes = encryptCipher.doFinal(secretMessageBytes); System.out.println("AES Encrypt: " + encryptedMessageBytes); /*** do Decrypt ***/ Cipher decryptCipher = Cipher.getInstance("AES"); decryptCipher.init(Cipher.DECRYPT_MODE, key); byte[] decryptedMessageBytes = decryptCipher.doFinal(encryptedMessageBytes); String decryptedMessage = new String(decryptedMessageBytes, StandardCharsets.UTF_8); System.out.println("AES Decrypt: " + decryptedMessage); }catch(Exception e){ e.printStackTrace(); } ``` ``` Origin Message: Message for Encrypt. AES Encrypt: [B@37e547da AES Decrypt: Message for Encrypt. ``` ### 【RSA】 非對稱加密的經典代表,因其加密為數值運算,因此加密前會把明文轉成二進位數進行運算。而其 Key 的處理目前支援 1024 bits, 2048 bits 及以上,因 768 bits 被破解關係,推薦至少使用到 1024 bits 以上,目前沒有一個有效的破解法能夠解開 1024 bits 以上的 RSA 加密。 詳細的 AES 加密邏輯推演,可參考 [iT邦幫忙-羊小咩著-Day 23. 非對稱式加密演算法 - RSA (觀念篇)](https://ithelp.ithome.com.tw/articles/10250721) 清楚、明白的優質好文章.Too !! ```java= try { /*** build Generator with 2048bits key ***/ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(2048); KeyPair pair = generator.generateKeyPair(); /*** build public and private keys ***/ PrivateKey privateKey = pair.getPrivate(); PublicKey publicKey = pair.getPublic(); /*** build a file to store public key ***/ FileOutputStream fos = new FileOutputStream("public.key"); fos.write(publicKey.getEncoded()); File publicKeyFile = new File("public.key"); byte[] publicKeyBytes = Files.readAllBytes(publicKeyFile.toPath()); KeyFactory keyFactory = KeyFactory.getInstance("RSA"); EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(publicKeyBytes); keyFactory.generatePublic(publicKeySpec); /*** build message ***/ String secretMessage = "Message for Encrypt."; System.out.println("Origin Message: " + secretMessage); /*** do Encrypt ***/ Cipher encryptCipher = Cipher.getInstance("RSA"); encryptCipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] secretMessageBytes = secretMessage.getBytes(StandardCharsets.UTF_8); byte[] encryptedMessageBytes = encryptCipher.doFinal(secretMessageBytes); System.out.println("RSA Encrypt: " + encryptedMessageBytes); /*** do Decrypt ***/ Cipher decryptCipher = Cipher.getInstance("RSA"); decryptCipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] decryptedMessageBytes = decryptCipher.doFinal(encryptedMessageBytes); String decryptedMessage = new String(decryptedMessageBytes, StandardCharsets.UTF_8); System.out.println("RSA Decrypt: " + decryptedMessage); }catch(Exception e){ e.printStackTrace(); } ``` ``` Origin Message: Message for Encrypt. RSA Encrypt: [B@358c99f5 RSA Decrypt: Message for Encrypt. ``` :::success ## 【Encrypt 之於密碼】 Encrypt 對於密碼來說可能不是最好的,但應是其中一道該有的做法,原因在 Hash 提到過,伺服端盡可能不去存取客戶真實密碼,而是改用 Hash 後的訊息替代,但如今透過窮舉的方式破解 Hash 過的訊息太過容易,因此針對這部分就必須要使用 Encrypt 方式處理,也就是先 Hash 再加密的方式,部分大型企業甚至會有好幾層方法交互應用。 ::: ## 【結語】 稍微整理了近幾年碰到這三類東西的一點經驗,寫了篇筆記在這裡,避免日後忘記,總結三者有各自的功用,依照需求選用才能達到預期的保護效果~ [GitHub 實作分享](https://github.com/moom50302/LearningPratice/tree/main/src/main/java/Encrypt) 首頁 [Kai 個人技術 Hackmd](/2G-RoB0QTrKzkftH2uLueA) ###### tags: `Spring Boot`