emqx-ssl链接认证

emqx-ssl链接认证

emqx的ssl配置

查看配置文件,MQTT TLS 的默认端口是 8883:

listener.ssl.external = 8883    
复制代码

需要在/etc/emqx/emqx.conf 文件中配置服务端证书和 CA:

listener.ssl.external.keyfile = etc/certs/key.pem
listener.ssl.external.certfile = etc/certs/cert.pem
listener.ssl.external.cacertfile = etc/certs/cacert.pem    
复制代码

默认的 etc/certs 目录下是 EMQ X 生成的自签名证书

其中的 key.pemcert.pemcacert.pem ,配置在emqx服务端中.

client-key.pemclient-cert.pemcacert.pem 则配置在客户端中

这个默认证书仅供测试使用, 实际使用还是需要我们自己生成证书

服务端支持的 cipher 列表需要显式指定,默认的列表与 Mozilla 的服务端 cipher 列表一致:

listener.ssl.external.ciphers = ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384...
复制代码

java中连接

连接配置

  1. 需要修改下端口号,直连是1883,ssl是8883端口。

  2. 需要在链接选项中配上 ssl链接工厂解析证书即可。

 public void createClient() throws Exception {
     try {
         // 创建client
         // broker emq的地址, clientId当前服务名(唯一), MqttClientPersistence 消息传输过程中,缓存内容
         client = new MqttClient(mqtt.getBroker(), mqtt.getClientId(), new MemoryPersistence());

         // MQTT 连接选项
         MqttConnectOptions connOpts = new MqttConnectOptions();
         connOpts.setUserName(mqtt.getUsername());
         connOpts.setPassword(mqtt.getPassword().toCharArray());
         // 清除会话
         connOpts.setCleanSession(true);
         // 心跳间隔
         connOpts.setKeepAliveInterval(180);
         connOpts.setAutomaticReconnect(true);

         // ssl连接,证书配置
         SSLSocketFactory factory = EmqxSSLFactory.createSocketFactory(
             ssl.getCaPath(),  // ca地址
             ssl.getCertPath(), // cert地址
             ssl.getKeyPath(),  // key地址
             ssl.getPassword()); // 密码(没密码 "")
         connOpts.setSocketFactory(factory);

         // 建立链接
         client.connect(connOpts);

         // 设置回调
         client.setCallback(new OnMessageCallback(client, topicListeners));

         // 首次订阅消息
         for (TopicListener topicListener : topicListeners) {
             client.subscribe(topicListener.getTopic(), topicListener);
         }

     } catch (MqttException me) {
         log.error("reason:{} ", me.getReasonCode());
         log.error("msg {}", me.getMessage());
         log.error("loc {}", me.getLocalizedMessage());
         log.error("cause :{}", JSONUtil.toJsonStr(me.getCause()));
         log.error("exception :{}", JSONUtil.toJsonStr(me));
         me.printStackTrace();
         throw me;
     }
 }
复制代码

pom依赖

<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcpkix-jdk15on</artifactId>
    <version>1.47</version>
</dependency>
复制代码

解析证书

具体的解析证书的代码,是从 mqtt.fx的客户端解jar包中扒的,其实和我在网上找的代码大同小异。原因是后面碰到的一个问题)

 public static SSLSocketFactory createSocketFactory(String caCertFile, String clientCertFile, String privateKeyFile, String password) throws Exception {
     Security.addProvider(new BouncyCastleProvider());

     X509Certificate caCert = parseCert(caCertFile);
     X509Certificate clientCert = parseCert(clientCertFile);

     PemReader pemReader = new PemReader(new FileReader(privateKeyFile));
     byte[] content = pemReader.readPemObject().getContent();
     PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(content);
     PrivateKey privateKey = KeyFactory.getInstance("RSA").generatePrivate(privateKeySpec);

     KeyStore caKs = KeyStore.getInstance(KeyStore.getDefaultType());
     caKs.load((InputStream) null, (char[]) null);
     caKs.setCertificateEntry("ca-certificate", caCert);
     TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
     tmf.init(caKs);

     KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
     ks.load((InputStream) null, (char[]) null);
     ks.setCertificateEntry("certificate", clientCert);
     ks.setKeyEntry("private-key", privateKey, password.toCharArray(), new java.security.cert.Certificate[]{clientCert});
     KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
     kmf.init(ks, password.toCharArray());

     SSLContext context = SSLContext.getInstance("TLSv1.2");
     context.init(kmf.getKeyManagers(), tmf.getTrustManagers(), (SecureRandom) null);
     return context.getSocketFactory();
 }

public static X509Certificate parseCert(String certPath) throws Exception {
    CertificateFactory cf = CertificateFactory.getInstance("X.509");
    InputStream inStream = new FileInputStream(certPath);
    return (X509Certificate) cf.generateCertificate(inStream);
}


复制代码

理论上,按照文档,这样简单的配置下,然后直接链接就可以。

然而…人生总是起起落落落落落落落落落落落落落落落落落落落落落落落落。

遇到的问题

问题一: 官方的默认ca证书中没有配置trust ip

报出的异常是 java.security.cert.CertificateException: No subject alternative names present,直译是 不存在主题替代名称

堆栈信息如下:

Caused by: java.security.cert.CertificateException: No subject alternative names present
	at sun.security.util.HostnameChecker.matchIP(HostnameChecker.java:156) ~[na:1.8.0_282]
	at sun.security.util.HostnameChecker.match(HostnameChecker.java:100) ~[na:1.8.0_282]
	at sun.security.ssl.X509TrustManagerImpl.checkIdentity(X509TrustManagerImpl.java:457) ~[na:1.8.0_282]
	at sun.security.ssl.X509TrustManagerImpl.checkIdentity(X509TrustManagerImpl.java:417) ~[na:1.8.0_282]
	at sun.security.ssl.X509TrustManagerImpl.checkTrusted(X509TrustManagerImpl.java:230) ~[na:1.8.0_282]
	at sun.security.ssl.X509TrustManagerImpl.checkServerTrusted(X509TrustManagerImpl.java:129) ~[na:1.8.0_282]
	at sun.security.ssl.CertificateMessage$T12CertificateConsumer.checkServerCerts(CertificateMessage.java:638) ~[na:1.8.0_282]
复制代码

首先,我查看了下其中的堆栈信息, 发现异常是在matchIp的时候, 这个subjAltNames为空. 看下面的for循环做的是从这个列表中拿到ip然后做匹配。

 private static void matchIP(String expectedIP, X509Certificate cert)
            throws CertificateException {
        Collection<List<?>> subjAltNames = cert.getSubjectAlternativeNames();
        if (subjAltNames == null) {
            throw new CertificateException
                                ("No subject alternative names present");
        }
        for (List<?> next : subjAltNames) {
            // For IP address, it needs to be exact match
            if (((Integer)next.get(0)).intValue() == ALTNAME_IP) {
                String ipAddress = (String)next.get(1);
                if (expectedIP.equalsIgnoreCase(ipAddress)) {
                    return;
                } else {
                    // compare InetAddress objects in order to ensure
                    // equality between a long IPv6 address and its
                    // abbreviated form.
                    try {
                        if (InetAddress.getByName(expectedIP).equals(
                                InetAddress.getByName(ipAddress))) {
                            return;
                        }
                    } catch (UnknownHostException e) {
                    } catch (SecurityException e) {}
                }
            }
        }
        throw new CertificateException("No subject alternative " +
                        "names matching " + "IP address " +
                        expectedIP + " found");
    }
复制代码

然后我查阅了ssl的资料后发现, 是证书中有个 subjectAltName字段,我们可以在其中加上 IP=127.0.0.1 / DNS = localhost 字段,把ip/域名添加到这个可信域名列表中, 其中的ip/dns都可以配置多个. 然后我也打开了默认的cacert.pem,能看到其中确实没有配置。

至于为什么是在ca.pem中呢,因为 从上文中的 context.init(kmf.getKeyManagers(), tmf.getTrustManagers(), (SecureRandom) null);可以看到这个tmf的初始化使用的就是cacertPath。

结论就是, 我的要访问的127.0.0.1,不在ca.pem的可信域名中. 所以需要我自己手动去生成一个证书,应该就没问题了。

实际开发时,一开始是用mqttx客户端工具验证的,发现127.0.0.1不可信,然后用了mqtt.fx客户端工具验证,发现是可以的。 所以一开始我以为是我代码写错了,所以我当时被搞懵了,没往证书有误的方向去想。

后来发现mqtt.fx是java写的,就解了jar包,发现它的链接方式和我写的一样,所以我猜测,这个客户端可能绕过了这个校验可信域名的步骤,或者默认127.0.0.1是本地的,可信的。由于没有找到源码,只有class文件,我就没更详细的看其中的相关代码。

问题二:生成证书时的文件访问权限

既然知道是证书的问题,我就搜了使用openssl生成证书的方法EMQ X 启用双向 SSL/TLS 安全连接

然后按照上文的步骤,生成了正确的证书。然后重复上午步骤,配置emqx证书,配置客户端证书,再次链接。

发现有新的错误

  • java链接报的是,emqx的服务器直接主动断开认证,链接失败。
  • mqtt.fx 客户端报的log是,检查证书以及密码的正确性。

看到这些个错误信息,我认为是我证书生成有误,然后去网上搜了其他的证书生成方式,生成方式大同小异,当然结果也是类似,都是失败,错误。

苦恼了一天,在反复查看emqx文档的时候,发现可以开启debug模式,有详尽的日志可以查看。

开启日志方式有两种:

  1. /etc/emqx/emqx.conf 中配置日志等级 log.level = debug ,然后重启
  2. 命令行 emqx_ctl log set-level <Level>,在运行时 动态改变日志等级

日志输出文件路径,在emqx.conf中可以找到

log.to = file
log.dir = /var/log/emqx
log.file = emqx.log

再次尝试连接发现,日志输出

2021-06-23T16:12:01.512814+08:00 [error] supervisor: 'esockd_connection_sup - <0.1861.0>'
, errorContext: connection_shutdown
, reason: {ssl_error,{options,{keyfile,"/etc/emqx/certs/emqx.key",{error,eacces}}}}, 
offender: [{pid,<0.2049.0>},{name,connection},{mfargs,{emqx_connection,start_link,
[[{deflate_options,[]},{max_conn_rate,500},{active_n,100},{zone,external}
{proxy_address_header,<<>>},{proxy_port_header,<<>>},{supported_subprotocols,[]}]]}}]
复制代码

根据信息发现了是 emqx.key 文件 {errror, eacces}, 这个我比较熟悉,在linux环境下不使用sudo,经常会碰到的文件权限不足

我是用sudo emqx start, 按理说emqx能读取任何文件。然后我依次查看,生成的证书文件,发现ca.pem, emqx.pem 等文件都是只读文件,只有emqx.key 这个key文件是不可读也不可写的。

然后修改文件为可读,再次尝试连接,连接成功。

总结

一路磕磕绊绊,好不容易完成这个“看似简单的需求”,实则确实很简单的需求。

发现在这个过程中,在网上搜索基本找不到解决方案,所以萌生了记录下解决过程的文章,一方面自己以后能回顾,另一方面希望能给之后碰到类似问题的人一个思路

参考文档

  1. emq官方文档
  2. EMQ X 启用双向 SSL/TLS 安全连接
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享