原創:扣釘日記(微信公眾號ID:codelogs),歡迎分享,轉載請保留出處。
前面在密碼學入門一文中講解了各種常見的密碼學概念、演演算法與運用場景,但沒有介紹過程式碼,因此,為作補充,這一篇將會介紹使用Java語言如何實現使用這些演演算法,並介紹一下使用過程中可能遇到的坑。
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), "協商的對稱金鑰不一致");
}
通常,對稱加密演演算法需要使用128位元位元組的金鑰,但這麼長的金鑰使用者是記不住的,使用者容易記住的是口令,也即password,但與金鑰相比,口令有如下弱點:
為了使得使用者能直接使用口令加密,又能最大程度避免口令的弱點,於是PBE(Password Based Encryption)演演算法誕生,思路如下:
因此,使用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演演算法加密模式有ECB
、CBC
、CFB
、CTR
、GCM
等,填充模式有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")
new GsonBuilder().disableHtmlEscaping()
禁用。隨著對密碼學瞭解的深入,會發現有特別多奇怪的名詞出現,讓人迷惑不已,如PKCS8
、X.509
、ASN.1
、DER
、PEM
等,接下來就來澄清下這些名詞是什麼,以及它們之間的關係。
首先,瞭解3個概念,如下:
ASN.1抽象語法標記(Abstract Syntax Notation One),和XML、JSON類似,用於描述物件結構,可以把它看成一種描述語言,簡單的範例如下:
Report ::= SEQUENCE {
author OCTET STRING,
title OCTET STRING,
body OCTET STRING,
}
這個語法描述了一個結構體,它包含3個屬性author、title、body,且都是字串型別。
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等都是公鑰密碼學標準(PKCS)組織制定的各種密碼學規範,該組織使用ASN.1語法為金鑰、證書、金鑰庫等定義了標準的物件結構,常見的如下:
這些規範都有相應的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一般使用.pfxopenssl命令提供了大量的工具,用以生成金鑰、證書與金鑰庫檔案,如下,是一個典型的生成金鑰與證書的過程:
# 生成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,只需要把---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);
}
在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));
}
在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證書檔案,可使用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規範的金鑰庫檔案,可使用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. 將證書導致到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的常用類:
本篇花了近一週時間整理,內容較多,對這塊不太熟悉的同學,可以先關注收藏起來當範例手冊,待需要時再參閱即可。