2.2.
SSL双向认证
2.2.1. SSL双向认证开发
我们在2.1章节的基础上进行开发,与单向认证不同的是服务端也需要对客户端进行安全认证。这就意味着客户端的自签名证书也需要导入到服务端的数字证书仓库中。
首先,生成客户端的自签名证书:
keytool -export -alias smcc -keystore cChat.jks -storepass cNetty -file cChat.cer |
最后,将客户端的自签名证书导入到服务端的信任证书仓库中:
keytool -import -trustcacerts -alias smcc -file cChat.cer -storepass sNetty -keystore sChat.jks |
证书导入之后,需要对SSL客户端和服务端的代码同时进行修改,首先我们看下服务端如何修改。
由于服务端需要对客户端进行验证,因此在初始化服务端SSLContext的时候需要加载证书仓库。首先需要对TrustManagerFactory进行初始化,代码如下:
初始化SSLContext的时候根据TrustManagerFactory获取TrustManager数组,代码如下:
最后,创建SSLEngine之后,设置需要进行客户端认证,代码如下:
完成服务端修改之后,再回头看下客户端的修改,由于服务端需要认证客户端的证书,因此,需要初始化和加载私钥仓库,向服务端发送公钥,初始化KeyStore的代码如下:
初始化SSLContext的时候需要传入KeyManager数组,代码如下:
客户端开发完成之后,测试下程序是否能够正常工作,运行结果如下所示。
客户端运行结果:
图2-5 Netty SSL双向认证客户端运行结果
服务端运行结果:
图2-6 Netty SSL双向认证服务端运行结果
在客户端控制台进行输入,看SSL传输是否正常:
图2-7 Netty SSL 安全传输测试
2.2.2. SSL双向认证原理分析
SSL双向认证相比单向认证,多了一步服务端发送认证请求消息给客户端,客户端发送自签名证书给服务端进行安全认证的过程。下面,我们结合Netty
SSL调测日志,对双向认证的差异点进行分析。
相比于客户端,服务端在发送ServerHello时携带了要求客户端认证的请求信息,如下所示:
*** CertificateRequest Cert Types: RSA, DSS, ECDSA Cert Authorities: <CN=localhost> <CN=localhost> *** ServerHelloDone |
客户端接收到服务端要求客户端认证的请求消息之后,发送自己的证书信息给服务端,信息如下:
matching alias: smcc *** Certificate chain chain [0] = [ [ Version: V3 Subject: CN=localhost Signature Algorithm: SHA1withRSA, OID = 1.2.840.113549.1.1.5
Key: Sun RSA public key, 2048 bits
modulus: 212639695562264078962258083015763969567082142460170954053624074
53705267323050920051941696590911289892005894127848317880153980200067657563
15944918691324084822137929027919841383304228071408660098765703368443353862
47349919704780645114810932016343908989985053434023995248208445566727867691
73042913746571760169661698040844437316556983406538131853892449014877947773
16977794500345715634646402492099542466990685058179767825995777860790787074
72339147926907851214779520246763960901175126351376922481444497141021631392
59603124160944922844840171133151822882039207352509182052426500279100525773
147139994269292585983679425433429361
public exponent: 65537
Validity: [From: Sun Jul 27 08:50:35 CST 2014,
To: Mon Jul 27 08:50:35 CST 2015]
Issuer: CN=localhost
SerialNumber: [ 53d44cdb] |
服务端对客户端的自签名证书进行认证,信息如下:
*** Found trusted certificate: [ [ Version: V3 Subject: CN=localhost Signature Algorithm: SHA1withRSA, OID = 1.2.840.113549.1.1.5
Key: Sun RSA public key, 2048 bits
modulus: 21263969556226407896225808301576396956708214246017095405362407
4537052673230509200519416965909112898920058941278483178801539802000676575
6315944918691324084822137929027919841383304228071408660098765703368443353
8624734991970478064511481093201634390898998505343402399524820844556672786
7691730429137465717601696616980408444373165569834065381318538924490148779
4777316977794500345715634646402492099542466990685058179767825995777860790
7870747233914792690785121477952024676396090117512635137692248144449714102
1631392596031241609449228448401711331518228820392073525091820524265002791
00525773147139994269292585983679425433429361
public exponent: 65537
Validity: [From: Sun Jul 27 08:50:35 CST 2014,
To: Mon Jul 27 08:50:35 CST 2015]
Issuer: CN=localhost
SerialNumber: [ 53d44cdb] |
2.3. 第三方CA认证
使用jdk keytool生成的数字证书是自签名的。自签名就是指证书只能保证自己是完整且没有经过非法修改,但是无法保证这个证书是属于谁的。为了对自签名证书进行认证,需要每个客户端和服务端都交换自己自签名的私有证书,对于一个大型网站或者应用服务器,这种工作量是非常大的。
基于自签名的SSL双向认证,只要客户端或者服务端修改了密钥和证书,就需要重新进行签名和证书交换,这种调试和维护工作量是非常大的。因此,在实际的商用系统中往往会使用第三方CA证书颁发机构进行签名和验证。我们的浏览器就保存了几个常用的CA_ROOT。每次连接到网站时只要这个网站的证书是经过这些CA_ROOT签名过的。就可以通过验证了。
CA数字证书认证服务往往是收费的,国内有很多数字认证中心都提供相关的服务,如下所示:
图2-8 商业的数字认证中心
作为示例,我们自己生成一个CA_ROOT的密钥对,部署应用时,把这个CA_ROOT的私钥部署在所有需要SSL传输的节点就可以完成安全认证。作为示例,如果要生成CA_ROOT,我们使用开源的OpenSSL。
在Windows上安装和使用OpenSSL网上有很多教程,也不是本文的重点,因此,OpenSSL的安装和使用本文不详细介绍。
下面我们对基于第三方CA认证的步骤进行详细介绍。
2.3.1. 服务端证书制作
步骤1:利用OpenSSL生成CA证书:
openssl req -new -x509 -keyout ca.key -out ca.crt -days 365 |
步骤2:生成服务端密钥对:
keytool -genkey -alias securechat -keysize 2048 -validity 365 -keyalg RSA -dname "CN=localhost" -keypass sNetty -storepass sNetty -keystore sChat.jks |
步骤3:生成证书签名请求:
keytool -certreq -alias securechat -sigalg MD5withRSA -file sChat.csr -keypass sNetty -storepass sNetty -keystore sChat.jks |
步骤4:用CA私钥进行签名:
openssl ca -in sChat.csr -out sChat.crt -cert ca.crt -keyfile ca.key -notext |
步骤5:导入信任的CA根证书到keystore:
keytool -import -v -trustcacerts -alias ca_root -file ca.crt -storepass sNetty -keystore sChat.jks |
步骤6:将CA签名后的server端证书导入keystore:
keytool -import -v -alias securechat -file server.crt -keypass sNetty -storepass sNetty -keystore sChat.jks |
2.3.2. 客户端证书制作
步骤1:生成客户端密钥对:
keytool -genkey -alias smcc -keysize 2048 -validity 365 -keyalg RSA -dname "CN=localhost" -keypass cNetty -storepass cNetty -keystore cChat.jks |
步骤2:生成证书签名请求:
keytool -certreq -alias smcc -sigalg MD5withRSA -file cChat.csr -keypass cNetty -storepass cNetty -keystore cChat.jks |
步骤3:用CA私钥进行签名:
openssl ca -in cChat.csr -out cNetty.crt -cert ca.crt -keyfile ca.key -notext |
步骤4:导入信任的CA根证书到keystore:
keytool -import -v -trustcacerts -alias ca_root -file ca.crt -storepass cNetty -keystore cChat.jks |
步骤5:将CA签名后的client端证书导入keystore:
keytool -import -v -alias smcc -file cNetty.crt -keypass cNetty -storepass cNetty -keystore cChat.jks |
2.3.3. 开发和测试
基于CA认证的开发和测试与SSL双向和单向认证代码相同,此处不再赘述。
3. Netty SSL源码分析
3.1. SSL客户端
当客户端和服务端的TCP链路建立成功之后,SslHandler的channelActive被触发,SSL客户端通过SSL引擎发起握手请求消息,代码如下:
发起握手请求之后,需要将SSLEngine创建的握手请求消息进行SSL编码,发送给服务端,因此,握手之后立即调用wrapNonAppData方法,下面具体对该方法进行分析:
因为只需要发送握手请求消息,因此Source ByteBuf为空,下面看下wrap方法的具体实现:
将SSL引擎中创建的握手请求消息编码到目标ByteBuffer中,然后对写索引进行更新。判断写入操作是否越界,如果越界说明out容量不足,需要调用ensureWritable对ByteBuf进行动态扩展,扩展之后继续尝试编码操作。如果编码成功,返回SSL引擎操作结果。
对编码结果进行判断,如果编码字节数大于0,则将编码后的结果发送给服务端,然后释放临时变量out。
判断SSL引擎的操作结果,SSL引擎的操作结果定义如下:
1.FINISHED:SSLEngine 已经完成握手;
2.NEED_TASK:SSLEngine 在继续进行握手前需要一个(或多个)代理任务的结果;
3.NEED_UNWRAP:在继续进行握手前,SSLEngine 需要从远端接收数据,所以应带调用SSLEngine.unwrap();
4.NEED_WRAP:在继续进行握手前,SSLEngine 必须向远端发送数据,所以应该调用
SSLEngine.wrap();
5.NOT_HANDSHAKING:SSLEngine 当前没有进行握手。
下面我们分别对5种操作的代码进行分析:
如果握手成功,则设置handshakePromise的操作结果为成功,同时发送SslHandshakeCompletionEvent.SUCCES给SSL监听器,代码如下:
如果是NEED_TASK,说明异步执行SSL Task,完成后续可能耗时的操作或者任务,Netty封装了一个任务立即执行线程池专门处理SSL的代理任务,代码如下:
如果是NEED_UNWRAP,则判断是否由UNWRAP发起,如果不是则执行UNWRAP操作。
如果是NOT_HANDSHAKING,则调用unwrap,继续接收服务端的消息。
服务端应答消息的接收跟服务端接收客户端的代码类似,唯一不同之处在于SSL引擎的客户端模式设置不同,一个是服务端,一个是客户端。上层的代码处理是相同的,下面我们在SSL服务端章节分析握手消息的接收。
3.2. SSL服务端
SSL服务端接收客户端握手请求消息的入口方法是decode方法,下面对它进行详细分析。
首先获取接收缓冲区的读写索引,并对读取的偏移量指针进行备份:
对半包标识进行判断,如果上一个消息是半包消息,则判断当前可读的字节数是否小于整包消息的长度,如果小于整包长度,则说明本次读取操作仍然没有把SSL整包消息读取完整,需要返回IO线程继续读取,代码如下:
如果消息读取完整,则修改偏移量:同时置位半包长度标识。
下面在for循环中读取SSL消息,因为TCP存在拆包和粘包,因此一个ByteBuf可能包含多条完整的SSL消息。
首先判断可读的字节数是否小于协议消息头长度,如果是则退出循环继续由IO线程接收后续的报文:
获取SSL消息包的报文长度,具体算法不再介绍,可以参考SSL的规范文档进行解读,代码如下:
对长度进行判断,如果SSL报文长度大于可读的字节数,说明是个半包消息,将半包标识长度置位,返回IO线程继续读取后续的数据报,代码如下:
对消息进行解码,将SSL加密的消息解码为加密前的原始数据,unwrap方法如下:
调用SSLEngine的unwrap方法对SSL原始消息进行解码,对解码结果进行判断,如果越界,说明out缓冲区不够,需要进行动态扩展。如果是首次越界,为了尽量节约内存,使用SSL最大缓冲区长度和SSL原始缓冲区可读的字节数中较小的。如果再次发生缓冲区越界,说明扩张后的缓冲区仍然不够用,直接使用SSL缓冲区的最大长度,保证下次解码成功。
解码成功之后,对SSL引擎的操作结果进行判断:如果需要继续接收数据,则继续执行解码操作;如果需要发送握手消息,则调用wrapNonAppData发送握手消息;如果需要异步执行SSL代理任务,则调用立即执行线程池执行代理任务;如果是握手成功,则设置SSL操作结果,发送SSL握手成功事件;如果是
应用层的业务数据,则继续执行解码操作,其它操作结果,抛出操作类型异常。
需要指出的是,SSL客户端和服务端接收对方SSL握手消息的代码是相同的,那为什么SSL服务端和客户端发送的握手消息不同呢?这些是SSL引擎负责区分和处理的,我们在创建SSL引擎的时候设置了客户端模式,SSL引擎就是根据这个来进行区分的,代码如下:
无论客户端还是服务端,只需要围绕SSL引擎的操作结果进行编程即可。
3.3. SSL消息读取
SSL的消息读取实际就是ByteToMessageDecoder将接收到的SSL加密后的报文解码为原始报文,然后将整包消息投递给后续的消息解码器,对消息做二次解码。基于SSL的消息解码模型如下:
SSL消息读取的入口都是decode,因为是非握手消息,它的处理非常简单,就是循环调用引擎的unwrap方法,将SSL报文解码为原始的报文,代码如下:
握手成功之后的所有消息都是应用数据,因此它的操作结果为NOT_HANDSHAKING,遇到此标识之后继续读取消息,直到没有可读的字节,退出循环,代码如下:
如果读取到了可用的字节,则将读取到的缓冲区加到输出结果列表中,代码如下:
ByteToMessageDecoder判断解码结果List,如果非空,则循环调用后续的Handler,由后续的解码器对解密后的报文进行二次解码。
3.4. SSL消息发送
SSL消息发送时,由SslHandler对消息进行编码,编码后的消息实际就是SSL加密后的消息,它的入口是flush方法,代码如下:
从待加密的消息队列中弹出消息,调用SSL引擎的wrap方法进行编码,代码如下:
wrap方法很简单,就是调用SSL引擎的编码方法,然后对写索引进行修改,如果缓冲区越界,则动态扩展缓冲区:
对SSL操作结果进行判断,因为已经握手成功,因此返回的结果是NOT_HANDSHAKING,执行finishWrap方法,调用ChannelHandlerContext的write方法,将消息写入发送缓冲区中,如果待发送的消息为空,则构造空的ByteBuf写入:
编码后,调用ChannelHandlerContext的flush方法消息发送给对方,代码如下:
|