Posted on :: Updated on :: Tags: , ,

TLS1.2 VS TLS1.3

要想将透明的mqtt通信设置tls,首先需要服务端设置证书,因为在非对称加密通信中,公钥一般存在证书文件中,服务器会将证书发送给客户端,客户端验证证书有效后,根据使用的tls版本选择不同的加密通信方式。

具体来说,如果使用tls1.2,则客户端会在本地生成RSA对称密钥,然后使用证书公钥加密这个对称密钥,然后将这个密钥发送给服务端,后续所有的通信使用这个对称密钥进行加密,也就是本次会话密钥。 MQTT_TLS1

如果使用tls1.3,则客户端在验证证书有效后,会强制使用 ECDHE 来进行密钥交换,具体的流程为:

  1. 客户端和服务端随机选取一个数a,b
  2. 客户端 用一个公开的数学方法(椭圆曲线运算)算出 A = a * G,然后把 A 发送给 服务端。
  3. 服务端 也用同样的方法算出 B = b * G,然后把 B 发送给 客户端。
  4. 客户端 用她的秘密 a 计算 S = a * B = a * (b * G);服务端 用他的秘密 b 计算 S = b * A = b * (a * G)。

S即为会话密钥,后续使用S加密后续消息。

ECDHE的核心在于,虽然G是公开的,椭圆曲线运算也是公开的,但是椭圆曲线运算在数学上是 单向不可逆 的,反推出a和b的概率很低。

单向认证

不管使用RSA对称密钥还是ECDHE方式的密钥,前提是都需要证书来进行身份认证。可以复用nginx的证书作为服务器证书。

mosquitto服务端设置证书

listener 8883
cafile /etc/letsencrypt/xxx.pem
certfile /etc/letsencrypt/xxx.pem
keyfile /etc/letsencrypt/xxx.pem
require_certificate false//为后面双向认证做准备

app设置

//由于使用的是受信任的CA机构签发的证书,因此使用默认的系统认证机制
val sslContext = SSLContext.getInstance("TLSv1.3")
sslContext.init(null, null, null)  // 默认的 TrustManager 和 KeyManager 会被使用
options.socketFactory = sslContext.socketFactory  // 设置 socketFactory 为系统默认

注意修改url地址,就可以正常进行单向认证通信。

测试结果: MQTT_TLS2

作为对比,在使用TLS加密之前,用户名密码等信息将会在网络上裸奔: MQTT_TLS3

双向认证(mTLS)

双向认证和单向认证的区别,就是在客户端验证服务器证书的基础上,服务器也需要验证客户端的身份,这种情况一般使用在物联网设备上,例如车载通信等。

在双向认证中,每个设备都有一个唯一代表本设备的证书,这个证书由自建CA签发,部署在内网中,整个身份认证流程都在私有环境下进行,服务器和客户端都只信任自建 CA 颁发的证书,实现双向 TLS 认证(mTLS),也就是一机一密。

1. 在服务器中创建自建CA目录

//推荐的自建 CA 目录结构
/etc/ca/
├── ca.key           # CA 私钥
├── ca.crt           # CA 根证书(用于 MQTT 服务器 & 设备端验证)
├── serial           # 证书序列号记录
├── index.txt        # 证书签发记录
├── certs/           # 存放签发的客户端证书
│   ├── device_001.crt
│   ├── device_002.crt
│   ├── ...
├── crl/             # 证书吊销列表(可选)
│   ├── ca.crl

mkdir -p /etc/ca/{certs,crl,newcerts}
cd /etc/ca/
touch index
echo 1000 | tee serial
echo 1000 | tee crlnumber

2. 添加cnf配置文件

该文件作为证书签发和管理操作的核心配置文件,用于定义CA在签发证书时的相关参数和行为。指定了如何生成证书、如何管理签发的证书、以及签发证书时所需要的各种策略和规则。

示例:通用证书配置文件示例

注意:cnf配置文件可以设置很多参数,并且还可以指定扩展文件和扩展参数。为了方便签发,我们可以设置一个通用配置文件,用于基本签发参数配置,使用-config进行指定,其次针对服务端证书,客户端证书,以及不同场景的设备,例如物联网设备证书,可以使用-extensions 和-extfile指定,其中-extensions为扩展节,-extfile为扩展文件。例如,对于服务器证书签发,必须含有SAN,但是在客户端中不应该含有此参数,因此我们可以将这个参数设置到扩展文件server_ext.cnf中:

示例:服务端证书配置文件示例

3. 使用openssl工具创建密钥,签发证书

#生成CA私钥
openssl genpkey -algorithm RSA -out /etc/ca/ca.key -pkeyopt rsa_keygen_bits:2048
#生成CA根证书
openssl req -new -x509 -days 3650 -key /etc/ca/ca.key -out /etc/ca/ca.crt -config /etc/ca/ca.cnf
#生成设备私钥
openssl genpkey -algorithm RSA -out /etc/ca/certs/xxx.key  -pkeyopt rsa_keygen_bits:2048
#生成设备证书签发请求csr
openssl req -new -key /etc/ca/certs/xxx.key -out /etc/ca/certs/xxx.csr -config /etc/ca/ca.cnf -subj "/C=CN/ST=Beijing/L=Beijing/O=Wakamda/CN=device01"
#生成设备证书
openssl ca -in /etc/ca/certs/xxx.csr -out /etc/ca/certs/xxx.crt -config /etc/ca/ca.cnf
#更新crl文件
openssl ca -gencrl -out /etc/ca/crl/crl.pem -config /etc/ca/ca.cnf

后期可以使用脚本自动化处理

当需要扩展文件配置特定参数,例如配置服务端证书时:

#生成特定设备证书
openssl ca -in certs/Server.csr -out certs/Server.crt -config ca.cnf -extensions server_ext -extfile server_ext.cnf

当需要吊销设备证书时:

#吊销证书
openssl ca -revoke /etc/ca/newcerts/1000.pem -config /etc/ca/ca.cnf
#更新crl文件
openssl ca -gencrl -out /etc/ca/crl/crl.pem -config /etc/ca/ca.cnf

4. 设置mqtt broker配置

listener 8883
cafile /etc/ca/ca.crt
certfile /etc/ca/certs/Server.crt
keyfile /etc/ca/certs/Server.key
require_certificate true
tls_version tlsv1.3

此时使用单向通信已经连接失败: MQTT_TLS4

5. 客户端绑定证书

#生成客户端所需要的p12文件
openssl pkcs12 -export -out xxx.p12 -inkey certs/xxx.key -in certs/xxx.crt

6. 修改客户端代码

// 导入p12文件
val p12File = applicationContext.resources.openRawResource(R.raw.client)
val p12Password = "111111"

// 加载 KeyStore,包含客户端证书和私钥
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(p12File, p12Password.toCharArray())

// 创建 KeyManagerFactory 来管理客户端证书和私钥
val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
keyManagerFactory.init(keyStore, p12Password.toCharArray())

// 创建 TrustManagerFactory 来验证服务器证书
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
// 默认使用系统信任的证书
//trustManagerFactory.init(null as KeyStore?)  

// 加载 CA 根证书
val caInput: InputStream = resources.openRawResource(R.raw.ca)
val cf = CertificateFactory.getInstance("X.509")
val ca = cf.generateCertificate(caInput)

// 创建一个包含 CA 证书的 KeyStore
val caKeyStore = KeyStore.getInstance(KeyStore.getDefaultType())
caKeyStore.load(null, null)
caKeyStore.setCertificateEntry("ca", ca)

// 初始化 TrustManagerFactory
trustManagerFactory.init(caKeyStore)

// 创建 SSLContext,并设置 KeyManager 和 TrustManager
val sslContext = SSLContext.getInstance("TLSv1.2")
sslContext.init(keyManagerFactory.keyManagers, trustManagerFactory.trustManagers, null)

//应用
options.socketFactory = sslContext.socketFactory

此时使用双向通信连接成功:(注意此时使用的是TLSv1.2) MQTT_TLS5

TLSv1.3相较于TLSv1.2来说,在双向认证过程中,简化了证书的交换流程,因此,在握手期间,使用wireshark抓包时仅能抓取类似于单向认证的包,也就是客户端请求包和服务端回复包。

val sslContext = SSLContext.getInstance("TLSv1.3")

MQTT_TLS6

双向认证中客户端私钥和CA证书的安全性问题

在双向认证中,客户端必须保存私钥(一机一密)和CA根证书(自建CA),因此私钥和证书的安全性保存至关重要。

在许多物联网设备中,一般会采用硬件安全模块(HSM),加密存储,固件加密和安全启动等形式进行安全加固。但是安卓app使用硬件方式不现实,一般可以使用其他方式,例如对私钥和根证书进行加密。

p12格式的文件保存着设备私钥以及证书链,在生成时默认有密码保护,为了安全起见,使用自定义密钥和aes加密算法对p12文件和CA根证书进行二次加密。

由于要求不能硬编码且不能使用网络传输密钥,因此该密钥在服务端进行加密,设备端进行解密,需要双方有共同的点来进行密钥生成,可以根据安卓设备指纹或其他指纹再加以变化,最终生成AES密钥。

具体措施

客户端的通信密钥和证书使用其他密钥进行二次加密后,可以直接放入资源文件res/raw中,此时文件是加密状态,然后在程序运行时在内存中解密,且解密后不保存文件,而是直接在内存中导入密钥和证书,保证密钥安全。

// 生成解密密钥
val key = generateKey()

// 加载加密文件
val encryptedP12File = resources.openRawResource(R.raw.client)
val encryptedCaFile = resources.openRawResource(R.raw.ca)

//解密
val p12Bytes = aesDecryptInMemory(encryptedP12File, key)
val caBytes = aesDecryptInMemory(encryptedCaFile, key)

//正常绑定options
...

其他措施

p12文件进行第一次加密时,也是用二次加密使用的密钥相同的方案生成加密密钥,以防止在app代码中硬编码被逆向。

一机一密的缺点

书接上回,设置为双向认证之后,客户端总需要绑定自己的证书,并且为了防止被攻击,证书需要加密,这样一来,每安装到一个设备,就需要重新编译一个app,这样显然是很行不通的。

我们可以将客户端证书的获取,设置为网络获取,这样编译好的app中就不会有CA文件,程序运行时才会下载到运行文件夹中,无法被其他程序获取;而且,程序的使用体验会更好,因为用户并不关心也不想去设置什么加密证书(听起来就很头疼😐)

使用网络传输时保证安全,使用https协议

服务端设置

要想从服务端https下载文件,需要进行nginx设置:

# 不要加 ^~,让正则匹配优先生效,如果加^~,就不会匹配正则了
location /certs/ {
    deny all;
}

location ~ "\.en$" {
    root /etc/ca/;
    autoindex off;
    default_type application/octet-stream;

    limit_except GET {
        deny all;
    }

    add_header Content-Disposition "attachment";
}

伪代码

  1. 程序启动时,初始化页面
  2. 进行网络状态,网络如果正常且没有证书存在,则进入ca下载程序,下载后进行验证(解密);如果网络不正常,重试或其他
  3. ca导入成功后,弹窗提示填入其他信息
  4. 必要信息填入后,再进行广播,服务初始化。

文件下载

安卓对于网络下载的文件存储有严格的分类,大致分为:

  1. 内部存储:适用于存储私密数据,只能应用访问。
  2. 外部存储:适用于存储较大文件,其他应用可以访问。
  3. 应用的外部文件目录:私有的外部存储,只有当前应用可以访问。

针对客户端证书来说,属于私密文件,应该存储在内部。

Copyright 2025 wakamda 冀ICP备2025102883号-1