Netty使用SSL实现双向通信加密

357 阅读9分钟

最近项目有个需求,TCP服务器实现基于证书通信加密,之前没做过,花了一些时间调研,今天整理下。 SSL(Secure Sockets Layer 安全套接字协议)

1、原理

image.png

算法原理

简而言之就是非对称加密算法 私钥自己持有,公钥发给对方,对方在发送信息的时候使用公钥进行加密数据,当接收到数据之后使用私钥进行解密。

CA原理

数字证书也就是你的身份证 CA 也叫证书颁发中心,可以类比为公安局,公安局可以对你发放身份证。 拿着你的身份证去CA验证。

验证原理

先预想一个场景,如果有10台计算机,10台计算机需要记住相互之间的公钥(publickey), 那有100台计算机,1000台呢? 他们之间都需要记住相互的公钥吗? 答案肯定是不能,那如何解决这些问题呢? 其实很简单,有个第三方中介机构。记住了这些1000台的公钥相对应的资料。 这种机构称为认证机构(Certification Authority, CA)。 CA开一个证明这是计算机A的信息,发给B计算机。 B计算机通过CA的证明,可以确认这是A计算机的信息。

◆ 如何生成证书? A计算机将自己的【公钥A】给CA CA用自己的【私钥CA】给【公钥A】加密,生成【数字签名A】 CA把【公钥A】,【数字签名A】,附加一些【A计算机的信息】整合在一起,生成证书,发给A计算机。

◆ 如何验证证书? A计算机发信息给B计算机的时候,会附加【数字签名A】 B计算机通过CA的公钥解密【数字签名A】,既可以确认这是A计算机发的信息。 (其实详细原理不是这样,是解证书得到哈希值,通过算法比较这个哈希值的。)

  • 单向认证:单向认证,即客户端只验证服务端的合法性,服务端不验证客户端。
  • 双向认证:与单向认证不同的是服务端也需要对客户端进行安全认证。这就意味着客户端的自签名证书也需要导入到服务端的数字证书仓库中。
  • CA认证:基于自签名的SSL双向认证,只要客户端或者服务端修改了密钥和证书,就需要重新进行签名和证书交换,这种调试和维护工作量是非常大的。因此,在实际的商用系统中往往会使用第三方CA证书颁发机构进行签名和验证。我们的浏览器就保存了几个常用的CA_ROOT。每次连接到网站时只要这个网站的证书是经过这些CA_ROOT签名过的。就可以通过验证了。

2、一些文件后缀

image.png

image.png 在调研的过程中看到各种格式,虽然不是很理解,但是也实现了功能,需要继续研究 java还有一种JKS的东西,不是很懂。

1.KeyManager 负责提供证书和私钥,证书发给对方peer, 在 SSL/TLS 握手过程中,KeyManager 的实现类负责向远程端证明自己的身份,并提供用于密钥协商的密钥材料 2.TrustManager 负责验证peer 发来的证书。 在 SSL/TLS 握手过程中,TrustManager 的实现类检查远程端的数字证书链,确保它们是由受信任的证书颁发机构签发的,并且没有被撤销。

3、openSSL的使用

3.1 openssl的下载和安装

下载路径:slproweb.com/products/Wi…

image.png

这里选择了Light这个版本,只有5M 安装的过程中有个选择的选项,忘记截图了,随便选吧,不行的话就切换成另外一个。 我这里安装在 D:\Program Files\OpenSSL-Win64\bin 注意: 这里有一个文件openssl.cnf,不知道是我本来电脑就带还是这个软件的,总之我用Everything在电脑上搜索到了, 拷贝到 D:\Program Files\OpenSSL-Win64\bin,不然可能会报错。

3.2 证书的生成

切换到bin目录,打开控制台 在生成的过程中需要输入各种参数,这里密码统一使用123456,有效时间是10年。

# 1. 生成CA认证机构的证书私钥ca.key (相当于自建一个派出所)
openssl genrsa -des3 -out ca.key 1024
# 2. 用私钥ca.key生成CA认证机构的证书ca.crt
openssl req -new -x509 -key ca.key -out ca.crt -days 365

# 3、生成服务端和客户端私钥 | 命令中需要输入密码测试可以都输入123456
openssl genrsa -des3 -out server.key 1024
openssl genrsa -des3 -out client.key 1024

# 4、根据key生成csr文件 
openssl req -new -key server.key -out server.csr -config openssl.cnf
openssl req -new -key client.key -out client.csr -config openssl.cnf

# 5、根据ca证书server.csr、client.csr生成x509证书
openssl x509 -req -days 3650 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt
openssl x509 -req -days 3650 -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt

# 6、将key文件进行PKCS#8编码
openssl pkcs8 -topk8 -in server.key -out pkcs8_server.key -nocrypt
openssl pkcs8 -topk8 -in client.key -out pkcs8_client.key -nocrypt

4、Netty 使用

image.png

基本原理是使用SSLHandler,这个直接加入到Netty的pipeline中,对数据进行加解密。

5、代码

image.png

服务端代码

NettyServer


import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.ssl.ClientAuth;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;

import javax.net.ssl.SSLException;
import java.io.File;


public class NettyServer {

    public static void main(String[] args) throws SSLException {
        new NettyServer().bing(8088);
    }

    private void bing(int port) throws SSLException {
        String dirPrefix = "C:\\Users\\xin.chong\\Desktop\\ssl\\src\\main\\resources\\server\\";
        //引入SSL安全验证
        File certChainFile = new File(dirPrefix + "server.crt");
        File keyFile = new File(dirPrefix + "pkcs8_server.key");
        File rootFile = new File(dirPrefix + "ca.crt");
        SslContext sslCtx = SslContextBuilder.forServer(certChainFile, keyFile).trustManager(rootFile).clientAuth(ClientAuth.REQUIRE).build();

        //配置服务端NIO线程组
        EventLoopGroup parentGroup = new NioEventLoopGroup(1); //
        EventLoopGroup childGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(parentGroup, childGroup)
                    .channel(NioServerSocketChannel.class)    //非阻塞模式
                    .option(ChannelOption.SO_BACKLOG, 128)
                    .childHandler(new MyChannelInitializer(sslCtx));
            ChannelFuture f = b.bind(port).sync();
            System.out.println("netty server start done. ");
            f.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            childGroup.shutdownGracefully();
            parentGroup.shutdownGracefully();
        }

    }

}


MyServerHandler.java


import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.ssl.SslHandler;
import lombok.extern.slf4j.Slf4j;

import java.security.Principal;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Slf4j
public class MyServerHandler extends ChannelInboundHandlerAdapter {

    /**
     * 当客户端主动链接服务端的链接后,这个通道就是活跃的了。也就是客户端与服务端建立了通信通道并且可以传输数据
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {

        ctx.pipeline().get(SslHandler.class).handshakeFuture().addListener((f) -> {
            Certificate[] certs = null;
            try{
                certs = ctx.pipeline().get(SslHandler.class).engine().getSession().getPeerCertificates();
                if (certs.length == 0) {
                    // 无法从连接中获取到有效的证书
                    return;
                }
            }catch (Throwable ex){
                // 无法从连接中获取到有效的证书
                log.error("",ex);
                return;
            }

            try{
                // 记录客户端证书
                String clientId = getCommonName(certs[0]);
                log.error("clientId   {}   channelActive",clientId);

            } catch (Throwable ex) {
                // 证书检查过程出现未知错误
            }
        });


        SocketChannel channel = (SocketChannel) ctx.channel();
        System.out.println("链接报告开始");
        System.out.println("链接报告信息:有一客户端链接到本服务端");
        System.out.println("链接报告IP:" + channel.localAddress().getHostString());
        System.out.println("链接报告Port:" + channel.localAddress().getPort());
        System.out.println("链接报告完毕");
        //通知客户端链接建立成功
        String str = " 通知客户端链接建立成功" + " " + new Date() + " " + channel.localAddress().getHostString() + "\r\n";
        ctx.writeAndFlush(str);
    }

    /**
     * 当客户端主动断开服务端的链接后,这个通道就是不活跃的。也就是说客户端与服务端的关闭了通信通道并且不可以传输数据
     */
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("客户端断开链接" + ctx.channel().localAddress().toString());
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        //接收msg消息
        System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + " 接收到消息:" + msg);
        //通知客户端链消息发送成功
        ctx.writeAndFlush("[SSL]服务端发送,客户端我在。\r\n");
    }

    /**
     * 抓住异常,当发生异常的时候,可以做一些相应的处理,比如打印日志、关闭链接
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        ctx.close();
        System.out.println("异常信息:\r\n" + cause.getMessage());
    }
    public static String getCommonName(Certificate cert) throws Exception {
        X509Certificate certificate = (X509Certificate) cert;
        Map<String, String> certUserInfoMap = getCertUserInfoMap(certificate);
        certUserInfoMap.forEach((k,v)->{
            log.error("k -> v  : {}  == {}",k,v);
        });
        return certUserInfoMap.get("CN");
    }

    static Map<String, String> getCertUserInfoMap(X509Certificate cert){
        Map<String, String> map = new HashMap<>();
        Principal dn = cert.getSubjectDN();
        String[] attrs = dn.getName().trim().split(",");
        for(String attr : attrs){
            String[] kv = attr.split("=");
            if(kv.length == 2){
                map.put(kv[0].trim(), kv[1].trim());
            }
        }
        return map;
    }
}


MyChannelInitializer.java

import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.handler.ssl.SslContext;

import java.nio.charset.Charset;

/**
 *
 */
public class MyChannelInitializer extends ChannelInitializer<SocketChannel> {

    private SslContext sslContext;

    public MyChannelInitializer(SslContext sslContext) {
        this.sslContext = sslContext;
    }

    @Override
    protected void initChannel(SocketChannel channel) {
        // 添加SSL安装验证
        channel.pipeline().addLast(sslContext.newHandler(channel.alloc()));
        // 基于换行符号
        channel.pipeline().addLast(new LineBasedFrameDecoder(1024));
        // 解码转String,注意调整自己的编码格式GBK、UTF-8
        channel.pipeline().addLast(new StringDecoder(Charset.forName("GBK")));
        // 解码转String,注意调整自己的编码格式GBK、UTF-8
        channel.pipeline().addLast(new StringEncoder(Charset.forName("GBK")));
        // 在管道中添加我们自己的接收数据实现方法
        channel.pipeline().addLast(new MyServerHandler());
    }

}


客户端代码

NettyClient


import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;

import javax.net.ssl.SSLException;
import java.io.File;


public class NettyClient {

    public static void main(String[] args) throws SSLException {
        new NettyClient().connect("127.0.0.1", 8088);
    }

    private void connect(String inetHost, int inetPort) throws SSLException {
        String dirPrefix = "C:\\Users\\xin.chong\\Desktop\\ssl\\src\\main\\resources\\client\\";

        //引入SSL安全验证
        File certChainFile = new File(dirPrefix + "client.crt");
        File keyFile = new File(dirPrefix + "pkcs8_client.key");
        File rootFile = new File(dirPrefix + "ca.crt");
        SslContext sslCtx = SslContextBuilder.forClient().keyManager(certChainFile, keyFile).trustManager(rootFile).build();

        //配置客户端NIO线程组
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            Bootstrap b = new Bootstrap();
            b.group(workerGroup);
            b.channel(NioSocketChannel.class);
            b.option(ChannelOption.AUTO_READ, true);
            b.handler(new MyChannelInitializer(sslCtx));
            ChannelFuture f = b.connect(inetHost, inetPort).sync();
            System.out.println("netty client start done.");
            f.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            workerGroup.shutdownGracefully();
        }
    }

}


MyClientHandler.java


import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.socket.SocketChannel;

import java.text.SimpleDateFormat;
import java.util.Date;


public class MyClientHandler extends ChannelInboundHandlerAdapter {

    /**
     * 当客户端主动链接服务端的链接后,这个通道就是活跃的了。也就是客户端与服务端建立了通信通道并且可以传输数据
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        SocketChannel channel = (SocketChannel) ctx.channel();
        System.out.println("链接报告开始");
        System.out.println("链接报告信息:本客户端链接到服务端。channelId:" + channel.id());
        System.out.println("链接报告IP:" + channel.localAddress().getHostString());
        System.out.println("链接报告Port:" + channel.localAddress().getPort());
        System.out.println("链接报告完毕");
        //通知客户端链接建立成功
        String str = "通知服务端链接建立成功" + " " + new Date() + " " + channel.localAddress().getHostString() + "\r\n";
        ctx.writeAndFlush(str);
    }

    /**
     * 当客户端主动断开服务端的链接后,这个通道就是不活跃的。也就是说客户端与服务端的关闭了通信通道并且不可以传输数据
     */
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("断开链接" + ctx.channel().localAddress().toString());
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        //接收msg消息
        System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + " 接收到消息:" + msg);
        //通知客户端链消息发送成功
        ctx.writeAndFlush("[SSL]客户端发送,服务端你在吗?\r\n");
    }

    /**
     * 抓住异常,当发生异常的时候,可以做一些相应的处理,比如打印日志、关闭链接
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        ctx.close();
        System.out.println("异常信息:\r\n" + cause.getMessage());
    }

}


MyChannelInitializer.java

package com.pdool.ssl.client;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.handler.ssl.SslContext;

import java.nio.charset.Charset;


public class MyChannelInitializer extends ChannelInitializer<SocketChannel> {

    private SslContext sslContext;

    public MyChannelInitializer(SslContext sslContext) {
        this.sslContext = sslContext;
    }

    @Override
    protected void initChannel(SocketChannel channel) throws Exception {
        // 添加SSL安装验证
        channel.pipeline().addLast(sslContext.newHandler(channel.alloc()));
        // 基于换行符号
        channel.pipeline().addLast(new LineBasedFrameDecoder(1024));
        // 解码转String,注意调整自己的编码格式GBK、UTF-8
        channel.pipeline().addLast(new StringDecoder(Charset.forName("GBK")));
        // 解码转String,注意调整自己的编码格式GBK、UTF-8
        channel.pipeline().addLast(new StringEncoder(Charset.forName("GBK")));
        // 在管道中添加我们自己的接收数据实现方法
        channel.pipeline().addLast(new MyClientHandler());
    }

}