-
需求背景
项目主要分为监管侧和企业侧,企业侧实时上传数据到云端,云端汇聚业务数据,上传过程需要保证传输的安全性。 -
技术实现
数据上传考虑到用HTTPS或者是TCP + TLS传输。其实使用HTTPS传输协议是比较简单的,但是项目硬件使用的4G无线网卡,而且需要实时检测设备运行状态,所以使用了TCP + TLS的方式,实时性更高并且减少了传输流量。然后考虑是单向认证还是双向认证,考虑为简化交互过程就采用了单向认证,在服务端生成自签证书分发给企业侧,企业侧(客户端)使用该证书发起认证即可。时间有限,本文主要介绍整体使用技术及TLS部分,其余技术细节就不展开了。
-
使用技术
jdk 1.8,netty 4.1.100.Final,boringssl(openssl), openssl 1.1.1,snakeyaml,logback,msgpack 0.6.12,mybatis-plus,Wireshark。
netty、boringssl和openssl主要实现传输层交互和证书生成
snakeyaml做配置管理
logback做日志输出
msgpack 做二进制的编解码;也有考虑用Protobuf但是操作复杂,要额外配置协议格式生成代码,不能动态实现任意类的编解码工作。
mybatis-plus做数据库的操作
Wireshark做网络分析 -
首先生成自签证书
2.1 生成pkcs8格式的证书
openssl genrsa -out rsa_private.key 2048
openssl pkcs8 -topk8 -nocrypt -in rsa_private.key -out private_key_pkcs8.pem
2.2 生成公钥
openssl rsa -in private_key_pkcs8.pem -pubout -out public_key.pem
2.3 使用私钥生成证书
openssl req -new -key private_key_pkcs8.pem -x509 -days 365 -out certificate.crt -subj "/C=CN/ST=SC/L=CD/O=csin/OU=test/CN=test.com/[email protected]"
我们会得到三个文件private_key_pkcs8.pem,public_key.pem,certificate.crt。这里私钥没有加密处理,后面会说明原因 -
ssl部分代码
3.1 服务端
String serverCert = "/cert/certificate.crt";
String serverKey = "/cert/private_key_pkcs8.pem";
List<String> ciphers = Lists.newArrayList("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", "TLS_RSA_WITH_AES_256_CBC_SHA");
SslContext sslContext = SslContextBuilder.forServer(file(serverCert), file(serverKey))
.sslProvider(SslProvider.OPENSSL)
.ciphers(ciphers)
.protocols("TLSv1.2") // 指定支持的协议版本
.build();
//initChannel
...
ChannelPipeline pipeline = ch.pipeline();
//ssl处理
SSLEngine sslEngine = sslContext.newEngine(ch.alloc());
sslEngine.setUseClientMode(false); // 设置为服务器模式
sslEngine.setNeedClientAuth(false); // 需要客户端验证
SslHandler sslHandler = new SslHandler(sslEngine);
pipeline.addFirst(sslHandler);
...
3.2 客户端
List<String> ciphers = Lists.newArrayList("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", "TLS_RSA_WITH_AES_256_CBC_SHA");
SslContext sslContext = SslContextBuilder.forClient()
.trustManager(file("/cert/certificate.crt"))
.sslProvider(SslProvider.OPENSSL)
.ciphers(ciphers)
.protocols("TLSv1.2") // 指定支持的协议版本
.build();
//initChannel
...
ChannelPipeline pipeline = socketChannel.pipeline();
//ssl处理
SSLEngine sslEngine = sslContext.newEngine(socketChannel.alloc());
sslEngine.setUseClientMode(true);
SslHandler sslHandler = new SslHandler(sslEngine, true);
pipeline.addLast(sslHandler);
...
//监听tcp连接成功后flush一下,很重要
future.addListener((ChannelFutureListener) futureListener -> {
if (futureListener.isSuccess()) {
channel = futureListener.channel();
//刷新触发tls握手
channel.flush();
//连接成功后,启动定时任务
log.info("Connect to server successfully!");
} else {
log.error("Failed to connect to server, try connect after 10s");
futureListener.channel().eventLoop().schedule(this::doConnect, 10, TimeUnit.SECONDS);
}
});
- 踩坑
- TLS协议版本使用错误
解决:使用了TLSv1.3的版本.protocols("TLSv1.2", "TLSv1.3") // 指定支持的协议版本
,而项目中jdk用的1.8,只支持TLSv1.2,所以只是用TLSv1.2就可以了.protocols("TLSv1.2") // 指定支持的协议版本
。 - 采用了密钥加密后一直以下报错,可能是本人使用方式不对,有遇到过的同学望告知,谢谢。
...
java.lang.IllegalArgumentException: Input stream does not contain valid private key.
at io.netty.handler.ssl.SslContextBuilder.keyManager(SslContextBuilder.java:416)
at io.netty.handler.ssl.SslContextBuilder.forServer(SslContextBuilder.java:138)
...
Caused by: java.io.IOException: ObjectIdentifier() -- data isn't an object ID (tag = 48)
at sun.security.util.ObjectIdentifier.<init>(ObjectIdentifier.java:257)
at sun.security.util.DerInputStream.getOID(DerInputStream.java:314)
at com.sun.crypto.provider.PBES2Parameters.engineInit(PBES2Parameters.java:267)
at java.security.AlgorithmParameters.init(AlgorithmParameters.java:293)
at sun.security.x509.AlgorithmId.decodeParams(AlgorithmId.java:132)
at sun.security.x509.AlgorithmId.<init>(AlgorithmId.java:114)
at sun.security.x509.AlgorithmId.parse(AlgorithmId.java:372)
at javax.crypto.EncryptedPrivateKeyInfo.<init>(EncryptedPrivateKeyInfo.java:95)
at io.netty.handler.ssl.SslContext.generateKeySpec(SslContext.java:1082)
at io.netty.handler.ssl.SslContext.getPrivateKeyFromByteBuffer(SslContext.java:1144)
at io.netty.handler.ssl.SslContext.toPrivateKey(SslContext.java:1134)
at io.netty.handler.ssl.SslContextBuilder.keyManager(SslContextBuilder.java:414)
... 3 common frames omitted
...
- 在
channelActive
中直接发送消息,出现以下错误。例:sendPing(ctx)
服务端报错:io.netty.handler.ssl.NotSslRecordException: not an SSL/TLS record:
解决:原因是在tcp连接后,tls握手还没有完成成功,发送数据没有经过SslHandler
处理。正确方式应该监听握手成功发送。例子如下:
...
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof SslHandshakeCompletionEvent) {
if (((SslHandshakeCompletionEvent) evt).isSuccess()) {
// SSL握手成功
log.info("SSL handshake completed successfully");
handshakeSuccess(ctx);
} else {
// SSL握手失败
log.info("SSL handshake failed: " + ((SslHandshakeCompletionEvent) evt).cause());
handshakeFailed(ctx);
}
}
...
- 在服务端错误配置startTls为true。例:
SslHandler sslHandler = new SslHandler(sslEngine, true);
解决:应该在客户端配置startTls为true,并且sslEngine.setUseClientMode(true);
- 客户端TCP连接成功后,不能发起握手,异步传输时候以下报错
handshake failed: io.netty.handler.ssl.SslHandshakeTimeoutException: handshake timed out after 10000ms
解决:在客户端TCP连接成功后,调用channel.flush();
触发tls握手,如下:
...
future.addListener((ChannelFutureListener) futureListener -> {
if (futureListener.isSuccess()) {
channel = futureListener.channel();
//刷新触发tls握手
channel.flush();
log.info("Connect to server successfully!");
} else {
log.error("Failed to connect to server, try connect after 10s");
futureListener.channel().eventLoop().schedule(this::doConnect, 10, TimeUnit.SECONDS);
}
});
...
- 证书的格式错误,刚开始生成PKCS#1格式证书,启动就出错
解决:netty支持PKCS#8的格式,生成PKCS#8格式的证书。 - Wireshark看不到tls过程
解决:导入生成的私钥就可以了
- 写在最后
文中代码实现是经笔者测试的结果,为回顾项目特此记录开发过程中的问题。由于个人精力有限,如有疏漏或者发现错误,望大家提出宝贵意见。