簡介
前面在密碼學入門一文中講解了各種常見的密碼學概念、算法與運用場景,但沒有介紹過代碼,因此,為作補充,這一篇將會介紹使用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,但與密鑰相比,口令有如下弱點:
- 口令通常較短,這使得直接使用口令加密的強度較差。
- 口令隨機性較差,因為用戶一般使用較容易記住的東西來生成口令。
為了使得用戶能直接使用口令加密,又能最大程度避免口令的弱點,于是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")
這也是一個非常隱晦的坑,如Gson默認就會有這種行為,可使用new GsonBuilder().disableHtmlEscaping()禁用。
生成與讀取證書
概念
隨著對密碼學了解的深入,會發現有特別多奇怪的名詞出現,讓人迷惑不已,如PKCS8、X.509、ASN.1、DER、PEM等,接下來就來澄清下這些名詞是什么,以及它們之間的關系。
首先,了解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
證書概念小結
Certificate
生成證書與密鑰庫
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點原因:
- 有些公司會自建CA,使用自簽證書,如早期的12306,而jdk只信任它預置的根證書,所以https握手時這種證書會認證失敗。
- 新成立的根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的常用類:
本篇花了近一周時間整理,內容較多,對這塊不太熟悉的同學,可以先關注收藏起來當示例手冊,待需要時再參閱即可。