Java實現7種常見密碼演演算法

2022-10-22 15:00:47

原創:扣釘日記(微信公眾號ID:codelogs),歡迎分享,轉載請保留出處。

簡介

前面在密碼學入門一文中講解了各種常見的密碼學概念、演演算法與運用場景,但沒有介紹過程式碼,因此,為作補充,這一篇將會介紹使用Java語言如何實現使用這些演演算法,並介紹一下使用過程中可能遇到的坑。

Java加密體系JCA

Java抽象了一套密碼演演算法框架JCA(Java Cryptography Architecture),在此框架中定義了一套介面與類,以規範Java平臺密碼演演算法的實現,而Sun,SunRsaSign,SunJCE這些則是一個個JCA的實現Provider,以實現具體的密碼演演算法,這有點像List與ArrayList、LinkedList的關係一樣,Java開發者只需要使用JCA即可,而不用管具體是怎麼實現的。

JCA裡定義了一系列類,如Cipher、MessageDigest、MAC、Signature等,分別用於實現加密、密碼學雜湊、認證碼、數位簽章等演演算法,一起來看看吧!

對稱加密

對稱加密演演算法,使用Cipher類即可,以廣泛使用的AES為例,如下:

public byte[] encrypt(byte[] data, Key key) {
    try {
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        byte[] iv = SecureRandoms.randBytes(cipher.getBlockSize());
        //初始化金鑰與加密引數iv
        cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));
        //加密
        byte[] encryptBytes = cipher.doFinal(data);
        //將iv與密文拼在一起
        ByteArrayOutputStream baos = new ByteArrayOutputStream(iv.length + encryptBytes.length);
        baos.write(iv);
        baos.write(encryptBytes);
        return baos.toByteArray();
    } catch (Exception e) {
        return ExceptionUtils.rethrow(e);
    }
}

public byte[] decrypt(byte[] data, Key key) {
    try {
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        //獲取密文前面的iv
        IvParameterSpec ivSpec = new IvParameterSpec(data, 0, cipher.getBlockSize());
        cipher.init(Cipher.DECRYPT_MODE, key, ivSpec);
        //解密iv後面的密文
        return cipher.doFinal(data, cipher.getBlockSize(), data.length - cipher.getBlockSize());
    } catch (Exception e) {
                return ExceptionUtils.rethrow(e);
    }
}

如上,對稱加密主要使用Cipher,不管是AES還是DES,Cipher.getInstance()傳入不同的演演算法名稱即可,這裡的Key引數就是加密時使用的金鑰,稍後會介紹它是怎麼來的,暫時先忽略它。
另外,為了使得每次加密出來的密文不同,我使用了隨機的iv向量,並將iv向量拼接在了密文前面。

注:如果某個演演算法名稱,如上面的AES/CBC/PKCS5Padding,你不知道它在JCA中的標準名稱是什麼,可以到 https://docs.oracle.com/en/java/javase/11/docs/specs/security/standard-names.html 中查詢即可。

非對稱加密

非對稱加密同樣是使用Cipher類,只是傳入的金鑰物件不同,以RSA演演算法為例,如下:

public byte[] encryptByPublicKey(byte[] data, PublicKey publicKey){
    try{
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.ENCRYPT_MODE, publicKey);
        return cipher.doFinal(data);
    }catch (Exception e) {
        throw Errors.toRuntimeException(e);
    }
}

public byte[] decryptByPrivateKey(byte[] data, PrivateKey privateKey){
    try{
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.DECRYPT_MODE, privateKey);
        return cipher.doFinal(data);
    }catch (Exception e) {
        throw Errors.toRuntimeException(e);
    }
}

一般來說應使用公鑰加密,私鑰解密,但其實反過來也是可以的,這裡的PublicKey與PrivateKey也先忽略,後面會介紹它怎麼來的。

密碼學雜湊

密碼學雜湊演演算法包括MD5、SHA1、SHA256等,在JCA中都使用MessageDigest類即可,如下:

public static String sha256(byte[] bytes) throws NoSuchAlgorithmException {
    MessageDigest digest = MessageDigest.getInstance("SHA-256");
    digest.update(bytes);
    return Hex.encodeHexString(digest.digest());
}

訊息鑑別碼

訊息鑑別碼使用Mac類實現,以常見的HMAC搭配SHA256為例,如下:

public byte[] digest(byte[] data, Key key) throws InvalidKeyException, NoSuchAlgorithmException{
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(key);
    return mac.doFinal(data);
}

數位簽章

數位簽章使用Signature類實現,以RSA搭配SHA256為例,如下:

public byte[] sign(byte[] data, PrivateKey privateKey) {
    try {
        Signature signature = Signature.getInstance("SHA256withRSA");
        signature.initSign(privateKey);
        signature.update(data);
        return signature.sign();
    } catch (Exception e) {
        return ExceptionUtils.rethrow(e);
    }
}

public boolean verify(byte[] data, PublicKey publicKey, byte[] sign) {
    try {
        Signature signature = Signature.getInstance("SHA256withRSA");
        signature.initVerify(publicKey);
        signature.update(data);
        return signature.verify(sign);
    } catch (Exception e) {
        return ExceptionUtils.rethrow(e);
    }
}

金鑰協商演演算法

在JCA中,使用KeyAgreement來呼叫金鑰協商演演算法,以ECDH協商演演算法為例,如下:

public static void testEcdh() {
    KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC");
    ECGenParameterSpec ecSpec = new ECGenParameterSpec("secp256r1");
    keyGen.initialize(ecSpec);
    // A生成自己的私密資訊
    KeyPair keyPairA = keyGen.generateKeyPair();
    KeyAgreement kaA = KeyAgreement.getInstance("ECDH");
    kaA.init(keyPairA.getPrivate());
    // B生成自己的私密資訊
    KeyPair keyPairB = keyGen.generateKeyPair();
    KeyAgreement kaB = KeyAgreement.getInstance("ECDH");
    kaB.init(keyPairB.getPrivate());

    // B收到A傳送過來的公用資訊,計算出對稱金鑰
    kaB.doPhase(keyPairA.getPublic(), true);
    byte[] kBA = kaB.generateSecret();

    // A收到B傳送過來的公開資訊,計算對對稱金鑰
    kaA.doPhase(keyPairB.getPublic(), true);
    byte[] kAB = kaA.generateSecret();
    Assert.isTrue(Arrays.equals(kBA, kAB), "協商的對稱金鑰不一致");
}

基於口令加密PBE

通常,對稱加密演演算法需要使用128位元位元組的金鑰,但這麼長的金鑰使用者是記不住的,使用者容易記住的是口令,也即password,但與金鑰相比,口令有如下弱點:

  1. 口令通常較短,這使得直接使用口令加密的強度較差。
  2. 口令隨機性較差,因為使用者一般使用較容易記住的東西來生成口令。

為了使得使用者能直接使用口令加密,又能最大程度避免口令的弱點,於是PBE(Password Based Encryption)演演算法誕生,思路如下:

  1. 既然密碼演演算法需要金鑰,那在加解密前,先使用口令生成金鑰,然後再使用此金鑰去加解密。
  2. 為了彌補口令隨機性較差的問題,生成金鑰時使用隨機鹽來混淆口令來產生準金鑰,再使用雜湊函數對準金鑰進行多次雜湊迭代,以生成最終的金鑰。

因此,使用PBE演演算法進行加解密時,除了要提供口令外,還需要提供隨機鹽(salt)與迭代次數(iteratorCount),如下:

public static byte[] encrypt(byte[] plainBytes, String password, byte[] salt, int iteratorCount) {
    try {
        PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());
        SecretKey key = SecretKeyFactory.getInstance("PBEWithMD5AndTripleDES").generateSecret(keySpec);

        Cipher cipher = Cipher.getInstance("PBEWithMD5AndTripleDES");
        cipher.init(Cipher.ENCRYPT_MODE, key, new PBEParameterSpec(salt, iteratorCount));
        byte[] encryptBytes = cipher.doFinal(plainBytes);
        byte[] iv = cipher.getIV();
        ByteArrayOutputStream baos = new ByteArrayOutputStream(iv.length + encryptBytes.length);
        baos.write(iv);
        baos.write(encryptBytes);
        return baos.toByteArray();
    } catch (Exception e) {
        throw Errors.toRuntimeException(e);
    }
}

public static byte[] decrypt(byte[] secretBytes, String password, byte[] salt, int iteratorCount) {
    try {
        PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());
        SecretKey key = SecretKeyFactory.getInstance("PBEWithMD5AndTripleDES").generateSecret(keySpec);

        Cipher cipher = Cipher.getInstance("PBEWithMD5AndTripleDES");
        IvParameterSpec ivParameterSpec = new IvParameterSpec(secretBytes, 0, cipher.getBlockSize());
        cipher.init(Cipher.DECRYPT_MODE, key, new PBEParameterSpec(salt, iteratorCount, ivParameterSpec));
        return cipher.doFinal(secretBytes, cipher.getBlockSize(), secretBytes.length - cipher.getBlockSize());
    } catch (Exception e) {
        throw Errors.toRuntimeException(e);
    }
}

public static void main(String[] args) throws Exception {
    byte[] content = "hello".getBytes(StandardCharsets.UTF_8);
    byte[] salt = Base64.decode("QBadPOP6/JM=");
    String password = "password";
    byte[] encoded = encrypt(content, password, salt, 1000);
    System.out.println("密文:" + Base64.encode(encoded));
    byte[] plainBytes = decrypt(encoded, password, salt, 1000);
    System.out.println("明文:" + new String(plainBytes, StandardCharsets.UTF_8));
}

注意,雖然使用PBE加解密資料,都需要使用相同的password、salt、iteratorCount,但這裡面只有password是需要保密的,salt與iteratorCount不需要,可以儲存在資料庫中,比如每個使用者註冊時給他生成一個隨機鹽。

到此,JCA密碼演演算法就介紹完了,來回顧一下:

整體來說,JCA對密碼演演算法相關的類設計與封裝還是非常清晰簡單的!

但使用密碼演演算法時,依賴SecretKey、PublicKey、PrivateKey物件提供金鑰資訊,那這些金鑰物件是怎麼來的呢?

金鑰生成與讀取

密碼學亂數

密碼學亂數演演算法在安全場景中使用廣泛,如:生成對稱金鑰、鹽、iv等,因此相比普通的亂數演演算法(如線性同餘),它需要更高強度的不可預測性,在Java中,使用SecureRandom來生成更安全的亂數,如下:

public class SecureRandoms {
	public static byte[] randBytes(int len) throws NoSuchAlgorithmException {
		byte[] bytes = new byte[len];
		SecureRandom secureRandom = SecureRandom.getInstance("SHA1PRNG");
		secureRandom.nextBytes(bytes);
		return bytes;
	}
}

SecureRandom使用了更高強度的隨機演演算法,同時會讀取機器本身的隨機熵值,如/dev/urandom,因此相比普通的Random,它具有更強的隨機性,因此,對於需要生成金鑰的場景,該用哪個要擰得清。

對稱金鑰

在JCA中對稱金鑰使用SecretKey表示,若要生成一個新的SecretKey,可使用KeyGenerator,如下:

//生成新的金鑰
public static SecretKey genSecretKey() {
    KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
    keyGenerator.init(SecureRandom.getInstance("SHA1PRNG"));
    SecretKey secretKey = keyGenerator.generateKey();
}

而如果是從檔案中讀取金鑰的話,則可以藉助SecretKeyFactory將其轉換為SecretKey,如下:

//讀取金鑰
public static SecretKey getSecretKey() {
    byte[] keyBytes = readKeyBytes();
    String alg = "AES";
    SecretKey secretKey = SecretKeyFactory.getInstance(alg).generateSecret(new SecretKeySpec(keyBytes, alg));
}

非對稱金鑰

在JCA中,對於非對稱金鑰,公鑰使用PublicKey表示,私鑰使用PrivateKey表示,若要生成一個新的公私鑰對,可使用KeyPairGenerator,如下:

//生成新的公私鑰對
public static void genKeyPair() {
    KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
    keyPairGen.initialize(2048);
    KeyPair keyPair = keyPairGen.generateKeyPair();
    PublicKey publicKey = keyPair.getPublic();
    PrivateKey privateKey = keyPair.getPrivate();
}

而如果是從檔案中讀取公私鑰的話,一般公鑰是X509格式,而私鑰是PKCS8格式,分別對應JCA中的X509EncodedKeySpec與PKCS8EncodedKeySpec,如下:

//讀取私鑰
public static PrivateKey getPrivateKey() {
    byte[] privateKeyBytes = readPrivateKeyBytes();
    PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
    PrivateKey privateKey = KeyFactory.getInstance("RSA").generatePrivate(pkcs8EncodedKeySpec);
}

//讀取公鑰
public static PublicKey getPublicKey() {
    byte[] publicKeyBytes = readPublicKeyBytes();
    X509EncodedKeySpec x509EncodedKeySpec = new X509EncodedKeySpec(publicKeyBytes);
    PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic(x509EncodedKeySpec);
}

注意,KeyGenerator、KeyPairGenerator與KeyFactory從命名上看起來有點相似,但它們實現的功能是完全不同的,KeyGenerator、KeyPairGenerator用於生成新的金鑰,而KeyFactory則用於將KeySpec轉換為對應的Key金鑰物件。

JCA金鑰相關類關係一覽,如下:

常見問題

密文無法解密問題

有時,在使用密碼演演算法時,會發現別人提供的密文使用正確的金鑰卻無法解密出來,特別容易發生在跨語言的情況下,如加密方使用的C#語言,而解密方卻使用的Java。

遇到這種情況,你需要和對方認真確認加密時使用的加密模式、填充模式以及IV等密碼引數是否完全一致。

如AES演演算法加密模式有ECBCBCCFBCTRGCM等,填充模式有PKCS#5, ISO 10126, ANSI X9.23等,以及對方是使用了固定的IV向量還是將IV向量拼在了密文中,這些都需要確認清楚並與對方保持一致才能正確解密。

簽名失敗問題

簽名失敗也是使用密碼演演算法時常見的情況,比如對方生成的MD5值與你生成的MD5不一致,常見有2種原因,如下:
1. 使用的字元編碼不一致導致
密碼演演算法為了通用性,操作物件都是位元組陣列,而你要簽名的物件一般是字串,因此你需要將字串轉為位元組陣列之後再做md5運算,如下:

  • 呼叫方:md5(str.getBytes())
  • 服務方:md5(str.getBytes())

看起來兩邊的程式碼一模一樣,但問題就在getBytes()函數中,getBytes()函數預設會使用作業系統的字元編碼將字串轉為位元組陣列,而中文Windows預設字元編碼是GBK,而Linux預設是UTF-8,這就導致當str中有中文時,呼叫方與服務方獲取到的位元組陣列是不一樣的,那生成的MD5值當然也不一樣了。

因此,強烈推薦在使用getBytes()函數時,傳入統一的字元編碼,如下:

  • 呼叫方:md5(str.getBytes("UTF-8"))
  • 服務方:md5(str.getBytes("UTF-8"))
    這樣就能有效地避過這個非常隱晦的坑了。

2. json的escape功能導致
有些json框架,做json序列化時會預設做一些跳脫操作,如把&字元跳脫為\u0026,但如果伺服器端做json反序列化時沒有做反跳脫,這會導致兩邊計算的簽名值不一樣,如下:

  • 呼叫方:md5("&")
  • 服務方:md5("\\u0026")
    這也是一個非常隱晦的坑,如Gson預設就會有這種行為,可使用new GsonBuilder().disableHtmlEscaping()禁用。

生成與讀取證書

概念

隨著對密碼學瞭解的深入,會發現有特別多奇怪的名詞出現,讓人迷惑不已,如PKCS8X.509ASN.1DERPEM等,接下來就來澄清下這些名詞是什麼,以及它們之間的關係。

首先,瞭解3個概念,如下:

  • 金鑰:包括對稱金鑰與非對稱金鑰等。
  • 證書:包含使用者或網站的身份資訊、公鑰,以及CA的簽名。
  • 金鑰庫:用於儲存金鑰與證書的倉庫。

ASN.1語法

ASN.1抽象語法標記(Abstract Syntax Notation One),和XML、JSON類似,用於描述物件結構,可以把它看成一種描述語言,簡單的範例如下:

Report ::= SEQUENCE {
author OCTET STRING,
title OCTET STRING,
body OCTET STRING,
}

這個語法描述了一個結構體,它包含3個屬性author、title、body,且都是字串型別。

DER與PEM

DER是ASN.1的一種序列化編碼方案,也就是說ASN.1用來描述物件結構,而DER用於將此物件結構編碼為可儲存的位元組陣列。

PEM(Privacy Enhanced Mail)是一種將二進位制資料,以文字形式進行儲存或傳輸的方案,早期主要用於郵件中交換證書,它的文字內容常以-----BEGIN XXX-----開頭,並以-----END XXX-----結尾,而中間 Body 部分則為 Base64 編碼後的資料,如下是一個證書的PEM樣例。

以上面證書為例,PEM與DER的關係大概如下:

PEM = "-----BEGIN CERTIFICATE-----" + base64(DER) +  "-----END CERTIFICATE-----"

X.509、PKCS8、PKCS12等

X.509、PKCS8、PKCS12等都是公鑰密碼學標準(PKCS)組織制定的各種密碼學規範,該組織使用ASN.1語法為金鑰、證書、金鑰庫等定義了標準的物件結構,常見的如下:

  • X.509規範:用於描述證書與公鑰的標準格式。
  • PKCS7規範:可描述的物件很多,不過一般也是用於描述證書的。
  • PKCS8規範:用於描述私鑰的標準格式。
  • PKCS12規範:用於描述金鑰庫的標準格式。
  • PKCS1規範:用於描述RSA演演算法及其公私鑰的標準格式。

這些規範都有相應的RFC檔案,感興趣的可以前往檢視:

PEM:https://www.rfc-editor.org/rfc/rfc7468   
X.509:https://datatracker.ietf.org/doc/html/rfc5280  
PKCS7:https://datatracker.ietf.org/doc/html/rfc2315  
PKCS8:https://datatracker.ietf.org/doc/html/rfc8351  
PKCS12:https://datatracker.ietf.org/doc/html/rfc7292  
PKCS1:https://datatracker.ietf.org/doc/html/rfc8017#appendix-A  

類比一下,如果把ASN.1比作Java,那X.509就是使用Java定義的一個名叫X509的類,這個類裡面包含身份資訊、公鑰資訊等相關欄位,而DER就是一種Java物件序列化方案,用於將X509這個類的物件序列化為位元組陣列,位元組陣列儲存為檔案後,這個檔案就是我們常說的證書或金鑰檔案。

常見證書檔案

由於PKCS組織並未給證書檔案定下標準的檔名字尾,所以證書檔案有非常多的字尾名,如下:

  • .der: DER編碼的證書,一般是X.509規範的,無法用文字編輯器直接開啟
  • .pem: PEM編碼的證書,一般是X.509規範的
  • .crt: 常見於unix類系統,一般是X.509規範的,可能是DER編碼或PEM編碼
  • .cer: 常見於windows系統,一般是X.509規範的,可能是DER編碼或PEM編碼
  • .p7b: 常見於windows系統,PKCS7規範證書,可能是DER編碼或PEM編碼
  • .pfx:PKCS12規範的金鑰庫檔案,也有取名為.p12的
  • .jks:java專用的金鑰庫檔案格式,在java技術棧內使用較多,非java一般使用.pfx

證書概念小結

生成證書與金鑰庫

openssl命令提供了大量的工具,用以生成金鑰、證書與金鑰庫檔案,如下,是一個典型的生成金鑰與證書的過程:

# 生成pkcs1 rsa私鑰
openssl genrsa -out rsa_private_key_pkcs1.key 2048
# 生成pkcs1 rsa公鑰
openssl rsa -in rsa_private_key_pkcs1.key -RSAPublicKey_out -out rsa_public_key_pkcs1.key

# 生成證書申請檔案cert.csr
openssl req -new -key rsa_private_key_pkcs1.key -out cert.csr
# 自簽名(演示時使用,生產環境一般不用自簽證書)  
openssl x509 -req -days 365 -in cert.csr -signkey rsa_private_key_pkcs1.key -out cert.crt
# ca簽名(將證書申請檔案提交給ca機構簽名)
openssl x509 -req -days 365 -in cert.csr -CA ca_cert.crt -CAkey ca_private_key.pem -CAcreateserial -out cert.crt

# 生成p12金鑰庫檔案
openssl pkcs12 -export -in cert.crt -inkey rsa_private_key_pkcs1.key -name demo -out keystore.p12

有時別人發來的金鑰或證書檔案無法讀取,也可使用openssl確認一下,如果openssl能讀出來,那大概率是自己程式有問題,如果openssl讀不出來,那大概率是別人發的檔案有問題,如下:

# 檢視pkcs1 rsa私鑰
openssl rsa -in rsa_private_key_pkcs1.key -text -noout
# 檢視pkcs1 rsa公鑰
openssl rsa -RSAPublicKey_in -in rsa_public_key_pkcs1.key -text -noout

# 檢視x.509證書
openssl x509 -in cert.crt -text -nocert

# 檢視pkcs12金鑰庫檔案
openssl pkcs12 -in keystore.p12
keytool -v -list -storetype pkcs12 -keystore keystore.p12

由於金鑰、證書、金鑰庫檔案,其實都是使用ASN.1語法描述的,所以它們都能按ASN.1語法解析出來,如下:

openssl asn1parse -i -inform pem -in cert.crt

證書格式轉換

某些情況下,我們需要在不同格式的金鑰或證書檔案之間轉換,也可使用openssl命令來完成。
金鑰格式轉換,如下:

# rsa公鑰轉換為X509公鑰
openssl rsa -RSAPublicKey_in -in rsa_public_key_pkcs1.key -pubout -out public_key_x509.key
# rsa私鑰轉換為PKCS8格式
openssl pkcs8 -topk8 -inform PEM -in rsa_private_key_pkcs1.key -outform PEM -nocrypt -out private_key_pkcs8.key
# pkcs8轉rsa私鑰
openssl pkcs8 -inform PEM -nocrypt -in private_key_pkcs8.key -traditional -out rsa_private_key_pkcs1.key

證書格式轉換,如下:

# 證書DER轉PEM
openssl x509 -inform der -in cert.der -outform pem -out cert.pem -noout
# x509證書轉pkcs7證書
openssl crl2pkcs7 -nocrl -certfile cert.crt -out cert.p7b
# 檢視pkcs7證書
openssl pkcs7 -print_certs -in cert.p7b -noout

由於金鑰庫中包含證書與私鑰,故可以從金鑰庫檔案中提取出證書與私鑰,如下:

# 從pkcs12金鑰庫中提取證書
openssl pkcs12 -in keystore.p12 -clcerts -nokeys -out cert.crt
# 從pkcs12金鑰庫中提取私鑰
openssl pkcs12 -in keystore.p12 -nocerts -nodes -out private_key.key
# pkcs12轉jks
keytool -importkeystore -srckeystore keystore.p12 -srcstoretype pkcs12 -srcalias demo -destkeystore keystore.jks -deststoretype jks -deststorepass 123456 -destalias demo
# 從jks中提取證書
keytool -export -alias demo -keystore keystore.jks -file cert.crt

讀取金鑰或證書檔案

使用JCA來讀取金鑰或證書檔案,也是非常方便的。

PEM轉DER

若要將PEM格式檔案轉換為DER,只需要把---BEGIN XXX------END XXX---去掉,然後使用Base64解碼即可,如下:

private static byte[] pemFileToDerBytes(String pemFilePath) throws IOException {
    InputStream is = ClassLoader.getSystemClassLoader().getResourceAsStream(pemFilePath);
    String pemStr = StreamUtils.copyToString(is, StandardCharsets.UTF_8);
    //去掉---BEGIN XXX---與---END XXX---
    pemStr = pemStr.replaceAll("---+[^-]+---+", "")
            .replaceAll("\\s+", "");
    //base64解碼為DER二進位制內容
    return Base64.getDecoder().decode(pemStr);
}

讀取PKCS8私鑰

在JCA中,使用PKCS8EncodedKeySpec解析PKCS8私鑰檔案,如下:

public static void testPkcs8PrivateKeyFile() {
    byte[] derBytes = pemFileToDerBytes("cert/private_key_pkcs8.key");
    PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(derBytes);
    RSAPrivateCrtKey rsaPrivateCrtKey = (RSAPrivateCrtKey)KeyFactory.getInstance("RSA").generatePrivate(pkcs8EncodedKeySpec);
    BigInteger n = rsaPrivateCrtKey.getModulus();
    BigInteger e = rsaPrivateCrtKey.getPublicExponent();
    BigInteger d = rsaPrivateCrtKey.getPrivateExponent();
    System.out.printf(" n: %X \n e: %X \n d: %X \n", n, e, d);
    BigInteger plain = BigInteger.valueOf(new Random().nextInt(1000000000));
    // RSA加密
    long t1 = System.nanoTime();
    BigInteger secret = plain.modPow(e, n);
    long t2 = System.nanoTime();
    // RSA解密
    BigInteger plain2 = secret.modPow(d, n);
    long t3 = System.nanoTime();
    System.out.printf(" plain: %d \n plain2: %d \n", plain, plain2);
    System.out.printf("enc time: %d \n", (t2 - t1));
    System.out.printf("dec time: %d \n", (t3 - t2));
}

讀取X.509公鑰

在JCA中,使用X509EncodedKeySpec解析X.509公鑰檔案,如下:

public static void testX509PublicKeyFile() {
    byte[] derBytes = pemFileToDerBytes("cert/public_key_x509.key");
    X509EncodedKeySpec x509EncodedKeySpec = new X509EncodedKeySpec(derBytes);
    RSAPublicKey rsaPublicKey = (RSAPublicKey)KeyFactory.getInstance("RSA").generatePublic(x509EncodedKeySpec);
    BigInteger e = rsaPublicKey.getPublicExponent();
    BigInteger n = rsaPublicKey.getModulus();
    System.out.printf(" e: %X \n n: %X \n", e, n);
}

讀取X.509證書

讀取X.509證書檔案,可使用CertificateFactory類,如下:

public static void testX509CertFile() {
    byte[] derBytes = pemFileToDerBytes("cert/cert.crt");
    Collection<? extends Certificate> certificates = CertificateFactory.getInstance("X.509")
            .generateCertificates(new ByteArrayInputStream(derBytes));
    for(Certificate certificate : certificates){
        X509Certificate x509Certificate = (X509Certificate)certificate;
        System.out.printf("SubjectDN: %s \n", x509Certificate.getSubjectDN());
        System.out.printf("IssuerDN: %s \n", x509Certificate.getIssuerDN());
        System.out.printf("SigAlgName: %s \n", x509Certificate.getSigAlgName());
        System.out.printf("Signature: %s \n", Hex.encodeHexString(x509Certificate.getSignature()));
        System.out.printf("PublicKey: %s \n", x509Certificate.getPublicKey());
    }
}

讀取PKCS12金鑰庫檔案

讀取PKCS12規範的金鑰庫檔案,可使用KeyStore類,如下:

public static void testPkcs12File() {
    KeyStore keyStore = KeyStore.getInstance("PKCS12");
    InputStream is = ClassLoader.getSystemClassLoader().getResourceAsStream("cert/keystore.p12");
    char[] password = "123456".toCharArray();
    keyStore.load(is, password);
    //獲取證書
    X509Certificate x509Certificate = (X509Certificate)keyStore.getCertificate("demo");
    System.out.println("X509Certificate: ");
    System.out.printf("SubjectDN: %s \n", x509Certificate.getSubjectDN());
    System.out.printf("IssuerDN: %s \n", x509Certificate.getIssuerDN());
    System.out.printf("SigAlgName: %s \n", x509Certificate.getSigAlgName());
    System.out.printf("Signature: %s \n", Hex.encodeHexString(x509Certificate.getSignature()));
    System.out.printf("PublicKey: %s \n", x509Certificate.getPublicKey());
    //獲取私鑰
    Key key = keyStore.getKey("demo", password);
    System.out.printf("PrivateKey: %s \n", key);
}

如果要讀取.jks檔案,只需要將KeyStore.getInstance("PKCS12")中的PKCS12更換為JKS即可,其它部分保持不變,不過由於JKS是java專有格式,目前java也不推薦使用了,所以能不用的話,就儘量不要用了。

常見問題

證書信任問題

證書的絕大多數應用場景是Https協定,但在存取https介面時,有時會由於證書信任問題導致https握手失敗,主要有以下2點原因:

  1. 有些公司會自建CA,使用自簽證書,如早期的12306,而jdk只信任它預置的根證書,所以https握手時這種證書會認證失敗。
  2. 新成立的根CA機構證書,沒預置在舊的jdk裡面,導致這些CA機構簽發的證書不被信任。

要解決這種證書信任問題,有兩種方法,如下:
1. 將證書導致到jdk的預置證書庫中

# 將cert.crt匯入jdk預置金鑰庫檔案,金鑰庫檔案密碼預設是changeit
sudo keytool -importcert -file cert.crt -alias demo -keystore $JAVA_HOME/jre/lib/security/cacerts -storepass changeit

# 檢視金鑰庫檔案,檢查是否匯入成功
keytool -list -v -alias demo -keystore $JAVA_HOME/jre/lib/security/cacerts -storepass changeit

2. 以編碼的方式信任證書
以jdk自帶的https sdk為例,可在程式碼中手動將問題證書新增到信任列表中,如下:

public String testReqHttpsTrustCert() throws Exception {
    // 讀取jdk預置證書
    KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
    try(InputStream ksIs = new FileInputStream(System.getProperty("java.home") + "/lib/security/cacerts")) {
        keyStore.load(ksIs, "changeit".toCharArray());
    }

    // 讀取證書檔案
    CertificateFactory cf = CertificateFactory.getInstance("X.509");
    try(InputStream certIs = this.getClass().getResourceAsStream("/cert/cert.crt")) {
        Certificate c = cf.generateCertificate(certIs);
        keyStore.setCertificateEntry("demo", c);
    }

    // 生成信任管理器
    TrustManagerFactory tmFact = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
    tmFact.init(keyStore);

    // 生成SSLSocketFactory
    SSLContext sslContext = SSLContext.getInstance("TLSv1.2");
    sslContext.init(null, tmFact.getTrustManagers(), new SecureRandom());
    SSLSocketFactory ssf = sslContext.getSocketFactory();

    // 傳送https請求
    URL url = new URL("https://www.demo.com/user/list");
    HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
    connection.setHostnameVerifier((hostname, session) -> hostname.endsWith("demo.com"));
    connection.setSSLSocketFactory(ssf);

    String result;
    try(InputStream inputStream = connection.getInputStream()){
        result = IOUtils.toString(inputStream, StandardCharsets.UTF_8);
    }
    connection.disconnect();
    return result;
}

注:雖然2種方法都可以解決問題,但第1種方法使得java程式對環境形成了依賴,一旦部署環境發生變化,java程式可能就報錯了,因此更推薦使用第2種方法。

總結

到這裡,JCA相關類的使用就介紹完了,如下表格中總結了JCA的常用類:

本篇花了近一週時間整理,內容較多,對這塊不太熟悉的同學,可以先關注收藏起來當範例手冊,待需要時再參閱即可。