2012年6月26日 星期二

在 Java 使用加密演算法(三):使用 RSA 加解密長資料

在前一篇文章「在 Java 使用加密演算法(二)」中雖然已經有用 RSA 加解密的程式碼
但那段程式碼沒有辦法加解密長度稍長的資料(大概是 100 多個 bytes 以上)
稍微查詢了一些資料,目前個人的了解應該是受限於 RSA 是個 block cipher
block cipher 本身的設計就是一次只用來加解密很小的資料。
網路上雖然討論在 Java 中使用 RSA 的文章很多
但多數似乎都假定看的人應該要知道 RSA 的 "一次加密" 只能加密很短的資料,就很少在文章中提到這件事....。
PS. "一次加密" 指的就是執行一次 RSA 的動作。

什麼是 block cipher?
那麼什麼是 block cipher 呢?這跟密碼學的分類法有點關係
可以參考 [1] 的 PDF 檔(由網址看來是稻江技術學院的教學投影片吧)
根據投影片的內容,密碼學可以用不同的方式進行分類,有一種分類方法是依照對明文的處理方式不同來分類
因此可區分為資料區段加密法(block cipher)和資料流加密法(stream cipher)
而兩種方法的差異自 [1] 節錄如下:
(1)「資料區段加密 (block cipher)」
將明文分成數個n個字元或位元的區段,並且對每一個區段資料應用相同的演算法則和鑰匙,數學式表示為 (M為明文,分割成M1、M2… Mn區段)
• E(M,K)=E(M1,K)E(M2,K)… ..E(Mn,K)
(2)「資料流加密 (stream cipher)」
資料流加密並不會將明文切分為區段,而是一次加密資料流的一個位元或是位元組。常見的作法是將較短的加密鑰匙延展成為無限長、近似亂碼的一長串金鑰串流(keystream),再將金鑰串流和原始資料(plain text)經過XOR運算後,產生密文資料(cipher text)。

RSA 的長度限制為何?
有關 RSA 加密的長度限制問題,在 Java 中允許的長度可以參考 [2] 的討論
根據 neilcoffey 的回應,長度限制好像是 (key_size/8)-11
key_size 是金鑰長度(bits),轉成 byte 為單位後扣掉 11 bytes 作為 meta data,剩下的才是放資料的長度。

如何使用 RSA 加解密長資料?
要用 RSA 加解密長資料,其實作法在上面針對 block cipher 的說明中就提到了
要將長資料切割成許多的小資料,然後對每個小資料個別用 RSA 和金鑰去加密
接收端收到密文時,就把密文切割開來之後,每段個別做解密,解完的所有片段組合起來就會變成原本的明文了。

這裡參考的資料為 [3],以下的程式碼是以上一篇文章「在 Java 使用公開金鑰的加密演算法(二)」為基礎,加入 [3] 的程式碼後的結果。
與上篇文一樣,主要分為三個部分,Main.java、myKeyPair.java 和 myEncryption.java,其中 [3] 的程式碼是加在 myEncryption.java 裡。

Main.java
import java.io.UnsupportedEncodingException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.Calendar;

public class Main {
  public static void main(String[] args) {
    myKeyPair adam = new myKeyPair();
    PublicKey publicKey = null;
    PrivateKey privateKey = null;
    try {
      String path = "D:/encrypt";
      // Load the keys
      System.out.println("Loaded Key Pair");
      KeyPair loadedKeyPair = 
          adam.LoadKeyPair(path, "RSA");     // Load the keys from files
      publicKey = loadedKeyPair.getPublic();
      privateKey = loadedKeyPair.getPrivate();
    } catch (Exception e) {
      e.printStackTrace();
      return;
    }
    long start, end;
    
    // Generate the plain text
    String plainText = "abc123!@#";
    for(int i=0 ; i<10 ; i++)
      plainText += plainText;
    
    System.out.println("Encryption:");
    start = Calendar.getInstance().getTimeInMillis();
    myEncryption encryption = new myEncryption();
    byte[] result = null;
    try {
      // Encrypt
      result = encryption.encryptByRSA(plainText.getBytes("UTF-8"), (RSAPublicKey)publicKey);
      end = Calendar.getInstance().getTimeInMillis();
      System.out.println("Encrypted time: " + (end-start) + "ms\n");
      
      // Decrypt
      System.out.println("Decryption:");
      start = Calendar.getInstance().getTimeInMillis();
      byte[] decryptResult = encryption.decryptByRSA(result, (RSAPrivateKey)privateKey);
      end = Calendar.getInstance().getTimeInMillis();
      System.out.println("Decrypted string: " + new String(decryptResult, "UTF-8"));
      System.out.println("Decrypted time: " + (end-start) + "ms\n");
    } catch (UnsupportedEncodingException e) {
      e.printStackTrace();
    }
  }
}

Main.java 這邊基本上跟前篇比起來沒太大改變,只有在明文的部份用小迴圈加長很多
(二)那篇的程式碼大概是迴圈跑三到四次的字串長度就會 Exception 了,這裡先讓它跑個十次。
另外 String 轉 byte array 的地方額外加上了使用 UTF-8 編碼。

myEncryption.java
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;

public class myEncryption {
  public byte[] encryptByRSA (byte[] data, RSAPublicKey publicKey) {
    try {
      Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
      cipher.init(Cipher.ENCRYPT_MODE, publicKey);
      return this.blockCipher(data, cipher, Cipher.ENCRYPT_MODE);
    } catch (NoSuchAlgorithmException e) {
      e.printStackTrace();
    } catch (NoSuchPaddingException e) {
      e.printStackTrace();
    } catch (InvalidKeyException e) {
      e.printStackTrace();
    } catch (IllegalBlockSizeException e) {
      e.printStackTrace();
    } catch (BadPaddingException e) {
      e.printStackTrace();
    }
    return new byte[0];
  }
  
  public byte[] decryptByRSA (byte[] data, RSAPrivateKey privateKey) {
    try {
      Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
      cipher.init(Cipher.DECRYPT_MODE, privateKey);
      return this.blockCipher(data, cipher, Cipher.DECRYPT_MODE);
    } catch (NoSuchAlgorithmException e) {
      e.printStackTrace();
    } catch (NoSuchPaddingException e) {
      e.printStackTrace();
    } catch (InvalidKeyException e) {
      e.printStackTrace();
    } catch (IllegalBlockSizeException e) {
      e.printStackTrace();
    } catch (BadPaddingException e) {
      e.printStackTrace();
    }
    return new byte[0];
  }
  
  private byte[] blockCipher(byte[] bytes, Cipher cipher, int mode) throws IllegalBlockSizeException, BadPaddingException{
    // string initialize 2 buffers.
    // scrambled will hold intermediate results
    byte[] scrambled = new byte[0];

    // toReturn will hold the total result
    byte[] toReturn = new byte[0];
    // if we encrypt we use 100 byte long blocks. Decryption requires 128 byte long blocks (because of RSA)
    int length = (mode == Cipher.ENCRYPT_MODE)? 100 : 128;

    // another buffer. this one will hold the bytes that have to be modified in this step
    byte[] buffer = new byte[length];

    for (int i=0; i< bytes.length; i++){
      // if we filled our buffer array we have our block ready for de- or encryption
      if ((i > 0) && (i % length == 0)){
        // execute the operation
        scrambled = cipher.doFinal(buffer);
        // add the result to our total result.
        toReturn = this.appendBytes(toReturn,scrambled);
        // here we calculate the length of the next buffer required
        int newlength = length;

        // if newlength would be longer than remaining bytes in the bytes array we shorten it.
        if (i + length > bytes.length)
           newlength = bytes.length - i;
        // clean the buffer array
        buffer = new byte[newlength];
      }
      // copy byte into our buffer.
      buffer[i%length] = bytes[i];
    }

    // this step is needed if we had a trailing buffer. should only happen when encrypting.
    // example: we encrypt 110 bytes. 100 bytes per run means we "forgot" the last 10 bytes. they are in the buffer array
    scrambled = cipher.doFinal(buffer);

    // final step before we can return the modified data.
    toReturn = this.appendBytes(toReturn,scrambled);

    return toReturn;
  }
  
  private byte[] appendBytes(byte[] prefix, byte[] suffix) {
    byte[] toReturn = new byte[prefix.length + suffix.length];
    for(int i=0 ; i<prefix.length ; i++)
      toReturn[i] = prefix[i];
    for(int i=0 ; i<suffix.length ; i++)
      toReturn[i + prefix.length] = suffix[i];
    return toReturn;
  }
}

這段可以看出,主要改變是 encryptByRSA() 和 decryptByRSA() 兩個函式都只有初始化 Cipher 而已
剩下的工作全部進入新貼進來的 blockCipher() 函式去進行
而 blockCipher() 函式的動作就是每 100 bytes 進行一次 RSA 加密,再把加密後的 bytes 續接到要輸出的 byte array。

程式輸出如下:
Loaded Key Pair
Encryption:
Encrypted time: 266ms

Decryption:
Decrypted time: 594ms

沒有印出加密後的密文和解密後的原始明文是因為字串太長了 XD
想測試的話可以自己加上去,我測下來是可以正確解密的。
另外結果也可以看出很明顯解密時間變長很多!加密時間倒是沒太大變化,讓我有點疑惑 XD...

2014-05-20 補充:
關於 blockCipher() 中第 行的描述,加密時是以每 100 bytes 做一次切割,而解密時則是以每 128 bytes 做切割
意味著 100 bytes 加密後應會產出 128 bytes 的結果,因此解密時要把同樣長度的資料解回來。
不過實務上這可能跟金鑰長度有關,比如說金鑰長度延展成 2048-bit 時,每 100 bytes 加密會產出 256 bytes 的結果。
另外演算法使用的填充方式不同,也會導致一輪加密中能夠加密的長度產生變化。
相關的討論可以參考 [4-5]。

參考資料:
1、密碼學原理與技術
2、Java Encryption: RSA Block Size?
3、Encrypting and decrypting large data using Java and RSA
4、RSA密钥长度、明文长度和密文长度
5、RSA 加密 明文长度 超出

沒有留言: