HTTPS其实就是在TCP到应用层之间加了一层加解密的环节。那一层就是SSL。
SSL原理简介
- 交换密钥
- 利用密钥做对称加解密
- 详细原理https://blog.csdn.net/qq_38265137/article/details/90112705
交换密钥(SSL四次握手)
- 客户端向服务端发送 Client Hello 消息,这个消息里包含了一个客服端生成一个随机数 Random1、客户端支持的加密套件(Support Ciphers)和 SSL Version 等信息,服务端收到后向客户端发送 Server Hello 消息,这个消息会从 Client Hello 传过来的 Support Ciphers 里确定一份加密套件,这个套件决定了后续加密和生成摘要时具体使用哪些算法,另外还会生成一份随机数 Random2。注意,至此客户端和服务端都拥有了两个随机数(Random1+ Random2)
- 服务器将发送4个数据包。1.数字证书和到根CA整个链(使客户端能用服务器证书中的服务器公钥认证服务器)2.服务器密钥交换(可选,这里视密钥交换算法而定)。3.证书请求:服务端可能会要求客户自身进行验证。4.服务器握手完成:第二阶段的结束,第三阶段开始的信号。
- 客户端发送3个数据包。1.证书(可选,为了对服务器证明自身,客户要发送一个证书信息,这是可选的,在IIS中可以配置强制客户端证书认证)。2.客户机密钥交换(Pre-master-secret),生成一个随机数Random3使用服务端的公钥进行加密(当然这个数是根据密钥交换算法而定,是发送随机数还是加密参数)。3.证书验证(可选),对预备秘密和随机数进行签名,证明拥有(a)证书的公钥。至此双方都有了Random1,Random2,Random3,双方通过这三个参数和加密方式计算出同一个对称密钥。
- 客户端发送一个Change Cipher Spec消息,告诉服务器以后的消息使用新的加密算法。然后,客户端用新的算法、密钥参数发送一个Finished消息,这条消息可以检查密钥交换和认证过程是否已经成功。其中包括一个校验值,对客户端整个握手过程的消息进行校验。服务器同样发送Change Cipher Spec消息和Finished消息。握手过程完成,客户端和服务器可以交换应用层数据进行通信。
加解密
- 应用层生成的数据,通过上述的对称密钥和协定好的加密算法加密,发送给TCP进行传输
- TCP接受到的数据,通过上述的对称密钥和协定好的加密算法解密,传递给应用层
SSLEngine
对于SSL的操作java已经有了实现SSLEngine,我们也就不用再造轮子了,使用方法在https://nowjava.com/docs/java-api-11/java.base/javax/net/ssl/SSLEngine.html
握手
可以通过SSLEngineResult.HandshakeStatus来判断握手的状态,它会指导我们下一步应该要做什么。
FINISHED:握手完成。
NEED_TASK:需要等待一些task的完成,否则handshake无法继续,出现这个情况时,后续engine的wrap和unwrap方法都会阻塞直到task完成。
NEED_UNWRAP:需要从peer端读取新的数据,否则handshake无法继续。
NEED_UNWRAP_AGAIN:与NEED_UNWRAP类似,但表示从peer读取的数据已经存在于本地了,这个状态下,不需要再重新走一遍网络,只要解析已经接收到的数据就可以了。NOTE:在java8_u151中,并没有这个枚举类型。
NEED_WRAP:需要向peer端发送数据,否则handshake无法继续。
NOT_HANDSHAKING:当前没有处于handshake阶段。
双向认证和自签名证书
对于需要认证客户端的这种情况,要求客户端也得有CA证书,当然我们可以花钱去受信任的机构去申请证书,也可以用java自带的工具生成证书。
执行下面命令
keytool -genkey -keyalg RSA -keysize 2048 -keystore /home/XXX.jks
注意, 此时需要输入此keystore的密码.
密码长度为至少6位
Enter keystore password:
Keystore password is too short - must be at least 6 characters
Enter keystore password:
Re-enter new password:
输入密码后,下一步需输入一些与此key相关的信息.
需要注意的部分是first and last name, 因为它表示应用这个证书的域名
What is your first and last name?
[Unknown]: XXX.XX.com
What is the name of your organizational unit?
[Unknown]: XX
What is the name of your organization?
[Unknown]: XX
What is the name of your City or Locality?
[Unknown]: Dalian
What is the name of your State or Province?
[Unknown]: CN
What is the two-letter country code for this unit?
[Unknown]: CN
Is CN=XXX.com, OU=XX, O=XX, L=Dalian, ST=CN, C=CN correct?
[no]: y
最后一步, 密码置空直接回车即可
Enter key password for <mykey>
(RETURN if same as keystore password):
这样, 在指定的目录下,jks证书已经被生成出来了.
然后我们可以用SSLContext来配置我们的证书(第一个参数)和受信任的证书列表(第二个参数)
sslContext.init(keyManagers(), trustManagers(), null);
如果服务器的ca证书不是受信任的机构颁发的,客户端的受信任的证书列表配置服务器的证书。同理服务器也一样。
加解密
SSLEngineResult res = sslEngine.wrap(appWBuffer, packetWBuffer);
SSLEngineResult res = sslEngine.unwrap(packetBuffer, appBuffer)
通过SSLEngineResult来判断加解密的状态
OK:wrap(发送数据)或者unwrap(接受数据)成功,没有错误。
CLOSED:对于handshake NEED_WRAP操作来说,就是当前端主动关闭了TLS通信;对于NEED_UNWRAP来说,就是peer主动调用了TLS通信,当前端获取到了peer发送过来的close_notify message。
BUFFER_UNDERFLOW(buffer空闲):理论上来说,这个情况不会出现在handshake NEED_WRAP阶段;对于NEED_UNWRAP阶段来说,(1)packetBuffer空间不足,需要扩容(可以初始化大小为sslSession.getPacketBufferSize());(2)packetBuffer读取的数据出现了半包问题,需要继续从socket中read(可以执行packetBuffer.compact(),然后继续读)。
BUFFER_OVERFLOW(buffer溢出):对于NEED_WRAP来说,myNetBuf空间不足,需要扩充或者清空;对于NEED_UNWRAP,peerAppBuf不足,需要扩容或者清空。
示例代码
因为我们是模仿浏览器发送https,所以不用加载我们自己的证书,也不用加受信任的证书。如果是访问私有网站,则需要把其证书放到受信任证书里面,如果对方要求双向认证,那么还需要加载自己的证书,并且把自己的证书发给对方配置为受信任(如果证书是受信任的ca机构颁发的就不用配置受信任证书了)
那么我们改一下上一篇的代码,以Content-Length为例(Transfer-Encoding等于chunked也一样,只是进行的数据的加解密)。这里的代码是非阻塞的,io多路复用的完整代码在github上 https://github.com/cxsummer/net-nio
public static void main(String[] args) throws Exception {
int port = 443;
String host = "www.ximalaya.com";
String path = "/";
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress(host, port));
socketChannel.configureBlocking(false);
SSLContext sslCtx = SSLContext.getInstance("TLS");
sslCtx.init(null, null, null);
SSLEngine sslEngine = sslCtx.createSSLEngine(host, port);
sslEngine.setUseClientMode(true);
sslEngine.beginHandshake();
SSLSession sslSession = sslEngine.getSession();
SSLEngineResult.HandshakeStatus handshakeStatus = sslEngine.getHandshakeStatus();
ByteBuffer appBuffer = ByteBuffer.allocate(sslSession.getApplicationBufferSize());
ByteBuffer packetBuffer = ByteBuffer.allocate(sslSession.getPacketBufferSize());
ByteBuffer appWBuffer = ByteBuffer.allocate(sslSession.getApplicationBufferSize());
ByteBuffer packetWBuffer = ByteBuffer.allocate(sslSession.getPacketBufferSize());
while (handshakeStatus != SSLEngineResult.HandshakeStatus.FINISHED && handshakeStatus != SSLEngineResult.HandshakeStatus.NOT_HANDSHAKING) {
switch (handshakeStatus) {
case NEED_UNWRAP:
socketChannel.read(packetBuffer);
packetBuffer.flip();
SSLEngineResult res = sslEngine.unwrap(packetBuffer, appBuffer);
packetBuffer.compact();
handshakeStatus = res.getHandshakeStatus();
break;
case NEED_WRAP:
packetWBuffer.clear();
res = sslEngine.wrap(appWBuffer, packetWBuffer);
handshakeStatus = res.getHandshakeStatus();
if (res.getStatus() == SSLEngineResult.Status.OK) {
packetWBuffer.flip();
while (packetWBuffer.hasRemaining()) {
socketChannel.write(packetWBuffer);
}
}
break;
case NEED_TASK:
Runnable task;
while ((task = sslEngine.getDelegatedTask()) != null) {
new Thread(task).start();
}
handshakeStatus = sslEngine.getHandshakeStatus();
break;
}
}
StringBuilder stringBuilder = new StringBuilder("GET " + path + " HTTP/1.1 \r\n");
stringBuilder.append("Host: " + host + "\r\n");
stringBuilder.append("Accept-Encoding: gzip, deflate\r\n");
stringBuilder.append("\r\n");
ByteBuffer byteBuffer = ByteBuffer.wrap(stringBuilder.toString().getBytes());
packetBuffer.clear();
SSLEngineResult res = sslEngine.wrap(byteBuffer, packetBuffer);
if (res.getStatus() != SSLEngineResult.Status.OK) {
throw new RuntimeException("SSL加密失败");
}
packetBuffer.flip();
while (packetBuffer.hasRemaining()) {
socketChannel.write(packetBuffer);
}
int num;
byte[] body = null;
int bodyIndex = 0;
int headerIndex = 0;
Integer contentLength = null;
byte[] originHeader = new byte[1024];
LinkedHashMap<String, List<String>> head = null;
appBuffer.clear();
packetBuffer.clear();
while ((num = socketChannel.read(packetBuffer)) > -2) {
packetBuffer.flip();
do {
res = sslEngine.unwrap(packetBuffer, appBuffer);
} while (res.getStatus() == SSLEngineResult.Status.OK);
packetBuffer.compact();
for (int i = 0; i < appBuffer.position(); i++) {
byte b = appBuffer.get(i);
if (head == null) {
originHeader[headerIndex++] = b;
if (originHeader.length == headerIndex) {
originHeader = byteExpansion(originHeader, 1024);
}
if (originHeader[headerIndex - 1] == '\n' && originHeader[headerIndex - 2] == '\r' && originHeader[headerIndex - 3] == '\n' && originHeader[headerIndex - 4] == '\r') {
String headerStr = new String(originHeader);
String[] headerList = headerStr.split("\r\n");
head = Arrays.stream(headerList).skip(1).filter(h -> h.contains(":")).collect(Collectors.groupingBy(h -> h.split(":")[0].trim(), LinkedHashMap::new, Collectors.mapping(h -> h.split(":")[1].trim(), Collectors.toList())));
contentLength = Optional.ofNullable(head.get("Content-Length")).map(c -> Integer.parseInt(c.get(0))).orElse(-1);
}
} else {
Integer finalContentLength = contentLength;
body = Optional.ofNullable(body).orElseGet(() -> new byte[finalContentLength]);
body[bodyIndex++] = b;
if (bodyIndex == contentLength) {
num = -2;
break;
}
}
}
if (num < 0) {
socketChannel.close();
System.out.println(new String(originHeader));
System.out.println(new String(uncompress(body)));
return;
}
appBuffer.clear();
}
}
需要注意
- 循环执行sslEngine.unwrap(packetBuffer, appBuffer)的原因是,有时候ssl解码,一次只会解密一部分,如果我们不循环执行的话,就会导致继续去执行read操作,浪费性能。比如访问哔哩哔哩总会出现,数据已经读完了,但是解码的时候会剩下31个字节,然后我们再执行read操作时服务器因为没数据了就会等超过5秒(超时)的时候返回断开,然后客服端会读到-1。也就是说我们浪费了很多时间直到超时。解决办法就再解码一次,就会讲剩下的31个字节解码出来。
- 如果出现Unsupported record version Unknown-12(任意数字).49(任意数字)或者出现Tag mismatch 报错,就需要检查一下包数据是否完整,即packetBuffer中的数据不完整或者position,limit的位置不对,检查下是否错误的执行了packetBuffer的相关方法。