哈希单向加密
哈希加密也称为单向哈希加密,是通过对不同输入长度的信息进行哈希计算得到固定长度的输出,是单向、不可逆的。所以,即使保存用户密码的数据库被攻击,也不会造成用户的密码泄漏。
最常见的哈希算法为MD5(Message-Digest Algorithm 5,信息摘要算法5),也是计算机广泛使用的哈希算法之一。主流编程语言普 遍都提供MD5实现,MD5的前身有MD2、MD3和MD4。曾经,MD5一度被广泛应用于安全领域。随着MD5的弱点不断被发现,以及计算机能力的不断提升,该算法不再适合当前的安全环境。 目前,MD5计算广泛应用于错误检查。例如,在一些文件下载中,软件通过计算MD5和检验下载所得文件的完整性。
MD5将输入的不定长度信息经过程序流程生成四个32位(Bit)数据,最后联合起来输出一个固定长度128位的摘要,基本处理流程包括求余、取余、调整长度、与链接变量进行循环运算等,最终得出结果。除了MD5,Java还提供了SHA1、SHA256、SHA512等哈希摘要函数的实现。除了在算法上有些差异之外,这些哈希函数的主要不同在于摘要长度,MD5生成的摘要是128位,SHA1生成的摘要是160位,SHA256生成的摘要是256位,SHA512生成的摘要是512位。
以下代码使用 Java 提供的MD5、SHA1、SHA256、SHA512等哈希摘要函数生成哈希摘要(哈希加密结果)并进行验证的案例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| public class HashCrypto {
public static String encrypt(String plain, String algorithm) { StringBuilder result = new StringBuilder(32); try { MessageDigest md = MessageDigest.getInstance(algorithm); byte[] array = md.digest(plain.getBytes(StandardCharsets.UTF_8)); for (byte b : array) { result.append(String.format("%02x", b)); } } catch (Exception ex) { System.err.println(ex.getMessage()); } return result.toString(); }
public static void main(String[] args) { String plain = "123456"; String cryptoMessage = HashCrypto.encrypt(plain, "SHA-224"); System.out.println("cryptoMessage: " + cryptoMessage); } }
|
对称加密算法
对称加密:使用同一个密钥加密和解密,优点是速度快;但是它要求共享密钥,缺点是密钥管理不方便、容易泄露。常见的对称加密算法有DES、AES等。DES加密算法出自IBM的数学 研究,被美国政府正式采用之后开始广泛流传,但是近些年来使用越 来越少,因为DES使用56位密钥,以现代计算能力24小时内即可被破解。下面是一个现代计算机标准的AES加密工具类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103
| import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Base64;
public class AESUtil {
private static final String ALGORITHM = "AES"; private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
public static String encrypt(String data, String key, String iv) throws Exception { byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); byte[] ivBytes = iv.getBytes(StandardCharsets.UTF_8);
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); Cipher cipher = Cipher.getInstance(TRANSFORMATION); IvParameterSpec ivSpec = new IvParameterSpec(ivBytes);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
return java.util.Base64.getEncoder().encodeToString(encrypted); }
public static String decrypt(String encryptedData, String key, String iv) throws Exception { byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8); byte[] ivBytes = iv.getBytes(StandardCharsets.UTF_8);
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); Cipher cipher = Cipher.getInstance(TRANSFORMATION); IvParameterSpec ivSpec = new IvParameterSpec(ivBytes);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] original = cipher.doFinal(Base64.getDecoder().decode(encryptedData)); return new String(original, StandardCharsets.UTF_8); }
private static String bytesToHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02x", b)); } return sb.toString(); }
private static byte[] hexToBytes(String hex) { int len = hex.length(); byte[] data = new byte[len / 2]; for (int i = 0; i < len; i += 2) { data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4) + Character.digit(hex.charAt(i+1), 16)); } return data; }
public static void main(String[] args) { try { String key = "1234567890123456"; String iv = "abcdefghijklmnop"; String content = "密涅瓦的猫头鹰在黄昏起飞。"; System.out.println("原文: " + content);
String encryptStr = AESUtil.encrypt(content, key, iv); System.out.println("密文 (Base64): " + encryptStr);
String decryptStr = AESUtil.decrypt(encryptStr, key, iv); System.out.println("解密后: " + decryptStr); } catch (Exception e) { e.printStackTrace(); } } }
|
非对称加密算法
非对称加密算法又称为公开密钥加密算法,需要两个密钥:一个称为公开密钥(公钥);另一个称为私有密钥(私钥)。公钥与私钥需要配对使用,如果用公钥对数据进行加密,只有用对应的私钥才能解密;如果使用私钥对数据加密,那么需要用对应的公钥才能解密。由于加解密使用不同的密钥,因此这种算法为非对称加密算法。
注意,从纯粹从数学公式上看,你随机选一个当公钥,另一个就自然成了私钥。在算法内部,公私钥是相对的。但是在实际的实现和使用上,私钥通常包含更多信息,例如为了计算方便,存储在 “.key” 或 “.pem” 文件中的私钥通常包含了推导出公钥所需的所有中间参数(如 p, q 等素数)。你可以从私钥轻松推导出公钥,但绝无可能从公钥推导出私钥。如果你把私钥当公钥发出去,由于它包含了生成公钥的所有逻辑,你的整个安全体系会瞬间崩塌。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111
| import lombok.extern.slf4j.Slf4j; import javax.crypto.Cipher; import java.io.*; import java.nio.charset.StandardCharsets; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.security.PublicKey; import java.util.Base64;
@Slf4j public class RSAEncrypt {
private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding"; private static final String ALGORITHM = "RSA"; private static final int KEY_SIZE = 1024;
private static final String PUBLIC_KEY_FILE = "PublicKey"; private static final String PRIVATE_KEY_FILE = "PrivateKey";
protected static void generateKeyPair() throws Exception { File pubFile = new File(PUBLIC_KEY_FILE); if (pubFile.exists()) { log.info("密钥对已存在,跳过生成步骤。"); return; }
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(ALGORITHM); keyPairGenerator.initialize(KEY_SIZE); KeyPair keyPair = keyPairGenerator.generateKeyPair();
try (ObjectOutputStream oos1 = new ObjectOutputStream(new FileOutputStream(PUBLIC_KEY_FILE)); ObjectOutputStream oos2 = new ObjectOutputStream(new FileOutputStream(PRIVATE_KEY_FILE))) { oos1.writeObject(keyPair.getPublic()); oos2.writeObject(keyPair.getPrivate()); log.info("RSA 密钥对生成成功并已保存。"); } }
public static String encrypt(String plain) throws Exception { PublicKey publicKey = loadPublicKey(); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] plainBytes = plain.getBytes(StandardCharsets.UTF_8);
byte[] encryptedBytes = cipher.doFinal(plainBytes); return Base64.getEncoder().encodeToString(encryptedBytes); }
public static String decrypt(String crypto) throws Exception { PrivateKey privateKey = loadPrivateKey(); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, privateKey);
byte[] cryptoBytes = Base64.getDecoder().decode(crypto); byte[] decryptedBytes = cipher.doFinal(cryptoBytes);
return new String(decryptedBytes, StandardCharsets.UTF_8); }
public static PublicKey loadPublicKey() throws Exception { try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(PUBLIC_KEY_FILE))) { return (PublicKey) ois.readObject(); } }
public static PrivateKey loadPrivateKey() throws Exception { try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(PRIVATE_KEY_FILE))) { return (PrivateKey) ois.readObject(); } }
public static void main(String[] args) { try { generateKeyPair(); String plain = "密涅瓦的猫头鹰在黄昏起飞。";
String dest = encrypt(plain); log.info("加密结果:\n{}", dest);
String origin = decrypt(dest); log.info("解密还原:{}", origin);
} catch (Exception e) { log.error("RSA加解密演示失败", e); } } }
|
数字签名
数字签名(Digital Signature)是确定消息发送方身份的一种方 案。在非对称加密算法中,发送方A通过接收方B的公钥将数据加密后 的密文发送给接收方B,B利用私钥解密就得到了需要的数据。这里还存在一个问题,接收方B的公钥是公开的,接收方B收到的密文都是使 用自己的公钥加密的,那么如何检验发送方A的身份呢?
一种非常简单的检验发送方A身份的方法为:发送方A可以利用A自己的私钥进行消息加密,然后B利用A的公钥来解密,由于私钥只有A知道,接收方只要解密成功,就可以确定消息来自A而不是其他地方。数字签名的原理就基于此,通常为了证明发送数据的真实性,利用发送方的私钥对待发送的数据生成数字签名。
数字签名的流程比较简单,首先通过哈希函数为待发数据生成较短的消息摘要,然后利用私钥加密该摘要,所得到的摘要密文基本上就是数字签名。发送方A将待发送数据以及数字签名一起发送给接收方B,接收方B收到之后使用A的公钥校验数字签名,如果校验成功,就说明内容来自发送方A,否则为非法内容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118
| import lombok.extern.slf4j.Slf4j; import javax.crypto.Cipher; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.security.PrivateKey; import java.security.PublicKey; import java.security.Signature; import java.security.SignatureException;
@Slf4j public class RSASignDemo {
private static final String SIGN_ALGORITHM = "SHA512withRSA"; private static final String CRYPTO_ALGORITHM = "RSA/ECB/PKCS1Padding";
private PrivateKey privateKey; private PublicKey publicKey;
public byte[] rsaSign(byte[] data, PrivateKey priKey) throws SignatureException { if (priKey == null) throw new SignatureException("私钥未加载"); try { Signature signature = Signature.getInstance(SIGN_ALGORITHM); signature.initSign(priKey); signature.update(data); return signature.sign(); } catch (Exception e) { throw new SignatureException("RSA签名发生异常", e); } }
public boolean verify(byte[] data, byte[] sign, PublicKey pubKey) throws SignatureException { if (pubKey == null) throw new SignatureException("公钥未加载"); try { Signature signature = Signature.getInstance(SIGN_ALGORITHM); signature.initVerify(pubKey); signature.update(data); return signature.verify(sign); } catch (Exception e) { throw new SignatureException("RSA验签发生异常", e); } }
public byte[] encrypt(PublicKey publicKey, byte[] plainTextData) throws Exception { if (publicKey == null) throw new Exception("加密公钥为空"); Cipher cipher = Cipher.getInstance(CRYPTO_ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, publicKey); return cipher.doFinal(plainTextData); }
public byte[] decrypt(PrivateKey privateKey, byte[] cipherData) throws Exception { if (privateKey == null) throw new Exception("解密私钥为空"); Cipher cipher = Cipher.getInstance(CRYPTO_ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, privateKey); return cipher.doFinal(cipherData); }
public static void main(String[] args) { RSASignDemo demo = new RSASignDemo(); try { demo.publicKey = RSAEncrypt.loadPublicKey(); demo.privateKey = RSAEncrypt.loadPrivateKey();
String sourceText = "密涅瓦的猫头鹰在黄昏起飞。"; byte[] sourceBytes = sourceText.getBytes(StandardCharsets.UTF_8);
log.info("--- 开始测试加解密 ---"); byte[] cipher = demo.encrypt(demo.publicKey, sourceBytes); byte[] decryptText = demo.decrypt(demo.privateKey, cipher); log.info("私钥解密结果:{}", new String(decryptText, StandardCharsets.UTF_8));
log.info("--- 开始测试签名验签 ---"); byte[] rsaSign = demo.rsaSign(sourceBytes, demo.privateKey); boolean isOk = demo.verify(sourceBytes, rsaSign, demo.publicKey); log.info("签名验证结果:{}", isOk);
String filePath = "config/system.properties"; if (Files.exists(Paths.get(filePath))) { byte[] fileBytes = Files.readAllBytes(Paths.get(filePath)); byte[] fileSign = demo.rsaSign(fileBytes, demo.privateKey); boolean fileVerify = demo.verify(fileBytes, fileSign, demo.publicKey); log.info("文件签名验证结果:{}", fileVerify); } } catch (Exception e) { log.error("演示过程发生错误:", e); } } }
|
SSL/TLS 四阶段握手
SSL/TLS 的握手过程之所以让人头大,是因为它混合了非对称加密、对称加密、摘要算法以及数字证书。如果把这个过程比作两个从未谋面的间谍建立秘密联系,那就非常清晰了。握手的核心目的只有两个:
- 身份验证:确认对方不是假冒的(通过证书)。
- 协商密钥:商量出一个只有双方知道的“暗号”(对称密钥),用于后续大规模数据传输。
第一阶段:客户端打招呼(Client Hello)
客户端(浏览器)先开口,把自己的底牌亮出来。客户端给服务器发一条消息,内容包括:
- 支持的协议版本(比如 TLS 1.2)。
- 支持的加密套件清单(就像在说:“我会说英语和法语,擅长 DES 和 AES”)。
- 一个随机数 $R_a$(Client Random)。
第二阶段:服务端回礼与出示证件(Server Hello)
服务器选定方案,并证明自己的身份。服务器回应客户端:
- 确认协议版本和加密套件(服务器说:“好,我们就用 TLS 1.2 和 AES 算法”)。
- 再给一个随机数 $R_b$(Server Random)。
- 发送数字证书(这是最关键的一步:相当于给客户端看自己的“身份证”)。
此时,客户端会拿着证书去问权威机构(CA): “这证件是真的吗?” 如果验证通过,客户端就相信眼前的服务器是真正的“官网”。
第三阶段:交换秘密(Client Key Exchange)
双方开始憋 “大招”,计算最终的暗号。
- 生成预主密钥:客户端在自己电脑里偷偷生成第三个随机数,叫 Pre-Master Secret ($P$)。
- 用公钥锁死:客户端从服务器的证书里取出公钥,把 $P$ 加密。这样全世界除了服务器(有私钥),没人能解开。
- 发送加密包:把加密后的 $P$ 发给服务器。
至此,关键反转发生了:只有客户端和服务器手里同时拥有三个零件:$R_a$、$R_b$ 和 $P$。他们各自通过这三个零件,用相同的公式算出最终的对称密钥(Master Secret)。
第四阶段:收尾确认(Finished)
互相确认“暗号”是否对得上。
- 改变编码规范,客户端告诉服务器:“从现在起,我发的所有消息都会用刚才算出的暗号加密了。”
- 握手结束:客户端发一段加密的测试数据。
- 服务器回应:服务器解密成功后,也发一段加密的测试数据作为回礼。
大功告成!握手结束,后续所有的网页内容(HTTP 数据)都将通过这个对称密钥进行高速加密传输。
总结:为什么要这么麻烦?
- 为什么要三个随机数? 为了保证随机性。即便其中一个随机数被猜到,整体密钥依然是安全的。
- 为什么不一直用非对称加密(RSA)? 因为 RSA 运算太慢了。握手阶段用 RSA 是为了安全地传递对称密钥的种子,一旦暗号对上了,后面就用 AES 这种快如闪电的对称加密了。
在实际的架构中,我们通常会在 Nginx 这一层就把 SSL 握手处理掉(解密成明文),然后 Nginx 到后端 Netty 服务器走内网 HTTP 或自定义协议。因为握手阶段的 RSA 计算极其消耗 CPU。如果在 Netty 业务服务器上频繁处理握手,会严重抖动你的消息推送延迟。
SSL/TLS双向认证
SSL/TLS 单向认证就是用户到服务器之间只存在单方面的认证,即客户端会认证服务端身份,而服务端对客户端身份进行验证。前面所介绍的SSL/TSL协议的握手过程的四个阶段是以单向认证的握手流程为蓝本进行介绍的。第一个阶段,客户端发起握手请求;第二个阶段,服务器收到握手请求后会选择适合双方的协议版本和密钥套件,然后将协商的结果和服务端的证书(含公钥)一起发送给客户端;第三个阶段,客户端利用服务端的公钥对要发送的数据(主要是第三个随机数)进行加密,并发送给服务端;第四个阶段,服务端收到第三个随机数后,会用本地私钥对收到的客户端加密数据进行验证,验证通过后计算会话密钥,然后给客户端进行最后的回复确认。 完成握手之后,通信双方都会使用生成的会话密钥,就可以开始安全通信过程了。
SSL/TLS双向认证就是双方都会互相认证,也就是两者之间将会交换证书。双向认证的基本握手过程和单向认证完全一样,只是在协商阶段多了几个步骤。在握手的第二个阶段,服务端在将协商的结果和自己的数字证书一起发送给客户端后,服务端会请求客户端的证书。在握手的第三阶段,客户端会将自己的数字证书发送给服务端,服务端则会验证客户端数字证书的合法性。
双向认证场景的第一阶段握手、第四阶段握手以及建立握手之后的加密通信过程与单向认证完全保持一致。SSL/TLS双向认证的握手流程如图所示。

在单向认证的场景下,仅仅需要将服务端的数字证书导入客户端的密钥库(或者信任库)。在双向认证之前,还需要在服务端密钥库 (或者信任库)导入客户端的数字证书。

SSL/TSL在握手过程中,客户端需要服务端提供身份证书(也叫数字证书),有的场景下甚至要求客户端也提供身份证书。安全数字证书主要包含自己的身份信息(如所有人的名称),以及对外的公钥。
数字证书与身份识别
在SSL/TSL加密传输开始的时候,客户端会通过Client Hello帧获 得服务端的公钥,这个过程可能会被第三方劫持,具体过程如图。

当客户端的Client Hello帧被劫持时,服务端发送到客户端的公钥会被第三方截获,然后第三方自己会伪造一对密钥(包含公钥和私钥),并将伪造的公钥发送给客户端。当服务端发送数据给客户端的时候,第三方也会将信息劫持,用一开始截获的公钥进行解密,然后 使用自己的私钥将数据再一次加密后发送给客户端,客户端收到后使用第三方(劫持方)的公钥去解密。反过来也是如此,当客户端发送 数据给服务端时,报文亦会被劫持方截取和转发,并且整个截取和转发的过程对于客户端和服务端都是透明和不可见的,但信息却被悄然泄露了。
为了防止这种情况,数字证书出现了。数字证书就是互联网通信中标志通信各方身份信息的一串数字,是由权威机构—— CA(Certificate Authority,认证中心)发行的,人们可以在网上用它来识别对方的身份。
一般数字证书的颁发过程为:用户首先产生自己的密钥对,并将公钥及身份信息提供给CA机构(认证中心)。认证中心在核实身份后,将执行一些必要的步骤,以确信请求确实是由用户提交的,然后认证中心将发给用户一个数字证书。一个证书中含有三个部分:证书内容、哈希算法、加密密文。该证书内包含服务端的个人信息和公钥信息;加密密文为证书内容通过哈希算法计算出摘要之后使用CA机构的私钥进行非对称加密后的密文,也可以理解成CA机构自己的数字签名。
当客户端发起请求时,服务端将该数字证书发送给客户端,客户端需要对证书进行验证,具体方法为:通过CA机构提供的公钥对服务端证书的数字签名(加密密文)进行解密,以获得服务端证书的内容摘要(哈希值),同时将证书内容使用相同的哈希算法获取摘要,对比两个摘要。如果两者相等,就说明证书中的公钥仍然是服务端原始公钥,没有被第三方篡改,即服务端证书没有问题、服务端并没有被劫持。
数字证书的格式普遍采用的是X.509国际标准。X.509是一种进行身份认证的行业安全标准,在该标准中,用户可生成一段信息及其摘 要(亦称作信息“指纹”),并用专用密钥对摘要加密以形成签名, 接收者用发送者的公共密钥对签名解密,并将之与收到的信息 “指纹” 进行比较,以确定其真实性。
X.509标准有不同的版本,其中 X.509/V2 和 X.509/V3 都是目前比较新的版本,但是都在原有版本(X.509/V1)的基础上进行功能的扩充。每一版的数字证书大致包含下列信息:
- 证书的版本信息。
- 证书的序列号,每个证书都有一个唯一的证书序列号(重要!)。
- 证书所使用的签名算法。
- 证书的发行机构名称,命名规则一般采用X.500协议格式。
- 证书的有效期,通用的证书一般采用UTC时间格式,计时范围为1950-2049。
- 证书所有人的名称,命名规则一般采用X.500协议格式。
- 证书所有人的公开密钥。
- 证书发行者对证书的数字签名。
命名规则一般采用X.500协议格式。X.500协议可以理解为用来查询有关人员信息(如邮政地址、电话号码、电子邮件地址等)的一种协议。X.500协议是构成全球分布式的名录服务系统的协议,该协议组织起来的数据就像一个很全的电话号码簿。X.500系统是一个分门别类的图书馆,某一机构建立和维护的X.500子数据库只是全球 X.500协议名录数据库的一部分。
存储密钥与证书文件格式
SSL/TLS 协议中存储密钥与证书的文件格式比较多,很容易被大家搞混,这里做个简单的梳理,大致会用到的文件格式如下:
.jks
“.jks” 格式文件表示 Java 密钥存储仓库(Java KeyStore)。这 种格式是 Java 的专利,表示一个密钥库,可以同时容纳多个公钥和私钥。Java 的 Keytool 工具能直接生成 “.jks” 格式文件,可以将 “.pfx” 格式文件转为 “.jks” 格式文件。
.keystore
“.keystore” 格式文件跟 “.jks” 基本是一样的,是默认生成的密钥存储库格式。
.cer
“.cer” 格式文件俗称数字证书文件,其中只包含了公钥以及证书拥有者和颁发者的消息。数字证书文件肯定不会有私钥。“.cer” 格式文件既可以是BASE64编码的文本文件,也可以是DER编码的二进制文件。可以通过 Java 的Keytool 工具将 “.cer” 证书文件导入密钥存储仓库(如 “.jks” 格式文件),或者从密钥存储仓库导出证书文件。
1 2
| 【.jks 或 .keystore】 ---[keytool export 导出命令]---> 【.cer】 <--[keytool import 导入命令]----
|
.truststore
“.truststore” 格式文件表示信任证书存储库,仅仅包含了被信任的通信对方的公钥。
.pfx
“.pfx” 格式文件也称为证书文件,是包含了公钥和私钥的二进制格式的证书文件,一般供客户端浏览器使用。与“.cer” 格式文件不同,“.pfx” 格式的数字证书是包含有私钥的,而 “.cer” 格式的数字证书里面只有公钥。当然,“.pfx” 格式文件一般有密码保护, 不输入密码是解不了密的。有些时候我们需要把“.pfx”转换为“.jks”密钥仓库,以便于 用Java进行安全通信,也可以通过浏览器从“.pfx”文件中导出包含 公钥的“.cer”证书文件。
1 2
| 【.pfx】 ---[浏览器导出]---> 【.cer】 ---[转换]--->【.jks 或 .keystore】
|
除了从CA机构获取证书,还可以通过工具生成自签名证书。CA机构证书是需要费用的,除非是很正式的项目或者生产需要(比如微信小程序不能使用自签名证书而需要CA证书),否则使用自己签发的证书即可。
Java 中管理和生成自签名证书的工具为 Keytool。Keytool 是 Java 中自带的工具,将密钥(Key)和证书(Certificates)存在一个格式 为 “.keystore”(或.jks)的文件中,然后可以导出自签发的数字证 书。在JDK安装过程中,Keytool 工具已经解压到对应的 JDK的/bin 目录 中,其可执行文件的文件名为 keytool.exe。
作为铺垫,首先介绍一下使用密钥的场景:假设客户端需要和服务端进行安全通信,客户端要用到服务端公钥进行通信加密。在这种场景下,首先需要生成服务端和客户端的密钥仓库,然后导出服务端证书,并导入到客户端密钥仓库中,具体流程如图。

将服务端证书导入客户端的工作使用Keytool工具大致有如下四步。
第一步:创建服务端(如Netty服务器)密钥并且保存到服务端密钥仓库文件。使用Keytool工具的genkey选项完成,具体命令大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
|
$ keytool -genkey -alias server -keypass 123456 -keyalg RSA -keysize 2048 -validity 365 -keystore ./server.jks -storepass 123456 -dname "CN=server"
$ keytool -list -v -keystore ./server.jks 输入密钥库口令: 123456
密钥库类型: PKCS12 密钥库提供方: SUN
您的密钥库包含 1 个条目
别名: server 创建日期: 2019年10月22日 条目类型: PrivateKeyEntry 证书链长度: 1 证书[1]: 所有者: CN=server 发布者: CN=server 序列号: 359dbd0d8ef45c69 生效时间: Sun Oct 22 22:53:03 CST 2019, 失效时间: Mon Oct 22 22:53:03 CST 2020 证书指纹: SHA1: CC:61:3E:9D:EC:EA:11:08:68:C2:73:5D:65:23:D9:FD:09:0B:D5:2B SHA256: EF:D4:98:1E:4B:FD:48:0F:C6:E1:FB:09:62:10:04:40:0D:AE:03:1D:84:33:01:51:84:24:B1:DB:E2:72:B6:9A 签名算法名称: SHA256withRSA 主体公共密钥算法: 2048 位 RSA 密钥 版本: 3
扩展:
SubjectKeyIdentifier [ KeyIdentifier [ 0000: EF 75 F8 85 38 E4 B0 8C 68 21 8F 62 37 4A 0C 9E .u..8...h!.b7J.. 0010: 93 A9 18 BC .... ] ]
******************************************* *******************************************
|
第二步:生成客户端的密钥到客户端的密钥仓库。还是使用 Keytool 工具的 genkey 选项完成,具体的命令大致如下:
1
| $ keytool -genkey -alias client -keysize 2048 -validity 365 -keyalg RSA -dname "CN=client" -keypass 123456 -storepass 123456 -keystore ./client.jks
|
第三步:需要将服务端的证书导出,然后导入到客户端的授信证书仓库(这里使用客户端密钥仓库)中。首先通过Keytool 工具的 export 选项完成服务端的数字证书 server.cer 文件导出,具体的命令大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| $ keytool -export -alias server -keystore ./server.jks -storepass 123456 -file server.cer
$ keytool -printcert -file server.cer
所有者: CN=server 发布者: CN=server 序列号: 359dbd0d8ef45c69 生效时间: Sun Oct 22 22:53:03 CST 2019, 失效时间: Mon Oct 22 22:53:03 CST 2020 证书指纹: SHA1: CC:61:3E:9D:EC:EA:11:08:68:C2:73:5D:65:23:D9:FD:09:0B:D5:2B SHA256: EF:D4:98:1E:4B:FD:48:0F:C6:E1:FB:09:62:10:04:40:0D:AE:03:1D:84:33:01:51:84:24:B1:DB:E2:72:B6:9A 签名算法名称: SHA256withRSA 主体公共密钥算法: 2048 位 RSA 密钥 版本: 3
扩展:
SubjectKeyIdentifier [ KeyIdentifier [ 0000: EF 75 F8 85 38 E4 B0 8C 68 21 8F 62 37 4A 0C 9E .u..8...h!.b7J.. 0010: 93 A9 18 BC .... ] ]
|
第四步:将服务的证书导入到客户端仓库(严格来说是信任仓库,只不过可以和密钥仓库合用)。使用Keytool工具的import选项完成,具体的命令大致如下:
1
| $ keytool -import -trustcacerts -alias server -file server.cer -keystore ./client.jks -storepass 123456
|
导入过程中会提示是否信任该证书,在确认之后,证书就会被成功添加到密钥库中。客户端就可以和服务器进行安全通信了。
上面通过Keytool工具管理密钥和证书,也是大家日常用得比较多的方式。除此之外,还可以通过 Java 程序完成密钥和证书的管理。