实现双向SSL/TLS安全连接的设备CA证书认证
背景消息
双向SSL/TLS安全连接
作为基于现代密码学公钥算法的安全协议,TLS/SSL 能在计算机通讯网络上保证传输安全,很多MQTT Broker 内置对 TLS/SSL 的支持,包括支持单/双向认证、X.509 证书、负载均衡 SSL 等多种安全认证。
SSL/TLS 安全优势
SSL/TLS 协议
TLS/SSL 协议下的通讯过程分为两部分,第一部分是握手协议。握手协议的目的是鉴别对方身份并建立一个安全的通讯通道。握手完成之后双方会协商出接下来使用的密码套件和会话密钥;第二部分是 record 协议,record 和其他数据传输协议非常类似,会携带内容类型,版本,长度和荷载等信息,不同的是它所携带的信息是加密了的。
下面的图片描述了 TLS/SSL 握手协议的过程,从客户端的 “hello” 一直到服务器的 “finished” 完成握手。有兴趣的同学可以找更详细的资料看。
**SSL/TLS 证书准备 **
在双向认证中,一般都使用自签名证书的方式来生成服务端和客户端证书,因此本文就以自签名证书为例。
通常来说,我们需要数字证书来保证 TLS 通讯的强认证。数字证书的使用本身是一个三方协议,除了通讯双方,还有一个颁发证书的受信第三方,有时候这个受信第三方就是一个 CA。和 CA 的通讯,一般是以预先发行证书的方式进行的。也就是在开始 TLS 通讯的时候,我们需要至少有 2 个证书,一个 CA 的,一个 MQTT服务端 的, MQTT服务端的证书由 CA 颁发,并用 CA 的证书验证。
在这里,我们假设您的系统已经安装了 OpenSSL。使用 OpenSSL 附带的工具集就可以生成我们需要的证书了。
使用OpenSSL创建生成CA证书、服务器、客户端证书及密钥
- 生成CA证书
- 生成服务器证书
- 生成客户端证书
各类证书与密钥文件后缀的解释
总得来说这些文件都与X.509证书和密钥文件有关,从文件编码上分,只有两大类:
PEM格式:使用Base64 ASCII进行编码的纯文本格式
DER格式:二机制格式
而CRT, CER,KEY这几种证书和密钥文件,它们都有自己的schema,在存储为物理文件时,既可以是PEM格式,也可以DER格式。
CER:一般用于windows的证书文件格式
CRT:一般用于Linux的证书,包含公钥和主体信息
KEY:一般用于密钥,特别是私钥, 与证书一一配对
打个比方:CER,CRT,KEY相当于论文,说明书等,有规定好的行文格式与规范,而PEM和DER相当于txt格式还是word格式。
CSR: Certificate Signing Request,即证书签名请求文件。证书申请者在生成私钥的同时也生成证书请求文件。把CSR文件提交给证书颁发机构后,证书颁发机构使用其根证书私钥签名就生成了证书公钥文件,也就是颁发给用户的证书。
证书生成
1.生成CA证书
1.创建CA证书私钥
openssl genrsa -out ca.key 2048
2.请求证书 证数各参数含义如下
openssl req -new -sha256 -key ca.key -out ca.csr -subj "/C=CN/ST=SZ/L=SZ/O=C.X.L/OU=C.X.L/CN=CA/emailAddress=123456@test.com"
3.自签署证书
openssl x509 -req -days 36500 -sha256 -extensions v3_ca -signkey ca.key -in ca.csr -out ca.crt
2.生成服务端证书
1.创建服务器私钥
openssl genrsa -out server.key 2048
新建 openssl.cnf 文件,
[req]
default_bits = 2048
distinguished_name = req_distinguished_name
req_extensions = req_ext
x509_extensions = v3_req
prompt = no
[req_distinguished_name]
countryName = CN
stateOrProvinceName = SZ
organizationName = C.X.L
organizationalUnitName = C.X.L
commonName = service
[req_ext]
subjectAltName = @alt_names
[v3_req]
subjectAltName = @alt_names
[alt_names]
IP.1 = 127.0.0.1
IP.2 = 192.168.5.249
2.请求证书
openssl req -new -sha256 -key server.key -config openssl.cnf -out server.csr
3.使用CA证书签署服务器证书
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 3650 -sha256 -extensions v3_req -extfile openssl.cnf
4.验证服务端证书
openssl verify -CAfile ca.crt server.crt
5查看服务端证书
openssl x509 -noout -text -in server.crt
6.Netty需要支持PKCS8格式读取私钥
openssl pkcs8 -topk8 -nocrypt -in server.key -out pkcs8_key.pem
**注:**错误日志也很明确的打印了:at sun.security.pkcs.PKCS8Key.decode(PKCS8Key.java:351),采用PKCS8无法解析证书。这是因为部分MQTT broker使用的是netty,netty默认使用PKCS8格式对证书进行解析,然而我们使用openssl生成的服务端server.key是PKCS1格式的,所以MQTT broker采用PKCS8无法对证书进行解析。
问题处理
对证书进行格式转行,将PKCS1格式转换成PKCS8即可。
证书格式区别:
- PKCS1的文件头格式 —–BEGIN RSA PRIVATE KEY—–
- PKCS8的文件头格式 —–BEGIN PRIVATE KEY—–
生成客户端证书
1.生成客户端私钥
openssl genrsa -out client.key 2048
2.请求证书
openssl req -new -sha256 -key client.key -out client.csr -subj "/C=CN/ST=SZ/L=SZ/O=C.X.L/OU=C.X.L/CN=CLIENT/emailAddress=123456@test.com"
3.使用CA证书签署客户端证书
openssl x509 -req -days 36500 -sha256 -extensions v3_req -CA ca.cer -CAkey ca.key -CAserial ca.srl -CAcreateserial -in client.csr -out client.crt
4.验证服务端证书
openssl verify -CAfile ca.crt client.crt
5.查看服务端证书
openssl x509 -noout -text -in client.crt
证书转换
CRT转为PEM
#.key 转换成 .pem:
openssl rsa -in server.key -out server-key.pem
#.crt 转换成 .pem:
openssl x509 -in server.crt -out server.pem -outform PEM
既然PEM与DER只是编码格式上的不同,那么不管是证书还是密钥,都可以随意转换为想要的格式:
PEM转DER
openssl x509 -outform der -in server.pem -out server.der
DER专PEM
openssl x509 -inform der -in server.der -out server.crt
注:也可以直接生成PEM格式的证书,生成方式和CRT一样
区别:
// 生成CA证书
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.pem
// 生成服务端证书
openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out server.pem -days 3650 -sha256 -extensions v3_req -extfile openssl.cnf
// 生成客户端证书
openssl x509 -req -days 3650 -in client.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out client.pem
SSL/TLS 双向连接的启用及验证
1.EMQX
在 EMQ X 中 mqtt:ssl 的默认监听端口为 8883。
将前文中通过 OpenSSL 工具生成的 server.crt 、server.key 及 ca.crt 文件拷贝到 EMQ X 的 etc/certs/ 目录下,并参考如下配置修改 emqx.conf:
## listener.ssl.$name is the IP address and port that the MQTT/SSL
## Value: IP:Port | Port
listener.ssl.external = 8883
## Path to the file containing the user's private PEM-encoded key.
## Value: File
listener.ssl.external.keyfile = etc/certs/server.key
## Path to a file containing the user certificate.
## Value: File
listener.ssl.external.certfile = etc/certs/emqx.pem
## Path to the file containing PEM-encoded CA certificates. The CA certificates
## Value: File
listener.ssl.external.cacertfile = etc/certs/ca.pem
## A server only does x509-path validation in mode verify_peer,
## as it then sends a certificate request to the client (this
## message is not sent if the verify option is verify_none).
##
## Value: verify_peer | verify_none
listener.ssl.external.verify = verify_peer
2.MQTT 连接测试
参照下图在 MQTT X 中创建 MQTT 客户端(Host 输入框里的 127.0.0.1 需替换为实际的 EMQ X 服务器 IP)
此时 Certificate 一栏需要选择 Self signed ,并携带自签名证书中生成的 ca.pem 文件, 客户端证书 client.pem 和客户端密钥 client.key 文件。
点击 Connect 按钮,连接成功后,如果能正常执行 MQTT 发布/订阅 操作,则 SSL 双向连接认证配置成功。
2.SMQTT
双向向认证配置
smqtt:
tcp: # MQTT配置
ssl: # ssl配置
enable: true # 开关
key: C:\Users\Administrator\Desktop\fsdownload\pkcs8_key.pem # 指定ssl文件 默认系统生成
crt: C:\Users\Administrator\Desktop\fsdownload\server.crt # 指定ssl文件 默认系统生成
ca: C:\Users\Administrator\Desktop\fsdownload\ca.crt
MQTT 连接测试
此时 Certificate 一栏需要选择 Self signed ,并携带自签名证书中生成的 ca.pem 文件, 客户端证书 client.pem 和客户端密钥 client.key 文件。
点击 Connect 按钮,连接成功后,如果能正常执行 MQTT 发布/订阅 操作,则 SSL 双向连接认证配置成功。
MQTT Java 客户端库
Eclipse Paho Java Client(opens new window)是用 Java 编写的 MQTT 客户端库(MQTT Java Client),可用于 JVM 或其他 Java 兼容平台(例如Android)。
Eclipse Paho Java Client 提供了MqttAsyncClient 和 MqttClient 异步和同步 API。
通过 Maven 安装 Paho Java
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<version>1.2.2</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15on</artifactId>
<version>1.70</version>
</dependency>
Paho Java 使用示例
Java 体系中 Paho Java 是比较稳定、广泛应用的 MQTT 客户端库,本示例包含 Java 语言的 Paho Java 连接SMQTT Broker ,并进行消息收发完整代码:
MqttConnect
import lombok.Data;
/**
* @author C.X.L
* @date 2022/10/24 0024 16:29
* @description
*/
@Data
public class MqttConnect {
/**
* 根证书路径
*/
private String CA_CRT_PATH="C:\\Users\\Administrator\\Desktop\\fsdownload\\ca.crt";
/**
* 设备crt证书路径
*/
private String DEVICE_CERT_PATH="C:\\Users\\Administrator\\Desktop\\fsdownload\\client.crt";
/**
* 设备key证书路径
*/
private String DEVICE_PEM_PATH="C:\\Users\\Administrator\\Desktop\\fsdownload\\client.key";
/**
* mqtt代理服务器地址
*/
private String host="ssl://127.0.0.1:1883";
/**
* 设备id
*/
private String clientId;
private boolean cleanSession = false;
/**
* 设备密码
*/
private String password="smqtt";
private String userName="smqtt";
}
SSLUtils
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import java.io.*;
import java.security.KeyPair;
import java.security.KeyStore;
import java.security.Security;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManagerFactory;
/**
* @author Charley
* @date 2022/12/05
* @description
*/
public class SSLUtils {
public static SSLSocketFactory getSingleSocketFactory(InputStream caCrtFileInputStream) throws Exception {
Security.addProvider(new BouncyCastleProvider());
X509Certificate caCert = null;
BufferedInputStream bis = new BufferedInputStream(caCrtFileInputStream);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
while (bis.available() > 0) {
caCert = (X509Certificate) cf.generateCertificate(bis);
}
KeyStore caKs = KeyStore.getInstance(KeyStore.getDefaultType());
caKs.load(null, null);
caKs.setCertificateEntry("cert-certificate", caCert);
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(caKs);
SSLContext sslContext = SSLContext.getInstance("TLSv1.2");
sslContext.init(null, tmf.getTrustManagers(), null);
return sslContext.getSocketFactory();
}
public static SSLSocketFactory getSocketFactory(final String caCrtFile,
final String crtFile, final String keyFile, final String password)
throws Exception {
Security.addProvider(new BouncyCastleProvider());
// load CA certificate
X509Certificate caCert = null;
FileInputStream fis = new FileInputStream(caCrtFile);
BufferedInputStream bis = new BufferedInputStream(fis);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
while (bis.available() > 0) {
caCert = (X509Certificate) cf.generateCertificate(bis);
}
// load client certificate
bis = new BufferedInputStream(new FileInputStream(crtFile));
X509Certificate cert = null;
while (bis.available() > 0) {
cert = (X509Certificate) cf.generateCertificate(bis);
}
// load client private key
PEMParser pemParser = new PEMParser(new FileReader(keyFile));
Object object = pemParser.readObject();
JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC");
KeyPair key = converter.getKeyPair((PEMKeyPair) object);
pemParser.close();
// CA certificate is used to authenticate server
KeyStore caKs = KeyStore.getInstance(KeyStore.getDefaultType());
caKs.load(null, null);
caKs.setCertificateEntry("ca-certificate", caCert);
TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509");
tmf.init(caKs);
// client key and certificates are sent to server, so it can authenticate
KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
ks.load(null, null);
ks.setCertificateEntry("certificate", cert);
ks.setKeyEntry("private-key", key.getPrivate(), password.toCharArray(),
new java.security.cert.Certificate[]{cert});
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory
.getDefaultAlgorithm());
kmf.init(ks, password.toCharArray());
// finally, create SSL socket factory
SSLContext context = SSLContext.getInstance("TLSv1.2");
context.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
return context.getSocketFactory();
}
}
MqttServiceTest
import com.demo.smqtt.vo.MqttConnect;
import com.demo.xl.utils.SSLUtils;
import org.eclipse.paho.client.mqttv3.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.SSLSocketFactory;
import static org.eclipse.paho.client.mqttv3.MqttConnectOptions.MQTT_VERSION_3_1_1;
/**
* @author Charley
* @date 2022/12/05
* @description
*/
public class MqttServiceTest {
private static Logger log = LoggerFactory.getLogger(MqttServiceTest.class);
/**
* 消息级别
*/
private final static int QOS = 0;
public static MqttClient createMqtt(MqttConnect mqttConnect) throws Exception {
MqttClient client = new MqttClient(mqttConnect.getHost(), mqttConnect.getClientId());
MqttConnectOptions connOpts = new MqttConnectOptions();
connOpts.setUserName(mqttConnect.getUserName());
connOpts.setPassword(mqttConnect.getPassword().toCharArray());
connOpts.setCleanSession(mqttConnect.isCleanSession());
connOpts.setKeepAliveInterval(90);
connOpts.setAutomaticReconnect(true);
connOpts.setMqttVersion(MQTT_VERSION_3_1_1);
SSLSocketFactory factory = SSLUtils.getSocketFactory(
mqttConnect.getCA_CRT_PATH(),
mqttConnect.getDEVICE_CERT_PATH(),
mqttConnect.getDEVICE_PEM_PATH(),"");
connOpts.setSocketFactory(factory);
client.connect(connOpts);
log.info("mqtt({}) connect success", mqttConnect.getClientId());
return client;
}
public static void subscribe(MqttClient client, String topic, String clientId) {
try {
// 创建MqttClient
if (!client.isConnected()) {
log.error("mqtt({}) sub is disconnect", clientId);
client.connect();
log.error("mqtt({}) is error");
}
client.setCallback(new MqttCallback() {
@Override
public void connectionLost(Throwable arg0) {
log.error("connectionLost : " + arg0.getMessage());
}
@Override
public void messageArrived(String topic, MqttMessage message) throws Exception {
log.info("recive message ->{} ", new String(message.getPayload()));
}
@Override
public void deliveryComplete(IMqttDeliveryToken token) {
log.info("delivery is Complete:" + token.isComplete() + " and delivery response:" + token.getResponse());
}
});
client.subscribe(topic, QOS);
} catch (Exception e) {
log.error(e.getMessage());
}
}
public static void publish(MqttClient client, String msg, String topic, String clientId) throws MqttException {
if (!client.isConnected()) {
log.error("mqtt({}) connect is error", clientId);
client.connect();
log.error("mqtt({}) connect reconnect", clientId);
}
MqttTopic mqttTopic = client.getTopic(topic);
MqttMessage message = new MqttMessage(msg.getBytes());
message.setQos(QOS);
mqttTopic.publish(message);
log.info("MQTTUtil({}) Send-> topic: " + topic + "\n Message: " + msg, clientId);
}
public static void main(String[] args) throws Exception {
MqttConnect mqttConnect = new MqttConnect();
mqttConnect.setClientId("123456");
MqttClient mqttClient = createMqtt(mqttConnect);
//订阅消息
subscribe(mqttClient,"test/hello","123456");
//发布消息
publish(mqttClient,"say hello~~~","test/hello","123456");
}
}
测试结果: