密碼學基礎:編碼方式、訊息摘要演算法、加密演算法總結

叉叉哥發表於2021-12-02

位元組碼轉文字的編碼方式

在計算機中,無論是記憶體、磁碟、網路傳輸,涉及到的資料都是以二進位制格式來儲存或傳輸的。

每一個二進位制位(bit)只能是 0 或 1。二進位制位不會單獨存在,而是以 8 個二進位制位組成 1 個位元組(byte)的方式存在,即 1 byte = 8 bit。

位元組碼無法直接轉為可列印的文字字元,有時想通過文字方式配置、儲存、傳輸一段二進位制位元組碼,比如配置檔案、HTML/XML、URL、e-mail 正文、HTTP Header 等僅支援文字的場景下,就需要將二進位制位元組碼轉為文字字串。

二進位制位元組碼轉文字字元有很多種方式,最簡單的方式是直接用 0 和 1 來表示。但是這樣的話,8 個 0/1 字元才能表示 1 個位元組,長度太長很不方便。

下面介紹兩種更加緊湊的方式:HEX 編碼和 Base64 編碼。

HEX 編碼

HEX 是 16 進位制的編碼方式,所以又稱為 Base16。

如果把一個位元組中的二進位制數值轉為十六進位制,使用 0-9 和 a-e(忽略大小寫)這 16 個字元,那每個字元就可以表示 4 個二進位制位(因為 2 的 4 次方等於 16),那麼僅需要兩個可列印字元就可以表示一個位元組。

Java 中使用 HEX 編碼(依賴 Apache Commons Codec):

String str = "相對論";
byte[] bytes = str.getBytes("UTF-8");

// Hex 編碼
String encodeString = Hex.encodeHexString(bytes);
System.out.println(encodeString); // 輸出:e79bb8e5afb9e8aeba

// Hex 解碼
byte[] decodeBytes = Hex.decodeHex(encodeString);
System.out.println(new String(decodeBytes, "UTF-8")); // 輸出:相對論

HEX 編碼使用場景非常多。下面介紹幾種常見的使用場景:

RGB 顏色碼

RGB 顏色通常用 HEX 方式表示。如橘紅色可以用 #FF4500 來表示:

.orangered { color: #FF4500; }

RGB 指紅(red)綠(green)藍(blue)三原色,這三種顏色按不同比例疊加後可以得到各式各樣的顏色。三種顏色每種強度取值範圍是 0~255,各需要 1 個位元組來表示,共 3 個位元組。

用 HEX 編碼的表示某種 RGB 顏色,是一個長度為 6 位的字串(通常還會加上 # 作為字首,此時長度是 7 位)。例如 #FF4500 表示紅綠藍三原色的強度分別為 255、69、0。

URL 編碼

由於 URL 中僅允許出現字母、數字和一些特殊符號,當 URL 中有漢字,需要經過 URL 編碼才可以。

例如百度百科"相對論"的頁面 URL 是: https://baike.baidu.com/item/...

其中 %E7%9B%B8%E5%AF%B9%E8%AE%BA 實際上是將 '相對論' 三個字用 UTF-8 編碼後得到 9 個位元組,再分別對這 9 個位元組使用 HEX 編碼並加上 '%' 字首得到的結果。

IPv6 地址

由於 IPv4 的地址即將面臨不夠用的問題,取而代之的將會是 IPv6。IPv6 使用了 128 個二進位制位的地址,通常會使用 HEX 編碼方式來表示,例如:

2001:0db8:0000:0000:0000:ff00:0042:8329

Base64 編碼

如果覺得 HEX 編碼不夠緊湊,那麼還有更加緊湊的編碼方式:Base64 編碼。

Base64 編碼共使用了 64 個字元來表示二進位制位:26 個大寫的 A-Z、26 個小寫的 a-z、10 個數字 0-9、2 個特殊符號 + 和 /。這意味著每個字元可以表示 6 個二進位制位,因為 64 等於 2 的 6 次方。

由於每個位元組是 8 個二進位制位,而 Base64 編碼每個字元表示 6 個二進位制位,那麼可以每湊夠 3 個位元組(即 24 個二進位制位),可將其編碼為 4 個字元。如果被 base64 編碼的原資料位元組數不是 3 的倍數,那麼會在末尾補上 1 或 2 個值為 0 的位元組,湊到 3 的倍數後再進行 Base64 編碼,編碼後會在末尾新增 1 或 2 個 = 符號,表示補了多少個位元組,這個在解碼時會用到。

Java 中使用 Base64 編碼:

String str = "相對論";
byte[] bytes = str.getBytes("UTF-8");

// Base64 編碼
String encodeString = Base64.getEncoder().encodeToString(bytes);
System.out.println(encodeString); // 輸出:55u45a+56K66

// Base64 解碼
byte[] decodeBytes = Base64.getDecoder().decode(encodeString);
System.out.println(new String(decodeBytes, "UTF-8")); // 輸出:相對論

Base64 編碼的使用場景也有很多。例如,由於圖片檔案不是文字檔案,沒辦法直接寫入到 HTML 中,而將圖片經過 Base64 編碼後的結果是一串文字,可以直接放到 HTML 中:

<img src="data:image/jpg;base64,/9j/4QMZR..." />

需要注意的是,Base64 不是加密演算法,有的開發人員把 Base64 當做加密演算法來用,這是極其不安全的,因為 Base64 任何人都可以解碼,不需要任何金鑰。

訊息摘要演算法

訊息摘要演算法(Message-Digest Algorithm),又稱為密碼雜湊函式(cryptographic hash function (CHF)),可以將任意長度的位元組碼資料通過雜湊演算法計算出一個固定大小的結果。常用的訊息摘要演算法有 MD5、SHA-1、SHA-2 系列(包括 SHA-256、SHA-512 等)。

以 MD5 為例,對任意一個資料進行 MD5 運算,結果是一個 128 個二進位制位(16 個位元組)的雜湊值。而我們日常看到的 32 位 MD5 字串,實際上是對 128 個二進位制位的雜湊值進行 HEX 編碼後得到的結果。

例如,當使用 MD5 對 "相對論" 這個字串進行運算,得到一個 32 位字元的 MD5 值,實際上是經過以下 3 個步驟(以下程式碼依賴 Apache Commons Codec):

String str = "相對論";
// 1. 將字串通過 UTF-8 編碼轉為位元組陣列
byte[] bytes = str.getBytes("UTF-8");
// 2. 對原始陣列進行 MD5,得到一個 128 個二進位制位(16 個位元組)的雜湊值
byte[] md5Bytes = DigestUtils.md5(bytes);
// 3. 將 128 位的雜湊值 HEX 編碼,得到一個長度為 32 的字串
String md5Hex = Hex.encodeHexString(md5Bytes);
System.out.println(md5Hex); // 輸出:fa913fb181bc1a69513e3d05a367da49

上面的程式碼僅僅是為了更清晰的看到計算一個字串 MD5 值的整個過程。實際開發中可以使用更加便捷的 API,將上面的 3 個步驟合為 1 步:

String str = "相對論";
// 使用預設的 UTF-8 編碼將字串轉為位元組陣列計算 MD5 後再進行 HEX 編碼
String md5Hex = DigestUtils.md5Hex(str);
System.out.println(md5Hex); // 輸出:fa913fb181bc1a69513e3d05a367da49

除此之外,Apache Commons Codec 中的 DigestUtils 還提供了 SHA-1、SHA-256、SHA-384、SHA-512 等訊息摘要演算法。

訊息摘要演算法有以下特點:

  • 相同的訊息通過訊息摘要演算法計算得到的結果總是相同的。
  • 不同的訊息通過訊息摘要演算法計算得到的結果要儘可能保證是不同的。如果兩個不同的資料訊息摘要後的結果相同,也就是發生了雜湊碰撞,雜湊碰撞出現的概率越大,那麼這個訊息摘要演算法就越不安全。
  • 不可逆,無法通過雜湊結果反向推算出原始資料。所以,我們一般認為訊息摘要演算法並不算是加密演算法,因為它無法解密。另外,這裡的不可逆是指運算不可逆,但是攻擊者通常會使用窮舉法或彩虹表來找到雜湊值對應的原始資料。

下面列舉一些典型的訊息摘要演算法的使用場景:

  • 對使用者的登入密碼使用訊息摘要演算法得到雜湊值後再儲存到資料庫,即使資料庫被黑客攻擊,拿到所有的資料,也很難獲得密碼的原始值。這相對明文儲存密碼來說更加安全。當然,直接使用雜湊值儲存也是不安全的,特別是對於一些弱密碼,黑客可以通過彩虹表輕鬆的查到對應的原始值。所以通常不會直接儲存雜湊值,而是經過一些處理,例如加鹽、HMAC 等方式。
  • 對比兩個檔案是否一致,只需要對比兩個檔案的訊息摘要是否一致即可,無需按位元組一個個去對比。例如百度網盤曾經就是用檔案的 MD5 來判斷新上傳的檔案是否已存在,如果已經存在則不需要重複上傳和儲存,達到節省空間的目的。
  • 用於數字簽名(Digital Signature),這個在本文後續會介紹。

在安全性要求比較高的場景下,MD5、SHA-1 目前都已經不建議使用了,現在用的比較多的是 SHA-2 系列演算法。

HMAC

HMAC 全稱是雜湊訊息認證碼(Hash-Based Message Authentication Code),它在訊息摘要演算法的基礎上,加上了一個金鑰(secret key)。

例如 HMAC-SHA256 就是在 SHA-256 演算法基礎上加了一個金鑰。以下為程式碼示例(依賴 Apache Commons Codec):

String str = "相對論";
String key = "12345678"; // 金鑰
HmacUtils hmacUtils = new HmacUtils(HmacAlgorithms.HMAC_SHA_256, key.getBytes("UTF-8"));
String result = hmacUtils.hmacHex(str.getBytes("UTF-8"));
System.out.println(result); // 輸出:3bd7bbf58159a6d0bff846016b346a617a588fc1e9c43ebbdf38be53d3fc455a

相對於直接使用訊息摘要演算法,使用 HMAC 優勢在於,它可以對訊息進行真實性(authenticity)和完整性(integrity)驗證。

只要金鑰沒有洩露,那麼只有持有金鑰才可以計算和驗證原始資料雜湊值。攻擊者在沒有金鑰的前提下,無法傳送偽造的訊息,也無法篡改訊息。

HMAC 可用於介面認證。例如一個暴露在網路環境中的 HTTP 介面,如果想要對呼叫方進行認證,可以將金鑰發放給呼叫方,要求呼叫方呼叫介面時,給所有請求引數使用金鑰通過 HMAC 計算一個簽名,被呼叫方驗證簽名,就可以保證請求引數的真實性和完整性。

另外,HMAC 由於在計算雜湊值時新增了金鑰,相對於直接使用訊息摘要演算法,更加不容易被窮舉法、彩虹表破解,使用者密碼經過 HMAC 後儲存更加安全。

JWT 中的 HMAC

HMAC 的一個典型的應用場景就是 JWT。JWT 全稱是 JSON Web Token。

傳統的認證方式一般會將認證使用者資訊儲存在服務端,而 JWT 直接將認證使用者資訊發放給客戶端儲存。既然 JWT 儲存在客戶端,那麼任何人都可以偽造或篡改。如何解決這個問題,其中一種方式就是服務端會對 JWT 的 token 使用 HMAC 進行簽名,並將簽名也放在 token 末尾。下次客戶端帶上 JWT 請求時,服務端再驗證簽名是否正確。只要金鑰不洩露,就可以保證 token 的真實性和完整性。

JWT token 分為三個部分:

  • Header:頭部,指定簽名演算法
  • Payload:包含 token 主要傳輸的資訊,這一部分可以包含使用者資訊,例如使用者名稱等
  • Signature:簽名,計算方式如下(secret 即金鑰):

    HMACSHA256(
      base64UrlEncode(header) + "." +
      base64UrlEncode(payload),
      secret)

最終對這三個部分 Base64 編碼後組合為 JWT 的 token:

加密演算法

加密演算法分為對稱加密演算法和非對稱加密演算法:

  • 對稱加密演算法(symmetric-key cryptography):加密和解密時使用相同的金鑰。最常用的是 AES 演算法。
  • 非對稱加密演算法(asymmetric-key cryptography):加密和解密使用不同的金鑰,例如公鑰加密的內容只能用私鑰解密,所以又稱為公鑰加密演算法(public-key cryptography)。使用最廣泛的是 RSA 演算法。

對稱加密演算法

常見的對稱加密演算法有 DES、3DES、AES,其中 DES 和 3DES 標準由於安全性問題,已經逐漸被 AES 取代。

AES 有多種工作模式(mode of operation)和填充方式(padding):

  • 工作模式:如 ECB、CBC、OFB、CFB、CTR、XTS、OCB、GCM,不同的模式引數和加密流程不同。
  • 填充方式:由於 AES 是一種區塊加密(block cipher)演算法,加密時會將原始資料按大小拆分成一個個 128 位元(即 16 位元組)區塊進行加密,如果需要加密的原始資料不是 16 位元組的整數倍時,就需要對原始資料進行填充,使其達到 16 位元組的整數倍。常用的填充方式有 PKCS5Padding、ISO10126Padding 等,另外如果能保證待加密的原始資料大小為 16 位元組的整數倍,也可以選擇不填充,即 NoPadding。

在實際工作中,需要跨團隊跨語言對資料加密解密,經常出現使用一個語言加密後,另一個語言無法解密的情況。這一般都是兩邊選擇的工作模式和填充方式不一致導致的。

下面的程式碼以 ECB 模式結合 PKCS5Padding 填充方式為例,對資料進行加密和解密:

public static byte[] encryptECB(byte[] data, byte[] key) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException {
    Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
    cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"));
    byte[] result = cipher.doFinal(data);
    return result;
}

public static byte[] decryptECB(byte[] data, byte[] key) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException {
    Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
    cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"));
    byte[] result = cipher.doFinal(data);
    return result;
}

public static void main(String[] args) throws IllegalBlockSizeException, InvalidKeyException, BadPaddingException, NoSuchAlgorithmException, NoSuchPaddingException {
    String data = "Hello World"; // 待加密的明文
    String key = "12345678abcdefgh"; // key 長度只能是 16、25 或 32 位元組

    byte[] ciphertext = encryptECB(data.getBytes(), key.getBytes());
    System.out.println("ECB 模式加密結果(Base64):" + Base64.getEncoder().encodeToString(ciphertext));

    byte[] plaintext = decryptECB(ciphertext, key.getBytes());
    System.out.println("解密結果:" + new String(plaintext));
}

輸出:

ECB 模式加密結果(Base64):bB0gie8pCE2RBQoIAAIxeA==
解密結果:Hello World

上面的 ECB 模式雖然簡單易用,但是安全性不高。由於該模式對每個 block 進行獨立加密,會導致同樣的明文塊被加密成相同的密文塊。下圖就是一個很好的例子:

在 CBC 模式中,引入了初始向量(IV,Initialization Vector)的概念,用於解決 ECB 模式的問題。

下面是 CBC 模式結合 PKCS5Padding 填充方式的程式碼示例,加密解密時相比 ECB 模式多了一個初始向量 iv 引數:

public static byte[] encryptCBC(byte[] data, byte[] key, byte[] iv) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException, InvalidAlgorithmParameterException {
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv));
    byte[] result = cipher.doFinal(data);
    return result;
}

public static byte[] decryptCBC(byte[] data, byte[] key, byte[] iv) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException, InvalidAlgorithmParameterException {
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv));
    byte[] result = cipher.doFinal(data);
    return result;
}

public static void main(String[] args) throws IllegalBlockSizeException, InvalidKeyException, BadPaddingException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidAlgorithmParameterException {
    String data = "Hello World"; // 待加密的原文
    String key = "12345678abcdefgh"; // key 長度只能是 16、25 或 32 位元組
    String iv = "iviviviviviviviv"; // CBC 模式需要用到初始向量引數

    byte[] ciphertext = encryptCBC(data.getBytes(), key.getBytes(), iv.getBytes());
    System.out.println("CBC 模式加密結果(Base64):" + Base64.getEncoder().encodeToString(ciphertext));

    byte[] plaintext = decryptCBC(ciphertext, key.getBytes(), iv.getBytes());
    System.out.println("解密結果:" + new String(plaintext));
}

輸出:

CBC 模式加密結果(Base64):K7bSB51+KxfqaMjJOsPAQg==
解密結果:Hello World

AES 使用非常廣泛,可以說只要上網,無論是使用手機 APP 還是 Web 應用,幾乎都離不開 AES 加密演算法。目前大部分網站,包括手機 APP 後端介面,都已經使用 HTTPS 協議,而 HTTPS 在資料傳輸階段大多都是使用 AES 對稱加密演算法。

但是,以 AES 為代表的的對稱加密演算法面臨一個問題,就是如何安全的傳輸金鑰。網路中發生資料交換的雙方,需要用同一個金鑰進行加密和解密,金鑰一旦暴露,傳輸的內容就不再安全。金鑰本身如果需要傳輸,如何保證安全?對於這個問題,就需要用到非對稱加密演算法。

非對稱加密演算法

1977 年,Rivest、Shamir、Adleman 設計了 RSA 非對稱加密演算法,並以此獲得了 2002 年的圖靈獎(計算機領域的國際最高獎項,被譽為"計算機界的諾貝爾獎")。至今,RSA 演算法一直是最廣為使用的非對稱加密演算法。

RSA 有兩個金鑰:公鑰(public key)和私鑰(private key)。

公鑰可以完全公開,任何人都可以獲取到。私鑰是私有的,要保證不能被洩露出去。

公鑰加密的內容,只有私鑰可以解密。私鑰加密的內容,也只有公鑰可以解密。

基於以上規則,RSA 有兩種不同的用法:

  • 公鑰加密,私鑰解密:服務端把公鑰公開出去,客戶端拿到公鑰,把想要傳輸給服務端的資料通過公鑰加密後傳輸,那麼這個資料只有服務端能夠解密,因為只有服務端擁有私鑰,其他任何中間人即使在傳輸過程中拿到資料,既不能解密,也無法篡改。
  • 私鑰簽名,公鑰驗證簽名:內容釋出者將釋出的內容用訊息摘要演算法(如 SHA-256)計算雜湊值,再用私鑰加密雜湊值,得到一個簽名,並將簽名加在釋出內容中一起釋出,其他人得到這個內容後,可以用公開的公鑰解密簽名得到雜湊值,再對比這個雜湊值和內容生成的雜湊值是否一致,來保證這份內容沒有被篡改過。

    由於只是驗證資料的真實性完整性,所以無需對整個內容進行加密,僅需對內容的雜湊值加密即可驗證,所以通常會結合訊息摘要演算法。例如 SHA256 with RSA 簽名,就是先用 SHA-256 計算出雜湊值,再用 RSA 私鑰加密。

上面說到的私鑰加密、公鑰解密只是理論上成立,實際上不會直接這樣用,而是隻用於簽名。因為一段私鑰加密的資料,解密的公鑰是公開的,意味著誰都可以解密,這樣加密就沒有任何意義了。

接下來通過 Java 程式碼來體驗一下 RSA 演算法。

首先,需要生成一對公鑰和私鑰。下面通過 openssl 命令來生成一對公鑰和私鑰:

# 建立一個 PKCS#8 格式 2048 位的私鑰
openssl genpkey -out private_key.pem -algorithm RSA -pkeyopt rsa_keygen_bits:2048
# 通過私鑰生成公鑰
openssl pkey -in private_key.pem -pubout -out public_key.pem

生成的公鑰和私鑰是 Base64 編碼的文字檔案,可以直接用文字編輯器開啟。拷貝到下面的程式碼中,可以驗證公鑰加密、私鑰解密,以及私鑰簽名、公鑰驗證簽名:

public static void main(String[] args) throws Exception {
    String publicKeyBase64 = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0XYlulDsTzDWUb6X66Ia\n" +
            "giSn1dKriHvLHYth9hCcaGomdeIQahGnxzE1o76slEyS2HZ164QHqx8Za+LuT6IV\n" +
            "yLhU/ZNLWAZABe/sdNEkhti6vSSOdJE43KS4UVADeSgtN+7uXDuVgm35EPWZjkfV\n" +
            "5hiRX4nT5ALr1niyi1Ax4BWWyG4qX00n1HzY8MvoyiLdNob71qB+amjUNy9bDhcz\n" +
            "CDWtgA/ywOYU5Ec6vMgYfbAXPKGWwo318rS3UH8QtsO8iGcQbZ76q05LNEL8G3fo\n" +
            "0Kssj4fjrVGwSsyGztRRMLfGkW/hOPCDj82+D6dGQlGB3gyB7P1xVbkD67FujQA/\n" +
            "jwIDAQAB";
    String privateKeyBase64 = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDRdiW6UOxPMNZR\n" +
            "vpfrohqCJKfV0quIe8sdi2H2EJxoaiZ14hBqEafHMTWjvqyUTJLYdnXrhAerHxlr\n" +
            "4u5PohXIuFT9k0tYBkAF7+x00SSG2Lq9JI50kTjcpLhRUAN5KC037u5cO5WCbfkQ\n" +
            "9ZmOR9XmGJFfidPkAuvWeLKLUDHgFZbIbipfTSfUfNjwy+jKIt02hvvWoH5qaNQ3\n" +
            "L1sOFzMINa2AD/LA5hTkRzq8yBh9sBc8oZbCjfXytLdQfxC2w7yIZxBtnvqrTks0\n" +
            "Qvwbd+jQqyyPh+OtUbBKzIbO1FEwt8aRb+E48IOPzb4Pp0ZCUYHeDIHs/XFVuQPr\n" +
            "sW6NAD+PAgMBAAECggEABT96joJ8iTdmB0JJOCQlmeElO1w94/uGCUV2vN2JrawL\n" +
            "LqTtWFr84ya+e03JsSWCAF5ncfEq6AStdGCJLAGZnh/QMVJBbwEpFXz/ZaXfzmkb\n" +
            "tKV31D/XNuABpjfk/mIdT+tymWj8w/nRZbVhlYkDOPKgoc4oOuw/0G3Ru1/VABI+\n" +
            "yulNx93A/JNFGk3Bkm4E7jRWyl0BkAqAX2BZkFbXG/u3Jc0eYXrG74JfMH+MEihG\n" +
            "GDMSpBKNyX5zWkUT6XxpG82t2erHPWYEoNSoFzAUu+7rZ4ECEXxazAQclEHTkR3r\n" +
            "duUZ/XF0GL1WB0GC7+qvV/Z0gxjXuwG9oToFO/0MQQKBgQDu4DuTPWcYwSWY0R1f\n" +
            "qZUOuYRwD+5OQnJMIlKAD32QmvYT/jnvigjss5Qf1IUwf1UMynj2FnVF4D7L+kvq\n" +
            "O7LzYvHAeDQwZGGt2xWBlqjfhumlfBqfklkkqUiH2A5DvfvtbX/kkiY3n9C+oYZp\n" +
            "2ejiOtSC+NqQeB74TluxroEkvwKBgQDgehynybpFl4KkmDhgj++BH5RR+xzXIChb\n" +
            "gtIbbspdE1EyXy7Z9iNAJ8PVjHkSwh8iEfAO4EuJFnonF8UNIsWLr3gsKbQytRxR\n" +
            "cewqaBhTL54Vgl5dmODNrYjkZva5HHDsCLioYGgljdrj5e/gPSAWBrgT6kI+HypQ\n" +
            "/5xyp+KJMQKBgQCMxut1P8eliBa/M+YqvYdR8TVC0bCwwGoZwlR6kiZ+9UQ2zimY\n" +
            "qPHPhZmzFI0V4sTdz+lvphahAqIfljftKBezZklxE6Y2KsKCMk4/W+nUKe9Cjpwm\n" +
            "FJqih31uSX9Gnw18hH7N1u/c8juUTR8o/LpJsUASm9Q7Nf+SeKODWINVgwKBgDEx\n" +
            "UXpLsPBzRYQAf8pZgKkRXJWirC1QtMdpIdY1L0+6Xf7l8QR+9janADmaMSY1OFFl\n" +
            "EPCRorwGGvraMKqyRgxYhcNX2E+MdQo8Jv8cFMiWFNSt3zQvvoQUVX2IOuVSIET5\n" +
            "nE354pjoP2HWD/1aJ9/r1Qc4PRAUEFfzzDssI27hAoGAOsYKtvW6iRn/WVduIRcy\n" +
            "UtBRHHX0U16zGv+I7nOOBIYK5Uan6AjgzG2MfPOBj3cUhMMBDPfVg1cTbonw5Y8F\n" +
            "nSO4VLOtqKy0BRxCIUFqltJXUmj1zAJs84IweCBQ3un/OLVUMgE7qGtaIQy2PBsy\n" +
            "M8mwuUjo3Fu7l11E2Vgz/qY=";

    // Base64 解碼
    byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyBase64.replace("\n", ""));
    byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyBase64.replace("\n", ""));

    // 生成 PublicKey(公鑰) 和 PrivateKey(私鑰) 物件
    PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(publicKeyBytes));
    PrivateKey privateKey = KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(privateKeyBytes));

    // 待加密、簽名的原文
    String data = "Hello World";

    // 公鑰加密,私鑰解密
    byte[] ciphertext = encrypt(data.getBytes(), publicKey);
    System.out.println("RSA 公鑰加密結果(Base64):" + Base64.getEncoder().encodeToString(ciphertext));
    byte[] plaintext = decrypt(ciphertext, privateKey);
    System.out.println("RSA 私鑰解密結果:" + new String(plaintext));

    // 私鑰簽名,公鑰驗證簽名
    byte[] signature = sign(data.getBytes(), privateKey);
    System.out.println("RSA 私鑰簽名結果(Base64):" + Base64.getEncoder().encodeToString(signature));
    boolean verifySuccess = verify(data.getBytes(), signature, publicKey);
    System.out.println("RSA 公鑰驗證簽名結果:" + verifySuccess);
}

/**
 * 公鑰加密
 */
public static byte[] encrypt(byte[] data, PublicKey publicKey) throws Exception {
    Cipher cipher = Cipher.getInstance("RSA");
    cipher.init(Cipher.ENCRYPT_MODE, publicKey);
    byte[] result = cipher.doFinal(data);
    return result;
}

/**
 * 私鑰解密
 */
public static byte[] decrypt(byte[] data, PrivateKey privateKey) throws Exception {
    Cipher cipher = Cipher.getInstance("RSA");
    cipher.init(Cipher.DECRYPT_MODE, privateKey);
    byte[] result = cipher.doFinal(data);
    return result;
}

/**
 * 私鑰簽名,使用 SHA256 with RSA 簽名
 */
public static byte[] sign(byte[] data, PrivateKey privateKey) throws Exception {
    Signature signature = Signature.getInstance("SHA256withRSA");
    signature.initSign(privateKey);
    signature.update(data);
    byte[] result = signature.sign();
    return result;
}

/**
 * 公鑰驗證簽名
 */
public static boolean verify(byte[] data, byte[] sign, PublicKey publicKey) throws Exception {
    Signature signature = Signature.getInstance("SHA256withRSA");
    signature.initVerify(publicKey);
    signature.update(data);
    return signature.verify(sign);
}

輸出:

RSA 公鑰加密結果(Base64):zoY6KM/RdCjAs7upJ9SIwqfXsSn3hAPu/z/ZPHbKgWN6+X0PpyVJVYT8jacEkzB7S2sJe/wLkO2TqXB2gqvL1AuDRgepVlxV2f6Uwx4DxM2/5RE0fAdTiICV5JEEIw81oLix0GGQ7nLjOhJxN9LaTJ2cXtwgR8gUtLtJ0tdWrxSMuN8FHLA45Nv8Ea1EAUQCvfanYZ2L39l++3/zBdg2wYQwCE6XGFnWnayUsGKYjC7JIufnq5f9VDL/kguLKceLmeTHqq31ccRTOQyhuoZjHCsbfXPlW2AT9ejgAcXy7LkXhYCfma50DBM+KUCfC4YrKBg6wKRqdZee90ZPcUKTkw==
RSA 私鑰解密結果:Hello World
RSA 私鑰簽名結果(Base64):AbP5zSV/qvkF8fCseVkEaZMscvznQBUDtO3g0U/FIXVmzeR6WXFwPsMd3cC3oCHtnnqsL/aRQrpW6pHU6EzSJ5w6FgY6kD4kWREq9f8LOnyQm7CoS6CK0tUiAjIgG16rtmS+oPbG+mYaZkLzo1Cpkpz2MzuMMbWNivvXRMbj3wLiXyIMqUefawipvm+GPwrWRxesRot2sGtuZcxtMMZs3NHpJ0CXV/mQlYJWEzIiHUY4mqfqpMDL/djPf9td74ABpjk38O6r1Jt75TLnMvkwRdh7pHBQLZ0Tn/6Vx2cVD2D+sE9BuhinO66B6I0QOGVcl3a5C2whp+85zEovvdGlSg==
RSA 公鑰驗證簽名結果:true

目前隨處可見的 HTTPS 協議,是基於 SSL/TLS 協議的。在 SSL/TLS 協議中,建立加密的傳輸通道前,首先有一個握手過程。在握手過程中,客戶端會生成一個隨機值,並使用公鑰加密後傳給服務端。這個隨機值用於生成對稱加密演算法的金鑰,僅有服務端的私鑰可以解密,任何第三方都無法解密,這就解決了前面所說到的對稱加密演算法金鑰傳輸過程中的安全問題。而握手成功後的通訊階段,則使用對稱加密演算法進行通訊。因為非對稱加密演算法更加複雜,相對於對稱加密演算法來說效率不高,不適合用來做大量資料的加密解密。

另外,SSL/TLS 中用到的數字證照(digital certificate),為了防止偽造,也會由 CA 機構進行數字簽名。目前大多數 HTTPS 網站使用的數字證照都是使用 SHA256 with RSA 簽名。

例如,在瀏覽器上開啟 https://xxgblog.com/ ,點選位址列左側的小鎖按鈕,檢視網站使用的證照,其數字簽名演算法就是 SHA256 with RSA :

相關文章