2012年6月19日 星期二

在 Java 使用加密演算法(一):產生與儲存 RSA 的金鑰(2017-07-19 調整)

正在嘗試要實作公開金鑰的加密演算法,目前搜尋了一些資料,原本有在疑惑應該選用哪個演算法
不過在搜尋到 [4] 以後,就決定還是用 RSA 了。

參考資料
1、JAVA 上加密演算法的實作範例
2、Generate Public and Private Keys
3、Save/Load Private and Public Key to/from a file
4、RSA Versus DSA and EL GAMAL
5、有關如何安全地儲存 key 的討論,可以看看:How to securely store a PrivateKey in code

這裡分成三個部分來討論,首先是要如何在 Java 裡面產生 RSA 的 Public Key 和 Private Key
以及把產生的兩把 Key 都儲存成檔案,再把它從檔案裡讀出來,最後才是真正把資料加密和解密。

生成金鑰

生成金鑰和讀寫金鑰可參考以下的程式碼,基本的程式碼是參考 [3],只有做小幅度的修改而已。
程式碼分為以下幾個 method:

  • main():程式的主要進入點,除了產生金鑰以外,也會負責呼叫其他 method。
  • printKeyPair():單純為了將產生的金鑰印出。而因為金鑰會是以 byte 形式存在的東西,因此必須透過某些編碼轉成能夠被印出來的字串。這裡是使用 Base64 編碼。
  • saveKeyPair():將金鑰分別輸出到指定路徑底下,並產生 “public.key” 和 “private.key” 兩個檔案。
  • loadKeyPair():從指定路徑中尋找 “public.key” 和 “private.key” 兩個檔案,並將他們載入成 KeyPair 型態到程式中。

以下就分別是各個 method 的原始碼以及相關的說明。

main()
public static void main(String[] args) throws Throwable {
  String path = "D:/encrypt";

  SecureRandom random = new SecureRandom();
  random.setSeed("test".getBytes());

  // Generate the key pair (public key and private key).
  KeyPairGenerator keygen = KeyPairGenerator.getInstance("RSA");
  // Specify that the key should be 1024-bit.
  keygen.initialize(1024, random);
  KeyPair generatedKeyPair = keygen.generateKeyPair();

  // Print the key. The key will be encoded by Base64.
  log.trace("Generated key pair:");
  printKeyPair(generatedKeyPair);

  // Store the key as files.
  log.info("Output the key pair to {}", path);
  saveKeyPair(path, generatedKeyPair);

  // Load the keys
  log.info("Loaded Key Pair from {}", path);
  KeyPair loadedKeyPair = loadKeyPair(path, "RSA"); // Load the keys from files

  // Print the loaded key pair to ensure that they are exactly the same.
  log.trace("Loaded key pair:");
  printKeyPair(loadedKeyPair);
}

4~5 行是用指定的字串去產生金鑰,透過這個方法的話,只要指定的字串是同一個,產生的金鑰就會是同一個。
這裡指定的字串是 "test"。
7~11 行是產生金鑰的程式碼,其中第 10 行的 1024 是指要產生的金鑰長度為 1024-bit
長度設得越長,產生金鑰所需要的時間也越長~
我的電腦產生 512 bits 的金鑰一瞬間就出來了,1024-bit 要大概一兩秒
2048-bit 就要稍微等個三秒之類的吧...(秒數是純感覺,沒有做過任何精準的測試)

11 行將 KeyPair 產生出來,也就是同時產生 Private Key 和 Public Key,會被存在同一個物件裡。
然後後面就依序是先把產生的金鑰印出來,接著把金鑰輸出成檔案
(預期輸出位置是 D:\encrypt\public.key 和 D:\encrypt\private.key)
然後再從檔案中把金鑰讀進來,再印出來,可以用眼睛確認跟輸出前是否長得一樣。

printKeyPair()

public static void printKeyPair(KeyPair keyPair) {
  log.trace("  public key: {}",
      Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()));
  log.trace("  private key: {}",
      Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()));
}

這裡很單純,就只是利用 Java 原生支援的 Base64 編碼器,把金鑰的內容輸出成文字印出來而已。

saveKeyPair()

public static void saveKeyPair(String path, KeyPair keyPair) throws IOException {
  PrivateKey privateKey = keyPair.getPrivate();
  PublicKey publicKey = keyPair.getPublic();

  // Store Public Key.
  File fileForPublicKey = Paths.get(path, "public.key").toFile();
  log.trace("Public key will be output to '{}'.", fileForPublicKey);
  
  X509EncodedKeySpec x509EncodedKeySpec = new X509EncodedKeySpec(publicKey.getEncoded());
  try (FileOutputStream fos = new FileOutputStream(fileForPublicKey)) {
    fos.write(x509EncodedKeySpec.getEncoded());
  }

  // Store Private Key.
  File fileForPrivateKey = Paths.get(path, "private.key").toFile();
  log.trace("Private key will be output to '{}'.", fileForPrivateKey);
  
  PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(privateKey.getEncoded());
  try (FileOutputStream fos = new FileOutputStream(fileForPrivateKey)) {
    fos.write(pkcs8EncodedKeySpec.getEncoded());
  }
}

5 ~ 7 行是產生路徑,實際執行起來,這裡產生的路徑會是 D:\encrypt\public.key。
9 ~ 12 行是把 Public Key 的內容輸出到檔案 D:\encrypt\public.key。

同樣地,接著 15 ~ 16 行也是產生路徑,實際執行時這個路徑會是 D:\encrypt\private.key。
18 ~ 21 行則是把 Private Key 的內容輸出到檔案 D:\encrypt\private.key。

loadKeyPair()

// Load the keys from files.
public static KeyPair loadKeyPair(String path, String algorithm)
    throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {

  // Initiate the factory with specified algorithm.
  KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
  
  // Read public key from file.
  File fileForPublicKey = Paths.get(path, "public.key").toFile();
  log.trace("Public key will be loaded from '{}'.", fileForPublicKey);
  
  PublicKey publicKey = null;
  try (FileInputStream fis = new FileInputStream(fileForPublicKey)) {
    byte[] loadedBytes = new byte[(int) fileForPublicKey.length()];
    fis.read(loadedBytes);
    
    X509EncodedKeySpec spec = new X509EncodedKeySpec(loadedBytes);
    publicKey = keyFactory.generatePublic(spec);
  }
  
  // Read private key from file.
  File fileForPrivateKey = Paths.get(path, "private.key").toFile();
  log.trace("Private key will be loaded from '{}'.", fileForPrivateKey);
  
  PrivateKey privateKey = null;
  try (FileInputStream fis = new FileInputStream(fileForPrivateKey)) {
    byte[] loadedBytes = new byte[(int) fileForPrivateKey.length()];
    fis.read(loadedBytes);
    
    PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(loadedBytes);
    privateKey = keyFactory.generatePrivate(privateKeySpec);
  }

  return new KeyPair(publicKey, privateKey);
}

這裡程式碼看起來好像比較長,但其實邏輯跟前一段 saveKeyPair() 沒什麼差別
就是組成路徑,然後讀檔案,再把讀入的 bytes 轉成金鑰的物件。因為流程上大同小異,這裡就先忽略細節了 XD

以上述的 method 全部放在同一個 Class 合併起來執行的話,會跑出類似下面這樣的 log 輸出:

2017-07-19 22:58:44,252 | Generated key pair:
2017-07-19 22:58:44,254 |   public key: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCvIaeWOTOx0aBWaz+fZNwsbork4e5RW94CbWR9rOtNE2jtKmgC6hPpWz7ANxzGL7jie9D/BpziyuSmhd3aquf4rmftiNdoTp55earfArlBO2D8Z0vaGnEUetDcahn0UD1bCekRC93iLzRNN73dm1T0CynFFrvFVA/CLgS/BvWEZwIDAQAB
2017-07-19 22:58:44,260 |   private key: MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAK8hp5Y5M7HRoFZrP59k3CxuiuTh7lFb3gJtZH2s600TaO0qaALqE+lbPsA3HMYvuOJ70P8GnOLK5KaF3dqq5/iuZ+2I12hOnnl5qt8CuUE7YPxnS9oacRR60NxqGfRQPVsJ6REL3eIvNE03vd2bVPQLKcUWu8VUD8IuBL8G9YRnAgMBAAECgYAMiyaLtfEj7VXEms3lxr2WWRyNpDkDjsbp+ZfXAImh7Z/4TK9Cdi2S6zwlXE0tTMG7Rw8DFSArhki2PKRVQyR2Jvp1m7v3ybwLSbP1GH9APKvfDZvjlNobzTPyut5W7jHFDVcAzSQujFcROKWG4gZk+CF9ywatmm3QaAIpiMgz8QJBAN3RlDj8OdzEcyoUNeYmszMWNEPJjoYMiiwrBYN7GCM5ZhK5fRF67PO38Y//PN1Ve5ViwGlKjyo9cRvIlVyRnnsCQQDKHlV71jCkeMherp0WqdtfAExd0BwzVR//CVHktTtCrutOcXOvEiZlnB92ExPzRCJDCD/gkqnU/h6UOM5tUoQFAkB95RFHNoBwuF7UpxvgQF68xAFt59uoYT2ay+AZO6f7dfxk7Dn7zdTmjqPfonGc/YNiyeWC3PpccvrbVgDPxSY5AkBF7wD8/DuQbQpHWHuaH+N7l4rU2vEnAck0YXEohVyf0g4w8iho5wrKFZ79J9S7U1PXhb80YQrKW7MQ7ibexLJRAkA5TcwKY9IEnYZcQlHI2ZZ0GZEL6TANxMXcFzr3OIk+3CHxBw0FKiG56zussDuRGdopey4KUEsCW19HupLsZamE
2017-07-19 22:58:44,260 | Output the key pair to D:/encrypt
2017-07-19 22:58:44,261 | Public key will be output to 'D:\encrypt\public.key'.
2017-07-19 22:58:44,262 | Private key will be output to 'D:\encrypt\private.key'.
2017-07-19 22:58:44,263 | Loaded Key Pair from D:/encrypt
2017-07-19 22:58:44,263 | Public key will be loaded from 'D:\encrypt\public.key'.
2017-07-19 22:58:44,264 | Private key will be loaded from 'D:\encrypt\private.key'.
2017-07-19 22:58:44,264 | Loaded key pair:
2017-07-19 22:58:44,264 |   public key: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCvIaeWOTOx0aBWaz+fZNwsbork4e5RW94CbWR9rOtNE2jtKmgC6hPpWz7ANxzGL7jie9D/BpziyuSmhd3aquf4rmftiNdoTp55earfArlBO2D8Z0vaGnEUetDcahn0UD1bCekRC93iLzRNN73dm1T0CynFFrvFVA/CLgS/BvWEZwIDAQAB
2017-07-19 22:58:44,264 |   private key: MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAK8hp5Y5M7HRoFZrP59k3CxuiuTh7lFb3gJtZH2s600TaO0qaALqE+lbPsA3HMYvuOJ70P8GnOLK5KaF3dqq5/iuZ+2I12hOnnl5qt8CuUE7YPxnS9oacRR60NxqGfRQPVsJ6REL3eIvNE03vd2bVPQLKcUWu8VUD8IuBL8G9YRnAgMBAAECgYAMiyaLtfEj7VXEms3lxr2WWRyNpDkDjsbp+ZfXAImh7Z/4TK9Cdi2S6zwlXE0tTMG7Rw8DFSArhki2PKRVQyR2Jvp1m7v3ybwLSbP1GH9APKvfDZvjlNobzTPyut5W7jHFDVcAzSQujFcROKWG4gZk+CF9ywatmm3QaAIpiMgz8QJBAN3RlDj8OdzEcyoUNeYmszMWNEPJjoYMiiwrBYN7GCM5ZhK5fRF67PO38Y//PN1Ve5ViwGlKjyo9cRvIlVyRnnsCQQDKHlV71jCkeMherp0WqdtfAExd0BwzVR//CVHktTtCrutOcXOvEiZlnB92ExPzRCJDCD/gkqnU/h6UOM5tUoQFAkB95RFHNoBwuF7UpxvgQF68xAFt59uoYT2ay+AZO6f7dfxk7Dn7zdTmjqPfonGc/YNiyeWC3PpccvrbVgDPxSY5AkBF7wD8/DuQbQpHWHuaH+N7l4rU2vEnAck0YXEohVyf0g4w8iho5wrKFZ79J9S7U1PXhb80YQrKW7MQ7ibexLJRAkA5TcwKY9IEnYZcQlHI2ZZ0GZEL6TANxMXcFzr3OIk+3CHxBw0FKiG56zussDuRGdopey4KUEsCW19HupLsZamE
透過 RSA 加密與解密

請參考下篇文章:在 Java 使用加密演算法(二):使用 RSA 加密與解密

附錄:對稱金鑰加密(symmetric-key algorithm)

原本弄錯了以為 AES 也是屬於公開金鑰加密,不過後來查到的程式碼覺得怪怪的
於是仔細查了一下 AES,發現它應該是對稱金鑰加密的演算法才對~(參閱下面參考資料中的連結 6)
也就是發送端與接收端共用同一個 secret key。

AES 破解的資訊可參考:New Attack on AES
微軟在 2011 年 8 月發現可以加快破解 AES 的方法,例如以 AES-128 來說,可以將複雜度降低到 2126.1
不過其實 2126.1 還是很多 XD,也就是目前來說 AES 還是安全性可接受的加密方法~。

參考資料:
6、AES encryption, what are public and private keys?
7、Using AES with Java Technology
8、【java】AES加密解密|及Base64的使用
9、Java AES Encrypt & Decrypt Example(加解密)
10、JAVA AES 加密步骤解释

5 則留言:

Unknown 提到...

你好,我想詢問一下檔案執行後會再D槽產生的兩個檔案public.key 和 private.key都是一般的.txt檔嗎?
因為自己依造上述的參考執行了程式,但並沒有產生任何的檔案,實在不知道問題出在哪裡

Wayne Yeh 提到...

因為這篇是蠻多年前寫的,現在看起來覺得程式碼有點雜亂,因此稍微以 Java 7 的方式修改了一下。不過~是的,這篇的範例程式碼正常執行完,應該會產生 public.key 和 private.key 這兩個檔案。而這兩個檔案.....並非是一般的 .txt 檔,也就是他們沒辦法用一般的文字編輯器打開,只會顯示一堆亂碼。因為就如同程式碼的流程一樣,金鑰就是一堆 bytes,沒有經過適當的編碼程序的話,這些 bytes 是沒辦法有意義地被顯示成字串。

如果在依照上述的範例程式碼,修改了以後卻沒有產生檔案、也沒有發生錯誤的話,比較可能的狀況通常是輸出成檔案的那段程式根本沒有執行、或者是那段程式出錯了,但被某個 try-catch 給隱藏起來,因此看不出來。建議要除錯可以先在程式碼各處塞一些無意義的 log,確認是否每一段程式碼都有被正確執行到。

程式學習紀錄 提到...

不好意思,想請問一下
LoadKey的時候沒有用Base64做轉換,是因為當初在存的時候不是用Base64的編碼下去做的
所以才不用做轉換,是嗎??

程式學習紀錄 提到...

我知道Base64可以防止亂碼產生,那如果不用Base64存成檔案,應該就算是亂碼儲存的,那之後讀出來的值一樣是正確的嗎??

Wayne Yeh 提到...

電腦在儲存資料時,本來就是以二進位的形式(在 Java 中是 byte[])表達,而且也必須是二進位,才能夠被電腦處理。二進位的資料直接輸出到螢幕會變成亂碼,單純只是因為他無法配對到正確的字元而已,這並不表示亂碼裡的內容就是錯誤的,只不過是無法正確顯示而已。具體的細節建議了解一下字元編碼(不過大概需要花上一些功夫就是,因為這概念本身不是很容易解釋)。

Base64 是其中一種常用的編碼,通常用來把二進位資料「在螢幕上印成人類看得懂的字母和符號」。主要目的大多是為了讓人類可以比較容易做判斷,例如判斷兩個二進位資料到底是不是一樣等等的。但因為 Base64 嘗試用 64 個字元來表達所有的可能性,因此基本上二進位資料轉換成 Base64 一定會變長。換言之,如果沒有必要讓人類看懂時,就沒必要把二進位資料用 Base64 編碼來存放,因為這樣只是無意義地在浪費儲存空間而已。