[Android] MQTT + MQTTX 사용해보기
안드로이드에서 MQTT 통신을 하는 방법은 아래와 같다. 1. org.eclipse.paho:org.eclipse.paho.client.mqttv3에서 제공하는 MqttClient 클래스 사용 2. org.eclipse.paho:org.eclipse.paho.android.service에서 제공하는 MqttAndroidCli
ogyong.tistory.com
지난번에 SSL 인증 없이 MQTT를 사용해 봤는데, 이번에는 SSL 인증을 추가적으로 해보려고 한다.
(이미 인증서를 가지고 있다고 가정)
eclipse에서 MQTT 통신을 하도록 제공하는 클래스는 사용하지 않으려 한다.
Android 12 버전부터 오류가 발생하는데, 이 부분을 해결한 hannesa2님이 만든 라이브러리를 사용했다.
외부 라이브러리 등록과 gradle 설정은 위 링크를 참고하면 된다.
+ 사전 작업
위 이미지와 같이 res에 raw 폴더를 만들고 인증서 파일들을 넣어준다.
raw에 넣은 파일들은 resources.openRawResource로 접근하여 읽을 수 있다..
// Bouncycastle
implementation 'org.bouncycastle:bcpkix-jdk15on:1.59'
gradle에 해당 의존성을 추가한다.
boundcycastle의 PemReader를 통해 key파일을 판독할 것이다.
MQTT + SSL 통신 작업
class MainActivity : AppCompatActivity() {
// ssl 인증을 해야할 때 URI는 tcp가 아닌 ssl 이어야 함.
private val serverUri: String = "ssl://ip 입력:포트 입력" //서버 IP
private val topic:String = "o_gyong test" // 토픽
private var sendText = ""
private var receiveText = ""
private lateinit var mBinding: ActivityMainBinding
// Mqtt 방식의 통신을 지원하는 클래스, MqttAndroidClient 객체 생성
private lateinit var mqttClient: MqttAndroidClient
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = ActivityMainBinding.inflate(layoutInflater)
setContentView(mBinding.root)
// MqttAndroidClient 초기화
// 마지막 인자 값으로 Act의 AUTO_ACT와 MANUAL_ACK이 있는데 정확한 사용법을 모르겠음..
// 기본적으로 AUTO_ACT로 동작함 → 메시지가 반환되면 프로세스에 메시지가 도착했다고 즉시 알림
mqttClient = MqttAndroidClient(this, serverUri, MqttClient.generateClientId())
// Mqtt의 Client가 서버에 연결하는 방법을 제어하는 클래스, MqttConnectOptions 객체 생성
val mqttConnectOptions = MqttConnectOptions()
// MqttConnectOptions의 소켓 팩토리 초기화
mqttConnectOptions.socketFactory = mqttSSLAuth()
// No subjectAltNames on the certificate match 에러 무시
// TODO : 브로커의 IP와 호스트의 IP가 일치하는지를(인증) 무시하는 것 같음 → 다른 해결법 필요?
mqttConnectOptions.isHttpsHostnameVerificationEnabled = false
// Mqtt 서버와 연결
// 연결 결과 콜백 → callbackConnectResult
mqttClient.connect(mqttConnectOptions, null, callbackConnectResult)
}
/**
* SSL 인증 처리
*/
private fun mqttSSLAuth(password: String = ""): SocketFactory {
// raw에 등록한 인증 파일들을 InputStream 변수로 만듬(파일을 읽기 위한 처리)
val caInputStream: InputStream = BufferedInputStream(resources.openRawResource(R.raw.ca))
val clientCrtInputStream: InputStream = BufferedInputStream(resources.openRawResource(R.raw.client_crt))
val clientKeyInputStream: InputStream = BufferedInputStream(resources.openRawResource(R.raw.client_key))
// X.509 인증서 타입의 CertificateFactory 객체 생성
// → 각 파일들을 CertificateFactory 객체로 초기화 시키는데 사용.
val certificateFactory = CertificateFactory.getInstance("X.509")
// 파일 데이터로 Certificate 객체 생성 및 초기화
val caCertificate = certificateFactory.generateCertificate(caInputStream)
val clientCertificate = certificateFactory.generateCertificate(clientCrtInputStream)
// client key를 전달하여 PemReader 객체 생성
// → Private Key로 된 '.key' 파일을 PemReader로 판독
val keyPemReader = PemReader(InputStreamReader(clientKeyInputStream))
// key 파일 내용을 ByteArray로 가져옴
val pemContent = keyPemReader.readPemObject().content
keyPemReader.close() // PemReader 종료(초기화)
// PemReader로 구한 ByteArray를 PKCS #8 표준에 따라 인코딩
// → PKCS #8는 일반적으로 PEM base64 인코딩 형식으로 변환된다고 함
val keySpecPKCS8 = PKCS8EncodedKeySpec(pemContent)
// 암호화 된 키를 기본 키로 변환하기 위해 KeyFactory 사용
// → RSA로 암호화 되어있기 때문에 RSA 알고리즘을 전달하여 KeyFactory 객체 반환
val keyFactory = KeyFactory.getInstance("RSA")
// 인코딩 된 keySpec을 받아서 개인 키를 생성
val privateKey = keyFactory.generatePrivate(keySpecPKCS8)
// KeyStore를 이용하여 ca 인증서가 신뢰할 수 있는 인증서라고 정의
// → 초기화와 신뢰할 수 있는 인증서로 정의를 안하면 ssl 인증에 실패
val caKeyStore = KeyStore.getInstance(KeyStore.getDefaultType())
caKeyStore.load(null, null) // 초기화
caKeyStore.setCertificateEntry("ca-certificate", caCertificate) // 신뢰할 수 있는 인증서로 정의
// 기본 TrustManagerFactory 알고리즘을 전달하여 TrustManagerFactory 객체 생성
// KeyStore를 전달 받아 TrustManagerFactory를 초기화
// → SSLContext를 초기화하는데 사용 예정
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(caKeyStore)
// KeyStore를 이용하여 client_crt 인증서에 개인 키를 전달하고 신뢰할 수 있는 인증서라고 정의
// → 초기화와 신뢰할 수 있는 인증서로 정의를 안하면 ssl 인증에 실패
// → setKeyEntry를 이용해 개인 키로 client 인증서를 인증 (암호는 아무 값이나..?)
val clientKeyStore = KeyStore.getInstance(KeyStore.getDefaultType())
clientKeyStore.load(null, null)
clientKeyStore.setCertificateEntry("certificate", clientCertificate)
clientKeyStore.setKeyEntry("private-key", privateKey, password.toCharArray(), arrayOf<Certificate>(clientCertificate))
// 기본 KeyManagerFactory 알고리즘을 전달하여 KeyManagerFactory 객체 생성
// KeyStore와 암호를 전달 받아 KeyManagerFactory를 초기화
// → SSLContext를 초기화하는데 사용 예정
// → 암호는 아무 값이나..?
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
kmf.init(clientKeyStore, password.toCharArray())
// MqttConnectOptions에 소켓팩토리를 정보를 주기 위해 SSLContext 객체 생성
// KeyManagerFactory, TrustManagerFactory를 넘겨서 초기화
val context = SSLContext.getInstance("TLSv1.3")
context.init(kmf.keyManagers, tmf.trustManagers, null)
return context.socketFactory
}
/**
* connect 결과 처리
*/
private var callbackConnectResult = object : IMqttActionListener {
override fun onSuccess(asyncActionToken: IMqttToken?) {
println("성공 $asyncActionToken")
mqttClient.subscribe(topic, 1)
mqttCallBack()
sendMessageMqtt()
}
override fun onFailure(asyncActionToken: IMqttToken?, exception: Throwable?) {
println("실패 $exception")
}
}
/**
* 메시지 전송
*/
private fun sendMessageMqtt() {
mBinding.buttonSend.setOnClickListener {
mqttClient.publish(topic, MqttMessage(mBinding.etSend.text.toString().toByteArray())) // 메세지 전송
sendText = sendText + mBinding.etSend.text.toString() + "\n"
mBinding.tvSend.text = sendText
}
}
/**
* 메시지 상태 콜백
*/
private fun mqttCallBack() {
// 콜백 설정
mqttClient.setCallback(object : MqttCallback {
// 연결이 끊겼을 경우
override fun connectionLost(p0: Throwable?) {
println("연결 끊어짐")
}
// 메세지가 도착했을 때
override fun messageArrived(topic: String?, message: MqttMessage?) {
println("topic $topic")
println("message $message")
receiveText = receiveText + message.toString() + "\n\n"
mBinding.tvReceive.text = receiveText
}
// 메시지 전송이 성공했을 때
override fun deliveryComplete(p0: IMqttDeliveryToken?) {
println("메세지 전송 성공")
}
})
}
}
전체 코드는 위와 같다.
MqttConnectOptions의 소켓 팩토리를 인증 정보를 지닌 SSLContext의 소켓 팩토리로 초기화시켜줘야 한다.
이후 MqttAndroidClient를 connect 할 때 MqttConnectOptions 객체를 넘겨주면 된다.
전체 과정을 요약하면 아래와 같다.
1. raw의 파일들을 읽기 위해 BufferedInputStream으로 InputStream 변수로 만든다.
2. crt(인증서)와 key 파일 처리
- crt
CertificateFactory 객체로 생성 → Certificate 객체 생성
- key
PemReader 객체 생성(key 판독) → PKCS8EncodedKeySpec로 키 스펙 생성(ByteArray 인코딩)
→ KeyFactory로 개인 키 생성
3. KeyStore로 ca 인증서를 신뢰할 수 있는 인증서라고 정의한 뒤 TrustManagerFactory 객체 생성
(ca 인증서는 Certifiacte 객체)
4. KeyStore로 client 인증서에 개인 키를 전달, 신뢰할 수 있는 인증서로 정의한 뒤 KeyManagerFactory 객체 생성
(client 인증서는 Certifiacte 객체)
5. SSLContext객체를 만들고 TrustManagerFactory와 KeyManagerFactory 객체 값으로 초기화
6. MqttConnectOptions의 소켓 팩토리를 SSLContext 소켓 팩토리로 초기화
7. MqttAndroidClient connect에 MqttConnectOptions 객체 전달
URI 설정
private val serverUri: String = "ssl://ip 입력:포트 입력" //서버 IP
이전에는 URI 설정을 'tcp://ip:포트'로 설정을 했었다.
SSL 인증을 해야 하는 경우 URI의 시작을 'ssl'로 설정해야 한다.
그렇지 않으면 '지정된 SocketFactory 유형이 브로커 URI와 일치하지 않음 (32105)' 에러가 발생한다.
(여기서 좀 헤맸다... → 도움 된 자료)
raw에 등록한 파일 읽기
// raw에 등록한 인증 파일들을 InputStream 변수로 만듬(파일을 읽기 위한 처리)
val caInputStream: InputStream = BufferedInputStream(resources.openRawResource(R.raw.ca))
val clientCrtInputStream: InputStream = BufferedInputStream(resources.openRawResource(R.raw.client_crt))
val clientKeyInputStream: InputStream = BufferedInputStream(resources.openRawResource(R.raw.client_key))
crt(인증서) 파일을 Certificate 객체를 만드는 데 사용
// X.509 인증서 타입의 CertificateFactory 객체 생성
// → 각 파일들을 CertificateFactory 객체로 초기화 시키는데 사용.
val certificateFactory = CertificateFactory.getInstance("X.509")
// 파일 데이터로 Certificate 객체 생성 및 초기화
val caCertificate = certificateFactory.generateCertificate(caInputStream)
val clientCertificate = certificateFactory.generateCertificate(clientCrtInputStream)
X.509는 가장 많이 사용되는 표준 인증서 형식이라고 한다.
X.509 형식의 CertificateFactory 객체를 만들고 각 인증서의 정보를 지닌 Certificate 객체로 만든다.
key 판독하기
위 이미지는 client_key 파일인데 맨 윗부분과 아랫부분에 'BEGIN' 과 'END'가 적혀있다.
이런 형식을 PEM 이라고 하는데, Base64로 인코딩한 텍스트 형식의 파일이라고 한다.
일반적으로 암호화 키를 저장하는 데 사용된다고 한다.
// client key를 전달하여 PemReader 객체 생성
// → Private Key로 된 '.key' 파일을 PemReader로 판독
val keyPemReader = PemReader(InputStreamReader(clientKeyInputStream))
// key 파일 내용을 ByteArray로 가져옴
val pemContent = keyPemReader.readPemObject().content
keyPemReader.close() // PemReader 종료(초기화)
PemReader로 PEM 형식의 key 내용을 구한다.
개인 키 생성
// PemReader로 구한 key 내용으로 키 스펙을 생성
// → PKCS #8는 일반적으로 PEM base64로 인코딩 된 값을 반환함
val keySpecPKCS8 = PKCS8EncodedKeySpec(pemContent)
// 암호화 된 키를 개인 키로 변환하기 위해 KeyFactory 사용
// → RSA로 암호화 되어있기 때문에 RSA 알고리즘을 전달
val keyFactory = KeyFactory.getInstance("RSA")
// 인코딩 된 keySpec을 받아서 개인 키를 생성
val privateKey = keyFactory.generatePrivate(keySpecPKCS8)
PemReader로 구했던 pemContent는 이미 인코딩 된 값이고, PKCS8EncodedKeySpec으로 키 스펙을 생성한다.
그다음, 개인 키를 반환하는 KeyFactory 객체를 만들고 generatePrivate로 개인 키를 생성한다.
KeyStore로 신뢰할 수 있는 인증서 정의 및 TrustManagerFactory 객체 생성
// KeyStore를 이용하여 ca 인증서가 신뢰할 수 있는 인증서라고 정의
// → 초기화와 신뢰할 수 있는 인증서로 정의를 안하면 ssl 인증에 실패
val caKeyStore = KeyStore.getInstance(KeyStore.getDefaultType())
caKeyStore.load(null, null) // 초기화
caKeyStore.setCertificateEntry("ca-certificate", caCertificate) // 신뢰할 수 있는 인증서로 정의
// 기본 TrustManagerFactory 알고리즘을 전달하여 TrustManagerFactory 객체 생성
// KeyStore를 전달 받아 TrustManagerFactory를 초기화
// → SSLContext를 초기화하는데 사용 예정
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(caKeyStore)
KeyStore는 인증서와 키를 저장한다.
setCertificateEntry를 사용해 ca 인증서를 신뢰할 수 있는 인증서로 재정의 한다.
TrustManagerFactory는 신뢰 자료를 관리한다고 한다.
KeyStore를 받아 init으로 초기화하면서 KeyStore의 신뢰 결정을 위해 사용한다고 한다.
KeyStore로 인증서에 개인 키 전달, 신뢰할 수 있는 인증서 정의 및 KeyManagerFactory 객체 생성
// KeyStore를 이용하여 client_crt 인증서에 개인 키를 전달하고 신뢰할 수 있는 인증서라고 정의
// → 초기화와 신뢰할 수 있는 인증서로 정의를 안하면 ssl 인증에 실패
// → setKeyEntry를 이용해 개인 키로 client 인증서를 인증 (암호는 아무 값이나..?)
val clientKeyStore = KeyStore.getInstance(KeyStore.getDefaultType())
clientKeyStore.load(null, null)
clientKeyStore.setCertificateEntry("certificate", clientCertificate)
clientKeyStore.setKeyEntry("private-key", privateKey, password.toCharArray(), arrayOf<Certificate>(clientCertificate))
// 기본 KeyManagerFactory 알고리즘을 전달하여 KeyManagerFactory 객체 생성
// KeyStore와 암호를 전달 받아 KeyManagerFactory를 초기화
// → SSLContext를 초기화하는데 사용 예정
// → 암호는 아무 값이나..?
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
kmf.init(clientKeyStore, password.toCharArray())
마찬가지로 client 인증서도 신뢰할 수 있는 인증서라고 재정의를 한다.
그리고, KeyStore는 setKeyEntry를 이용해 개인 키로 인증서를 인증할 수도 있다.
(키를 이용해 인증하는 것을 체인이라고 하는 것 같다.)
KeyManagerFactory는 키 자료를 기반으로 한 자료들을 관리한다고 한다.
KeyStore를 받아 init으로 초기화해 준다.
MqttConnectOptions의 소켓 팩토리를 SSLContext의 소켓 팩토리로 초기화
// MqttConnectOptions에 소켓팩토리를 정보를 주기 위해 SSLContext 객체 생성
// KeyManagerFactory, TrustManagerFactory를 넘겨서 초기화
val context = SSLContext.getInstance("TLSv1.3")
context.init(kmf.keyManagers, tmf.trustManagers, null)
return context.socketFactory
// MqttConnectOptions의 소켓 팩토리 초기화
mqttConnectOptions.socketFactory = mqttSSLAuth()
// No subjectAltNames on the certificate match 에러 무시
// TODO : 브로커의 IP와 호스트의 IP가 일치하는지를(인증) 무시하는 것 같음 → 다른 해결법 필요?
mqttConnectOptions.isHttpsHostnameVerificationEnabled = false
// Mqtt 서버와 연결
// 연결 결과 콜백 → callbackConnectResult
mqttClient.connect(mqttConnectOptions, null, callbackConnectResult)
SSLContext는 SSL에 대한 소켓 프로토콜을 받아서 신뢰할 수 있는 자료들을 관리한다.
'TLSv1.3'이라는 소켓 프로토콜의 객체를 만들고 위에서 생성한 신뢰할 수 있는 자료들을 넘겨 초기화를 한다.
그리고 SSLContext의 소켓 팩토리를 MqttConnectOptions의 소켓 팩토리로 전달한다.
'MqttConnectOptions.isHttpsHostnameVerificationEnabled = false' 부분이 없는 상태에서 실행을 하면,
'No subjectAltNames on the certificate match'라는 에러가 발생한다.
해당 자료를 보고 위 코드를 넣어줘서 통신이 성공했지만 보안 문제가 있지 않을까 싶다.
2022.12.21 내용 추가
implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.1.0'
implementation 'org.eclipse.paho:org.eclipse.paho.android.service:1.1.1'
eclipse.paho.client.mqttv3의 버전이 1.1.0~1.2.0인 경우
1. MqttAndroidClient를 사용 시
Targeting S+ (version 31 and above) requires that one of FLAG_IMMUTABLE or FLAG_MUTABLE be specified when creating a PendingIntent.
Strongly consider using FLAG_IMMUTABLE, only use FLAG_MUTABLE if some functionality depends on the PendingIntent being mutable, e.g. if it needs to be used with inline replies or bubbles.
에러가 발생한다. → 이 문제는 hannesa2의 MqttAndroidClient를 사용하면 해결된다.
2. MqttClient 사용 시
정상 동작한다.
implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5'
implementation 'org.eclipse.paho:org.eclipse.paho.android.service:1.1.1'
eclipse.paho.client.mqttv3를 1.2.1 이상인 경우
javax.net.ssl.SSLHandshakeException: No subjectAltNames on the certificate match 에러가 발생한다.
→ MqttClient의 경우 'MqttConnectOptions().isHttpsHostnameVerificationEnabled=false'로 해결이 가능하다.
(인증서 파일의 subjectAltNames가 127.0.0.1로 되어있어 발생한 에러로 IP가 일치하면 에러가 안 나지 않을까 싶다.)
하지만 MqttAndroidClient는 'MqttConnectOptions().isHttpsHostnameVerificationEnabled=false' 값을 주고 나면
Targeting S+ (version 31 and above) requires that one of FLAG_IMMUTABLE or FLAG_MUTABLE be specified when creating a PendingIntent.
Strongly consider using FLAG_IMMUTABLE, only use FLAG_MUTABLE if some functionality depends on the PendingIntent being mutable, e.g. if it needs to be used with inline replies or bubbles. 에러가 발생한다.
→ hannesa2의 MqttAndroidClient를 사용하면 해결이 가능하다.
결론은 MqttClient는 eclipse 라이브러리를, AndroidMqttClient는 hannesa2 라이브러리를 사용하자.
'Android > MQTT' 카테고리의 다른 글
[Android] MQTT + MQTTX 사용해보기 (0) | 2022.12.06 |
---|
댓글